diff --git a/.dockerignore b/.dockerignore index 7257071..e0d3fb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,26 +1,26 @@ -**/.mount -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE +**/.mount +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE README.md \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index f68f722..67cd1d5 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,227 +1,207 @@ -# https://editorconfig.org - -# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/identifier-names -# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions -# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview - -# https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md -# https://github.com/dotnet/runtime/blob/main/.editorconfig - -# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format -# dotnet format style --verify-no-changes --severity=info --verbosity=detailed - -# Root config -root = true - -# Defaults -[*] -charset = utf-8 -end_of_line = crlf -indent_size = 4 -indent_style = space -insert_final_newline = true -trim_trailing_whitespace = true - -# Markdown files -[*.md] -end_of_line = crlf -trim_trailing_whitespace = false - -# Xml files -[*.{xml,csproj,props,targets}] -end_of_line = crlf -indent_size = 2 - -# Yaml files -[*.{yml,yaml}] -end_of_line = crlf -indent_size = 2 - -# Workflow YAML is LF: Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of -# mixed. git still leaves endings alone (`* -text`); this and CI (editorconfig-checker) enforce it. Other YAML is CRLF. -[.github/workflows/*.{yml,yaml}] -end_of_line = lf - -# JSON and JSONC files -[*.{json,jsonc}] -end_of_line = crlf - -# Linux scripts -[*.sh] -end_of_line = lf - -# Dockerfiles - CRLF breaks RUN heredocs and line continuations -[{Dockerfile,*.Dockerfile}] -end_of_line = lf - -# Windows scripts -[*.{cmd,bat,ps1}] -end_of_line = crlf - -# --- .NET-only below: C# and ReSharper style. Everything above is the line-ending -# governance every derived repo carries; a non-.NET repo may drop from here down. --- - -# C# files -[*.cs] -end_of_line = crlf -# Suppressions follow CODESTYLE.md "Analyzer Diagnostics and Suppressions": prefer a -# [SuppressMessage] attribute or the owning project's .editorconfig; relax a rule -# repo-wide here only when it applies to every project (never a brownfield batch). -dotnet_diagnostic.IDE0055.severity = none -dotnet_analyzer_diagnostic.severity = suggestion -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = false -csharp_indent_labels = one_less_than_current -csharp_indent_switch_labels = true -csharp_new_line_before_catch = true -csharp_new_line_before_else = 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_between_query_expression_clauses = true -csharp_prefer_braces = true -csharp_prefer_simple_default_expression = true -csharp_prefer_simple_using_statement = true -csharp_prefer_static_anonymous_function = true -csharp_prefer_static_local_function = true -csharp_prefer_system_threading_lock = true -csharp_preferred_modifier_order = public,private,protected,internal,file,static,abstract,sealed,virtual,override,readonly,unsafe,volatile,async,extern,new,partial:warning -csharp_preserve_single_line_blocks = true -csharp_preserve_single_line_statements = false -csharp_space_after_cast = false -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_after_comma = true -csharp_space_after_dot = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after -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 -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 -csharp_style_conditional_delegate_call = true -csharp_style_deconstructed_variable_declaration = true -csharp_style_expression_bodied_accessors = true -csharp_style_expression_bodied_constructors = true -csharp_style_expression_bodied_indexers = true -csharp_style_expression_bodied_lambdas = true -csharp_style_expression_bodied_local_functions = true -csharp_style_expression_bodied_methods = true -csharp_style_expression_bodied_operators = true -csharp_style_expression_bodied_properties = true -csharp_style_implicit_object_creation_when_type_is_apparent = true -csharp_style_inlined_variable_declaration = true -csharp_style_namespace_declarations = file_scoped -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_implicitly_typed_lambda_expression = true -csharp_style_prefer_index_operator = true -csharp_style_prefer_local_over_anonymous_function = true -csharp_style_prefer_method_group_conversion = true -csharp_style_prefer_not_pattern = true -csharp_style_prefer_null_check_over_type_check = true -csharp_style_prefer_pattern_matching = true -csharp_style_prefer_primary_constructors = true -csharp_style_prefer_range_operator = true -csharp_style_prefer_readonly_struct = true -csharp_style_prefer_readonly_struct_member = true -csharp_style_prefer_switch_expression = true -csharp_style_prefer_top_level_statements = true -csharp_style_prefer_tuple_swap = true -csharp_style_prefer_unbound_generic_type_in_nameof = true -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 -csharp_style_var_elsewhere = false -csharp_style_var_for_built_in_types = false -csharp_style_var_when_type_is_apparent = false -csharp_using_directive_placement = outside_namespace -dotnet_code_quality_unused_parameters = all -dotnet_hide_advanced_members = false -dotnet_member_insertion_location = with_other_members_of_the_same_kind -dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion -dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style -dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields -dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style -dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields -dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion -dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style -dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields -dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case -dotnet_naming_style.camel_case_underscore_style.required_prefix = _ -dotnet_naming_style.pascal_case_style.capitalization = pascal_case -dotnet_naming_style.static_prefix_style.capitalization = camel_case -dotnet_naming_style.static_prefix_style.required_prefix = s_ -dotnet_naming_symbols.constant_fields.applicable_kinds = field -dotnet_naming_symbols.constant_fields.required_modifiers = const -dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal -dotnet_naming_symbols.private_internal_fields.applicable_kinds = field -dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected -dotnet_naming_symbols.static_fields.applicable_kinds = field -dotnet_naming_symbols.static_fields.required_modifiers = static -dotnet_prefer_system_hash_code = true -dotnet_property_generation_behavior = prefer_throwing_properties -dotnet_remove_unnecessary_suppression_exclusions = none -dotnet_search_reference_assemblies = true -dotnet_separate_import_directive_groups = false -dotnet_sort_system_directives_first = true -dotnet_style_allow_multiple_blank_lines_experimental = true -dotnet_style_allow_statement_immediately_after_block_experimental = 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_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 -dotnet_style_predefined_type_for_locals_parameters_members = true -dotnet_style_predefined_type_for_member_access = true -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 -dotnet_style_qualification_for_event = false -dotnet_style_qualification_for_field = false -dotnet_style_qualification_for_method = false -dotnet_style_qualification_for_property = false -dotnet_style_readonly_field = true -dotnet_style_require_accessibility_modifiers = for_non_interface_members - -# ReSharper settings -resharper_csharp_trailing_comma_in_multiline_lists = true -resharper_csharp_var_for_built_in_types = false -resharper_csharp_var_when_type_is_apparent = false -resharper_csharp_var_when_type_is_not_apparent = false +# https://editorconfig.org + +# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/identifier-names +# https://learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/overview + +# https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md +# https://github.com/dotnet/runtime/blob/main/.editorconfig + +# https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format +# Verify with: dotnet format style --verify-no-changes --severity=info --verbosity=detailed + +# Root config +root = true + +# Defaults: LF is the default, and only the CRLF exception below is declared. +# `.gitattributes` mirrors these two defaults as Git's normalization fallback. +# CI verifies the committed bytes against this file. +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Markdown files +[*.md] +trim_trailing_whitespace = false + +# Xml files +[*.{xml,csproj,props,targets}] +indent_size = 2 + +# Yaml files +[*.{yml,yaml}] +indent_size = 2 + +# Windows batch and command scripts: the one CRLF exception to the `[*]` LF default above. +[*.{bat,cmd}] +end_of_line = crlf + +# .NET-only below, covering C# and ReSharper style. +# Everything above is the line-ending governance every derived repo carries, and a non-.NET repo carries this section unused, per CODESTYLE.md's whole-file model. + +# C# files +[*.cs] +# Suppressions follow CODESTYLE.md "Analyzer Diagnostics and Suppressions". +# Prefer a [SuppressMessage] attribute, or the owning project's .editorconfig. +# Relax a rule repo-wide here only when it applies to every project, never for a brownfield batch. +dotnet_diagnostic.IDE0055.severity = none +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true +csharp_new_line_before_catch = true +csharp_new_line_before_else = 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_between_query_expression_clauses = true +csharp_prefer_braces = true +csharp_prefer_simple_default_expression = true +csharp_prefer_simple_using_statement = true +csharp_prefer_static_anonymous_function = true +csharp_prefer_static_local_function = true +csharp_prefer_system_threading_lock = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,abstract,sealed,virtual,override,readonly,unsafe,volatile,async,extern,new,partial:warning +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = false +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +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 +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 +csharp_style_conditional_delegate_call = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = true +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = true +csharp_style_expression_bodied_methods = true +csharp_style_expression_bodied_operators = true +csharp_style_expression_bodied_properties = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_namespace_declarations = file_scoped +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_implicitly_typed_lambda_expression = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_method_group_conversion = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_primary_constructors = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true +csharp_style_prefer_switch_expression = true +csharp_style_prefer_top_level_statements = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_unbound_generic_type_in_nameof = true +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 +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false +csharp_using_directive_placement = outside_namespace +dotnet_code_quality_unused_parameters = all +dotnet_hide_advanced_members = false +dotnet_member_insertion_location = with_other_members_of_the_same_kind +dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion +dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style +dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields +dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields +dotnet_naming_rule.static_fields_should_have_prefix.severity = suggestion +dotnet_naming_rule.static_fields_should_have_prefix.style = static_prefix_style +dotnet_naming_rule.static_fields_should_have_prefix.symbols = static_fields +dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case +dotnet_naming_style.camel_case_underscore_style.required_prefix = _ +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +dotnet_naming_style.static_prefix_style.capitalization = camel_case +dotnet_naming_style.static_prefix_style.required_prefix = s_ +dotnet_naming_symbols.constant_fields.applicable_kinds = field +dotnet_naming_symbols.constant_fields.required_modifiers = const +dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal +dotnet_naming_symbols.private_internal_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.applicable_accessibilities = private, internal, private_protected +dotnet_naming_symbols.static_fields.applicable_kinds = field +dotnet_naming_symbols.static_fields.required_modifiers = static +dotnet_prefer_system_hash_code = true +dotnet_property_generation_behavior = prefer_throwing_properties +dotnet_remove_unnecessary_suppression_exclusions = none +dotnet_search_reference_assemblies = true +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = true +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = 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_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 +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true +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 +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false +dotnet_style_readonly_field = true +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# ReSharper settings +resharper_csharp_trailing_comma_in_multiline_lists = true +resharper_csharp_var_for_built_in_types = false +resharper_csharp_var_when_type_is_apparent = false +resharper_csharp_var_when_type_is_not_apparent = false diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json index e019960..a3478e9 100644 --- a/.editorconfig-checker.json +++ b/.editorconfig-checker.json @@ -1,10 +1,17 @@ -{ - "Disable": { - "Charset": true, - "Indentation": true, - "IndentSize": true, - "TrimTrailingWhitespace": true, - "InsertFinalNewline": true, - "MaxLineLength": true - } -} +{ + "Exclude": [ + "(^|/)__pycache__/", + "(^|/)\\.mypy_cache/", + "(^|/)\\.pytest_cache/", + "(^|/)\\.ruff_cache/", + "(^|/)\\.venv/" + ], + "Disable": { + "Charset": true, + "Indentation": true, + "IndentSize": true, + "TrimTrailingWhitespace": true, + "InsertFinalNewline": true, + "MaxLineLength": true + } +} diff --git a/.gitattributes b/.gitattributes index ff4d8d9..efb6ddd 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,7 @@ -# Default: do not normalize line endings (`* -text`); .editorconfig end_of_line rules guide what the editor writes. -# The exception pins below are git's own enforcement - they force LF for execution-sensitive classes regardless of editor. -# git config --global core.autocrlf false -# git add --renormalize . -# git ls-files --eol -* -text - -# Exception: scripts must stay LF regardless of the `* -text` default - a CRLF shebang breaks execution. `.editorconfig` -# covers `*.sh`, but extensionless executables (s6 service scripts, hooks) match no extension rule, so pin them here so -# git enforces LF on checkout and `--renormalize`. A repo shipping extensionless scripts adds an explicit path rule, -# e.g. for s6-overlay init: `Docker/s6-overlay/** text eol=lf`. -*.sh text eol=lf - -# Dockerfiles must be LF - a CRLF breaks RUN heredocs and line continuations. -Dockerfile text eol=lf -*.Dockerfile text eol=lf +# Normalize every detected text file to LF in the index and on checkout. +# `text=auto` leaves binary files byte-preserved. +* text=auto eol=lf + +# Windows command scripts require CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4167414..356f274 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,154 +1,49 @@ -# Copilot Instructions - -Repository conventions for GitHub Copilot (and any other AI agent reading this file). - -The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root - read it first, including the [PR Review Etiquette](../AGENTS.md#pr-review-etiquette) review-loop contract this file's runbook implements. This file is intentionally narrow: commit/PR-title conventions (summarized inline so VS Code's commit-message and PR-title generators have them) plus the GitHub Copilot Review Runbook. - -For code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root. - -Do not duplicate language-specific rules here. **Project-specific conventions and API/behavioral contracts also belong in [AGENTS.md](../AGENTS.md), not here** - this file is intentionally limited to the inline commit/PR-title summary and the GitHub Copilot Review Runbook. Non-Copilot agents (Claude Code, Codex, Cursor, ...) are not directed to this file and don't read it by default, so any rule a reviewer must honor has to live in `AGENTS.md` to be provider-independent. - -## Commit Messages and Pull Request Titles - -Summarized for VS Code's generators; the full rules, rationale, and examples are in [AGENTS.md "Pull Request Title and Commit Message Conventions"](../AGENTS.md#pull-request-title-and-commit-message-conventions). - -- Imperative subject, <= 72 characters, no trailing period; optional blank-line-separated body for the non-obvious *why*. -- US English, title case with lowercase short bind words; no vague titles, no `Co-Authored-By:` unless asked, no release-bump magnitude (NBGV handles versioning). Dependabot's `Bump X from Y to Z` titles are fine. -- develop PRs squash-merge (`gh pr merge --squash`), main PRs merge-commit (`--merge`); a mismatched flag is rejected by branch protection. - -## GitHub Copilot Review Runbook - -> This runbook implements the [AGENTS.md "PR Review Etiquette"](../AGENTS.md#pr-review-etiquette) review-loop contract for GitHub Copilot. Without it in-repo, an agent has no pointer to the reliable Copilot mechanics and falls back to known-broken paths (the no-op `POST /requested_reviewers`, the wrong bot-login filter). In the API snippets below, `` is the PR number. - -Use this section for provider-specific mechanics. The expected review loop *contract* (request review on every push, verify head-SHA coverage, triage findings, reply + resolve, escalate when stuck) is defined in [AGENTS.md -> PR Review Etiquette](../AGENTS.md#pr-review-etiquette). This section only describes how to make GitHub Copilot reliably execute it. - -### Triggering and Polling - -Auto-review on push is configured (via the branch ruleset's `copilot_code_review` rule with `review_on_push: true`) but fires inconsistently in practice - treat it as best-effort, not guaranteed. After every push, **re-request a review programmatically** via the GraphQL `requestReviews` mutation, passing the Copilot reviewer's bot node id in `botIds`. This drives the loop end-to-end without a UI hand-off. - -**A review with no inline comments is still a completed review - not a failure, and not a reason to ask the maintainer to re-trigger.** Copilot very often posts a single formal review (GraphQL `state: COMMENTED`) whose body ends with "...reviewed N of N changed files ... and generated no comments" and adds **zero** inline threads. That review carries the head `commit.oid` and fully satisfies the loop - it is the clean-pass success case. Never read "no inline comments" as "the review didn't run," and never re-request or escalate to the maintainer because comments are absent. - -**Round 1 is normally auto-seeded - poll for it before trying to self-trigger.** Auto-review-on-open supplies the first review with no `botIds` call needed, but it can lag one to three minutes. After opening a PR (or the first push), **poll** for a Copilot review on the head SHA (see [Verify Review Covered Current Head](#verify-review-covered-current-head)) before concluding none ran. The `requestReviews` mutation below is for **re-requesting on later pushes** (a new head SHA); by then a prior review exists, so its bot node id is readable. A missing bot node id on round 1 therefore means "the auto-review has not landed yet - wait and poll," **not** "ask the maintainer to kick it off." - -> **The reviewer login differs by API.** In **GraphQL** (`gh api graphql` and `gh pr view --json reviews`, which is GraphQL-backed) the `Bot.login` is `copilot-pull-request-reviewer` - **no `[bot]` suffix**. In the **REST** API (`gh api repos/.../issues|pulls/...`) the same account's `user.login` is `copilot-pull-request-reviewer[bot]` - **with** the suffix. Each query below uses the correct form for its API; match the API, not a single spelling, when adapting them. - -```sh -# 1. PR node id + the Copilot reviewer's bot node id (read from any existing -# Copilot review; the reviewer login is `copilot-pull-request-reviewer`). -PR_NODE=$(gh pr view --json id --jq '.id') -BOT_ID=$(gh api graphql -f query=' -{ - repository(owner: "ptr727", name: "VSCode-Server-DotNetCore") { - pullRequest(number: ) { - reviews(first: 50) { nodes { author { __typename login ... on Bot { id } } } } - } - } -}' --jq '[.data.repository.pullRequest.reviews.nodes[] - | select(.author.login == "copilot-pull-request-reviewer") - | .author.id] | first') - -# 2. Re-request a Copilot review on the current head. -gh api graphql -f query=' -mutation($pr: ID!, $bot: ID!) { - requestReviews(input: { pullRequestId: $pr, botIds: [$bot], union: true }) { - pullRequest { id } - } -}' -F pr="$PR_NODE" -F bot="$BOT_ID" -``` - -The bot node id is read from an existing Copilot **formal** review (`pullRequest.reviews`), so step 1 needs at least one prior formal review on the PR - the auto-review-on-open normally supplies the first one (it may have **no inline comments**; that still counts, and its bot node id is still readable). Poll for it (give auto-review-on-open a few minutes) before deciding it is missing. The Copilot reviewer bot's global node id is `BOT_kgDOCnlnWA` (login `copilot-pull-request-reviewer`) if you need to skip discovery. If Copilot posted **only an issue comment** and no formal review, the head is covered but `reviews` yields no bot node id - read the id from the Copilot issue comment's author by querying the PR's issue comments in GraphQL (`pullRequest.comments` -> author `... on Bot { id }`), or request `Copilot` once through the GitHub PR UI to produce a formal review. Manual UI seeding is the fallback specifically when no formal review exists to read the id from; then use the mutation for every subsequent re-request. - -**Do NOT post `@Copilot review` as a PR comment.** That comment triggers the Copilot *coding agent* (`copilot-swe-agent[bot]`), which makes code changes rather than posting a review. - -Known non-working request paths (don't rely on them - use the `requestReviews` mutation above instead): - -- `POST /requested_reviewers` with `reviewers=[Copilot]` can return 200 but no-op. -- `copilot-pull-request-reviewer` as a requested reviewer slug returns 422. - -### Verify Review Covered Current Head - -Before merging, confirm Copilot reviewed the current PR head SHA. Copilot may respond as either a formal review (carries an exact commit SHA) or an issue comment (no SHA - use the most recent Copilot comment for manual confirmation). Check both. - -```sh -PR_HEAD=$(gh pr view --json headRefOid --jq '.headRefOid') - -# 1. Formal review - exact SHA match. -gh pr view --json reviews --jq \ - '.reviews[] | select(.author.login=="copilot-pull-request-reviewer") | .commit.oid' \ - | grep -q "$PR_HEAD" && echo "covered via formal review" - -# 2. Issue comment - show the most recent Copilot comment for manual -# confirmation. This is the REST API, so the login carries the `[bot]` suffix. -gh api repos/ptr727/VSCode-Server-DotNetCore/issues//comments --jq \ - '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | last | {created_at, body: .body[:200]}' -``` - -Coverage is confirmed when (1) exits 0 - **a formal review with no inline comments still satisfies path (1)**, because coverage is about the head SHA, not the comment count. For issue comments (path 2), body content is the only reliable signal - `created_at` is not: `git log -1 --format=%cI` is the **commit** timestamp, not the push timestamp, so amended or rebased commits can have an earlier timestamp and an older Copilot comment could satisfy a time check even though Copilot never saw the current head. Treat path (2) as confirmed only when the comment body explicitly refers to the current changes. - -### Bounded Retry Workflow - -This path is only for a **genuinely missing** review - no Copilot review (formal *or* issue comment) covers the current head SHA after polling. A review that covered the head but produced no comments is a clean pass, not a missing review; do not enter this retry path for it. - -If a review did not run on the current head, retry: - -1. Wait briefly and check head-SHA coverage (see above). -1. Re-request the review via the `requestReviews` mutation (see "Triggering and Polling"); fall back to the GitHub PR UI only if the mutation no-ops. -1. Retry up to two more times (three total). -1. If still missing, mark review as blocked and escalate to the user/maintainer with what was attempted. - -### Reply and Thread Resolution Workflow - -List unresolved threads. Use `first: 100` with cursor-based pagination; if `hasNextPage` is true, re-run with `after: ""` to retrieve the next page: - -```sh -gh api graphql -f query=' -{ - repository(owner: "ptr727", name: "VSCode-Server-DotNetCore") { - pullRequest(number: ) { - reviewThreads(first: 100) { - nodes { - id isResolved path - comments(first: 1) { nodes { author { login } body } } - } - pageInfo { hasNextPage endCursor } - } - } - } -}' | jq ' - .data.repository.pullRequest.reviewThreads | - (.pageInfo | "hasNextPage=\(.hasNextPage) endCursor=\(.endCursor)"), - (.nodes[] | select(.isResolved == false)) -' -``` - -Reply on a thread, then resolve it: - -```sh -gh api graphql -f query=' -mutation($threadId: ID!, $body: String!) { - addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { - comment { id } - } -}' -F threadId="PRRT_..." -F body="Fixed in : ." - -gh api graphql -f query=' -mutation($threadId: ID!) { - resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } -}' -F threadId="PRRT_..." -``` - -Issue-level Copilot comments (those in `issues//comments`) have no resolution action - GitHub provides no API or UI to resolve them. Reply if the finding warrants it; no resolution step is needed or possible. - -Reply-body conventions: - -- Accepted bug/style fix: include fixing commit SHA and a one-line summary. -- Declined style comment: cite the rule (AGENTS.md or the CODESTYLE.md language section) and the existing-tree precedent. -- Declined architecture proposal: one-sentence rationale. - -After the final push, sweep-resolve stale older threads for removed code paths. - -## When in Doubt - -Read [AGENTS.md](../AGENTS.md) for this repo's conventions. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions - keep those focused on the change itself. - -**In a derived repo:** if you find a discrepancy that should be fixed in the template itself (this file or AGENTS.md is out of date, a rule is missing, something bit this repo and would bite the next), open an issue upstream in [`ptr727/ProjectTemplate`](https://github.com/ptr727/ProjectTemplate) rather than only fixing it locally - see the template's [AGENTS.md "Staying in Sync and Reporting Drift Upstream"](https://github.com/ptr727/ProjectTemplate/blob/main/AGENTS.md#staying-in-sync-and-reporting-drift-upstream). +# Copilot Instructions + +Repository-wide instructions for GitHub Copilot. + +Read [AGENTS.md](../AGENTS.md) first. It routes every standing repository rule to its canonical document. When performing code review, load and follow the `fleet-code-review` skill in `.github/skills/fleet-code-review/SKILL.md`, then load every language, documentation, or workflow skill that it selects for the changed files. GitHub Copilot reads these files from the pull request's head branch, so review the instructions in that tree. + +Do not duplicate rules from `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, or `WORKFLOW.md` here. This file contains only Copilot-specific bootstrap and output requirements. + +## Commit Messages and Pull Request Titles + +Use an imperative subject of at most 72 characters with no trailing period. Use US English and title case with lowercase short bind words. Do not add `Co-Authored-By:` unless requested. Do not put a release-bump magnitude in the title. The full contract is in [GOVERNANCE.md "Pull Request Title and Commit Message Conventions"](../GOVERNANCE.md#pull-request-title-and-commit-message-conventions). + +## Reviewing Carried Fleet Content + +Follow the fidelity declared for the file. A byte-locked reference to shared infrastructure that this repository does not carry is intentional, not a broken link. Raise substantive defects in canonical content, but locate the fix at its canonical source instead of proposing a local edit. + +`.github/skills/`, and in the hub `.claude-plugin/fleet-skills/`, are generated by the hub's `scripts/build_dist.py` from its `.agents/skills/`, so a defect in either is fixed in the source or the generator and never in the copy. A defect inside an include region, the text between the marker lines `` and `` that every copy carries as its authored source does, is fixed in the hub under the heading that key names, since the region is generated from that heading's body and the key's path resolves against the hub's root rather than this repository's copy of the same file. Where that heading's body is itself a region, the fix sits one hop further, under the heading its own key names. Post no review comment on a file under `.github/skills/` or, in the hub, `.claude-plugin/fleet-skills/`. When the pull request changes the file the fix belongs in, comment on that file instead, and otherwise state the finding in the review summary. + +## GitHub Copilot Review Runbook + +For every review: + +1. Read the full pull request diff and count its changed files. +2. Follow `.github/skills/fleet-code-review/SKILL.md` and every skill it selects. +3. Publish every supported finding. Never suppress a finding or place it in a low-confidence or hidden findings block. +4. Use an inline comment when a changed line can anchor the finding. Use the review body only when no valid inline anchor exists. +5. End the review body with the exact machine-readable marker required by the `fleet-code-review` skill. + +The review automation is `scripts/pr_review.py`, run from a hub checkout. Use its `status`, `wait`, `comment`, and `reply --resolve` commands instead of reconstructing GraphQL queries or copying review identifiers by hand. Use `comment` for a suppressed-finding answer in the pull request conversation. Its status gate verifies the current head, diff coverage, output shape, inline threads, body-only findings, and required checks. + +A formal review with no findings is complete only when it covers the current head and full diff coverage is stated for the change set that head has. The round covering the head states it, or the newest round that states it at all does and the pull request changes the same set of files at both commits, which is the only condition under which a statement carries forward. Only that newest round is consulted, so an older round whose change set does match carries nothing. A round reporting partial coverage of the diff blocks the merge, and so does a refusal, a coverage statement that does not reach this head, meaning absent from every round or carried by none because the change set moved or could not be compared, an unrecognized output shape, an unresolved thread, or a body-only finding. Re-run the loop after every fix push. Never infer review completion from `mergeStateStatus: CLEAN`. + +Review effort is user-controlled. The automation observes `Lite`, `Balanced`, or `Max`, including an inherited `Default ()`, and never selects or changes the setting. Effort does not determine coverage or completion. A request can complete without a `copilot_work_started` event, so absence of that event is not a stalled-review verdict. When `wait` returns `PENDING` with `requested=yes`, report the state and rerun `wait` for another bounded interval by default, reading that field as acceptance of the request rather than as delivery of a round. Do not clear the request on that first timeout, because it may still be active. Where a second bounded wait times out as well, read the pending set, clear it only where no human or team reviewer is requested alongside the bot, and rerun `wait`, which then has nothing outstanding to defer to and requests afresh, or polls and says so on its own auto-request line where it finds no reviewer node id to request with. The clear replaces that set rather than adding to it and nothing restores a request it drops, so a stall on a pull request that has a human or team reviewer requested goes to the maintainer, and so does one still pending after the wait that follows a clear. That clear is a recovery step the script does not implement, and `docs/pr-reviewer-reference.md`, in the hub checkout the script is run from, carries it. This recovery replaces only the review request and never changes the effort setting. + +### Disproved Claims + +**A disproof is proof about this repository, and the thread it was written in is not where the next round looks.** [GOVERNANCE.md "PR Review Etiquette"](../GOVERNANCE.md#pr-review-etiquette), which routes to the `pr-review-conduct` Skill, closes a false finding by disproving it in the thread, addressed to the reviewer so it does not raise the same thing again, and while the pull request is open that is the right place for it. Afterwards it is the wrong one. The pull request merges, the next round begins with no memory of the last, and the second occurrence reaches a maintainer with no way to tell it from a first. Each entry below is a claim that was tested against this repository and found false, kept so the proof is read rather than built twice. + +**An entry names the claim, what was run or read to disprove it, the revision it was proved against, and what ends it.** A disproof is true of one tree at one revision, so an entry whose subject moves is deleted by the change that moves it rather than edited to look current, which is the same sweep the [GOVERNANCE.md "Documentation Style Conventions"](../GOVERNANCE.md#documentation-style-conventions) rule already requires of prose asserting a behavior that has changed underneath it. This is deliberately not a list to append to, since an entry outliving the code it was proved against becomes a reason not to check, and that is strictly worse than proving the claim a second time. + +**The record answers a repeated claim and never dismisses a new one.** An entry is cited only where the revision it names is still what the tree carries, and the reply carries the proof re-read rather than a pointer to the entry, since a reviewer that cannot open this file learns nothing from being pointed at it. Judge a finding on its merits first and match it against this record second, because reading it the other way round is how a real finding gets closed by a stale proof. + +**The entries are this repository's own.** Each names a file and a revision, so a repository holding a copy of this file carries the shape and the rules above rather than these findings, deletes an entry whose subject it does not carry, and records what it has proved itself. + +No entries yet. This repository records here what it has proved itself. + +## When in Doubt + +Stop and report the uncertainty. Do not guess at an instruction, suppress a possible finding, or claim coverage that the review did not perform. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c107c9a..cb44e58 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,27 +1,25 @@ -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -# Each ecosystem appears once per branch (main + develop) so both stay current independently; rationale in -# AGENTS.md "Branching Model". This repo ships a Docker image only, so github-actions is the sole ecosystem. -version: 2 -updates: - - # main - - package-ecosystem: "github-actions" - target-branch: "main" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" - - # develop - - package-ecosystem: "github-actions" - target-branch: "develop" - directory: "/" - schedule: - interval: "daily" - groups: - actions-deps: - patterns: - - "*" +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + + # main + - package-ecosystem: "github-actions" + target-branch: "main" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" + + # develop + - package-ecosystem: "github-actions" + target-branch: "develop" + directory: "/" + schedule: + interval: "daily" + groups: + actions-deps: + patterns: + - "*" diff --git a/.github/skills/add-host-tool/SKILL.md b/.github/skills/add-host-tool/SKILL.md new file mode 100644 index 0000000..22b10df --- /dev/null +++ b/.github/skills/add-host-tool/SKILL.md @@ -0,0 +1,46 @@ +--- +name: add-host-tool +description: >- + Adds or changes a managed host tool across the ptr727/ProjectTemplate fleet contract, Linux and + Windows installers, platform documentation, and tests. Use this whenever adding, removing, + renaming, or changing the source, probe, version floor, install, report, upgrade, or dry-run + behavior of a tool in host-setup or spec/host-tools.json. Triggers even when the request names + only one platform, because a required fleet tool needs an executable remedy everywhere it + applies and native verification must stay on the platform being tested. +--- + +# Add Host Tool + +## Establish the Contract + +1. Read the issue and all follow-up comments before choosing a source or package identifier. +2. Add the tool to `spec/host-tools.json` in name order. +3. Use the executable's real version banner for the probe and pattern. +4. Set a floor only when it is measured or anchored to every supported distribution. +5. Provide `source` and executable `remedy` entries for every applicable platform. + +## Implement Each Platform + +- Keep the existing named-tool interface and default selection behavior. +- Prefer the distribution package when it meets the floor. +- Use the platform's established package manager and official package identifier. +- Keep install and upgrade idempotent. +- Before an apt-managed install, detect and remove an unowned downloaded copy that shadows it. +- Before a downloaded install, detect and remove a conflicting package-managed copy. +- Preserve report, list, explicit selection, install, upgrade, reinstall, and dry-run behavior. +- Do not test a Windows mutation on Linux or a Linux mutation on Windows. + +When a platform is unavailable, verify its registry and tests without claiming a native install. Hand off the exact native commands and expected observations to the operator. + +## Update the Complete Surface + +Update the platform installers, `spec/host-tools.json`, `docs/host-setup.md`, and the applicable platform READMEs. Update installer and host-gate tests for selection, reporting, installation, upgrade, and dry-run behavior. Sweep prose that describes tool sources or the managed set. + +## Verify + +1. Run the spec validator and the focused installer and host-gate tests. +2. Run the repository's formatting, lint, type, and test gates required by the changed files. +3. On the current native platform, exercise list and report first. +4. Exercise install and upgrade dry runs. +5. Apply the install, repeat it to prove idempotence, and run the host gate. +6. Record untested platforms explicitly and leave cross-platform verification open. diff --git a/.github/skills/agent-conduct/SKILL.md b/.github/skills/agent-conduct/SKILL.md new file mode 100644 index 0000000..e869ad1 --- /dev/null +++ b/.github/skills/agent-conduct/SKILL.md @@ -0,0 +1,114 @@ +--- +name: agent-conduct +description: >- + Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the decision moments they are + violated. Use this whenever about to claim work is done, verified, green, or fixed, whenever + about to pick a default, guess an intent, or resolve an ambiguity without asking, whenever work + is blocked on a decision or authorization only the user can give, whenever about to ask the + user anything or offer them more work, a closing "want me to...?" line included, whenever about + to file a question as an issue instead of asking it, whenever writing a handoff, which owes an + account of every question parked rather than asked, whenever work here waits on a fix in + another repository, and whenever an incident, a wrong answer, or a repeated correction just + taught something a future session must honor. Deliberately narrow: the carried AGENTS.md + "Context and Delegation Discipline" section is the always-on layer, so do not load this skill + as general background. + Where a sibling skill owns the moment, it wins: git-commit-conventions for committing, + pr-review-conduct for review and merge claims, local-strict-review for the passes a push owes, + comment-and-doc-style for prose. The GOVERNANCE.md sections it surfaces keep the full rules, + carried here whole as generated includes. +--- + +# Agent Conduct + +## Why This Exists + +The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons, accounting at handoff for what was parked rather than asked, recording a blocker that lives in another repository) lived only in doc sections nothing surfaced at the moment of violation, so they were honored by whoever happened to have read them recently. This skill is the decision-moment surface. The full rules stay in `GOVERNANCE.md` ("Verification Discipline", "Communicating with the User", "Durable Knowledge and Self-Improvement"), which keeps authority, and each of those three sections is carried here whole, as a generated include that `scripts/build_dist.py` fills from the section and holds to it, so the text that surfaces at the moment is the rule's own rather than a shorter list of it. The carried `AGENTS.md` "Context and Delegation Discipline" section is the always-on layer and is not carried here. A defect in included text is fixed in `GOVERNANCE.md` and regenerated, never edited in this file, per the `skill-lifecycle` Skill. + +## Before Claiming Done + +Read the section below before reporting success on anything non-trivial. It is `GOVERNANCE.md` "Verification Discipline", whole. + + + +The checks that separate work actually done from work that merely reports success. A pattern that matches less still exits zero, and a gate that stops gating still reports success. + +- **Locate every check a change owes before running any of them, and CI's coverage is not that list.** The checks are read from what the repository declares, meaning its `OPERATIONS.md` "Local Verification" section alongside the workflows, rather than inferred from whatever the pipeline happens to run. Part of a repository's contract is routinely unreachable from a runner, a redirect no build serves, a deploy no pull request performs, hardware no runner holds, so the check covering that part lives in a document rather than in a workflow and is run by hand before the pull request opens. Green is then the precise signal that it was skipped, because the pipeline reports success over the half it reaches while saying nothing about the half it cannot. Reading a document's own description of itself is not how such a check is found, since a topical document is named for its most visible function, usually a post-merge one, and an accurate description of that function routes a pre-merge task away from the file holding the gate. The destination is declared fleet-wide for that reason, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes: read that file whole where it exists and the workflows beside it either way, and report what is absent rather than reading its absence as an answer that no local check applies. +- **A test runner failing to spawn is not evidence that no test coverage applies here.** `uv run pytest` failing to spawn in a lint-only Python Scripts profile is that profile working as intended, not a missing dependency, per the `python-codestyle` Skill's Two Profiles. Read the actual invocation from the same `OPERATIONS.md` "Local Verification" section the bullet above names, rather than guessing a generic test-runner command, and report that document's own command result, not the guessed command's failure. +- **A test must assert the mechanism it names, and a gate has to be watched failing.** Label each case by the behavior it proves, then write the case that reintroduces the fault and confirm the gate objects to it. A case that passes for an incidental reason, the right answer reached by the wrong path, is worse than no case, because it is later cited as evidence. A proof that restates the gated data instead of reading it proves only that the function works, so drive the real table or the real config. And a gate that finds nothing is indistinguishable from a gate with nothing to find, so assert a floor on what a healthy run covers. +- **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. An identity allowlist used as a gate, for one, must raise an error when its list stops matching, not silently pass everything through. +- **Config with a uniqueness rule is validated on read, and its consumers assert what it promised.** A repeated key in a lookup table is not a precedence question to settle quietly, it is two answers to one question, and keeping whichever came last picks one of them where the reader sees no choice being made. Fail on the duplicate at the point the config is read, so the code downstream can rely on the invariant instead of re-deriving it. +- **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. +- **Every push toward a pull request is preceded by a local adversarial review of the branch's whole diff, and the pass is recorded.** The rule binds every push rather than the first one, so a fix push answering a reviewer's finding owes a pass exactly as the branch's first push did, and that is the round it is actually skipped on: the fix looks small, the branch was reviewed once already, and what goes up is content no review has read. Skipping it does not save the round, it moves it, into the fix-commit and review-comment cycle that spends wall-clock, Actions runtime, and agent tokens finding what a local pass would have. The pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, and `scripts/local_review.py` records it keyed on the content the reviewer actually saw, so a capture point can ask whether a receipt still covers what is about to be pushed rather than trusting the rule to have been remembered. The pass is mandatory and its findings are advisory, which are opposite claims worth keeping apart: a pass is recorded whether it raised ten findings or none, and disposing of each one is judgment, per `GOVERNANCE.md` "PR Review Etiquette". +- **Canonical content one repo authors and others carry is read the way a carrier reads it, whole, in the repo that can fix it, and that read is swept periodically rather than owed by a push.** Such content is written and merged against a diff of a few lines, and reaches a reviewer as a new file, in full, only when a repo carries it for the first time, so the first real read of a rule happens where nothing can be done about the result: the tree is manifest-owned, the copy is compared against the authoring repo's, byte for byte wherever the declared fidelity is verbatim, and a local edit there is drift on the next fidelity check. Where the fidelity is intent the carrier may adapt its own copy, and the defect still has to be fixed at the source, since every other carrier holds it too. Every carrier after that re-discovers the same defect, and the finding arrives in a session holding no checkout of the authoring repo and no standing to test the claim. The unit is what a reviewer reads whole, and the carry manifest, `spec/files.json` in the hub, rather than the document decides which, down to which files carry units at all, so the engine that reads that manifest is the authority on the set rather than any restatement of its rules. In the ordinary case a unit is one level-two section of a carried Markdown canonical, which is the fidelity unit `spec/section-model.md` declares. The read is of the unit's whole current text rather than of the diff that moved it, and the pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, exactly as they are for the pass above. **The read is swept because owing it at every push cost too much to keep owing it there.** Measured across this fleet's review rounds, the passes a push owed were a large share of what a pull request spent, and what they returned was never measured against that, so the read moves to a schedule on the cost alone rather than being owed by whichever change happens to touch a unit. A change that moves a unit is no longer refused over one, and no capture point asks a change for a pass of this kind, the diff pass the bullet above requires being owed by every push exactly as before. What replaces it is a schedule in the authoring repo, which gathers the work into one piece and files it where an agent session can run the passes and fix what they find. That work is every unit whose text has moved past the pass that read it, plus a bounded slice of the units nothing has read there at all, taken newest-committed first. The slice is what keeps a newly authored unit from waiting on a volunteer, since such a unit has no earlier pass to move past and would otherwise reach a carrier with nothing having asked to read it, which is the case this whole rule is about. A unit newly carried by widening the manifest alone is not reached that way, the order reading the unit's own file rather than the manifest, so it joins the backlog at that file's age, where a section written and declared in one commit leads like any other newly authored one. Bounding it is what keeps a long backlog from arriving as one week's work. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and names both sets, so the sweep's list is read off that record rather than remembered, and its ledger is tracked content the change carrying it commits like any other. Like the pass above, a pass the sweep asks for is mandatory and its findings are advisory. +- **Another round of edits after either pass is owed only while a defect this change introduced is open, never by a finding count.** Which findings count as introduced, what each class owes, and how many rounds a push may spend are the `local-strict-review` Skill's. +- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. +- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. Prefer literal replacement over regex reassembly. In Python the *default* path is a text-mode rewrite, which has the mirror failure: `Path.read_text()` decodes through universal newlines and `write_text()` translates each `\n` back to `os.linesep`, so a read-edit-write round trip rewrites every line ending in the file to the host's own while the edit itself looks correct. Work in bytes, or open the file explicitly with `newline=''` on both the read and the write, since a read that preserves the endings still hands them to a write that translates them. Use `open()` rather than `Path.read_text()`, which accepts that argument only on Python 3.13 and newer and raises `TypeError` below it. The corruption is worth naming because it is invisible in a rendered diff. +- **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. +- **Never write source text carrying backslash escapes through a shell construct that interprets them.** A `printf` format string, a `printf` argument consumed by `%b`, `echo -e`, POSIX `sh`'s builtin `echo`, and `$'...'` each consume the escape and write an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. A quoted heredoc, `<<"EOF"`, is not one of those constructs and writes every backslash literally. An unquoted `<///` returns an indistinguishable 404 whether the repository is private, the ref does not exist, or the path is wrong, so an agent that treats that response as "the content does not exist" has made the same unstated-branch mistake the bullet above names, only over visibility instead of branch. Where a repository's visibility is not confirmed public, read its content through the contents API with the raw media type instead, which hands back the bytes themselves and leaves no decode step to fail quietly: `gh api -H "Accept: application/vnd.github.raw" "repos///contents/?ref="`. Take the base64 `.content` field only where something needs the JSON around it, and then read `.encoding` alongside it, because a blob over 1 MB comes back with `content` empty and `encoding` set to `none`: the call succeeds, `base64 -d` decodes the empty string successfully, and the result is the failed-fetch-read-as-an-empty-success this bullet exists to prevent. Either form is its own command whose exit status is read before its output is used, never a producer piped straight into a consumer that reports only its own status. `gh api` writes a failed call's error body to standard output, so an unchecked capture or redirect stores that error where the content was supposed to go, and merging the error stream in with `2>&1` puts it inside the payload rather than beside it. Verify the ref resolves (a commit SHA is unambiguous where a branch name may have moved, been deleted, or never existed on the remote) before reading either failure as an answer about the content itself. +- **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding, so what gets reported is the output that process produced, and where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier, having announced the wait and never read it. Naming an external cause for such a stall afterwards, a throttle or a quota that appears nowhere in the record, turns a local defect into a story about someone else and closes the investigation on the wrong party, so read the record for the cause before naming one, and where the record does not carry it, report the cause as unknown. +- **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else, because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. +- **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. +- **A review flags an instance, so a fix covers the class, bounded to what this change touched or broke.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, and the finding is being fixed, sweep for its siblings before replying, since reviewers sample rather than enumerate, and fix each sibling that sits in a file the diff already touches. A sibling the change itself put in disagreement is this change's to fix wherever it sits, because the change made it wrong. A sibling that was wrong before the change and sits in a file the diff does not touch is filed rather than folded in, because every file the diff grows into is one more that each round reads again, so a sweep that widens the diff widens the loop it was meant to close. + +`GOVERNANCE.md` "Verification Discipline" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + + + +Claims about a pull request being reviewed, clean, or mergeable are owned by the `pr-review-conduct` skill, and claims that a commit landed by `git-commit-conventions`. The two review passes the section above requires, one over a push's diff and one over each canonical unit a sweep names, their delegation shape, and how each is recorded are the `local-strict-review` skill's. + +## Before Assuming + +- **Ask when the user can cheaply confirm.** An assumption that saves one question and is wrong costs the rework plus the trust, so a genuine ambiguity in intent, scope, or authorization is raised, not resolved by picking the likelier reading. Rules that already answer the question (the committed instruction set) are not ambiguity, so read them first rather than asking what they state. +- **The irreversible step (merging, publishing, releasing, force-pushing, deleting, changing branch protection) stays the maintainer's, and a grant given in a past session or for a different task authorizes nothing now.** Whether a credential's reach or a tool that happens to work authorizes anything is answered by `GOVERNANCE.md` "Repository Boundaries and Write Safety" rather than here. + +`GOVERNANCE.md` "Communicating with the User", carried whole below, covers how to ask, how to reference an issue, a pull request, or a commit wherever it is mentioned, how to raise work that is blocked on the answer, and what a session owes for the questions it parked rather than asked. + + + +- **Reference every pull request as a clickable link.** When you mention a PR on a surface that renders Markdown (chat, a summary, a report), render it as a Markdown link to the PR (`[#N](https://github.com/OWNER/REPO/pull/N)`), never a bare `#N`. The same applies to issues and commits. **The form follows the surface.** Some surfaces link neither a Markdown link nor a bare URL, an interactive prompt's question and option text among them, and pasting a full URL into one of those does not rescue it, since the reader gets a string to copy, which is the outcome this rule exists to prevent. There the reference is a bare `#N`, and the clickable link goes in the message that comes **before** the prompt rather than merely alongside it, because the prompt blocks on an answer and a message emitted after it is read once that answer is already given, which is the one moment the link is no longer any use. The test is whether the reader can click it where it is read, not whether it was written in the syntax that works elsewhere. +- **Ask every question through the interface's prompt, never in prose.** When you need the user to decide, answer, or approve anything, put it to them through the interface's own prompt mechanism, whether or not work is blocked on it, and an offer to do more work ("want me to file that?") is a question like any other. A question written into a message is lost in the report around it however short it is, and one closing a long report sits where the reader is least likely to reach it. Where no prompt mechanism is available, the questions go as a numbered list opening the message rather than closing it, so the user can reply per number. This bullet settles how a question is put once it is asked, and whether a question is asked now or recorded for later is the parking bullet's to settle, the one opening "A question filed as an issue is parked rather than asked". +- **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it through the interface's own prompt mechanism, at the point the work stops. Never leave it as prose in a summary: a handoff buried in a paragraph is a handoff that did not happen, because a summary reads as a report of finished work and the one line still waiting on the user is the easiest in it to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves**, and the one that unblocks the work names the action it authorizes ("squash and merge it"), so selecting it is the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing, since a prompt whose every choice is inaction reports the block rather than clearing it, and where the agent may not perform the authorized action itself, the option says who does it. Where no interactive prompt is available, the numbered list the bullet above names is the fallback. +- **Lead every choice with a recommendation and its reason.** Wherever the user is asked to choose, in a prompt or in a numbered list, the option the agent recommends comes first and is marked as the recommendation, and every option states the reason for it. A user who takes the recommendation then reads one line, and one who does not sees what the other options trade away. A question with no defensible recommendation says so rather than inventing one. Where an open question has no options and the agent does have an answer, that answer and its reason go in the question's text, never as an invented option. +- **A question filed as an issue is parked rather than asked, and it stays owed.** Where the work cannot continue without the answer, the interactive-prompt bullet governs and the question is asked at the point the work stops. Wherever the question is recorded rather than asked, whether because the work can continue without the answer or because the question was asked once and deferred, recording it is the right thing to do and recording it is still not asking it, so the issue carries the fleet's `decision` label and, when the filing session knows the choices the question is between, states them, which is what lets a later session, one that was not there when the issue was filed, find the question and put it to the user without inventing its answers. **The parked queue is the failure, not the parking.** An issue holding a question the user has never seen reads to every later session as tracked work rather than as a block, so each session files correctly and moves on, and presenting the accumulation is the step nobody owns. +- **The session that writes a handoff presents the parked queue in the same act.** This binds at the moment the session writes the handoff that `AGENTS.md` "Session Scope" defines, rather than at the moment the session ends, because a session also ends by interruption, where no agent acts at all and no rule reaches it. The session enumerates this repository's open issues carrying that label, states how many issues the queue holds, and puts the highest-ranked of them to the user as questions, **one question per parked decision, carrying that decision's own answers as its options**, which is the interactive-prompt bullet's own shape applied per decision rather than a single prompt whose options are topics. An issue that states no choices is put as the open question it is, never as invented options, since the label marks every decision waiting on the user rather than only the ones filed under this rule, and the issues that already carry the label predate the rule. Rank them longest-waited first. Every issue records how long it has waited, so any session can reproduce that order. Promote one ahead of that order where it blocks work in flight, and say in the handoff that it was promoted, since two sessions order the same queue differently where the key is subjective and nobody records it. Where more are parked than one round of questions can carry, the stated count still covers every one of them and the handoff names the remainder by issue number in that order, a list of numbers rather than of questions, which is what keeps the handoff inside the size rule that `AGENTS.md` "Session Scope" sets for it. **A count nobody states is a queue nobody can see**, which is how a backlog reported as healthy hides the questions inside it. Where a user is present but no prompt mechanism is available, the questions go as the numbered list the interactive-prompt bullet already names as its fallback, which reaches a reader who is there to read it. Where no user is present at all, the session asks nothing, names the whole queue by issue number in the handoff, and leaves the asking to the next session that has one. An item leaves the queue when the user answers it, and also at triage where a later change has already answered or overtaken its decision. Either way it leaves by losing the label, with the answer or the reason recorded on the issue, and the issue itself closes only where it held nothing but the question, since the queue holds issues that outlive their decision, and closing such an issue just to clear the queue discards the other work that issue tracks. + +`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` and `session-handoff` Skills at `.agents/skills/agent-conduct/SKILL.md` and `.agents/skills/session-handoff/SKILL.md` in the hub, not repo-relative links since those paths are hub-local and not carried into every fleet repo, each carry it whole as a generated include and surface it at its own decision moment. + + + +## Before Writing a Handoff + +Three sources govern this moment, and the first two are rules in the `GOVERNANCE.md` "Communicating with the User" include under "Before Assuming": the one opening "A question filed as an issue is parked rather than asked" and the one opening "The session that writes a handoff presents the parked queue in the same act". They state the obligation, how the queue is enumerated, how it is ranked, and what to do where no prompt or no user is available. The third is `AGENTS.md` "Session Scope", which the second of those bullets names and which keeps the handoff's own size rule and names what its sections carry, while the `session-handoff` Skill fixes their headings and their order and is what writes one and resumes from one. Read all three there, since a restatement here would be a second copy, and nothing would hold it to the first. + +## When a Failure Surfaces a Lesson + +Where a lesson lands, when it earns a mechanical hook, and where work blocked on another repository is recorded are all `GOVERNANCE.md` "Durable Knowledge and Self-Improvement", whole. + + + +- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`GOVERNANCE.md` for a cross-cutting rule, `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. +- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. +- **A blocker filed in another repository is recorded in the repository whose work it blocks.** The binding moment is the one where the upstream issue is filed or, where it already exists, found, because that session is the one that knows what stopped and why. It owes a second issue in the repository that is waiting rather than only the first, and where the upstream issue already exists the local issue names that one and no second upstream issue is filed. The local issue states what this repository cannot do and why, in its own terms rather than as a pointer to read elsewhere, since a reader who has to open the upstream issue to learn whether it affects them opens every one of them. The local issue names the upstream one as its blocker, carries the `blocked` label, and carries whatever labels its own work would carry anyway. A comment on the upstream issue then names the local one in return, a comment rather than an edit to the body because a second repository may join the same blocker later and because the body is often not this session's to rewrite. That order, the upstream issue and then the local issue and then the backlink comment, leaves a partial failure as a record naming its blocker rather than as a blocker naming a record nobody wrote. **A handoff does not do this job.** It carries the blockers a round met, and it belongs to one track and closes with its successor, where the wait outlives every session that met it and belongs in the backlog the whole repository reads. +- **The blocker record is written under the ordinary write rules, and the label on it is taken off deliberately.** Filing an issue and commenting on another are state-changing calls, so "Repository Boundaries and Write Safety" binds each of them exactly as it binds any other write, which keeps the upstream issue inside this owner and makes a blocker under a different owner a matter of explicit permission rather than of this rule. The `blocked` label is what every reader of this record selects on, so a repository not carrying it cannot host one until the fleet label set is applied there, which is a change to that repository's configuration rather than anything this rule writes, and a session that finds it missing reports that. The label comes off when the blocker clears, and the session closing the upstream issue is best placed to take it off, since the backlink comments naming every waiting repository are on the issue it is closing, while any later session that finds it cleared takes it off instead. A fix can land well before either of those, so the label lags the fix rather than tracking it, which is why a session meeting a `blocked` issue reads the state of the issue that issue's body names rather than the label. The local issue stays open when the label comes off, because the work it records still has to be done and is ordinary backlog from that moment on. +- **A handoff parked on a maintainer decision carries the `blocked` label too.** Its blocker is a `decision` issue in this same repository rather than an issue elsewhere, and the parking comment on the handoff names it. A reader reads the named issue rather than the label, and the blocker clears when that issue loses its `decision` label. Unlike a cross-repository blocker, a session finding it cleared does not take the label off. It comes off only when a session hands the link back to be worked, so a loop running meanwhile never takes a link a present maintainer is still working. +- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under `GOVERNANCE.md` "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. + +`GOVERNANCE.md` "Durable Knowledge and Self-Improvement" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moments. + + + +Two rules that bind at this moment have their homes elsewhere. A review flags an instance, so a fix covers the class, bounded to what the change touched or broke, stated under "Before Claiming Done" above. And a rule that keeps needing to be restated is a stale or missing skills install before it is a missing rule, per `AGENTS.md` "Where the Rules Live", with the `check-this-repo` Skill as the check. + +## When Work Stops on Another Repository + +The rules for this moment are two bullets in the `GOVERNANCE.md` "Durable Knowledge and Self-Improvement" include under "When a Failure Surfaces a Lesson" above. The first, opening "A blocker filed in another repository is recorded in the repository whose work it blocks", states what the two issues are, what the local one says, the `blocked` label it carries, and the backlink the upstream issue takes in return. The second, opening "The blocker record is written under the ordinary write rules", states which write rules bind those writes, what a repository not carrying the `blocked` label owes before it can host the record, and when the label comes off. Read both there, since a restatement here would be a second copy and nothing would hold it to the first. + +## Delegation, in One Paragraph + +The always-on rules live in `AGENTS.md` "Context and Delegation Discipline", loaded in every session, and are not restated here. The two that bind at a conduct moment are its rule on briefing a subagent and its rule on never tiering down the seat holding the judgment. diff --git a/.github/skills/audit-a-repo/SKILL.md b/.github/skills/audit-a-repo/SKILL.md new file mode 100644 index 0000000..487dd8e --- /dev/null +++ b/.github/skills/audit-a-repo/SKILL.md @@ -0,0 +1,37 @@ +--- +name: audit-a-repo +description: >- + Drives AUDIT.md's read-only measurement of a named ptr727 fleet repo against the fleet ground truth, ending in a committed report, never an edit to the repo being measured. Use this whenever asked to audit, measure, or verify conformance of a named repo, to judge a conformance claim someone else made, or to decide whether an onboarding is actually complete. Run from a hub checkout of ptr727/ProjectTemplate against the named target. Triggers even when the repo believes it is conformant, because conformance asserted without a committed report is conformance nobody can check, and that is the case most often skipped. This completes the procedure triangle: standup-a-repo creates a repo, resync-a-repo applies findings to one already stood up, and this skill measures, while check-this-repo is the in-repo self-check with no named target and no standing hub checkout. AUDIT.md keeps authority over the procedure, this skill is the summary that routes into it. +--- + +# Audit a Repo + +## Why This Exists + +The audit is the fleet's measurement procedure, and the two failure shapes it guards against are both silent: a repo judged conformant with no committed evidence, and an audit that quietly edits what it was supposed to measure. `AUDIT.md` in the hub is the procedure and keeps authority. This skill carries the rules that get skipped in practice and says which section owns each step. + +## Before Measuring Anything + +- **Route first.** A repo with no carried instruction set, or a partial one, has a baseline that never arrived rather than drift to report, so it goes to `STANDUP.md` sections 1A and 2 first (`AUDIT.md` section 0). Auditing it anyway produces a report that is all absences and reads as catastrophe. +- **Verify the host.** Run `python3 scripts/host_gate.py --repo ` from the hub checkout before any hub tool, and pass `--repo`, since a bare run skips the target's own `host-tools.json` overlay. A stale tool answers `--version`, looks healthy, and produces a wrong answer. +- **Read `main` as ground truth**, for both workflow models, and read `develop` only to detect divergence (`AUDIT.md` section 1). An `operational` repo's `develop` is mid-flight by design, so conformance work sitting there is un-promoted work, not a defect, and it counts when it reaches `main`. Use `spec/audit.py --branch ` to preview in-flight work, which stamps the override so the finding cannot be mistaken for one against ground truth. + +## Measuring + +- **Resolve the repo's types from `registry/repos.json`** and classify a `classificationPending` entry from the tree (`AUDIT.md` section 2). The applicability gate is `WORKFLOW.md` section 1, extended to `AUDIT.md`'s own checks: an item or check governing an absent construct is N/A, excluded from the verdict, and never a defect (`AUDIT.md` section 3). +- **Know what the runner does and does not prove.** `spec/audit.py` mechanizes the deterministic subset only: settings, rulesets, secret names, file and section presence, verbatim hashing, interface wiring, Dependabot coverage, branch facts. It evaluates no check under a type in `spec/project-types.json`, so every per-type check is judged by hand, and a clean run is no evidence for them (`AUDIT.md` section 4). Silence from a tool that was never looking reads exactly like a pass. +- **Judge letter and intent per check** and keep the vocabulary: letter miss with intent satisfied is a drift finding, both missing is a defect, and operational is binary over the applicable set (`AUDIT.md` sections 4 and 7). Do not invent a parallel scheme. +- **Assert the Actions implement `WORKFLOW.md`** by outcome, not by matching catalog snippets byte for byte: the 5A static audit, each applicable guarantee cited in the form 5A sets out, then the 5B trace scenarios (`AUDIT.md` section 5). Read a workflow the repo only calls at the SHA it pins, for both. The `workflow-ci-contract` skill summarizes that contract. +- **Check live settings, rulesets, and secrets from a hub checkout at `main`** with `AUDIT.md` section 6. Run `repo-config/configure.sh check` with the target repository and model for settings and rulesets, and `spec/audit.py [RepoName]` for secrets, rather than constructing a local comparison. The hub payloads are the only repository-configuration source. + +## Reporting + +- **Write `reports//audit.md` from `reports/_template.md`**, findings ranked most severe first, each with the `file:line` it was judged against, and quote the run stamp, since findings are a point-in-time snapshot (`AUDIT.md` section 8). +- **The hub authors the report.** A downstream repo never opens a hub pull request to write its own, which would be self-certification. Downstream context goes into issues filed against the hub instead. +- **Generate a convergence issue, never compose one**: `spec/audit.py --issue ` emits it from live findings. An agent picking such an issue up re-runs the audit first and acts on the live result, not the pasted findings. +- **Reconcile registry `driftNotes` in the same pass**: a resolved deviation's note is deleted, not left describing finished work, and a note naming a check id is retired by a person, not by a run (`AUDIT.md` section 8). +- **Stale-versus-modified classification needs a full hub clone with git history.** Without one, compare against the current hub canonical on `main`, which decides current-match only. + +## After the Report + +Measuring and fixing are separate phases. Converging is `AUDIT.md` section 10: fixes ship as pull requests on the target repo, one focused pull request per drift class, the Copilot loop driven to green per the `pr-review-conduct` skill, and the maintainer merges. For a repo already stood up, `RESYNC.md` sequences the findings, since order matters (a deletion lands before the re-vendor that would refresh it). Systemic drift shared by many repos is fixed in the hub spec, not hand-patched per repo, and spec questions are escalated rather than resolved silently (`AUDIT.md` section 9). diff --git a/.github/skills/backlog-burndown/SKILL.md b/.github/skills/backlog-burndown/SKILL.md new file mode 100644 index 0000000..bb4c611 --- /dev/null +++ b/.github/skills/backlog-burndown/SKILL.md @@ -0,0 +1,527 @@ +--- +name: backlog-burndown +description: >- + Burns a ptr727/ProjectTemplate fleet repository's open-issue backlog down by rounds: rank the + open issues, group them so no two groups touch the same file, dispatch one subagent per group to + drive its own feature -> develop pull request to merge, open at most one develop -> main + promotion pull request per round for the maintainer to merge, then re-rank and go again, because + every review round files new issues that change what the next round should pick. Use this + whenever asked to work the backlog, burn the backlog down, clear the open issues, resolve or + cull the backlog, or run issues in parallel until they are gone, and whenever the ask is a + standing one rather than a single named issue. Triggers even when the backlog looks small enough + to work by hand, because the failure it exists to prevent is two agents editing the same + prose-heavy Markdown file in the same round, which surfaces as a merge conflict long after both + branches are already deep in review. Drives one repository, the one the session is in, never a + fleet-wide sweep. Ends when a re-rank finds nothing left it can act on, and never merges main, + which stays the maintainer's own step through merge-and-release. With no maintainer present, the + loop is `unattended-handoff` instead, which works one issue needing no decision per round and + parks the rest. +--- + +# Backlog Burndown + +## Why This Exists + +Asking for one issue to be fixed is `drive-pr`'s job and needs no skill above it. Asking for a +whole backlog to be worked down is a different problem, and three things about it are not obvious. +Parallelism is bounded by file overlap rather than by agent count, so the grouping decides the +throughput. The backlog is not a fixed list, since every review round files deferral issues that +belong in the next round's ranking, so a plan made once is stale by its second round. And a +prose-heavy repository conflicts on content rather than on syntax, so two agents rewording the +same section produce a conflict no tool resolves and no reviewer catches early. + +## The Two Seats + +Everything below turns on which seat is acting, so both are named once here. + +- **The orchestrator** is the session this skill runs in. It ranks, groups, dispatches, and drives + the promotion pull request. It opens no feature branch and fixes no issue itself, which is what + keeps it out of every worker's files. It does write: it comments on issues, it drives and + amends the promotion pull request, and it owns worktree and branch cleanup, which "Dispatching a + Worker" states in full. +- **A worker** is one dispatched subagent holding one group, one worktree, and one feature branch, + the dispatched task `AGENTS.md` "Session Scope" describes. It drives its own pull request into + develop and ends there. + +## Scope + +One repository, the one the session is in, resolved from its own `origin`. A run staying inside the +one repository it was invoked for is narrower than `GOVERNANCE.md` "Repository Boundaries and Write +Safety" requires, deliberately. Reading another repository's issues is governed there and not here, +working them is out of this skill's scope, and a fleet-wide backlog sweep is a different request. + +## What Invoking This Skill Authorizes + +- Naming this skill is the maintainer's explicit go-ahead for the feature -> develop squash merges + this run performs, in every round of it. A per-round merge question would idle every agent at + every boundary, which is the thing this skill exists to avoid. +- **The grant is bounded by the session it was named in.** A run interrupted and resumed in a new + session needs the skill named again, which costs one sentence and is the difference between a + grant and a mode. A grant read back from a note is one nobody gave. +- The grant does not weaken the `pr-review-conduct` Merge Gate. It answers that gate's explicit-permission item for + this run's feature -> develop merges and nothing else, so a pull request with one open finding + still does not merge. +- It is never authorization to merge a develop -> main promotion pull request, to dispatch a + release, to close an issue on judgment, or to touch another repository. Each stays the + maintainer's, and merging a promotion pull request is `merge-and-release`, invoked on its own. + +## The Round + +A round is the unit. Each one runs these steps in order. + +1. **Rank** every open issue, per "Ranking". +2. **Group** the top of that ranking, per "Grouping and File Claims". +3. **Verify** each group's predicted file set against everything in flight before dispatching + anything. A group whose files are already claimed waits for the next round. +4. **Dispatch** at most four workers, one per group, per "Dispatching a Worker". +5. **Collect** each worker's outcome: merged to develop, stopped on a question only the + maintainer can answer, parked behind another group's file claim, or abandoned, which is what + the adjudication in "Grouping and File Claims" and a confirmed-gone worker both produce. Bound + this wait per "Bounding the Wait on a Worker". +6. **Clean up** the worktrees, local branches, and merged remote branches of every group that has + finished or been abandoned, per "Dispatching a Worker". +7. **Promote**, per "The Promotion Boundary". +8. **Re-rank from scratch**, and note that the next round prepares under the freeze "The Promotion + Boundary" describes whenever a promotion pull request is still waiting on the maintainer, so it + ranks, groups, and verifies claims, and dispatches nothing until that merge lands. Do not carry + the previous round's ranking forward. The deferral issues this round's reviews filed are now + open issues with a claim on the next round's attention, and an issue that ranked low last round + can rank high once a sibling fix lands. + +## Ranking + +Where the repository carries no priority label, and the hub does not, the ranking is the +orchestrator's judgment against stated criteria rather than a field read off the issue. Where a +repository does carry one, that label is the first input and these criteria order what it leaves +tied. Write the ranking, and the reason for the top of it, into the report this skill makes at +each round boundary, per "Ending the Run". + +Rank on these, highest first where they conflict: + +- **It blocks other work.** An issue whose fix changes a rule, a gate, or a shared contract that + other issues' fixes must then obey is worth doing before them, not after. +- **It is a correctness or safety defect** in something that runs, over an improvement to + something that reads. +- **It is a root cause rather than a leaf.** A parent issue grouping several filed symptoms is + worth more than any one of its children, and fixing it may close them. +- **It is new.** An issue filed by a recent review round is evidence of something the current + content actually got wrong, and it is the freshest context anyone has on it. +- **It is small and self-contained**, as a tie-break only. Size breaks a tie between two issues of + equal value, and it never promotes a trivial issue over a real defect. + +An issue carrying the `handoff` label is not ranked and is not counted. It is a link in the session +handoff chain `AGENTS.md` "Session Scope" defines, so it records work to do next rather than work of +its own, and an open one is present by design for as long as that chain is in use. Counting it +inflates the number this run reports as the backlog by one for every lane in use, and a backlog +count this fleet reports wrong is a failure with its own history, so filter the label out of the +ranking and out of every count of the open backlog rather than out of the ranking alone. + +An issue carrying the `blocked` label is counted and is not ranked while its blocker stands. It +records real work this repository owes, which is why it stays in the count, and the label says the +work cannot start yet, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". So until the +blocker clears it has no group, no worker, and no claim, and spends none of the round's four worker +slots. Whether the blocker still stands is read from what its body names rather than from the label, +since the label comes off by hand and lags the fix, and a fix merged into `develop` leaves the issue +it fixes open, per "Grouping and File Claims" below on closing keywords. Where that read cannot be +made, from a private or deleted repository or a reference nothing can be read from, the blocker +stands rather than being assumed cleared. The round's report names every issue it held back this way +and the blocker each one waits on, since the maintainer reads the report rather than the issue +bodies, and a stuck issue nobody names reads as ordinary backlog that simply never moves. + +An issue that asks a question rather than states a defect is not ranked and is never guessed at. +It has no group, no worker, and no claim, so nothing in "Raising a Blocked Question" applies to it +except how the question travels. It goes to the maintainer at the end of ranking, per +`GOVERNANCE.md` "Communicating with the User", batched with any other question the run is sending +at that moment and in a prompt of its own otherwise, rather than waiting for a stop that may not +come. It stays unranked until answered. + +## Grouping and File Claims + +Group so that **no file is claimed by two live groups at once.** This is the rule the skill exists +for, and it binds harder than any throughput target. + +- **Group by the files a fix will touch**, not by the issues' subject matter. Two issues that read + as unrelated but both edit a shared governance file are one group. Two issues that read as near + duplicates but touch different files are two groups. +- **Genuine duplicates are one group.** Never close an issue during triage on the orchestrator's + own judgment. Comment to cross-link the pair, and let the fix close both. +- **Closing keywords go on the promotion pull request**, not the feature pull request, per + `branching-and-release-model`. A feature pull request merging into develop fires no + auto-close, so a `Fixes #N` line there closes nothing. **The feature pull request body instead + carries a line reading `Closes on promotion: #N`**, listing every issue that pull request + actually fixes and nothing it merely mentions, which is the line the promotion body is assembled + from, per "The Promotion Boundary". Without that line nothing records which issues a merged pull + request closes, since the closing keyword is deliberately absent and a body's other issue + references are not the same set. +- **Predict each group's file set** by reading the issues, not by guessing from their titles. +- **Record every claim on the issue itself**, as a comment naming the predicted file set **and the + branch that holds it**, before dispatching. The branch name is what lets a later session walk + from a worktree it found back to the claim explaining it, which is the direction the next bullet + actually travels. Working notes do not survive the session, so a claim living only in them is + invisible to the round that has to respect it, and recording it on the issue is what makes the + next bullet a read of durable state rather than of the orchestrator's memory. +- **Verify the prediction before dispatching**, against everything in flight, which is wider than + this round: the files changed by every open **feature** pull request on this repository (`gh pr + diff --name-only` per open pull request), and the claim comments of every group still + holding a branch, parked groups from earlier rounds included. `git worktree list` reports the + registered worktrees and the branch checked out in each, which is not the same as every branch + that exists, so pair it with `git branch -r`, after `git fetch --prune origin`, for one that was + pushed and whose worktree is already gone, and with `git branch` for one that was never pushed + and whose worktree is already gone. That third read is not optional here: the worktree-only + disposition retires a tree and leaves its branch standing, so this skill produces exactly that + state, and a local branch holding commits no remote has is invisible to both other reads. Prune + rather than plain fetch because `--prune` is what drops a remote-tracking ref whose branch is + gone from the remote, deleted there by another session or through the web interface, and a + plain fetch leaves that ref in `git branch -r` to defer valid groups forever. Stop and report a + failed fetch rather than reading `git branch -r` anyway, per `GOVERNANCE.md` "Verification + Discipline" on what a local clone answers for: here the scan would miss a branch pushed since + the last successful fetch and keep one deleted since it. The round + stops there and reports, rather than dispatching against a stale answer, and stopping rather + than deferring is what the cleanup and promotion steps need too, since both read the same + remote. + Those three enumerate the branches, with one gap: a branch in the standalone clone `repo-worktree` allows as a fallback is + reached only once it is pushed, since `git branch -r` inventories the remote rather than this + repository's checkouts. The claim comments are what say which files each branch holds, since a branch name says nothing about a file set, an unpushed branch has no + pull request diff to read, and no diff of any branch reports the predicted set a claim records + before the work is committed. **Exclude the open promotion pull request from that enumeration.** + Its diff is all of `origin/main..origin/develop`, so counting it claims nearly every file any + earlier round touched, and a round run during the freeze would defer every group it formed. A + predicted claim that collides with a real one is a group deferred to the next round, never one + dispatched hoping the overlap stays small. +- **A branch no claim comment covers still has to yield a file set.** The maintainer's own + worktree and a hand-driven task's branch are both enumerated above and neither carries a claim + comment, so reading only the comments records them as holding nothing, which is the collision + this section exists to prevent rather than the absence of one. Read the branch itself instead: + `git diff --name-only origin/develop...` for what it has committed, and, for a + registered worktree, `git -C status --porcelain` for what it holds uncommitted. Where + neither read is available the set is unknown rather than empty, and an unknown set collides with + every group, so ask the maintainer what that branch holds per "Raising a Blocked Question". +- **Re-verify when a worker reports that its real file set grew** beyond its claim. A worker + needing a file another group holds stops and reports rather than editing it, and the + orchestrator decides which group keeps the file, then tells the loser which of three things to + do rather than leaving it to choose: narrow its change to drop that file, park until the holder + merges, or abandon its branch and return the issue to the next round's ranking. Where the loser + has already opened a pull request, say whether it closes or waits, since one left open on an + abandoned branch reads to every later round as a live claim. **Update the claim comment whenever + the adjudication changes what a group holds**, in either direction, or the durable record drifts + from the real claim it exists to report. +- **Cap the round at four workers**, whatever the grouping allows. + +## Bounding a Prose Group + +A group whose files are prose-heavy Markdown bloats in a way a code group does not, and it needs +its own bound stated in the worker's brief. + +- **The change stays inside the units the issue names.** Rewording an adjacent section because it + now reads inconsistently is the next issue, filed, not this one's diff. +- **Deleting a claim beats qualifying it.** Where a review disproves something a rule leaned on, + remove the rule that leaned on it. A narrowed qualifier is where a new false claim gets + introduced, and it is the most common way a prose round produces the finding the following round + then fixes. +- **The review-round budget is `local-strict-review` "Disposing of Findings"'s.** The brief names + it and states no second one. + +## Dispatching a Worker + +Brief on `AGENTS.md` "Context and Delegation Discipline"'s subagent shape. + +- **The worker drives its group to a develop merge**, by invoking `drive-pr` with the target + stated as develop only. That skill owns the review loop, the finding disposition, and the merge, + so brief the group and the bounds rather than restating the loop. +- **The worker creates its own worktree**, always, as `drive-pr`'s worktree isolation and + `repo-worktree`'s task-start mandate already require of the task itself. No worker inherits another's worktree, + which is why "Bounding the Wait on a Worker" either removes a dead worker's tree and its + branch or leaves that tree untouched for the maintainer, and never passes it on. +- **The worker does no cleanup**, which is this skill's one stated override of `drive-pr`'s + post-merge cleanup and of `repo-worktree`'s post-merge procedure. Say so in the brief, because + a worker following either alone will clean up. The worker still performs the merge itself, and + what the override moves is the two cleanup halves `drive-pr` runs after it, the worktree + procedure and the verify-then-delete of the merged remote branch, **both** rather than only the + first. "Cleanup Is the Orchestrator's" below, in this + same section, says why and what it covers. +- **The worker runs `local-strict-review` before every push**, per `GOVERNANCE.md` "Verification + Discipline". That pass dispatches a reviewer of its own, so a harness where a subagent cannot + dispatch one leaves the worker unable to run it and unable to push. It reports that rather than + pushing, and its worktree is then retired, since git refuses to attach that branch anywhere else + while the reporting tree holds it. The branch is left standing for its own reason, that the + commits it already carries are what the re-dispatched worker continues from. This is the + worktree-only disposition "Cleanup Is the Orchestrator's" separates out, so a clean tree is the + whole test. A clean + tree is retired and the group re-dispatched to a seat that can dispatch. A dirty one is left + exactly as it stands and the group stopped for the maintainer, as is a group for which no seat + that can dispatch exists. That retire-and-re-dispatch case presumes the branch is reachable from + this repository, which the standalone clone `repo-worktree` allows as a fallback breaks: a worker that never + pushed holds its commits only in that clone, where this repository has no ref to hand a + replacement and nothing to retire, so re-dispatching loses the work rather than continuing it. + That group stops for the maintainer with the clone named, and no seat this skill defines resumes + it, since a worker never inherits another's checkout and the orchestrator opens no branch and + edits nothing. Neither the worker nor the orchestrator pushes around the missing pass. +- **The brief names the branch the worker will use**, which is what lets the claim comment record + it before dispatch. The worker still creates its own worktree, on that named branch rather than + one of its choosing, since a claim naming a branch nobody used points at nothing. +- **The brief requires the `Closes on promotion:` line** in the pull request body, listing exactly + the issues this group fixes. A worker never reads this skill, and `drive-pr` does not ask for the + line, so a brief that omits it produces a pull request nothing can derive a closing set from. +- **The brief names the files this group owns and the files it must not touch.** A subagent never + reads this skill, so a claim it was never given is a claim it cannot respect. State this group's + claimed set, state that any other group's file is out of bounds, and state the duty that makes + the re-verification path work: a worker needing a file outside its claim stops and reports rather + than editing it, and waits for the orchestrator to adjudicate. +- **The worker never merges to main** and never resolves a thread it did not actually dispose of. + +### Cleanup Is the Orchestrator's + +`repo-worktree`'s post-merge procedure returns the base clone to current develop before proving +the cleanup, and `branching-and-release-model` states that requirement independently. Four workers doing +that concurrently mutate one shared checkout, which `GOVERNANCE.md` "Repository Boundaries and +Write Safety" forbids. A worker also cannot +finish the procedure from inside its own worktree, since removing that worktree leaves it with no +working directory in which to delete its branch. + +So the whole procedure moves to the orchestrator, which runs it from the base clone at the round's +cleanup step, while no worker is live in a tree it touches. It carries the remote half of `drive-pr`'s +post-merge cleanup too, verifying the merged branch's tip against the pull request's `headRefOid` before +`git push origin --delete`, since taking that step from the worker without naming a new owner +would leave a live remote branch behind every group. It covers every group that is done with its tree, +which is the finished ones **and the abandoned ones**: a group told to abandon its branch keeps a +registered worktree until something removes it, and that worktree holds a live claim that would +collide with the very group the next round re-forms for the same issue. For a merged group the +procedure is deferred rather than changed: `repo-worktree`'s verify-before-removing and +prove-the-cleanup steps run unchanged, just later and in one seat. + +**Retiring a worktree and deleting its branch are two dispositions with two tests**, and citing +one for the other is how a removal that discards nothing gets routed to the maintainer, or a +removal that discards commits gets waved through. Retiring a worktree alone, the branch left +standing, risks only what is uncommitted in it: a clean tree is the whole test, the branch's own +contents do not enter it, and a tree that is not clean is left exactly as it stands while the +group goes to the maintainer per "Raising a Blocked Question". Deleting the branch as well risks what is committed, so it carries +whichever branch check the group's state calls for, `repo-worktree`'s verify-before-removing for a +group whose pull request merged and the no-merge substitute below for one whose has not. Which +check that is matters: a squash merge never makes the feature tip an ancestor of develop, so the +substitute would fail on every merged group if it were read as covering them. Every disposition in +this skill names which of the two it is. + +An abandoned group, and a dead worker's clean tree, have no merged pull request for +`repo-worktree`'s verify-before-removing step to read, so the check that step gives way to here is +what it exists to establish, that nothing unmerged is +being thrown away: confirm the branch carries no commit that is not already on develop, and that +its worktree is clean. Both hold, and the worktree and branch go the same way a merged group's do, +with no remote branch to delete where none was pushed. Either fails, and cleanup stops there and +the group goes to the maintainer per "Raising a Blocked Question", since past that point removal +discards work. A worker still live, a promotion fix included, keeps its worktree until the next +round's cleanup step. + +### Choosing the Worker's Model Tier + +`AGENTS.md` "Delegation" owns the model-tier rule. Read it there. This is only what it leaves to +judgment here: the tier is chosen per group rather than defaulted, because a stronger tier +produces better work up front and takes fewer review rounds to land it, which often costs less +than a cheaper worker looping. Three kinds of group are never tiered down: + +- One touching **carried canonical content**, as `GOVERNANCE.md` "Verification Discipline" bounds + it, since a wrong rule propagates to every carrier. +- One touching **anything `AGENTS.md` "Delegation" calls a design change**, however small the diff + looks. +- One whose issues are **complex or entangled**, where the fix depends on reasoning across several + files or on a contract not stated in the file being edited. + +State the chosen tier and its reason in the round's report. + +## Bounding the Wait on a Worker + +`AGENTS.md` "Delegation" binds this wait as it binds any other, and this section is how the bound +is met here. A worker +reports merged, parked, or stopped. A worker that reports nothing at all is the case needing a +bound, since it is indistinguishable from a slow one and dying mid-drive is ordinary here. + +The bound is a state read rather than a clock: when the other workers in the round have reported, +read the silent worker's branch and pull request directly, `git log` on that branch and +`gh pr view `, passing the branch as the positional argument that command takes, since a +bare `gh pr view` resolves the pull request of whatever branch the caller is standing on and never +the worker's. Let what they show decide. A pull request that is merged, or a branch whose work is +complete, means the worker died after doing the work and the group is finished. + +Anything else needs one thing established before anything is touched: whether that worker is gone +or merely slow. No git read answers that, and the two call for opposite actions, so the answer +comes from the dispatch mechanism itself, which knows whether the subagent it started is still +running. Nothing about the worktree is acted on while the answer is "still running", however long +that is. Waiting costs a round's latency and guessing costs another task's uncommitted work. + +Once the worker is confirmed gone, its worktree decides what follows. **A clean one** is cleaned up +as an abandoned group's is, per "Cleanup Is the Orchestrator's", which confirms the branch carries +no commit that is not already on develop before anything is removed. A worker that committed its +fix and then died leaves a clean tree standing over commits develop has never seen, so that check +is what separates the two. It holds, and the issue returns to the next round's ranking to be +dispatched fresh, its claim comment released with the worktree. It fails, and cleanup stops there +and the group goes to the maintainer, since past that point removal discards work. **A dirty one is left exactly as it stands** +and the group is stopped for the maintainer per "Raising a Blocked Question", naming the worktree +and what is uncommitted in it. The orchestrator does not commit that work, hand the tree to a +replacement to commit, or remove it, per `GOVERNANCE.md` "Repository Boundaries and Write Safety". +Where no other worker remains to bound the wait, the same liveness answer bounds it alone. + +## Raising a Blocked Question + +A group reaching a question only the maintainer can answer stops that group and nothing else. +`pr-review-conduct`'s "Escalate to the maintainer when" list is what makes a finding a question +rather than a decision. + +- **The group stops, and nothing about it is disposed of.** No thread is resolved, no finding is + answered on the orchestrator's own judgment, and no pull request merges. +- **The other groups keep driving.** One stopped group never idles the round. +- **The question travels worker to orchestrator to maintainer, and is asked when the group + stops.** A worker escalates to whoever dispatched it, per `pr-review-conduct`, since a + dispatched subagent is not the seat that can prompt anyone. The orchestrator is that seat, and + it asks then and there, per `GOVERNANCE.md` "Communicating with the User". Holding the question + for a round boundary is what that section forbids, and a boundary can be a long way off or, for + a group blocking the promotion pull request, never arrive at all. Where several groups stop + close together, their questions go in one prompt, which is batching without deferral. +- **The question is also written on its issue**, so it survives the session that asked it. +- **A stopped group keeps its branch and its claim**, and its worktree is left exactly as it + stands while the question is open, since the answer may be that the work in it continues. Say in + the prompt whether that worktree holds uncommitted work, because what becomes of it is part of + what is being asked rather than something to settle while waiting. +- **Resuming retires that worktree first, then dispatches a fresh worker.** Git refuses to attach a + branch already checked out somewhere, per `repo-worktree`, so a fresh worker cannot take the + branch while the stopped tree holds it. Once the answer is in, remove that worktree if it is + clean, or apply what the answer said about its uncommitted work and then remove it, and only then + dispatch. This is the same retire-then-dispatch shape "Bounding the Wait on a Worker" uses, and + no worker ever inherits another's tree. + +## The Promotion Boundary + +Each round ends with at most one develop -> main promotion pull request, driven to green and left +for the maintainer, so that one carries a single round rather than accumulating several. + +**This section assumes the release workflow model**, where feature work reaches develop through +squash-merged pull requests and a promotion pull request carries develop to main. A repository +whose registry `workflowModel` reads `operational` reaches develop differently, per `GOVERNANCE.md` +"Operational Repositories". Confirm with the maintainer whether a promotion pull request per round +is wanted there. + +That difference changes nothing about how this run's own work is read. Every worker invokes +`drive-pr` whatever the model, so this run's fixes still arrive as squash-merged feature pull +requests carrying the `Closes on promotion:` line, and the two hops still read them. What the +operational model adds is a second kind of commit in the same range, a direct push to develop that +never had a pull request, whose issues are recoverable only from the commit message itself. Read both, the pull requests for this +run's work and the commit messages for the direct pushes, since reading either alone returns a +partial set, and the range rather than this round is still what covers earlier work no promotion +has carried. + +1. **Open it whenever develop is ahead of main**, which `git fetch origin` and then + `git rev-list --count origin/main..origin/develop` answers, and this round's own outcome does + not. Fetch first every time: a stale remote-tracking ref reports zero and the round would report + nothing to promote while develop carries work. A round in which every group deferred or parked + can still owe a promotion pull request, for work an earlier round landed and no promotion has + yet carried. A count of zero is the only case with nothing to promote, and the round reports + that instead of attempting one. +2. Drive its review loop per the promotion half of `drive-pr` "The Drive Loop", **with a + review-round budget set before the first round**. That loop repeats until the promotion pull + request meets every `pr-review-conduct` Merge Gate item except the maintainer's explicit + permission to merge, and nothing in that loop terminates on its own, so when the budget is + reached, stop and put the state to the maintainer rather than continuing to spend the run's only + forward gear on one pull request. +3. Put the ready pull request to the maintainer, per `GOVERNANCE.md` "Communicating with the + User", with its merge as the action asked for. The maintainer's merge is the run's clock, so one + reported in a closing paragraph and never actually asked about stalls every round behind it. + Do not merge it. +4. **While it waits, develop takes only what that pull request itself needs.** A finding against + it lands as its own feature -> develop pass, and that landing moving its head is expected, since + its head **is** develop. **That pass is dispatched as a worker like any other**, which is the + one push the freeze permits and the reason the orchestrator still opens no branch of its own. + `drive-pr` "The Drive Loop" sends the seat driving a promotion pull request back through its + own feature -> develop pass for such a fix, and here that seat dispatches rather than drives it. +5. **A promotion fix outranks any file claim.** A group holding a file it needs yields, because the + promotion pull request is what the whole run is queued behind. A holder that is merely parked + yields by handing the file over. A holder that already pushed and has an open pull request + yields by having that pull request wait, its branch untouched, and by the promotion fix taking + the file, since the two must not be in flight on one file at once. Once the fix lands, that + pull request waits untouched until the freeze lifts. Reconciling its content is the next round's + worker's job rather than the orchestrator's, which opens no branch and edits nothing. The + orchestrator retires that group's worktree, the branch and its pull request left standing, + which is the worktree-only disposition "Cleanup Is the Orchestrator's" separates out and the + retire-then-dispatch shape "Raising a Blocked Question" uses, and then dispatches a fresh + worker on that same branch, briefed either to merge develop in to pick the fix up or to narrow + the change to drop the file. Never rebase it, + since its branch is already pushed and a rebase there needs what `GOVERNANCE.md` "Git and Commit + Rules" forbids. +6. **Nothing else pushes, and nothing else is dispatched.** The promotion fix of step 4 is the one + exception to both, and everything in this step is said of the next round's work rather than of + it. That round's preparation is orchestrator work and continues: rank, group, and verify claims. + Its dispatch waits, because a worker has exactly one procedure, `drive-pr`, which + pushes and opens a pull request, so a next-round worker dispatched under the freeze would either + break it or sit in a state that procedure does not describe. None is left running across the + wait either, since a worker held idle for an unbounded maintainer wait is one doing nothing at a + cost, and dispatching it after the merge starts it against the state that merge produced rather + than the state it was briefed on. +7. The merge unfreezes the run, and the prepared round dispatches then. + +The run advances no faster than the maintainer merges promotion pull requests. That is the human +gate, stated plainly rather than left for a stalled round to reveal. + +### Assembling the Promotion Body + +The body carries one `Fixes #N` per issue whose fix is on develop and not yet on main. Two hops +are needed rather than one, because the commits in `origin/main..origin/develop` are squash merges +whose subjects carry the **pull request** number and not the issue number, and this skill +deliberately keeps the closing keyword off the feature pull request, so nothing in the range names +an issue directly. Read the pull request numbers out of that range, freshly fetched, then read each +of those pull requests for its `Closes on promotion:` line, the one "Grouping and File Claims" +requires every feature pull request to carry and every worker brief to ask for. + +**That line exists because the set has to be stated rather than inferred**, distinct from any +issue a body merely mentions. A body routinely references an issue it +does not fix, the deferral issues its own review round filed most of all, and those have to stay +open as the next round's ranking input. Sweeping in everything a body mentions would close them at +the promotion merge and delete the next round's backlog, so the promotion body reads the explicit +line and never the mentions. An issue named nowhere is one nothing closes, which is a missed +closure a later round notices, where the opposite error destroys work. + +Deriving the set from the range rather than from what this round dispatched is what covers a group +that deferred or parked, contributing none, and an earlier round's work that no promotion has yet +carried. A fix landing during the freeze adds its issue to a body already written, so amend the +body when it lands rather than leaving the issue to be closed by hand. + +## Run State + +- **Working notes outside the repository hold the round**: the ranking, the working groups, the + tier choices, and the worker assignments. A scratch file the harness gives a session serves + where there is one, and any note kept out of the tree serves where there is not. It is the + in-flight session state `GOVERNANCE.md` "Durable Knowledge and Self-Improvement" describes, and + nothing about it is committed. +- **GitHub holds what outlives the session.** A claim comment records a group's file set, a pull + request body records what a round carried, a `Fixes #N` line records what the promotion closes, a + deferral issue records what was put off and why, a thread reply records how a finding was + disposed of, and a stopped group's question is a comment on its issue. +- **No tracker file is committed for the run.** A committed tracker is a file every round rewrites, + which is the contention the file-claim rule exists to prevent. + +## Ending the Run + +The run ends at either of two points, and they are different endings. + +- **The backlog is worked out**, meaning a full re-rank finds no open issue this skill can act on. + That is not the same as zero open issues, since a backlog of nothing but maintainer questions and + issues whose blockers still stand is a finished run. Report it as finished, with the questions put to the maintainer. +- **The session ends**, for a context limit or because the maintainer stops it. The run ends with + it, since the merge authorization was bounded to that session. What the rounds already landed + stands on its own in GitHub, and the branches, claim comments, and questions left behind are + what a later run reads to pick the work up. That later run is a new run, named again, not this + one continuing. + +Report at every round boundary and at either ending: what merged to develop, what the promotion pull +request carries, what was newly filed, what is stopped and on which question, what was held back and +on which blocker, and what the next round would pick. + +## Mechanics Live Elsewhere + +- The review loop, the Merge Gate, the five finding outcomes, and when a finding is a question: + `pr-review-conduct`. +- Driving one pull request, and the promotion-pull-request wrinkle: `drive-pr`. +- Worktree isolation, the base branch, and the cleanup procedure this skill re-seats: + `repo-worktree`. +- Closing keywords, branch protection, and the promotion trap: `branching-and-release-model`. +- The pre-push adversarial pass and its recorded receipt: `local-strict-review`. +- Merging the promotion pull request and dispatching a release: `merge-and-release`, invoked + separately. +- Delegation briefing shape, model-tier rules, wait discipline, and session scope: `AGENTS.md` + "Context and Delegation Discipline". diff --git a/.github/skills/branching-and-release-model/SKILL.md b/.github/skills/branching-and-release-model/SKILL.md new file mode 100644 index 0000000..c8bfac9 --- /dev/null +++ b/.github/skills/branching-and-release-model/SKILL.md @@ -0,0 +1,159 @@ +--- +name: branching-and-release-model +description: >- + Governs how a ptr727/ProjectTemplate fleet repo branches, promotes, and publishes: the feature + -> develop -> main flow, squash-only vs. merge-commit-only branch protection, the two promotion + traps (never delete develop, EOL-only conflicts), the two-phase publish model (PRs smoke-test + only, a human merge never auto-publishes), NBGV versioning, and the operational-repo delta + (direct-to-develop commits, advisory CI, dispatch-only release) that applies whenever the + registry's workflowModel reads operational. Use this whenever choosing a target branch, + promoting develop to main, resolving a promotion conflict, deciding whether a config change + needs a PR or can commit straight to develop, bumping version.json, adding or dropping a release + target, or reasoning about why a merge did or didn't publish. Triggers even when the request + sounds like ordinary git housekeeping ("just push this config fix", "merge develop into main"), + because the two workflow models genuinely differ and a step correct in one is a rule violation + in the other. This is the git policy: `workflow-ci-contract` keeps the YAML half, and performing + a promotion merge or a release dispatch is `merge-and-release`, which wins where both fire. +--- + +# Branching and Release Model + +## Why this exists + +Two workflow models exist because the underlying repos are two different things. Most fleet repos +ship versioned units of delivery, so they earn a feature -> `develop` -> `main` flow with real +release gates. A handful of repos instead track a live service's running state (Home Assistant, +ESPHome, Vantage, home automation configs) where the "release" is the config already committed, +not something built and shipped later. Applying the release model's ceremony to an operational +repo, or skipping the release model's gates on a repo that actually ships versioned artifacts, is +each wrong in its own repo and correct in the other, which is why this is one skill keyed on which +repo you're in rather than two skills that never talk to each other. + +## Which model this repo uses + +Read the registry `workflowModel` field for this repo (`release`, the default, or `operational`). +The rest of this skill's "Branching" and "Publishing" sections describe the `release` model. The +"Operational repositories" section below is the complete delta for `operational` repos. Anything +not mentioned there is unchanged. When in doubt which one applies, check `registry/repos.json` +rather than guessing from the repo's contents. + +## Branching (release model) + +- **GitHub's repository setting for "default branch" reads `main`, but `develop` is where work starts and where in-flight content lives.** A worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content that has merged to `develop` but not yet been promoted. Before branching off a change, or asserting something absent from this repo, check `develop`, not just whichever branch a tool defaulted to. See GOVERNANCE.md "Verification Discipline" on naming the branch a "does not exist" claim was checked against, and the `repo-worktree` skill, which owns the worktree-creation moment this base-branch choice is made at. +- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**, which + keeps `develop` linear. +- `develop -> main` is **merge-commit only** (no squash, no rebase). Merge commits preserve + `develop`'s commit list as a real second-parent reference on `main`, which lets the release + model attribute releases to the develop commits that produced them. Branch protection enforces + this: the `develop` ruleset allows only `squash`, the `main` ruleset allows only `merge`. +- All commits on both branches must be cryptographically signed (SSH or GPG), see + `git-commit-conventions`. Squash and merge commits created via the GitHub UI are signed by + GitHub's web-flow key. +- **`develop` is forward-only, with no `main -> develop` back-merges.** The `develop` ruleset's + squash-only setting physically blocks merge commits on `develop`. Any historical back-merge + commits in `git log` predate this rule and must not be repeated. +- **Never delete `develop`, and take the EOL-only conflict by taking develop's side.** A + promotion PR's head *is* `develop`, so `--delete-branch` deletes it. An EOL-only conflict on a + workflow YAML file resolves on a throwaway branch off `main`, not on `develop`. Full recovery and + conflict-resolution commands: `references/branch-protection-and-promotion.md`. +- **A merge or release ends with worktree cleanup and the base clone on current `develop`.** Run the `repo-worktree` post-merge procedure after a feature squash merge. Run it again after a promotion or release completes, unless the user explicitly asks to retain a checkout or branch. Remove finished task, conflict-resolution, installer, and release helpers. Never delete `develop`, and never leave the base clone on `main` merely because `main` was promoted or released. +- **Issue-closing keywords (`Closes #N`, `Fixes #N`) go in the `develop -> main` promotion PR, not + the feature -> `develop` PR.** GitHub auto-closes an issue only when the closing keyword merges + into the **default branch** (`main`), so a feature -> `develop` PR merge never fires it. + Reference the issue in the `develop` PR body if useful, but the actual closing keyword belongs on + the promotion PR. Closing by hand is the ordinary route wherever the keyword cannot fire (a + promotion that already merged without it, or completed work with no promotion imminent), not a + repair for a botched promotion, cite the squash SHA and re-read that commit before closing. +- **Neither ruleset requires branches to be up to date before merging**, for different reasons on + each branch (a graph-based check that would fail every release on `main`, a check that stalls + bot auto-merge on `develop`). Detail: `references/branch-protection-and-promotion.md`. +- **Configuring branch protection: import the committed ruleset payloads, don't hand-build them.** + Exactly two rulesets, named `develop` and `main`. Full procedure, including the operational + `develop` payload and the brownfield-repo signing caveat: + `references/branch-protection-and-promotion.md`. +- **Dependabot and codegen target both `main` and `develop` in parallel**, each branch absorbing + its own bot PRs independently so neither falls behind, with the merge-bot dispatching the merge + form (`--squash`/`--merge`) that matches each PR's base ruleset. Codegen output must be + deterministic from its inputs alone, never per-run state, or the two branches' legs conflict on + every promotion. Full mechanics: `references/branch-protection-and-promotion.md`. +- **App-token workflows authenticate with Client ID, not the deprecated App ID.** Use + `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}` at any new App-token call site. + +## Publishing (release model) + +- **The two-phase model is the default: PRs build fast, publishing is batched.** A PR only + smoke-tests (unit tests plus a reduced build of the changed targets), it never pushes anything. + `publish-release.yml` is the sole publisher, and each run builds a **single trigger branch** + (`main` a release, `develop` a prerelease). +- **A human merge never auto-publishes.** Publishing fires on a **`workflow_dispatch`** of + `main`/`develop` (a human-initiated release), a **code-affecting bot push to `main`** (the + codegen App merging a Dependabot/codegen PR, gated on `github.actor` so a human + merge/promotion skips it), or a **weekly `schedule`** (Docker only, to refresh the base image). + A source-only repo publishes on dispatch only. +- **The changes-detection job is a required check that must succeed, not just not fail.** A + paths-filter error must never let a target-changing PR merge with its smoke build silently + skipped. A skipped smoke job (no matching change) passes, `failure`/`cancelled` blocks. +- **Versioning is semantic and maintainer-controlled.** `version.json`'s `major.minor` is the + version floor, edited by the maintainer for functional changes only, in the PR that introduces + the work, never on a fixed cadence or mechanically after a release. NBGV appends the git height + automatically on every commit, so a release always gets a fresh build version with **no + post-release bump** and no develop-ahead requirement. +- **Docs reference the 2-digit `major.minor` line, never a 3-digit build.** `README.md`, + `HISTORY.md`, and release notes name the version as `Version 1.0` (the floor), never the concrete + build height, which is both wrong (the real height differs) and a maintenance trap. + "Correcting" `1.0` to `1.0.0` is a defect. +- **A no-op publish on a schedule or push trigger (unchanged NBGV `SemVer2`) re-pushes nothing to + any target keyed on the version string, except Docker, which always re-pushes** (a dispatch + refreshes the release instead of skipping) to pick up upstream base-image + refreshes. Full guarantee and the `version.json` `pathFilters` boundary: + `references/release-publish-mechanics.md`. +- **A package push can fail after the release is already cut**, since it runs after the release + task and no gate covers it. A full re-run is always available inside its bounded + window and is the only route once the branch tip has moved: + `references/release-publish-mechanics.md`. +- **Adding, dropping, or wiring a release target** (which leaf task, which artifact-naming + contract, which seam a given output belongs to: a GitHub Release asset, a package-registry push, + an image-registry push, a filesystem deploy, or a source-only repo with no build layer at all), + and tracking an upstream release from a wrapper repo: `references/release-publish-mechanics.md`. + See also `WORKFLOW.md` for the full CI/CD contract this section's rules are load-bearing + excerpts of. + +## Operational repositories (the complete delta) + +Everything above is the `release` model. An `operational` repo (registry `workflowModel: +operational`) tracks a live service's running state rather than shipping versioned units of +delivery, and differs from the `release` model in exactly these ways, everything not listed here +stays the same: + +- **Commit configuration directly to `develop`.** There is no feature branch requirement, the + maintainer commits straight to `develop`, and only *occasionally* opens a `develop -> main` PR to + bless a known-good snapshot. The `develop` ruleset drops the PR and status-check gate, so direct + signed pushes are allowed (force-push, deletion, and unsigned commits are still blocked), and CI + runs on the push as **advisory** feedback that never rejects a commit. +- **A PR into `develop` stays available, and CI runs on it, reported but not required.** Dropping + the requirement permits the direct push, it does not withdraw the pull request, so a change worth + reviewing takes one and both paths into `develop` are legitimate. +- **Take the pull request whenever the change is not one a reader takes in at a glance and + reverts cleanly.** What decides it is the shape of the change, not a line count: restructuring + rather than adjusting a value, touching several files at once, introducing a device, an + integration, or an automation that did not exist before, and anything whose failure shows up on + the live service rather than in a lint run are each the pull request case. So is a change the + author cannot state in one sentence. This stays a judgment call by design, adding a + `pull_request` rule to the operational `develop` ruleset would gate the direct push too and + withdraw the allowance the model exists to give. +- **The `main` promotion gate is unchanged.** The shared `main` ruleset still **enforces** the + required `Check pull request workflow status job` on the `develop -> main` PR. For an operational + repo that check is lint/validation only (editorconfig/EOL plus a domain linter such as a Home + Assistant or ESPHome config validation, never unit tests), so `develop` stays the live surface + and a broken config can never reach `main`. +- **Release only by manual dispatch.** Operational repos carry `releaseTrigger: dispatch-only` and + run no codegen or auto-publish bots, publishing **only** on a manual `workflow_dispatch` (the + same source-only release the publisher already supports: tag, source zip, README, LICENSE, + NBGV-versioned), never automatically. The `develop -> main` promotion just blesses a known-good + snapshot, a release is a separate, deliberate dispatch. +- **Fleet sync still applies.** Dependabot's dual-target sync and the App-signed merge-bot run on + **every** tier, operational included, so both branches stay in sync and a promotion stays a + clean forward merge. +- **Line-ending policy differs too**, following the consuming app's native platform rather than the + fleet LF default, per the registry `lineEndings` field. That rule belongs to + `comment-and-doc-style`, not repeated here. diff --git a/.github/skills/branching-and-release-model/references/branch-protection-and-promotion.md b/.github/skills/branching-and-release-model/references/branch-protection-and-promotion.md new file mode 100644 index 0000000..9ca2276 --- /dev/null +++ b/.github/skills/branching-and-release-model/references/branch-protection-and-promotion.md @@ -0,0 +1,110 @@ +# Branch Protection and Promotion Mechanics + +Full detail for the "Branching" rules in `SKILL.md`. Load this when configuring or reconstructing +branch protection on a fleet repo, executing a `develop -> main` promotion, recovering a lost +`develop`, resolving an EOL-only promotion conflict, or working on the dual-target bot wiring +(Dependabot, codegen, the merge-bot), not for an ordinary feature-branch PR (the SKILL.md summary +covers that case). + +## Configuring branch protection: don't hand-build the rules + +Delete **all** classic branch-protection rules and stray rulesets because rulesets are the only +protection mechanism. From a hub checkout at `main`, create **exactly two rulesets named `develop` +and `main`** from the hub's `repo-config/*.json` payloads. Run +`repo-config/configure.sh apply / release|operational` from that checkout. The names +are load-bearing because governance content and workflows reference them. The registry +`workflowModel` selects the `develop` payload for a registered repository. Pass the model +explicitly for a repository outside the registry. + +## Executing a `develop -> main` promotion safely + +Two traps, both learned the hard way: + +- **Never delete `develop`.** A promotion PR's head *is* `develop`, so `gh pr merge --delete-branch` + (and a repository's "Automatically delete head branches" toggle, which the fleet keeps off for + exactly this reason) deletes `develop` itself. Merge a promotion + with a plain `gh pr merge --merge`, no `--delete-branch`. If `develop` is ever lost this way, + restore it to the merged PR's head SHA, which is still reachable as the merge commit's second parent: + `gh api -X POST "repos///git/refs" -f ref=refs/heads/develop -f sha="$(gh pr view --json headRefOid --jq .headRefOid)"`. +- **Spurious EOL-only conflicts resolve by taking `develop`.** When `develop`'s `.editorconfig` + line-ending default has changed (for example the fleet-wide CRLF-to-LF flip) while `main` hasn't + caught up yet, `develop -> main` conflicts *whole-file* on every renormalized path. + `develop`'s `required_linear_history` plus PR rulesets forbid resolving on `develop` (no merge + commit, no force-push), so resolve on a throwaway branch off `main`: + `git checkout -b promote/develop-to-main origin/main && git merge origin/develop`, take + `develop`'s side for the EOL-conflicted files (`git checkout --theirs `) **after + confirming each is content-identical modulo EOL, or that `develop` is a strict superset** + (`diff <(git show ":2:" | tr -d '\r') <(git show ":3:" | tr -d '\r')`), then open that branch into + `main`. Verify no genuine `main`-only content is dropped (build/test where the repo supports it). + +## Why both rulesets omit "Require branches to be up to date before merging" + +The flag is off on `main` and on `develop`, for related but distinct reasons. + +- **Main**: the check is graph-based, it asks whether `main`'s tip commit is reachable from + `develop`, not whether the two branches have the same content. After any `develop -> main` + release, `main`'s tip is a brand-new merge commit that `develop`'s history doesn't contain. + Forward-only `develop` never adds it (no back-merge of `main` into `develop`), so the check + would fail on every subsequent release. Other technical workarounds (rebasing `develop` onto + `main`, or rewriting `develop`'s history) exist but contradict the squash-only `develop` ruleset + and the linearity invariant. +- **Develop**: the check stalls bot auto-merge when two bot PRs against `develop` land within the + same window. As soon as the first merges, the second flips to `mergeStateStatus: BEHIND` and + GitHub's auto-merge will not fire while strict is on. The merge-bot only *enables* auto-merge on + `opened`/`reopened` and never auto-updates bot branches, and Dependabot's rebase isn't real-time, + so the second PR sits OPEN with all checks green indefinitely. Squash mechanics still rebase the + diff onto `develop`'s tip on merge, `required_linear_history` still enforces linearity, textual + conflicts still block `mergeable: CONFLICTING`, and the required `Check pull request workflow + status job` still gates merges. The only thing lost is pre-merge detection of + *semantic-but-not-textual* conflicts, which the post-merge `develop` CI run catches anyway. + +## Dual-target bots + +**Dependabot and codegen target both `main` and `develop` in parallel.** +`.github/dependabot.yml` duplicates every ecosystem entry (one per branch) and the codegen +workflow runs as a matrix over both branches with branch names `codegen-main` and +`codegen-develop`. Each branch absorbs its own bot PRs independently, so neither falls behind, and +the forward-only rule still holds, nothing is back-merged from `main` to `develop`, both branches +receive their updates directly. The merge-bot (`.github/workflows/merge-bot-pull-request.yml`) +dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form +matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against +the repo default branch (`main`) regardless of `target-branch`, and the same `case` statement +covers them. The merge-bot auto-merges **every** Dependabot tier including semver-major (no +ecosystem or update-type guard), the required CI checks are the gate, not the bump magnitude, so a +major that breaks the build fails its checks and never merges. + +**Why parallel dual-target rather than develop-only with eventual flow-through:** +push-distribution channels (HACS for Home Assistant integrations, Linux distros that vendor from +`main`, etc.) consume `main` directly. A develop-only model would leave `main` running stale code +during long-running develop features. Codegen content can also be production-critical (live +API-derived data, language lists, build catalogs) rather than just sample/demo content, so both +branches need fresh codegen on their own cadence. + +**Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's +`merge-dependabot` and `merge-codegen` jobs only fire on `opened`/`reopened` events (auto-merge is +enabled exactly once per PR). When a maintainer pushes commits to a bot's branch (a `synchronize` +event with an actor that isn't the same bot), the merge-bot's +`disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`. The +maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable +auto-merge manually (`gh pr merge --auto ` or the GitHub UI) when ready. + +## Codegen determinism + +The codegen workflow is a mechanism to refresh files that are checked into the repo: it runs a +matrix over `main` and `develop`, each leg regenerating against its own checkout and opening its +own PR (`codegen-main -> main`, `codegen-develop -> develop`). For the two legs not to conflict on +`develop -> main`, the generated output must depend only on its inputs, never on per-invocation +state (timestamps, GUIDs, build IDs), which would diverge every run and conflict on every release. +**What** a repo regenerates (data files, source, or both) and **how** (download and process an +external source, transform local inputs, whatever) is entirely its own concern. The constraint is +only that the output be input-deterministic, not how it is produced. A repo adopting codegen +supplies its own input-deterministic generator and wires the codegen reference workflow +(`run-codegen-pull-request-task.yml` and its scheduler). + +## App-token workflows use Client ID, not App ID + +`actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0. Use +`client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}`. When adding new App-token call sites, use the +same form, and do not reintroduce `app-id` / `CODEGEN_APP_ID`. A mechanism needing a secret this +repository does not hold is a configuration question for the maintainer rather than something to +work around in the workflow. diff --git a/.github/skills/branching-and-release-model/references/release-publish-mechanics.md b/.github/skills/branching-and-release-model/references/release-publish-mechanics.md new file mode 100644 index 0000000..fb6080c --- /dev/null +++ b/.github/skills/branching-and-release-model/references/release-publish-mechanics.md @@ -0,0 +1,144 @@ +# Release Build and Publish Mechanics + +Full detail for the "Publishing" rules in `SKILL.md`. Load this when adding or removing a release +target, wiring a new leaf build task, deciding where a build output belongs (a GitHub Release +asset, a package-registry push, an image push, a deploy), recovering a package push that failed +after the release was already cut, or setting up a wrapper repo that tracks an upstream release, +not for reading the release model's shape (the SKILL.md summary covers that). + +## Reusable-task parameter contract + +Every `build-*-task.yml` and `build-release-task.yml` takes `ref` (git ref to check out/version), +`branch` (logical branch driving config/tags/prerelease, where `main` => Release/`latest`/ +non-prerelease, else Debug/`develop`/prerelease), and where relevant `smoke`. +**Branch-derived config keys off `inputs.branch`**: each run builds one branch, and the top-level +publisher passes `branch: ${{ github.ref_name }}`, which the tasks forward and read as +`inputs.branch` (not `github.ref_name`) for config/tags/prerelease. `get-version-task.yml` takes a +`ref` so NBGV versions the right branch. + +## Per-target subsetting + +`build-release-task.yml` is a hub-hosted task with per-target `enable_*` inputs, so a repo drops a +target by setting its `enable_: false` at the caller stub rather than deleting a job: the +hub task carries the full job graph for every repo, and the caller stub's `with:` block is where +the target list is expressed. A repo still curates, in `test-pull-request.yml`, its +path-filter entry, that filter's output, and the `smoke-build` enable-forward, all three together +per D6.4, since an entry nothing consumes never smoke-builds the target. And, for a package +target, the `publish-nuget` or `publish-pypi` job in its own `publish-release.yml`, since +`id-token: write` belongs at that one entry point. CodeGen, versioning, merge-bot, and Dependabot +are target-agnostic. + +## Orchestration vs. build: the override seam + +The split between the generic **orchestration** layer and the repo-owned **build** layer, the +`release-asset--` pattern handoff that keeps the seam between them clean, and +what a repo curates when it adds or drops a target are `WORKFLOW.md` section 3's, under "Two +Layers: Orchestration vs Build" and "The Seam Contract", which the `workflow-ci-contract` skill +carries whole as its `references/architecture.md`. A caller of the hub-hosted +`build-release-task.yml` also sets the inputs its enabled targets need, `docker_image` and the +project-path inputs among them. + +## Map your outputs to the right seam + +Pick by where each artifact *goes*, not by language: + +- **Files attached to the GitHub Release** (zips, binaries, packaged libraries): a dotnet-publish + hook or a build-nuget hook per output, each uploading `release-asset--`. This is where the + .NET `dotnet publish` or `dotnet build` lives, though a package push does not. The hub default takes an explicit + project path, and a project needing different build behavior replaces the hook. A data-only + repo's own output (e.g. a symbol library) is not yet + expressible as a hub hook or an `enable_*` input, so it stays a carried leaf until the hub task + grows one. +- **Package-registry pushes** (NuGet.org, PyPI): both are split, and the push never sits in the + hook. OIDC trusted publishing validates the token's `job_workflow_ref` claim, which names the + workflow the job actually ran from, so a push from a hub-hosted task is rejected at the token + exchange, NuGet.org answering `HTTP 401` and PyPI under its own code. And because D7.2 has a callee + declare `permissions:` only where every caller grants that scope at startup, the release task's + jobs declare none and run under the calling job's whole grant, so a push anywhere inside that + task would put `id-token: write` on every job in it. The + build-nuget hook uploads a `nuget-build-` artifact for a separate `publish-nuget` job in + the caller's own `publish-release.yml`, which authenticates through `NuGet/login` and therefore + carries `id-token: write` (plus `actions: write` to delete the artifact it consumed), *and* also + uploads a `release-asset-*` (.7z) for the GitHub release. PyPI is the same shape: the build-pypi hook only builds and uploads the + `pypi-build-` artifact, and the separate `publish-pypi` job in the caller's own + `publish-release.yml` does the OIDC Trusted-Publishing upload, behind an `environment: pypi` + gate and with `skip-existing: true` (`id-token: write` is granted only at that one entry + point), and PyPI contributes **no** `release-asset-*`. +- **Image-registry pushes** (Docker Hub): `build-docker-task.yml`, hub-hosted like + `build-release-task.yml`, pushes the default branch multi-arch (amd64+arm64) and any other + branch `amd64`-only, and contributes **no** `release-asset-*`. The image set comes from a docker-prepare hook (the hub default emits the + single vanilla entry an `image` input implies). A multi-image or upstream-pinned repo carries its + own hook, and a shared base layer comes from a required docker-build-base hook with no hub + default. To publish the Docker Hub repository overview, the hub-hosted `publish-docker-readme-task.yml` + pushes a readme via `peter-evans/dockerhub-description` (single-repo by default, matrix per + image for multi-image repos), wired into `publish-release.yml` and gated to `main` both by the + caller's `branch` input and inside the task itself. A `docker-readme-transform` hook sets a + `readme-filepath` step output naming which file to push, defaulting to `Docker/README.md` if + present else `README.md` as-is, so a repo needs a hook only to render the file first or to + override that default. +- **Filesystem on a host the project owns** (a static site, a config tree): a deploy leaf builds + the tree and ships it over the repo's own transport, contributing **no** `release-asset-*`. It + is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit + mints no tag, and its credentials come from a **per-environment GitHub Environment** rather than + the repository secret store. Its last step asserts what the host actually serves, the release id + and the environment, never that the transport exited zero. Retention at the destination is + bounded by a declared count, and one side is recorded as owning the prune: the deploy where its + credential can observe the destination, the host where that credential is deliberately + write-only. +- **Source-only / no build** (validate + tag + release): the repo has no leaf build tasks. + Its dispatch-only `publish-release.yml` calls the hub-hosted `build-release-task.yml` after the repo's reusable validation task succeeds. + The caller sets `github: true`, every `enable_*` input to false, and `expect_release_assets: false`. + The reusable task runs NBGV and creates the release with the tag, automatic source archive, README, and LICENSE. + +`get-version-task.yml` installs the .NET SDK only because NBGV needs the runtime to compute the +version/tag, which is heavyweight but expected even for a non-.NET repo, and acceptable as-is. + +## No-op republish guarantee + +A scheduled or push publish where NBGV `SemVer2` is **unchanged** (no new commit since the last +publish) re-pushes **nothing** to GitHub Releases (the `github-release` job's `release-exists` +check skips the create step, and a dispatch refreshes the release instead of skipping), NuGet (`dotnet nuget push --skip-duplicate`), or PyPI +(`gh-action-pypi-publish` `skip-existing: true`), since all three key on the version string. +**Docker always re-pushes** by design: it picks up upstream base-image refreshes (e.g. +`ubuntu:rolling`) that aren't visible in the repo. Boundary: `version.json` has **no +`pathFilters`**, so *any* commit, including a CI/workflow-only or docs-only change, advances the +NBGV git height and therefore `SemVer2`, and the next publish *does* create a fresh release for it +even when the shipped binary is byte-identical. This is accepted NBGV behavior, and `pathFilters` +are intentionally not added. + +## Recovering a failed registry push + +A package publish job is gated like everything else, `needs:` the release-task call, so a failed build skips it. The **push inside it** is what no gate can reach, because it runs after the whole release task and therefore after `github-release`. `WORKFLOW.md` D4.5 names the two recovery routes and leaves their mechanics here. A rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window, and a re-dispatch only while the branch tip has not moved**, so the tip decides whether there is a choice at all rather than which route to take. What re-dispatch buys, where it is available, is that it outlives the re-run window. + +**Re-dispatch, available only while the tip has not moved.** A `workflow_dispatch` takes a ref rather than a commit, and D2.3 admits only `main` or `develop`, so what it builds is that branch's tip at dispatch time. While the tip is still the commit whose push failed, a re-dispatch rebuilds the same version and runs its push again, refreshing the release the way any dispatch does. + +This is a time-of-check-to-time-of-use race rather than a guarded operation: nothing compares the tip against the failed run, so a push landing between the two mints a new version instead of erroring, and the operator sees a green publish that left the failed version unpublished. Confirm the failed run's own head commit still equals the branch tip immediately before dispatching, reading it as `gh run view --json headSha` against `gh api repos/{owner}/{repo}/branches/` for the branch that run built rather than whichever branch is to hand. Where the two differ, or where the check is not worth making, prefer the re-run route, which is bound to that commit by construction, and fall back to re-dispatch only once the re-run window below has closed. + +**Re-run all jobs, available inside the window whatever the tip has done.** `gh run rerun ` replays the run under the original event's `GITHUB_SHA` and `GITHUB_REF` and re-executes every job rather than only the failed ones. The publisher pins the release task to that commit with `ref: ${{ github.sha }}`, so `get-version` recomputes the same version from the same commit and history, each build leaf checks out the `GitCommitId` that job emits, the package artifact D5.2 deleted is rebuilt and re-uploaded rather than missing when `publish-` downloads it, and that job retries the push it failed. The release itself needs nothing from the re-run, the failed run having already cut it, though on a dispatch-triggered run the re-run re-enters `github-release`, which refreshes the release per D4.4's dispatch leg and runs the `release-asset-*` delete with it per D5.2. A re-dispatch here would build the new tip instead, and NBGV derives the version from git height, so that is a further version and the one whose push failed never reaches the registry. + +Three qualifications come with the re-run route. + +- D4.4 and `WORKFLOW.md` 5B's S9 describe a re-run whose predecessor push **succeeded**, where the registry dedupes the second one. This is the case they do not cover, and its retried push is the first the registry ever receives for that version. +- GitHub offers a re-run only within **30 days** of the initial run, and a repository's own **log** retention setting can be shorter, so the usable window is the shorter of the two. This is the run's own retention and is unrelated to D5.4's `retention-days: 1`, which bounds an uploaded artifact rather than the run. +- **Re-run failed jobs** (`--failed`) does not serve here. D5.2's delete runs on the path that reaches this case, its gate being `!cancelled()` and the download having succeeded, so it has already removed the package artifact a `--failed` re-run would download, and only the full re-run rebuilds it. + +Past the window, a moved tip leaves that version with no route to the registry. The release and tag already name it, and removing them is not the answer: leave them, and let the next publish carry a later version, recording the gap in `HISTORY.md`, since the release body is regenerated on any later dispatch refresh and cannot hold the record. + +What no route settles in advance is whether the registry accepts the retried push. + +## Wrapper repos that track an upstream release + +A repo wrapping an upstream release uses the hub-hosted `check-upstream-version-task.yml`: a +required `resolve-upstream` hook sets a `versions` step output, a **JSON object of +`name -> version`**, written to a committed state file at the **repo root beside `version.json`** +(default `upstream-version.json`, since it is a build-input version source, not GitHub-platform +config, so it does not belong under `.github/`), and opens a rolling App-signed bump PR per branch +that the merge-bot auto-merges (`merge-upstream-version`). The object carries one key for the +common single-version case (`{"version": "X"}`) or N keys for a wrapper that pins several upstream +components (e.g. an image plus a companion tool), and the build reads each component by key, and +the bump PR's title/body name only the keys that actually moved. Call it from a scheduled +entry-point workflow and matrix only the branches that ship the version (a CI-only version uses +`["develop"]`). A merged bump ships on the **next publish**, not immediately, which is the +two-phase latency tradeoff. A tracker whose bump needs a human decision instead of auto-merge, for +example one that snapshots a package list to review rather than a version to adopt outright, sets +`auto-merge: false`, which prefixes the head so no merge-bot rule matches it. diff --git a/.github/skills/carried-instruction-file-guard/SKILL.md b/.github/skills/carried-instruction-file-guard/SKILL.md new file mode 100644 index 0000000..c618722 --- /dev/null +++ b/.github/skills/carried-instruction-file-guard/SKILL.md @@ -0,0 +1,42 @@ +--- +name: carried-instruction-file-guard +description: >- + Stops a blind overwrite of a downstream repo's AGENTS.md, GOVERNANCE.md, CODESTYLE.md, or + WORKFLOW.md when resyncing or updating it to match the ptr727/ProjectTemplate hub template. Use + this whenever about to edit, replace, re-vendor, or sync-to-match-the-hub any of those four + files in a repository that is not ProjectTemplate itself, or whenever asked to bring a repo's + instruction set up to date, run a conformance sweep that applies fixes, or fix drift against the + hub. Triggers even when the request sounds routine, such as copying the hub's AGENTS.md over or + resyncing a repo's docs, because that phrasing is exactly how a real incident happened, where a + downstream repo's local rules were silently deleted by a full-file overwrite. Do not skip this + just because the task looks mechanical. It applies only when a write is about to happen, so a + read-only `audit-a-repo` run does not fire it, it co-fires with `resync-a-repo` rather than + replacing it, `check-this-repo` never writes these files and escalates instead, and + `.github/copilot-instructions.md` is `copilot-instructions-keeper`'s. +--- + +# Carried Instruction File Guard + +## Why this exists + +A downstream repo's `AGENTS.md`/`GOVERNANCE.md`/`CODESTYLE.md`/`WORKFLOW.md` can hold two different kinds of content mixed in one file: sections that are stale copies of the hub's fleet-wide rules, and local rules the repo wrote for a fault the fleet has never seen elsewhere. Re-vendoring the hub's canonical version over the whole file deletes the second kind silently, because nothing about the diff looks wrong. This has actually happened: a resync replaced a repo's `AGENTS.md` wholesale with the hub's, and the repo's own local additions were gone with no error, no warning, and no review comment calling it out. + +The fix is not "be careful." Being careful is what failed the first time. The fix is a mechanical check you run before any overwrite touches one of these four files, every time, regardless of how routine the request sounds. + +## Before you touch any of these four files + +1. **Check whether the file's content is declared `verbatim` or `intent`.** The hub's `spec/section-model.md` (fetch it from a hub checkout, `github.com/ptr727/ProjectTemplate`, if you don't have one) names, section by section, which parts of `AGENTS.md` and `GOVERNANCE.md` are universal fleet law (safe to byte-match against the hub) and which describe the repo itself (never safe to overwrite from another repo). `CODESTYLE.md` and `WORKFLOW.md` are carried whole at `intent` fidelity, judged by meaning, not hashed. +2. **If any part of the file is `intent`, or if the file predates a clean split into hub-governed sections, do not diff-and-replace. Probe instead.** For each rule or paragraph in the current file that is not obviously boilerplate: + - Pick the phrase in it that is most peculiar to this repo, not generic governance vocabulary. A rule about "always sign commits" is generic. A rule about "this repo's Docker image pins Alpine 3.19 because 3.20 broke the s6 supervisor" is peculiar. + - Grep the hub's canonical copy of the same file for that peculiar phrase. + - **Absent from the hub canonical means it is a local addition.** It is never dropped because it looks similar to something else, and never dropped because a merge or overwrite would be simpler without it. +3. **A local addition found by the probe gets a destination, not a deletion.** Either it names a rule that should apply fleet-wide (flag it for the maintainer to promote into the hub), or it is genuinely specific to this repo and moves to the repo's own topical doc before the carried file is touched: `CODESTYLE.md` for a language/formatting convention, `ARCHITECTURE.md` for a design decision, `OPERATIONS.md` for a runbook or operational note, `TODO.md` for backlog. Move it, confirm it is not lost, and only then proceed with the carry. +4. **Do not trust a similarity or word-overlap check for step 2.** A repo-specific rule written in ordinary governance language reads as a reworded duplicate of an unrelated hub rule to that kind of check, and it will confidently tell you the local content is redundant when it is not. Exact phrase presence or absence is the only check that has held up. + +## What is actually safe to overwrite without this procedure + +A section `spec/section-model.md` names as `verbatim`, in a file that is already cleanly split (the file carries only that declared section, nothing else mixed in), can be re-vendored directly: byte-matching it against the hub canonical is the point of `verbatim` fidelity, and the audit already checks it that way. The guard above is for everything else: `intent`-fidelity content, a file that has not been split yet, or any file you are not certain is clean. + +## If you are not sure which case you are in + +Stop and say so, rather than guessing. Naming the uncertainty costs one sentence. Silently overwriting the wrong thing costs someone's local rules with no way to notice until much later. diff --git a/.github/skills/check-this-repo/SKILL.md b/.github/skills/check-this-repo/SKILL.md new file mode 100644 index 0000000..f16338b --- /dev/null +++ b/.github/skills/check-this-repo/SKILL.md @@ -0,0 +1,80 @@ +--- +name: check-this-repo +description: >- + Checks, from inside a downstream repo's own session, whether this repo and this machine are + current against the ptr727/ProjectTemplate hub, and safely self-applies what it can. Use this + whenever asked to check if this repo is up to date with the hub, whenever a fleet rule or Skill + seems to not be applying and the cause is unclear, or whenever about to work in a fleet repo and + wanting to confirm the ground under that work is current before trusting it. Needs no standing + hub checkout of its own and no named target repo, only the repo the session is already in, + though the check itself fetches a hub checkout to reach scripts/skills_install.py, since + scripts/ is hub-hosted rather than carried. This is the counterpart to resync-a-repo, which + needs both a hub checkout already in hand and a named external target to drive change from the + hub side instead. Also triggers on "why do I have to keep restating this rule every session," + since a stale or missing Skills install is the most common cause and the cheapest one to rule + out first. +--- + +# Check This Repo + +## Why this exists + +A downstream repo today only finds out it has drifted when someone runs an audit or a resync +against it. Nothing notices from the inside on its own. This skill is that inside check, +run with no hub-side operator watching, so a stale Skills install or an out-of-date `AGENTS.md` +pointer gets noticed and fixed without waiting for a fleet-wide sweep to reach this particular +repo. + +## What it checks + +1. **Is the Skills install current on this machine.** `scripts/` is hub-hosted and reached rather + than carried, per GOVERNANCE.md "Hub-Hosted Tooling", so fetch a hub checkout + (`github.com/ptr727/ProjectTemplate`, `main` branch, fetched fresh) and run + `python3 scripts/skills_install.py --report` from it. A snapshot not current, or no stamp, is very often + the direct answer to "why isn't a fleet rule applying": the harness never loaded the current + content in the first place, and no amount of re-reading `GOVERNANCE.md` fixes that. For a + Claude Code session, read `live` as well, since that channel loads the registered checkout in + place rather than the copy: a checkout that is missing, detached, or on an old branch is an + answer there whatever the exit code says, and moving that checkout is the fix rather than + re-installing. +2. **Does this repo's own carried content still match the hub.** Compare `AGENTS.md`'s + "Where the Rules Live" pointer text, and any other verbatim `AGENTS.md`/`GOVERNANCE.md` section + this repo carries, against the same hub checkout's current wording, by reading the text rather + than by feel. + +## What it is safe to fix on its own + +- **Re-run the installer**, `python3 scripts/skills_install.py`, from that same `main` checkout, + when `--report` exits non-zero. + This is a per-machine, local-only change, nothing in it touches this repo's git history or + needs a review. + +Nothing else. This skill never re-vendors a carried file, never deletes one, and never applies a +setting or ruleset. Converging that drift is a resync, a separate change on its own branch, run +per the hub's `RESYNC.md` by this repo's own session or by `resync-a-repo` from a hub checkout. + +## Refresh cadence + +Re-run the installer from a hub checkout on a freshly fetched `main` when `--report` exits +non-zero, and after any promotion to `main` that touches `.agents/skills/`. A copy taken from +`develop` reads not current by design, since the snapshot is judged against the promoted +revision. Session entry runs no automatic check, by design: the trigger is suspicion, +and the restated-rule symptom below is the loudest form of it. `docs/host-setup.md` +"Fleet Skills Install" in the hub states the same cadence for the host side, and an automated +refresh stays out of scope until the fleet has evidence the manual cadence fails. + +## What it escalates instead of touching + +- **A carried section that differs from the hub in a way that reads as a genuine local addition** + rather than plain staleness, the exact case `carried-instruction-file-guard` exists to protect. + Report precisely what differs and stop there, naming a resync as the next step, where + `carried-instruction-file-guard` decides the merge. +- **Anything the installer alone cannot resolve**, a broken `claude` CLI marketplace + registration, a settings or ruleset drift, a workflow interface mismatch. Name it and hand it to + the maintainer or a resync per the hub's `RESYNC.md` rather than patching around it locally. + +## Answering "why isn't a fleet rule applying" + +Check the install stamp first, before assuming a Skill's description is worded wrong or that the +rule was never carried to this repo at all. It is the most common cause, and it is the cheapest +one to confirm. diff --git a/.github/skills/comment-and-doc-style/SKILL.md b/.github/skills/comment-and-doc-style/SKILL.md new file mode 100644 index 0000000..7c6f9d6 --- /dev/null +++ b/.github/skills/comment-and-doc-style/SKILL.md @@ -0,0 +1,374 @@ +--- +name: comment-and-doc-style +description: >- + Governs prose, comment, Markdown, character-set, line-ending, and PR-title/commit-message + conventions for every ptr727/ProjectTemplate fleet repo. Use this whenever writing or editing a + code comment, workflow comment, Markdown doc, commit message, or PR title, whenever choosing + which characters to type in agent-authored text, whenever the file being edited is CRLF, and + whenever naming a tool in prose or docs. Triggers even when the task looks purely mechanical, + such as "just fix a typo" or "add a one-line comment", because the fleet's ASCII character-set + tiers, no-semicolon rule, comment-growth discipline, and CRLF-preservation rule are each easy to + violate without noticing: an em dash slipped into a sentence, a comment that grew by one more + clause, or a text-mode edit that silently flattens a CRLF file to LF. Also triggers when + authoring a new Markdown file (reference-style links, Table of Contents, present tense), when a + carried instruction file (AGENTS.md, GOVERNANCE.md, CODESTYLE.md, WORKFLOW.md, + .github/copilot-instructions.md) is being edited (no coordination references to the template or + a sibling repo), and when writing a PR title or commit message (imperative subject, no vague + titles, no unsolicited Co-Authored-By, no release-bump magnitude). +--- + +# Comment and Doc Style + +## Why this exists + +These are the fleet's mechanical prose rules, kept in one place instead of re-derived per repo or +per session: how to write a comment, which characters an agent may type, how a Markdown file is +structured, how a carried instruction file may reference the hub, and how a PR title or commit +message reads. None of these are matters of taste. Each is checked, by `prose_lint.py`, +`editorconfig-checker`, `markdownlint`, `cspell`, or a human reviewer, and each has been the exact +subject of a real review finding. + +## Naming tools in prose + +Use each tool's official casing in task labels, docs, and prose: `.NET` (not `.Net`), +`CSharpier`, `ruff`, `pyright`, `uv`. Do not invent personal variants. + +## Markdown files: linting and spelling + +- **Markdown lints clean, repo-wide.** Every `.md` file is error and warning free via + `markdownlint-cli2` against the shared `.markdownlint-cli2.jsonc`. A rule it deliberately + disables (for example `MD013` line length) stays disabled, do not "fix" it. `MD033` inline HTML + stays enabled: HTML comments, and `details`/`summary` (no Markdown equivalent for a + collapsible), are allowed, everything else with a native Markdown equivalent uses the Markdown. +- **A repo-local exclusion goes in a nested config, never in the root one.** The shared + `.markdownlint-cli2.jsonc` at the repo root is fleet-fixed, and its `ignores` list covers only + what every repo has, third-party Markdown under `node_modules`. A repo excluding a subtree of + its own that it does not treat as authored prose, a committed data archive, a vendored theme, + or a hand-maintained record, puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` + beside that content. A config inside a + tree that is re-imported or re-vendored wholesale is deleted by the next refresh, so it is + re-added with the import. Excluding through the CI workflow's negated glob input instead is a + CI-only fix, and leaves those same files flagged for anyone who runs the linter locally. +- **What decides whether a nested config works.** It applies to the directory it sits in and to + every subdirectory below it, and it filters those files even when a run names them explicitly + as arguments, so a bare local run and the CI step honor it alike. Its `ignores` patterns + resolve against that directory rather than against the repo root, so an entry written + repo-root-relative matches nothing and reports no error saying so. Its settings + merge with those above it rather than replacing them, so the fleet rule block still governs + the files it does not exclude. And the exclusion has to be expressed as `ignores`: the `globs` + and `gitignore` keys are read only from the config in the directory the linter is run from, so + a nested copy of either is inert. +- **Spelling is US English**, checked by CSpell against the shared `cspell.json` + (`"language": "en-US"`, so a British spelling is flagged). Add a project term to `cspell.json`'s + `words` list, never to a `.code-workspace`'s own `cspell.words` block. +- **CI's spelling gate covers `README.md` and `HISTORY.md` only**, deliberately not every `.md` + file, so a new topical doc is not spell-gated in CI (the editor extension still flags it live). + A repo may widen its own CI list, README plus HISTORY is the default. A repo shipping no + `HISTORY.md` drops it from the CI workflow, the `Lint: Spelling` task, and the GOVERNANCE.md + cspell line together, all three or none. +- **`HISTORY.md` mirrors the README's opening**: the same `# `, the same tagline verbatim + (the first line after the README's H1), then its own `## Release History`. It never repeats a + paragraph below the README's tagline. +- **"Markdown" is a proper noun in prose** (a Markdown file, a Markdown-only repo), lowercase only + for what a machine reads: a tool or package name (`markdownlint`), a settings key, a heading + anchor, a file extension. + +## Docker lint authorization + +A restricted executor treats Docker socket access, image fetching, and repository exposure as +separate permissions. Repository exposure needs explicit maintainer approval even when the mount +is read-only. Use the hub's `scripts/docker_lint.py` wrapper for the standard lint shape. It +discovers targets, pulls images in a separate phase, resolves each digest, and announces the +boundary before repository mounts begin. Each Docker command has a timeout and visible result. +Lint containers disable networking and mount the checkout read-only. Persist approval only when +the executor constrains that whole shape. Never allow an unconstrained `docker run` prefix. +PSScriptAnalyzer downloads its pinned module in a separate container that has network access and +no repository mount. `GOVERNANCE.md`'s hub-only "Running the Linters Locally (Known-Working +Invocations)" section owns the exact invocation and full authorization model. + +Agent-specific authorization stays in provider-labeled bullets so one agent's configuration does +not read as a shared requirement: + +- **Codex:** execution rules match exact argument prefixes, so they cannot safely cover changing + worktree paths and digests. Smart Approvals can prompt per task. No-prompt operation is + supported only inside an external sandbox because it removes command-wide protection. + +## Markdown formatting + +- **Reference-style links everywhere**, except the four files read one section at a time rather + than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, `.github/copilot-instructions.md`. + Those keep inline links so a target resolves where it is read. Every other Markdown file defines + every URI at the bottom, grouped by type under an HTML-comment header, each group alphabetized + by reference name rather than by the full definition line (a name that is a prefix of another + sorts first, `[governance]` above `[governance-branching-model]`). A URL inside a fenced code + block stays inline. See `references/markdown-links.md` for the full grouping and naming + convention. +- **Table of Contents**: generated by the Markdown All in One extension on save, never + hand-authored or hand-edited. Exclude a heading with an inline `<!-- omit from toc -->` marker. +- **One logical paragraph per line**, no hard-wrap line-length limit. For an intentional line + break within a block (stacked badges, status lines), end the line with a trailing backslash + rather than trailing whitespace. +- **Headings use the PR-title casing rule** below. +- **Write in the present tense.** State what *is*, never a change from a prior state ("X does Y", + not "X now does Y" or "X no longer does Z"). This applies to docs and code/workflow comments + alike. Before/after framing belongs in changelogs, commit messages, and PR descriptions, where + the prior state is the point. +- **When a behavior changes, grep for prose asserting the old one.** Comments, diagram labels, + workflow-input descriptions, and audit statements elsewhere may still describe the prior + behavior, and each was accurate when written. No linter catches a claim that is merely untrue, + so this sweep is the only mechanism that will. + +## Sentence structure + +The structural half of ASD-STE100 is the adopted house style for agent-authored prose, and the +controlled dictionary is deliberately not adopted: vocabulary stays unrestricted, structure is +restricted. Each structural rule a pattern can reach lands as a `prose_lint.py` check +incrementally, and this section names each check as it ships. + +- **Short sentences: at most 25 words in one sentence**, ASD-STE100's descriptive cap, checked by + the `sentence-length` rule in `prose_lint.py`. The check is opt-in like `sentence-split`, + because the existing corpus predates the cap and a default gate would fail whole files nobody + is editing. Write new prose under the cap, and scope a run to a change with + `--check sentence-length --diff <base>`. +- **One instruction per sentence.** A procedure step states one action, and a second action is a + second step. No pattern reaches this, so it is authoring discipline with no check. +- **Active voice, imperative mood for procedure steps.** Write "run the gate", never "the gate + should be run". Also authoring discipline, since a reliable passive-voice pattern does not + exist. + +## Comments + +Applies to code and workflow (`#`) comments alike. + +- Comment only when the code does not explain itself, or the logic is genuinely complex. + Self-evident code needs no comment. +- State only the non-obvious *why*, for the human reading *this* project's code now. No + cross-project references, no historic or design narrative, no rule citations. Governance lives + in the fleet's own instruction set, not echoed inline. +- **Keep it short**: one line is the default. A second line is earned only by a constraint the + code cannot otherwise carry. +- **Structured, not prose**: one sentence per line, never wrapped across lines, never a + multi-sentence run-on. A comment that genuinely needs several sentences is several lines, each + one sentence. +- A comment line opening prose starts with a capital. A trailing label, or the version pin an + action-pinning rule requires, does not. +- Mark a sub-topic with `-` after the comment marker (`# -`), only for genuine parallel sub-items + hanging off a lead line, never a continuation of one thought. +- **No file, class, or type header summary blocks.** A type or file gets a comment only for a + specific non-obvious point, never a block restating what it contains (a license or provenance + header a tool or policy requires is not a summary and is unaffected). +- **Never let a comment grow across edits.** Touching code near an existing comment means the + comment comes out the same length or shorter, never one more clause of rationale appended. + +A continuation stays unindented, one sentence per line: + +```text +# Change gate for the compile tests. +# An esp-idf build costs minutes, so gate on what each test covers. +# A diff that cannot be computed runs everything. +``` + +Sub-topics take a `-` after the comment marker, each elaborating a distinct item named in the lead: + +```text +# Source lint plus change-gated compile tests. +# - compile-test builds the external component. +# - template-compile-test builds one example device per template. +``` + +A change that adds a comment line in code or config fails the `comment-added` rule in the prose +gate. It reports a prose comment that opens its own line, in the diff's scope, and a diff counts a +modified line as an added one, so rewording one and re-indenting one each report it. That is the +rule's cost and the label is its answer, since a comment worth keeping takes the same label as a +comment worth writing. A trailing comment is out of scope, since which mid-line marker opens a +comment differs by language in ways a gate cannot settle from the marker alone. A docstring is not +a comment line, an instruction to a tool is not a comment the rule reads, and Markdown is out of +scope. + +Deleting the comment is the ordinary answer, since the bullets above already say what one has to +earn. Where a comment is genuinely owed, and a rule requiring one is the clearest case of that, the +pull request carries the `comments` label and the gate stands down for that change. Locally a +`PROSE_ALLOW_COMMENTS` does the same, for as long as it is set to anything but a false spelling, +and `--allow-comments` does it for one run by hand. The label and the variable are separate deliberately, so a variable +left exported reaches the commit and never the merge gate. + +Three things about reaching those escapes read as a broken gate until they are known. The label is read off the event that started the run, so a label added after a run fails +applies to the next push rather than to a re-run of that one, and labeling the pull request when it +is opened is what avoids the round trip. The label reaches a repository only when the fleet label +set is applied to it, so a repository that has not had that applied since the label was declared +cannot carry it, and there the finding names a remedy that is not yet available. And the local +escape reaches a commit before any of that, which is where a repository meets this rule first, since +a hook runs on every commit while the label decides a pull request. + +## Issue, pull request, and commit references + +No comment, no docstring, and no instruction document names an issue, a pull request, or a commit. +The surfaces are code and workflow comments, a docstring, a documentation comment, the Skills trees, +and the fleet's own rule documents: `AGENTS.md`, `AUDIT.md`, `CLAUDE.md`, `CODESTYLE.md`, +`GOVERNANCE.md`, `OPERATIONS.md`, `RESYNC.md`, `STANDUP.md`, `WORKFLOW.md`, and +`.github/copilot-instructions.md`. A tracker, a history, a plan, and a README outside those trees +are the repository's own narrative and keep their references, as do a commit message and a pull +request body, which are the surfaces a reference belongs on. + +Two carve-outs, each stated as a single case. Whatever neither of them affirmatively permits is +banned by the paragraph above, which is the whole of the test and is why no list of banned cases +follows. The first: **in a code or workflow comment, a URL naming an issue or a pull request on a +public repository other than this one is a source citation and is permitted.** It does the same job +as the datasheet link, the vendor wiki link, and the SDK doc link the rule already leaves alone on +the adjacent line. The second is the revision record in the paragraph below. + +The second carve-out: a record whose subject is the revision itself keeps it. A disproved-claims +entry in `.github/copilot-instructions.md` names the revision its proof was read against, since a +proof is true of one tree at one revision and an entry whose subject has moved is deleted rather +than edited to look current. The revision there is the record's own load-bearing field rather than a +citation beside a claim, which is the distinction this rule turns on. + +Separately, `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md` carry no three-part +version and no commit SHA, full or abbreviated, whether a pin's value, an example, a minimum +version, or a fixed constant, since a pin's copy goes stale at the next Dependabot bump and every +other kind reads exactly like one. Neither carve-out above lifts this ban. Item 3 of this skill's +carried-doc-references reference says what to write instead of each and carries the audit that flags +a literal. + +Three reasons, and the first decides it. + +- **A reference is a second lookup, and the reader is already holding the file.** A comment earns + its place by explaining the line under it to whoever reads that line now. A number they have to + go and resolve somewhere else is the opposite of that. +- **The lookup can be impossible.** A repository may be private, so a reference in content carried + into a public one names something its reader cannot open at all. +- **It pollutes the content.** A rationale block that takes one more citation per round is how a + file comes to teach a house style the rules forbid, which is what happened here. + +The first carve-out is where all three fail at once, which is what makes it one case rather than a +taxonomy. Another repository's status is not a fact this file can hold, it changes without anyone +touching this file, and the constraint the citation stands in for is therefore unwritable. The +lookup is not impossible, that repository being public. And a URL line is not pollution on a surface +where the datasheet, the forum thread, and the component docs already sit on the adjacent lines, the +hostname being the only thing that separates them. A workaround whose justification is an open +report on the project it works around is the routine case, and a reader revisiting the workaround +needs to know whether the cause still stands. + +Move one of that carve-out's conditions and a reason comes back. An instruction document states its +constraint rather than citing a tracker for it, so the first reason holds there whatever the tracker +names. A private repository's tracker cannot be opened by a reader of the content carried into a +public repository, which is the second reason exactly. This repository's own tracker holds status +this file can state, so the first and the third hold on every surface. And a bare reference carries +no destination a reader can open at all, the hash-and-number form resolving against whichever +repository the reader happens to be in, which is why that carve-out is written as a URL. + +Write the constraint the reference was standing in for, or drop the clause where the reference was +the whole of its value. "A prior version re-scanned from every unmatched open, which was O(N^2)" +carries what the reader needs, and the number of the round that found it does not. Inside the +carve-out there is nothing to rewrite, the referenced thing being live status somewhere else, and +the carve-out is why that case needs no remedy rather than a remedy an author is expected to find. + +The `issue-ref` rule in the prose gate reads the pattern-detectable half of this: a bare reference +in a comment, in a Python docstring, and in instruction text, and in instruction text a URL naming +an issue or a pull request as well, written as an inline link destination, as a reference +definition, or bare in the prose. It reads one forge's URL paths, so a URL naming a tracker it does +not know is banned there and goes unreported. Whatever a gate does not reach is unlicensed all the +same, since the rule binds a reader rather than a scan. Reading the URL in instruction text is what +stops the gate reporting the bare spelling and passing the URL on the one surface it reads both, +since an author met by the bare form's finding is otherwise pointed at respelling the reference +rather than at removing it. On every other surface the gate reads the bare form alone, so a banned +URL is banned there and goes unreported. + +A bare commit reference is not a shape the prose gate can read, since a short SHA carries the same +shape as a blob id, a version fragment, and a fixture hash. The audit's version-literal scan reads it +in `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md`, bare or inside a URL, and +elsewhere the text above is the whole of what covers a commit. A reference in a +string literal is not read either: a test builds the numbers it asserts against, and reading those +would report a fixture rather than a claim about this repository. + +## Character set + +Agent-authored text is ASCII by default: documentation, code, comments, commit messages, and PR +descriptions. A non-ASCII character is read against three tiers, because whether one is +typography or meaning depends on where it sits. A character in no tier is a finding rather than a +silent pass. + +- **Tier 1, never legitimate.** Typography carrying no meaning its ASCII form loses. Remove on + sight: + - em dash (U+2014) and en dash (U+2013) to a restructured sentence, two sentences or a comma, + never a spaced hyphen + - right arrow (U+2192) to `->`, double arrow (U+21D2) to `=>` + - curly quotes (U+2018/U+2019/U+201C/U+201D) to straight `'` and `"` + - ellipsis (U+2026) to `...`, bullet (U+2022) to `-` + - no-break space (U+00A0) to a space, non-breaking hyphen (U+2011) to `-` +- **Tier 2, legitimate only next to a number.** Relational and arithmetic operators: U+2264, + U+2265, U+2260, U+00B1, U+2212, U+00D7, U+00F7, U+00B7. Keep one when an adjacent non-space token + is a number, a tier-3 symbol, or another tier-2 operator, so a threshold table or a measured + range reads as the range it is. In flowing prose write the ASCII form: `<=`, `>=`, `!=`, `+/-`, + `-`, `x`, `/`. A tier-2 operator directly before a number in a table of thresholds is the range + it describes and stays, the same character between two words in a sentence is prose and takes + the ASCII form. +- **Tier 3, always legitimate.** Scientific and unit symbols whose ASCII form would be a lie: + micro (U+00B5), degree (U+00B0), ohm (U+2126), pi (U+03C0), superscript two and three (U+00B2, + U+00B3), section (U+00A7). Keep the symbol, never approximate it away or spell it out. +- **Unicode a developer deliberately typed** stays regardless of tier, such as emoji used for + emphasis or as callout markers. Never strip a developer's own characters, this is developer + authored text and not a license for the agent to add its own. +- **An unrecognized non-ASCII character is reported, not allowed.** Classify it into a tier above + before using it. +- **No semicolon in agent-authored prose.** Recast a mid-sentence semicolon as a comma or as two + sentences. A semicolon separating items in a list that already contains commas, or a statement + terminator in code, is unaffected. +- **No spaced hyphen joining or interrupting a sentence** (` - `, or the paired aside ` - x - `). + Recast as a comma, two sentences, or parentheses. A hyphen inside a compound word, a leading + list marker, a range, and the `- **Label** - explanation` bullet separator are unaffected. +- **In carried verbatim content, fix the whole class at the hub**, not one instance, since a + downstream repo cannot edit a section byte-matched against the hub. Everywhere else, correct as + each file is next edited, not swept. + +## Line endings + +This repo's default is LF (`[*] end_of_line = lf` in `.editorconfig`), with CRLF pinned only for +`*.bat` and `*.cmd`, the one type Windows itself requires it for. +**Preserve a file's existing line ending when editing it, never reflow as a side effect of a +content change.** A text-mode tool, including a naive programmatic write, can silently flip CRLF +to LF and turn a one-line change into a whole-file diff. After any programmatic edit, verify with +`git diff --stat` (it should touch only the lines you changed) and a byte scan, `file` and a naive +`git ls-files --eol` are both unreliable here. Idempotent normalize: +`b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. The full policy, choosing an ending for a new +file type, operational-repo overrides, extensionless-script pins, and auditing, is in +`references/line-endings.md`. + +## Carried files reference no coordination machinery + +`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, +the `spec/` files and the carried `AUDIT.md` never reference the template repo +(in prose or a link), and never name a sibling fleet repo as an illustrative example. State the +behavior a carried rule needs, not the coordination flow that produced it, the maintainer supplies +the destination out of band. A contextually relevant link to a related project (the image this +config feeds, a library this depends on) is not a coordination reference and is expected. The full +exceptions, a verbatim section that must name the hub to do its job, and a pointer to a +hub-hosted tool the reader runs, are in `references/carried-doc-references.md`. + +## PR titles and commit messages + +- **Format**: an imperative subject, 72 characters or fewer, no trailing period ("Add 24-Hour + PM2.5 Average Sensor", not "Added X" or "Adds X"). An optional body, blank-line separated, + explains *why* the change is being made when that is non-obvious, the diff already shows *what*. +- **Rules**: no vague titles (`update stuff`, `wip`). Dependabot's default `Bump X from Y to Z` + titles are fine as-is. No `Co-Authored-By:` lines unless the developer explicitly asks. No + release-bump magnitude in the title ("minor", "patch", "release v0.2.0"), Nerdbank.GitVersioning + computes the next version from `version.json` and git history. A dependency version in a + dependency-bump title is fine and expected. US English spelling, and title case with lowercase + short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from), a hyphenated + compound capitalizes both parts unless the second is a short preposition (*Built-in*, + *EPA-Corrected*, *24-Hour*). + +```text +Add Structured Logging Extensions to Library +Pin softprops/action-gh-release to Commit SHA +Drop net8.0 Multi-Targeting from Console Project +Bump xunit.v3 from 3.2.2 to 3.3.0 +Clarify Devcontainer Setup Steps in README +``` + +## Quantitative claims + +A quantitative claim in `README.md` (a count, a size, a version floor, a supported-platform list) +is verified against current code before it is written. When a doc number is derived from a code +constant, mark the dependency in a source-code comment so the next editor knows to update both. diff --git a/.github/skills/comment-and-doc-style/references/carried-doc-references.md b/.github/skills/comment-and-doc-style/references/carried-doc-references.md new file mode 100644 index 0000000..9b66d1b --- /dev/null +++ b/.github/skills/comment-and-doc-style/references/carried-doc-references.md @@ -0,0 +1,76 @@ +# Carried Files Carry No Coordination References + +Full detail for the "Carried files reference no coordination machinery" rule in `SKILL.md`. Load +this when editing one of the carried files themselves, not when writing an ordinary repo-owned +doc. + +## Which files this governs + +`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, +the `spec/` files and the carried `AUDIT.md`, the files the fleet carries +verbatim or at `intent` fidelity from the hub into every repo. This rule governs carried template +content only. A repo's own `README.md` and topical docs are its own content, never carried +verbatim, and this rule does not reach them. + +## What is banned + +Three things, in the files above: + +1. **Any reference to the template repo**, in prose or in a link. The coordination flow that + produced a carried file is machinery a consumer of that repo should never have to see, and + naming where a file came from is exactly the derived-from framing the present-tense rule (in + `SKILL.md`'s "Markdown formatting" section) independently forbids. Where a carried file must + express a template-level behavior ("report a rule discrepancy upstream"), state the behavior + rather than the destination. The maintainer supplies the destination out of band. +2. **A sibling fleet repo named as an illustrative example** ("repo X does it this way", "see repo + Y's adoption"), which couples the repos and rots as they diverge. To point at a current good + example, name it in the onboarding or conformance issue, never in a carried doc. +3. **A three-part version or a commit SHA**, full or abbreviated, of any kind, in `AGENTS.md`, + `GOVERNANCE.md`, `CODESTYLE.md`, or `WORKFLOW.md`. The other files above are outside this item. A + pin's value ("SHA-pinned at hub release 2.0.x", with a real number in place of the x) is stale at + the next Dependabot bump, since the pin lives in the workflow or manifest that uses it, and it + sends the next agent to edit governance for a change that needed none. An illustrative example, a + minimum version, and a fixed constant read exactly like a copied pin, so no check can tell them + apart, and the rule covers them too. Write each as its mechanism instead: a pin as "SHA-pinned to + a hub release, with the release in a trailing comment", an example with a placeholder (`1.0.N` + publishing as `1.0.(N+1)`, or a dependency bump "from X to Y"), a minimum version by naming the + manifest or skill that holds it, and a fixed constant by what it is ("the all-zero placeholder + version"). A two-part language or runtime version, such as a minimum Python minor, stays. The + audit flags a three-part version, a full SHA, or an abbreviated one in those four files, outside + their verbatim sections downstream and across the whole file in the hub. + `.github/copilot-instructions.md` is outside it, since its disproved-claims records name a + revision by design, which the revision carve-out in `GOVERNANCE.md` "References" permits. + +## The two exceptions + +**The first exception is a verbatim section**, and `AGENTS.md` "Fleet Bootstrap" is why it exists. +That section's whole function is to name where the canonical rules live, for an agent in a +repository whose carried copies are stale, partial, or absent, which is exactly when no other file +present can say it. Its bytes are fixed fleet-wide, so a repository cannot edit the reference out +without failing the verbatim check instead, and a rule banning it would be unsatisfiable rather +than merely strict. The exception is scoped to the verbatim region and never leaks past it: the +same document's own prose, outside that region, is governed normally. A reference that reaches a +verbatim section is a defect in the canonical, fixed once at the source rather than reported +against every repository carrying it. + +**The second exception is a hub-hosted tool the reader is told to run**, which is a different kind +of reference. A rule naming a gate, a script, or a reference snippet the reader executes or copies +states an instruction rather than a provenance, and an instruction with no destination is +unfollowable, which is precisely how a pointer in carried text comes to read as decorative. The +test is whether the reference is something the reader *does* or something that *happened to this +file*: where the content came from stays out, what the reader runs stays in. Such a pointer names +the hub's canonical rather than this repository's provenance, so it is the hub's to keep resolving +and never a repository's to edit out or re-point at a local path. What is reached rather than +carried, and how, is `GOVERNANCE.md` "Hub-Hosted Tooling". In `AGENTS.md` and `GOVERNANCE.md` this +belongs in verbatim rule text, the same region the first exception already covers, so the whole +fleet reads one wording and no repository is asked to answer for a reference it did not write. + +## What is not a coordination reference + +**A contextually relevant link to a related project is expected, not banned.** Where another repo +is part of this repo's subject matter (the image that consumes this config, the builder that +generates this hardware, a library this depends on), link it normally. The test is whether the +link serves a reader of *this* repo's content, not whether the target happens to be in the fleet. + +This pairs with the present-tense rule: state the current shape, not a history of which repo it +came from. diff --git a/.github/skills/comment-and-doc-style/references/line-endings.md b/.github/skills/comment-and-doc-style/references/line-endings.md new file mode 100644 index 0000000..8337cbc --- /dev/null +++ b/.github/skills/comment-and-doc-style/references/line-endings.md @@ -0,0 +1,117 @@ +# Line Ending Policy + +Full detail for the "Line endings" rule in `SKILL.md`. Load this when choosing an ending for a +new file type, working in an operational (config) repo, pinning an extensionless executable, or +auditing a repo's endings, not for an ordinary content edit to an existing file (the SKILL.md +summary, preserve the existing ending and verify with a byte scan, covers that case). + +## The defaults + +- **`.editorconfig` sets the line ending.** `[*] end_of_line = lf` is the default, every file + type is LF unless pinned otherwise, with CRLF pinned for the one exception Windows requires: + `*.bat` and `*.cmd` (cmd.exe's line handling is unreliable on LF). Only the CRLF exception is + declared, the redundant per-type LF rules are intentionally omitted, since the default already + gives shell scripts, Dockerfiles, workflow YAML, `uv.lock`, and every shebang-executed `.py` + the ending they need without a path-specific pin. +- **`.gitattributes` mirrors the repository-wide defaults**: `* text=auto eol=lf` normalizes every + detected text file to LF while leaving binary files byte-preserved. `*.bat` and `*.cmd` override + that default to CRLF. Do not add per-language or per-file LF pins where the global LF default + already applies. The CRLF-native exception for POSIX-executed paths is defined below. +- **Both files are required together.** `.editorconfig` governs the editor, `.gitattributes` + governs git (checkout, commit, `--renormalize`). A repo missing either file, or whose + `.editorconfig` sets no global `end_of_line` default (for example declares it only under + `[*.md]`), accumulates files mixed between LF and CRLF, the exact failure these two files + prevent together. Carry both files whole. An inert `[*.cs]` block costs nothing in a non-.NET + repo. + +## Choosing an ending for a new file type + +LF is the default, since it is what every tool, CI runner, and Dependabot bump produces, and +Windows GUI editors (VS Code, Visual Studio, Notepad, WordPad) all read and write it cleanly. Pin +CRLF only for a type Windows itself requires it for: `*.bat` and `*.cmd`. Everything else, +including YAML (workflow and non-workflow alike, no distinction needed now that both are LF), +`.gitignore`, `.dockerignore`, and a tool-owned format with a native LF ending (KiCad), takes the +`[*]` default with no override. + +## Operational (config) repos + +The global default follows the consuming application's native platform, not the fleet LF default. +A config repo (registry `workflowModel: operational`) is a view into an application's +configuration directory, often the exact tree mounted into that app's container, so its files use +the ending the app itself reads and writes, and forcing the fleet LF default would fight an app +that needs CRLF. Set the `[*] end_of_line` default to the app's native ending and record it in the +registry `lineEndings` field (`lf` or `crlf`): the field is required for every operational repo +because a config repo's ending is a load-bearing decision tied to its consuming app. A +Linux-native app or container config uses the `lf` fleet default. A Windows-native app that uses +CRLF requires `[*] end_of_line = crlf` and +`* text=auto eol=crlf`. `release` repos keep the LF fleet defaults above. Do not re-normalize an +operational repo to +the fleet default, that is exactly the over-normalization these per-repo endings exist to +prevent, whichever direction the fleet default currently points. + +**Mixed-consumer config: prefer to split by platform into single-platform repos, not one mixed +repo.** When a config repo would be consumed on two platforms (a Linux app plus a Windows-edited +subtree), the clean answer is a repo per consumer, each single-platform with its own +`lineEndings`. For example a controller config edited by a Windows-native editor lives in its own +CRLF repo, not as a subtree inside a Linux `lf` config repo. Fallback only if a subtree genuinely +cannot be split out: keep the global default at the primary consumer and pin the odd subtree with +an `.editorconfig` path override (for example `[<subtree>/**] end_of_line = crlf`) matching its +consumer. Pair the same path override in `.gitattributes`, since both layers must resolve the +path to the same ending. + +## Scripts and extensionless executables + +Must be LF. A CRLF shebang (`#!/usr/bin/env bash\r`) breaks execution. The paired global LF +defaults cover extensionless executables, shell scripts, and directly executed Python without +path-specific pins. A CRLF-native operational repo adds narrow matching LF overrides in both +files only for scripts it executes on POSIX. + +For a type that genuinely needs an ending the `[*]` default no longer supplies (a Windows-native +tool-owned format outside `.bat`/`.cmd`, or a byte-preserve data directory whose exact bytes the +consumer may depend on), still pair a `.gitattributes` pin with a matching `.editorconfig` +override, since the git pin alone is not enough there, `.gitattributes` governs git while the +editor follows `.editorconfig`. For a byte-preserve directory, disable all editor normalization, +not just EOL: `[<dir>/**]` with `charset = unset`, `end_of_line = unset`, `insert_final_newline = +false`, `trim_trailing_whitespace = false` (`unset` is EditorConfig's spec-defined special value +that removes an inherited property, and `**` is needed rather than `*` so a nested file under the +directory is covered too, since `*` excludes `/` and only matches one path component). + +## Editing discipline + +- **New files**: create with the `.editorconfig`-mandated ending. +- **Editing an existing file**: preserve its current line endings, do not reflow them as a side + effect of a content change, even if the file is already non-compliant. A tool that rewrites a + file in text mode (a script, a bulk find/replace) can silently flip CRLF to LF and turn a + one-line change into a whole-file diff. After any programmatic edit, verify before staging: + `git diff --stat` should touch only the lines you changed, and a byte check should confirm the + expected ending. If a diff balloons to the whole file, the endings flipped, restore them and + re-stage. +- **Fixing a non-compliant file**: bring it to its `.editorconfig` ending as a deliberate change, + and prefer to isolate it in its own EOL-only commit so the churn is reviewable. When a broader + maintenance change has to normalize endings alongside content edits, call it out explicitly in + the commit or PR description and verify the content separately with + `git diff --ignore-cr-at-eol`. + +## Auditing + +Don't trust `file` or a naive `git ls-files --eol`. The authoritative check is a byte scan that +classifies by which endings are present: CRLF-only (every `\n` preceded by `\r`), LF-only (no +`\r`), or mixed (both forms present). Flag mixed explicitly rather than lumping it in with CRLF, +and skip binaries via a NUL-byte check. `file` mislabels some types (it reports a CRLF `.json` or +`.code-workspace` as plain "JSON text data" with no CRLF note), and `git ls-files --eol`'s `attr/` +column holds multiple tokens that shift naive field-splitting into false positives. Scope a +repo-wide audit to `git ls-files` plus `git ls-files --others --exclude-standard`, never a raw +`find`, which sweeps self-ignoring caches (`.mypy_cache`, `.artifacts`). + +Idempotent normalize: `b.replace(b"\r\n", b"\n").replace(b"\n", b"\r\n")`. A single within-line +string replace is EOL-safe, but a tool that inserts multiple lines or writes a new file into a +CRLF file must emit `\r\n`, since a naive `\n` insert creates mixed endings. `.code-workspace` is +JSONC (it has `//` comments), so strip them before JSON-parsing it. + +Editing CRLF files programmatically with a regex has a sharper trap: `.` matches `\r`, so a +captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. A text-mode +rewrite has the mirror failure, silently flattening CRLF to LF. Prefer line-based edits +(`splitlines(keepends=True)`) or literal replacement over regex reassembly. In Python the +text-mode failure is the default: `Path.read_text()` decodes through universal newlines and +`write_text()` writes `\n` back, so a read-edit-write round trip flattens the whole file while the +edit itself looks correct. Pass `newline=''` to both, or work in bytes. diff --git a/.github/skills/comment-and-doc-style/references/markdown-links.md b/.github/skills/comment-and-doc-style/references/markdown-links.md new file mode 100644 index 0000000..c3cd7e1 --- /dev/null +++ b/.github/skills/comment-and-doc-style/references/markdown-links.md @@ -0,0 +1,64 @@ +# Reference-Style Links + +Full detail for the "Markdown formatting" reference-style-links rule in `SKILL.md`. Load this +when actually authoring or reorganizing a Markdown file's link definitions, not for a small +in-place prose edit. + +## Where the rule applies + +Every Markdown file in the repo uses reference-style links only, except the four files that are +read one section at a time rather than end to end: `AGENTS.md`, `GOVERNANCE.md`, `OPERATIONS.md`, +and `.github/copilot-instructions.md`. Those keep inline `[text](uri)` links, since a reader +jumping straight to one section needs the target to resolve where it is, while a definition parked +at the bottom of the file is never reached. The exception is that closed list of four files, never +a category to argue from case by case. Every other Markdown file follows the rule regardless of +its audience. + +## The definition block + +Every URI, an internal path, an anchor, an external URL, or a shield image, is defined at the +bottom of the file, split into groups by type under an HTML-comment header, for example: + +```markdown +<!-- Shields --> + +[license-shield]: https://img.shields.io/... + +<!-- Repo --> + +[governance]: ./GOVERNANCE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model + +<!-- External --> + +[markdownlint-cli2]: https://github.com/DavidAnson/markdownlint-cli2 +``` + +Within a group, definitions are alphabetized by **reference name alone**, the text inside the +brackets, never by the whole definition line. Where one name is a prefix of another, the shorter +one sorts first: `[governance]` above `[governance-branching-model]`, `[repo-config]` above +`[repo-config-settings]`. Sorting the full line instead inverts every such pair, because `-` +precedes `]` in byte order, so the two readings disagree on exactly the names a reader looks up +together, and a plain `sort -c` over the block passes on the inverted order regardless. + +## Naming a reference + +Reference names are contextual and encode both the target and its group: + +- `foo-shield` for a shield image +- `foo-link` for an external URL +- a bare `foo` for a local path or anchor + +For example `[license-shield]`, `[releases-link]`, `[repo-config]`. Never a numeric name (`[1]`) +and never an opaque one. + +## Mechanics + +- No inline `[text](uri)` targets in prose, in any file outside the four-file exception above. +- **A URL inside a fenced code block stays inline.** Reference links do not resolve inside a code + block, so do not extract it there, and exclude fenced code from any link-integrity check + (bracket literals like `["a", "b"]` otherwise read as undefined references). +- **Removing a link also removes its reference definition.** An orphaned definition fails the + no-unused-defs rule. +- The one exception to "no inline links" is the Table of Contents, whose entries stay inline + anchor links, since the ToC extension generates them that way and they are never hand-edited. diff --git a/.github/skills/copilot-instructions-keeper/SKILL.md b/.github/skills/copilot-instructions-keeper/SKILL.md new file mode 100644 index 0000000..1899ce8 --- /dev/null +++ b/.github/skills/copilot-instructions-keeper/SKILL.md @@ -0,0 +1,98 @@ +--- +name: copilot-instructions-keeper +description: >- + Helps keep a repo's .github/copilot-instructions.md in sync with the ptr727/ProjectTemplate hub + canonical, and stops the one mistake specific to this file: silently wiping its repo-local + "Disproved Claims" ledger entries during a resync. Use this whenever about to edit, overwrite, + re-vendor, or carry .github/copilot-instructions.md into a repo, whenever checking a repo's copy + for drift against the hub, whenever GitHub Copilot's review mechanics in this file look stale, + wrong, or missing something the fleet runbook should cover, or whenever standing up a new repo + and carrying this file for the first time. Also triggers on "why isn't the audit catching that + this file is out of date," since the fleet's mechanical audit checks this file, at intent + fidelity, for file presence and each named section's heading, never for content drift inside a + section, so nothing else notices a stale section here except a live check like this one. An + `audit-a-repo` run checks this file's presence, headings, and a date-based staleness hint + without judging its content, so content drift in it stays this skill's, and a resync fires it + beside `resync-a-repo` and `carried-instruction-file-guard`, which guards the other four carried + files. +--- + +# Copilot Instructions Keeper + +## Why this exists + +`.github/copilot-instructions.md` is read directly by GitHub Copilot and bootstraps the shared +`AGENTS.md` instruction set and review-focused skills. Its Copilot-specific rules stay fully +intact in every repo that carries it. This skill maintains that carried copy, it does not replace +the bootstrap. + +`spec/files.json` declares it `intent` fidelity, `whole: true`, covering four named sections +(`Commit Messages and Pull Request Titles`, `Reviewing Carried Fleet Content`, `GitHub Copilot +Review Runbook`, `When in Doubt`), with `<owner>`, `<repo>`, and `<N>` placeholders filled per +repo. **The fleet audit checks an `intent` file for file presence and each named section's +heading, never for content drift inside a section.** A section that is present but has fallen out +of date against the hub, the exact gap this skill exists to catch, produces no finding anywhere in +the mechanical audit. Noticing that has to happen in a live session like this one. + +## The one thing this file has that others don't: repo-local ledger entries + +The file's own "Disproved Claims" section states its rule plainly. **The section's shape and +governing rules are carried, but its entries are not.** Each entry records a finding that was +raised against this specific repository and disproved against this repository's code at a named +revision. A repository carrying a copy of this file carries the shape and rules, deletes any +entry whose subject it does not hold, and records what it has proved for itself. + +This means a blind re-vendor of the hub's canonical `.github/copilot-instructions.md` over a downstream +repo's copy is wrong in both directions: + +- Copying the hub's own "Disproved Claims" entries (about `ProjectTemplate` itself) into a + downstream repo attaches proofs about code that repo does not carry. +- Overwriting a downstream repo's copy wholesale deletes any entries that repo itself has earned, + a live disproof, run against that repo's own tree, thrown away with no record. + +**Before touching this file in any repo other than the hub itself:** + +1. Read the current "Disproved Claims" section in that repo's copy, if it has one, and preserve + every entry that names a file or behavior that repo actually carries. +2. Update everything else, the runbook mechanics, the four named sections, the rule text, to + match the hub canonical. +3. Never carry the hub's own repo-specific "Disproved Claims" entries downstream. They name + `ProjectTemplate`'s own files and revisions, not the target repo's. +4. If in doubt whether an entry is still valid for the current tree, treat it per the guard skill + below rather than guessing. + +The `carried-instruction-file-guard` skill stops this same failure class for `AGENTS.md`, +`GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md`: a +routine-sounding overwrite silently deleting content that is not a stale copy of the hub. Run +that skill's distinctive-phrase probe against this file too before any full-file replace. It is +not in that skill's own file list because its failure mode, ledger entries rather than fleet +rules, is specific enough to warrant its own skill, but the underlying discipline, probe before +overwrite, give a local addition a destination rather than deleting it, is the same. + +## Checking a repo's copy for drift + +1. Fetch the hub (`github.com/ptr727/ProjectTemplate`) `main` branch fresh. A stale local clone + answers confidently instead of failing. +2. Compare the target repo's `.github/copilot-instructions.md` against the hub's, section by + section, at **intent** fidelity, judged by meaning, not by byte match. A content-identical + file with different `<owner>`/`<repo>` placeholder fills is current, not drifted. +3. Read the "Disproved Claims" section separately from the rest. Judge its **shape and rules** + against the hub, and judge its **entries** only against what that repo itself carries (see + above), never against the hub's own entries. +4. Report what is actually stale (a runbook mechanic that changed, a rule that moved, a new + section) versus what only looks different because it is correctly repo-specific. + +## Carrying it fresh, new repo or full resync + +Follow `RESYNC.md`'s general apply order for carried files, with the ledger rule above applied at +the point this file is touched: carry the hub's current rule text and runbook mechanics, keep the +target repo's own "Disproved Claims" entries (if any existed pre-resync) rather than replacing +them with the hub's, and start a new repo's ledger empty rather than seeded from the hub's own +proofs. + +## What this skill does not cover + +Content-style rules for other carried files (`AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, +`WORKFLOW.md`) are `carried-instruction-file-guard`'s job. The review-loop contract this file's +runbook implements, the merge gate, triage, escalation, is `pr-review-conduct`'s job. This skill +is narrowly about keeping this one file's carried copy correct. diff --git a/.github/skills/dotnet-codestyle/SKILL.md b/.github/skills/dotnet-codestyle/SKILL.md new file mode 100644 index 0000000..7eb2089 --- /dev/null +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -0,0 +1,223 @@ +--- +name: dotnet-codestyle +description: >- + Governs C#/.NET code style for ptr727/ProjectTemplate fleet repos: the zero-warnings build + policy and its three-task clean-compile chain, central Directory.Build.props/ + Directory.Packages.props configuration, C# language and naming conventions, XML documentation, + analyzer suppression scope, the library-versus-application logging split, async and + error-handling patterns, xUnit v3 + AwesomeAssertions testing conventions, and AOT-compatible + project configuration. Use this whenever writing, reviewing, or editing a .cs file, a .csproj, + Directory.Build.props, or Directory.Packages.props, whenever choosing where to suppress an + analyzer diagnostic, whenever a NuGet library needs to log without depending on Serilog + directly, or whenever writing or reviewing an xUnit test. Triggers even when the task looks + like a small local fix ("just silence this warning", "add a quick log line", "bump a package + version"), because the zero-warnings policy, the suppression-scope order, the central-package- + management rule, and the library/application logging split are each easy to violate one file at + a time without the pattern ever showing up as a single obvious diff. Applies only to a repo's + .NET side, a repo with no .NET projects has no use for this Skill. +--- + +# .NET Codestyle + +## Why this exists + +This is the .NET-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every +language shares (clean-compile verification as a concept, the suppression-scope order, tooling +casing in prose), this Skill is everything specific to a C#/.NET project on top of that: the +concrete `.NET Format` task chain, the analyzer configuration that makes the zero-warnings policy +real, and the language, naming, logging, and testing conventions. + +## Build requirements + +### Zero warnings policy + +All builds must complete without warnings, enforced three ways: + +- **The `.NET Format` clean-compile task.** It chains `CSharpier Format` -> `.NET Build` -> + `dotnet format style --verify-no-changes`. A repo carries those three task definitions in its + own `.vscode/tasks.json`, matching the canonical `vscode-tasks.json` snippet at + `github.com/ptr727/ProjectTemplate/blob/main/catalog/snippets/configs/vscode-tasks.json`. Run + the `.NET Format` task after any code change, before commit. To run it natively instead, + reproduce that exact task chain (`CSharpier Format`, then `.NET Build`, then + `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`) without dropping + or loosening any argument, reading it from that same canonical snippet. Bare `dotnet format` + alone, skipping CSharpier or the build, is not sufficient. +- **Analyzer configuration.** `<EnableNETAnalyzers>true</EnableNETAnalyzers>` with + `<AnalysisLevel>latest-all</AnalysisLevel>` and `<AnalysisMode>All</AnalysisMode>` (the full + analyzer set), plus `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`, so any diagnostic + surfaced as a warning fails the build and must be fixed or deliberately suppressed at the + narrowest scope that fits (see Analyzer suppressions below), never left to accumulate. +- **CI lint backstop.** CI runs the clean-compile checks on every PR as the authoritative gate. + Husky.Net is wired from the canonical `catalog/snippets/husky/` config in the hub, hub-local + and not carried into every fleet repo. Its hook needs a .NET tool manifest declaring + Husky.Net, which the snippet does not ship. A repo keeping no such manifest takes the other + canonical shape, `catalog/snippets/pre-commit/`, and so does a repo that simply prefers the + `pre-commit` framework. Each shape carries whichever language checks its own repo keeps. A repo may also wire an equivalent hook of its own at `.husky/pre-commit`, + enabled with `core.hooksPath` and sourcing nothing, since the husky snippet's own hook sources + a file only `dotnet husky install` generates. That path and `.pre-commit-config.yaml` are the + two the audit reads. + GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section + carries the obligation itself, what the hook must cover, its audit treatment, and the + per-clone enablement steps. + +**A new port is not a license to silence diagnostics.** Brownfield or just-ported status never +justifies relaxing analyzer severities or muting newly surfaced warnings. Fix them. (The only +brownfield allowance in the fleet is the one-time git-signing / line-ending migration described in +GOVERNANCE.md and README.md, which has nothing to do with code analysis.) + +### Central build and package configuration + +Shared MSBuild configuration is centralized at the repository root, never duplicated per project: + +- **`Directory.Build.props`** carries the properties every project shares: the analyzer set and + `TreatWarningsAsErrors` from the zero-warnings policy above, plus `LangVersion`, + `TargetFramework` where uniform, and any repo-wide build metadata. A `.csproj` carries only what + is genuinely project-specific (`OutputType`, `IsPackable`, project references). +- **`Directory.Packages.props`** owns central package management: it sets + `ManagePackageVersionsCentrally` to `true` (in this file, not `Directory.Build.props`) and + declares every dependency version once as a `PackageVersion` item, so a `.csproj`'s + `PackageReference` items are versionless. One file to review on a bump, one Dependabot surface, + no version skew between projects. + +A repo whose projects still carry per-project analyzer settings or versioned `PackageReference` +items is drifted, move the shared property or version up to the root file rather than editing it +in place. + +### Build tasks + +Run these from VS Code's task runner (Terminal -> Run Task) or an agent's task-running tool. The +three clean-compile tasks are carried unchanged, and a repo adds its own convenience tasks (tool +updates, dependency upgrades, benchmarks) on top: + +- `.NET Build`: build with diagnostic verbosity *(clean-compile)* +- `CSharpier Format`: auto-format code with CSharpier *(clean-compile)* +- `.NET Format`: run CSharpier and build, then verify formatting and style with + `--verify-no-changes` *(clean-compile, the task to run after edits)* + +## Tooling and editor + +- **CSharpier** is the primary code formatter, invoked by the `CSharpier Format` task or + `dotnet csharpier format --log-level=debug .`. +- **`dotnet format`** verifies style: + `dotnet format style --verify-no-changes --severity=info --verbosity=detailed`. +- **`dotnet-outdated-tool`** checks for dependency updates, and Nerdbank.GitVersioning owns + version management. +- CI is the authoritative lint backstop. A repo already keeping a .NET tool manifest declaring + Husky.Net wires its local pre-commit hook from `catalog/snippets/husky/` in the hub, hub-local + and not carried into every fleet repo. A repo keeping none, or one preferring the `pre-commit` + framework, takes `catalog/snippets/pre-commit/` instead. Either shape covers the shared doc + gates alongside the language checks. Each snippet's own README names the per-clone steps and + the second file to copy alongside it. +- **Required VS Code extensions**: CSharpier, markdownlint, CSpell. Use the workspace settings + without overrides. + +## Coding standards and conventions + +Key rules: no `var` (always explicit types), file-scoped namespaces, Allman braces, Nullable +enabled, modern C# features (primary constructors, pattern matching, collection expressions). Every +public surface has XML documentation. Private fields use `_camelCase`, static fields `s_camelCase`, +constants PascalCase. Member ordering follows StyleCop SA1201. + +For language features, naming, code structure, and XML documentation examples, see +`references/conventions.md`. + +## Analyzer suppressions (.NET) + +CODESTYLE.md's General section sets the suppression-scope order fleet-wide: narrowest scope first, +symbol-scoped before project-scoped before repo-wide, and only for a genuine false-positive or a +deliberate, documented exception, never a blanket relaxation to get a brownfield port to build. +The .NET mechanics, narrowest first: + +- **Never use `#pragma warning disable`** to silence an analyzer. +- **Symbol-scoped**: a `[System.Diagnostics.CodeAnalysis.SuppressMessage(...)]` attribute with a + `Justification`, on the specific member or type: + + ```csharp + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1034:Nested types should not be visible", + Justification = "https://github.com/dotnet/sdk/issues/51681" + )] + ``` + +- **Project-scoped** (e.g. a test project): a `dotnet_diagnostic.<RULE>.severity` entry in that + project's own `.editorconfig`, with a comment explaining why. +- **Repo-wide**: a `dotnet_diagnostic.<RULE>.severity` entry in the root `.editorconfig`, only + when the rule is genuinely not applicable to any project. Relaxing a batch of `CA*` rules (or + `dotnet_analyzer_diagnostic.severity`) to push a brownfield port through the build is exactly + what this forbids. + +## Error handling and logging + +1. **Structured logging**: use structured message templates. Serilog is the application's concrete + backend, and a library never references it directly (see item 2): + + ```csharp + logger.LogError(exception, "{Function}", function); + ``` + +2. **Libraries log through abstractions, never a concrete backend.** A NuGet library depends only + on `Microsoft.Extensions.Logging.Abstractions` and exposes an `ILoggerFactory` seam: a settable + global factory defaulting to `NullLoggerFactory.Instance` (fallback `NullLogger.Instance`) with + `SetFactory`/`TrySetFactory`, and/or an `ILoggerFactory`/`ILogger` parameter in its API. It + must not reference Serilog or any sink, which would force a logging framework on every consumer + and drag in AOT-incompatible dependencies. The consuming application owns the concrete logger + (Serilog is fine there), bridges it to `ILoggerFactory` (e.g. `SerilogLoggerFactory` from + `Serilog.Extensions.Logging`), and injects it. Reference pattern: a `LogOptions` seam in the + library, against which the consuming CLI builds the Serilog-backed factory and injects it via + `LogOptions.SetFactory`. +3. **CallerMemberName**: use for automatic function name tracking: + + ```csharp + public bool LogAndPropagate( + Exception exception, + [CallerMemberName] string function = "unknown" + ) + ``` + +4. **Logger extensions**: use `Extensions.cs` for logger and other extension methods: + + ```csharp + extension(ILogger logger) + { + public bool LogAndPropagate(Exception exception, ...) { } + } + ``` + +5. **Exceptions**: do not swallow exceptions, either log and rethrow or translate to a + domain-specific exception. + +## Code patterns + +1. **Guard clauses**: prefer early returns for validation and error handling. +2. **Async all the way**: avoid blocking calls (`.Result`, `.Wait()`), use `async`/`await`. +3. **Cancellation tokens**: accept `CancellationToken` as the last parameter and pass it through. +4. **ConfigureAwait**: in library code, use `ConfigureAwait(false)` unless context is required. Do + not call `ConfigureAwait(false)` in xUnit tests (see xUnit1030). +5. **Disposables**: use `await using` for async disposables, prefer `using` declarations. +6. **LINQ vs loops**: use LINQ for clarity, loops for hot paths or allocations. +7. **HTTP**: reuse `HttpClient` via factory, never per-request instantiation. +8. **Collections**: prefer `IReadOnlyList<T>`/`IReadOnlyCollection<T>` for public APIs. +9. **Immutability**: prefer immutable records, use init-only setters when records are not + suitable, and prefer immutable or frozen collections for read-only data. +10. **Exceptions as control flow**: avoid using exceptions for expected flow. +11. **Sealing classes**: seal classes that are not designed for inheritance. +12. **Lazy initialization**: use `Lazy<T>` for static, thread-safe instantiation (e.g. a logger + factory, an HTTP factory). + +## Testing conventions + +xUnit v3 (`xunit.v3`, not the legacy `xunit`) + AwesomeAssertions (`.Should()` API, never native +asserts). Arrange-Act-Assert pattern, descriptive underscore names, `[Theory]`/`[InlineData]` for +parameterized tests. A test project on `xunit.v3` 4.0.0 or later is MTP-based, and also carries a `global.json` runner declaration, a `Microsoft.Testing.Extensions.CodeCoverage` floor, and no `xunit.runner.visualstudio`. See `references/testing.md` for the framework setup template and that configuration. + +## Project configuration + +.NET 10.0 target, AOT-compatible (`IsAotCompatible=true`, `VerifyReferenceAotCompatibility=true`), +SourceLink, embedded untracked sources, `InternalsVisibleTo` for test/benchmark access. See +`references/project-config.md` for the full property list. + +## Best practices + +All changes go through pull requests. diff --git a/.github/skills/dotnet-codestyle/references/conventions.md b/.github/skills/dotnet-codestyle/references/conventions.md new file mode 100644 index 0000000..46897c8 --- /dev/null +++ b/.github/skills/dotnet-codestyle/references/conventions.md @@ -0,0 +1,136 @@ +# .NET Coding Standards and Conventions + +Code snippets below are illustrative examples only, replace namespaces and types to match your +project. + +## C# language features + +1. **File-scoped namespaces**: + + ```csharp + namespace Example.Project.Library; + ``` + +2. **Nullable reference types**: enabled (`<Nullable>enable</Nullable>`), use nullable annotations + appropriately, use `required` for mandatory properties. +3. **Modern C# features**: prefer modern language constructs, primary constructors when + appropriate, top-level statements for console apps, pattern matching over traditional checks, + collection expressions when types loosely match, extension methods (the classic + `this`-parameter form or an `extension(<receiver>) { ... }` block on C# 14+), implicit object + creation when the type is apparent, range and index operators. +4. **Expression-bodied members**: use for applicable methods, properties, accessors, operators, + lambdas, local functions. +5. **`var` keyword**: do NOT use `var`, always use explicit types: + + ```csharp + // Correct + int count = 42; + string name = "test"; + + // Incorrect + var count = 42; + var name = "test"; + ``` + +## Naming conventions + +1. **Private fields**: underscore prefix with camelCase: + + ```csharp + private readonly HttpClient _httpClient; + private int _counter; + ``` + +2. **Static fields**: `s_` prefix with camelCase: + + ```csharp + private static int s_instanceCount; + ``` + +3. **Constants**: PascalCase: + + ```csharp + private const int MaxRetries = 3; + ``` + +## Code structure + +1. **Global usings**: use `GlobalUsings.cs` for common namespaces: + + ```csharp + global using System; + global using System.Net.Http; + global using System.Threading.Tasks; + global using Microsoft.Extensions.Logging; + ``` + +2. **Usings placement**: outside the namespace, sorted with `System` directives first: + + ```csharp + using System.CommandLine; + using System.Runtime.CompilerServices; + using Example.Project.Library; + + namespace Example.Project.Console; + ``` + +3. **Braces**: Allman style: + + ```csharp + public void Method() + { + if (condition) + { + // code + } + } + ``` + +4. **Indentation**: C# files 4 spaces, XML/csproj files 2 spaces, YAML files 2 spaces, JSON files + 4 spaces. +5. **Line endings**: not specified here, governed per repo by `.editorconfig` / `.gitattributes` + per GOVERNANCE.md's "Line Endings" section. +6. **`#region`**: do not use regions, prefer logical file/folder/namespace organization. +7. **Member ordering (StyleCop SA1201)**: const -> static readonly -> static fields -> instance + readonly fields -> instance fields -> constructors -> public (events -> properties -> indexers + -> methods -> operators) -> non-public in same order -> nested types. + +## Comments and documentation + +XML documentation is on: `<GenerateDocumentationFile>true</GenerateDocumentationFile>`, and +missing XML comments for public APIs are suppressed in `.editorconfig`. Every public surface must +still be documented: a single-line summary, additional details in remarks, documented input +parameters, return values, exceptions, and crefs. + +```csharp +/// <summary> +/// Example of a single line summary. +/// </summary> +/// <remarks> +/// Additional important details about usage. +/// Multiple lines if needed. +/// </remarks> +/// <param name="category"> +/// The quote category to request +/// </param> +/// <param name="cancellationToken"> +/// A <see cref="System.Threading.CancellationToken"/> that can be used to cancel the request. +/// </param> +/// <returns> +/// A <see cref="string"/> containing the quote text. +/// </returns> +/// <exception cref="System.ArgumentException"> +/// Thrown when <paramref name="category"/> is not a supported value. +/// </exception> +public async Task<string> GetQuoteOfTheDayAsync(string category, CancellationToken cancellationToken) +{ + if (category is not ("motivational" or "humor")) + { + throw new ArgumentException($"Unsupported category: {category}", nameof(category)); + } + + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(1, cancellationToken); + return $"Quote for {category}"; +} +``` diff --git a/.github/skills/dotnet-codestyle/references/project-config.md b/.github/skills/dotnet-codestyle/references/project-config.md new file mode 100644 index 0000000..42b8fd1 --- /dev/null +++ b/.github/skills/dotnet-codestyle/references/project-config.md @@ -0,0 +1,21 @@ +# .NET Project Configuration + +1. **Target framework**: .NET 10.0 (`<TargetFramework>net10.0</TargetFramework>`). +2. **AOT compatibility**: `<IsAotCompatible>true</IsAotCompatible>`, + `<VerifyReferenceAotCompatibility>true</VerifyReferenceAotCompatibility>`. +3. **Assembly information**: use semantic versioning, include SourceLink + (`<PublishRepositoryUrl>true</PublishRepositoryUrl>`), embed untracked sources + (`<EmbedUntrackedSources>true</EmbedUntrackedSources>`). +4. **Internal visibility**: use `InternalsVisibleTo` for test and benchmark access (adapt the + project names to your repo's test/benchmark projects): + + ```xml + <ItemGroup> + <InternalsVisibleTo Include="YourBenchmarkProject" /> + <InternalsVisibleTo Include="YourTestProject" /> + </ItemGroup> + ``` + +5. **Nullable and XML documentation**: `<Nullable>enable</Nullable>`, + `<GenerateDocumentationFile>true</GenerateDocumentationFile>` (see `references/conventions.md` + for the XML documentation format every public surface needs). diff --git a/.github/skills/dotnet-codestyle/references/testing.md b/.github/skills/dotnet-codestyle/references/testing.md new file mode 100644 index 0000000..4ec0c4e --- /dev/null +++ b/.github/skills/dotnet-codestyle/references/testing.md @@ -0,0 +1,41 @@ +# .NET Testing Conventions + +1. **Framework**: xUnit v3 or later (the `xunit.v3` package, never the legacy v2 `xunit` package) + with AwesomeAssertions for every assertion. Native xUnit asserts (`Assert.Equal`, + `Assert.True`, ...) are not allowed, use the fluent `.Should()` API. Dynamic test skipping + (`Assert.Skip`, `Assert.SkipWhen`) is control flow, not an assertion, and stays native: + + ```csharp + [Fact] + public void MethodName_Scenario_ExpectedBehavior() + { + // Arrange + int expected = 42; + + // Act + int actual = GetValue(); + + // Assert + actual.Should().Be(expected); + } + ``` + +2. **Organization**: Arrange-Act-Assert pattern. +3. **Naming**: descriptive names with underscores. +4. **Theory tests**: use `[Theory]` with `[InlineData]`. + +## Microsoft.Testing.Platform and coverage + +A test project on `xunit.v3` 4.0.0 or later is MTP-based, and the .NET 10 SDK and later refuse to run one through the VSTest target, so such a project also carries: + +- a root **`global.json`** declaring `{"test": {"runner": "Microsoft.Testing.Platform"}}`, which is what selects the driver `dotnet test` runs the project through, +- **`Microsoft.Testing.Extensions.CodeCoverage`** at **18.9.0 or later**, in place of `coverlet.collector`, whose VSTest data collector MTP ignores without failing, +- no **`xunit.runner.visualstudio`**, the VSTest adapter MTP replaces. + +A project not yet MTP-based keeps the VSTest collector, and that lagging state is a migration owed rather than drift, until its own `xunit.v3` bump forces the move. + +**The version floor is load-bearing rather than cautionary.** Below 18.1.0 the extension is built against Microsoft.Testing.Platform 1.x, and an 18.0.x resolution, which is what a `>= 18.0.0` range picks, throws a `TypeLoadException` against the 2.x platform `xunit.v3` 4.0.0 carries, runs zero tests, and **still writes a well-formed Cobertura file reporting full coverage**, so only the non-zero exit says the run reported nothing. 18.9.0 is the first release on Microsoft.Testing.Platform 2.3.x, where every test project writes into the one shared `--results-directory` the invocation names rather than resolving that relative path per project. + +The CI invocation `WORKFLOW.md` D1.6 requires is `dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`. Two further details of it are equally load-bearing, and neither failure reds the job on its own. `--coverage-output` stays unset, because pinning one filename gives every test project in the solution the same path and a solution with more than one then keeps only whichever ran last. Leaving it unset produces the default name `<guid>.cobertura.xml`, which `codecov-cli`'s own file finder does not match, so the report is renamed before the upload reads the directory, per `WORKFLOW.md` D1.6. + +**Diagnosing a local run.** `dotnet test` under the CI configuration reports zero tests on some machines where CI reports the full suite on the same SDK, which reads as a broken repository and is a broken driver. The target string the run prints separates the two: `net10.0` with no architecture means the driver resolved none, and `net10.0|<arch>` with no tests means the tests did not register, which is the case that points back at the three requirements above. diff --git a/.github/skills/drive-pr/SKILL.md b/.github/skills/drive-pr/SKILL.md new file mode 100644 index 0000000..7322ec1 --- /dev/null +++ b/.github/skills/drive-pr/SKILL.md @@ -0,0 +1,213 @@ +--- +name: drive-pr +description: >- + Drives a ptr727/ProjectTemplate fleet pull request through its review loop, feature branch into + develop and, when asked, on to a mergeable develop -> main promotion PR, disposing of every + reviewer finding along the way under pr-review-conduct's outcomes, carried here whole as a + generated include, and escalating to whoever dispatched the drive where the drive's own seat + cannot reach the maintainer. Use this whenever asked to drive, land, take, chase, or push + a PR toward develop or main, or to run the review loop hands off instead of narrating each + round. When the request does not say how far ("drive this PR", "land it"), ask once whether the + target is develop or a mergeable main promotion PR, rather than guessing. Triggers even when + only one PR is named, because a finding raised against the develop -> main promotion PR + routinely needs its own feature -> develop fix cycle before the promotion PR can go green, and + stopping at the first promotion-PR finding is the early exit this skill exists to prevent. Ends + at develop merged, or at a promotion PR meeting every pr-review-conduct Merge Gate item except + the maintainer's explicit permission to merge, never merges main itself, that is the separate + merge-and-release skill, its own go-ahead. +--- + +# Drive PR + +## Why This Exists + +The same request repeats every time a change is ready: drive it through review, resolve whatever +a reviewer raises, and keep going until develop, or main, actually has it. Re-explaining the +finding-disposition policy and the promotion-PR wrinkle each time is the cost this skill removes. +The wrinkle: a finding raised against the develop -> main promotion PR usually cannot be fixed on +that PR directly, its diff is develop's diff against main, so the fix lands as its own +feature -> develop PR first. Stopping at the first such finding, or forgetting to loop back to the +promotion PR once the fix lands, is the early exit this skill exists to prevent. + +## How Far to Drive + +- Read the invocation for an explicit target first. "To develop" or "to dev" means stop once + merged into develop. "To main", "through to main", or "all the way" means continue to a + mergeable promotion PR. Act on either without asking. `session-handoff`'s attended session, + invoked by "resume the handoff", states the second, and naming that procedure names this skill. +- When the request names no target ("drive this PR", "land it", "take this PR"), ask once, + before the first push: develop only, or all the way to a mergeable main promotion PR. Recommend + "all the way to main" as the default, a promotion PR left to go stale once develop is ready is + the more common regret than driving one step too far. +- A drive dispatched as part of a larger run takes its target from the brief and asks no one, + since a subagent stopping to ask stalls a run designed to keep moving without one, and the seat + that dispatched it is the seat that holds the maintainer's answer. `backlog-burndown` is such a + run, and it briefs develop only, driving the develop -> main promotion pull request in its own + seat under "The Drive Loop"'s promotion steps. A brief naming no target at all is one to stop + and ask its dispatcher about, and asking the dispatcher is the whole of what a dispatched drive + does about an authorization question. A dispatched drive is not the seat that can verify a + grant, so it does not try: the responsibility for having the maintainer's go-ahead sits with the + dispatcher, and a worker inventing a check it cannot perform would only launder that + responsibility rather than discharge it. +- **A brief is never itself the authorization**, which binds the dispatching seat. What authorizes + a merge is what the maintainer said, recorded where the skill that carries the grant states its + scope, the way `backlog-burndown`'s own "What Invoking This Skill Authorizes" does. Writing an + approval into a brief creates none, since an agent cannot widen its own permission by writing + itself one, and a merge is the outward-facing act this skill's own "What Invoking This Skill + Authorizes" keeps tied to something the maintainer actually said. +- A repo on the operational workflow model (registry `workflowModel: operational`) has no + standing promotion PR expectation, confirm whether a promotion PR is even wanted before opening + one, per branching-and-release-model's "Operational repositories (the complete delta)" + section. + +## What Invoking This Skill Authorizes + +- Naming this skill, and answering its how-far question, is the maintainer's explicit, current + go-ahead for every feature -> develop squash merge the drive performs to reach that target. +- A dispatched drive answers no such question, so what stands in its place is the go-ahead the + dispatching seat holds, per "How Far to Drive" above, and the drive performs the same merges on + it. Reading this bullet list is not how such a drive establishes that, since a worker cannot + verify a grant made in a seat it has no access to. +- It is never authorization to merge the develop -> main promotion PR, or to dispatch a release. + Those stay in merge-and-release, invoked on its own so the maintainer keeps a checkpoint before + the harder-to-reverse step. +- The pr-review-conduct Merge Gate still gates every merge this skill performs on its own. The + go-ahead removes the "may I merge to develop" question, not the gate itself, a feature PR with + an open finding does not merge regardless of target. + +## The Drive Loop + +1. Isolate into a worktree per repo-worktree, based on the branch that skill's base rule names, develop unless the task is explicitly about main-only content, before the first edit. +2. Commit the work, then run `local-strict-review` and record its pass in the order that skill + gives, its diff receipt following the commit. Where the change also carries the canonical ledger, + that skill's carried-content records instead precede the commit, because that ledger is tracked. + Then push the branch and open the feature -> develop PR if it does not exist yet. Open it + carrying the `comments` label where the change adds or edits a comment line in code or config, + since the prose gate refuses one otherwise and reads the label off the event that started the + run, so adding it after a failing check applies to the next push rather than to a re-run of + that one. + A push refused by a `.husky/pre-push` hook, which the hub carries and a + repository has only if it adds one, is that gate working rather than an + obstacle to route around, and that + skill's refusal table says what each refusal means and what clears it. +3. Drive pr-review-conduct's review loop on it to the Merge Gate, disposing of every finding per + "Disposing of Every Finding" below. +4. Capture the branch's own tip before merging, `gh pr view <number> --repo <owner>/<repo> --json headRefOid --jq + .headRefOid`, needed for the verify-then-delete step below since `gh pr merge` itself reports + the resulting squash commit on `develop`, not the PR's `headRefOid`. Merge the feature PR into + develop, `gh pr merge <number> --squash --repo <owner>/<repo>`. Never `--delete-branch` on this + call, it is run from inside the task's own worktree per step 1, where the feature branch is + checked out, and `gh pr merge --delete-branch` needs to switch that worktree to the base branch + to delete it, which fails when `develop` is already checked out somewhere else, the ordinary + case in this layout. Instead run repo-worktree's post-merge cleanup from the base clone, remove + the worktree and delete the now-merged local task branch, then verify before deleting the remote + one, which is this skill's own step rather than that one's. Both remote commands resolve `origin`, + so they hold only where the pull request's head branch lives in this repository, which step 1 + guarantees by branching here. A pull request opened from a fork follows + `upstream-contribution-workflow` instead and neither command applies to it, since `origin` would + name the base repository and exit `2` would mean the branch was never there rather than already + deleted. The object id in `git ls-remote --heads --exit-code -- origin "refs/heads/<branch>"`, + which prints `<oid>\t<ref>` so the id is its first field, matches the `headRefOid` captured above, `--` before `origin` and the fully-qualified ref. `--heads origin + "<branch>"` alone still tail-matches a differently-prefixed branch sharing the same suffix, and + `--` placed after `origin` instead of before it is not equivalent either, verified empirically + against a `refs/heads/other/--` ref: after-origin also matched it, before-origin matched only + the one intended. `--exit-code` distinguishes exit `2`, branch genuinely gone, from any other + non-zero exit, a failed query, an unreachable remote and a gone branch both print nothing to + stdout otherwise. Exit `2` means the remote branch is already gone, so the delete is done and + the step is complete. Stop and report either a mismatch or a failed query rather than deleting, + someone could have pushed to the branch after the merge, or the name could have been reused. + `<branch>` is the real value, substituted as its own quoted argument (a shell variable + expansion such as `"$branch"`, or an argv element), never handed to `eval` or `sh -c` for a + second round of shell parsing, the only way an embedded `$()` or backtick would actually run. + A valid ref can start with `-` or carry a shell metacharacter, which is why it stays quoted + regardless. Only once it matches, `git push origin --delete -- "<branch>"`. Never + `--force-with-lease` here, git-commit-conventions forbids it + unconditionally, this plain verify-then-delete is the safety gate, not a compare-and-swap at + delete time. The + repo's auto-delete-head-branches setting is kept off fleet-wide (to protect `develop` and + `main` from it, GitHub has no per-branch exception), so nothing deletes an ordinary feature + branch automatically. Stop here and report the merged PR when the target is develop only. +5. Open the develop -> main promotion PR if it does not exist yet, or find the existing one. +6. Drive its review loop the same way. A finding that needs a code change never gets pushed to + the promotion PR directly, its head is develop, so land the fix as a fresh pass through steps + 1 to 4 in its own worktree and branch, then return here. +7. The fix landing on develop updates the promotion PR's diff and head SHA on its own, re-request + a review on the new head and continue the loop. +8. Repeat 6 and 7 until the promotion PR meets every pr-review-conduct Merge Gate item except the + maintainer's explicit permission to merge. +9. Report the promotion PR number and its ready state. Do not merge it. + +## Disposing of Every Finding + +The rule below is a generated include, so a defect in it is fixed in `pr-review-conduct` and +regenerated rather than edited here. A drive that cannot reach the maintainer directly, a +dispatched one being the ordinary case, escalates per `pr-review-conduct` "Escalate to the +maintainer when". + +<!-- include: .agents/skills/pr-review-conduct/SKILL.md > Every finding ends in one of five outcomes --> + +1. **Real, so fix it, and fix the class rather than the instance.** A reviewer samples rather + than enumerates, so sweep for the finding's siblings before replying and fix each one sitting + in a file the diff already touches or that this change itself made wrong, filing the rest, per + `GOVERNANCE.md` "Verification Discipline". That sweep is owed the first time the finding is + raised, not once it recurs. Take the fix through `local-strict-review` the same way the push + that opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply + with the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per + `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent + elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. +2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** + Disprove a wrong finding with the command and its output, the code path that makes it + impossible, or the rule that governs it. A finding that is factually correct but not this + repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that + sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape + closes the thread on its own evidence, and the agent resolves such a thread itself rather than + leaving it for the maintainer. What makes that safe is the evidence being checkable by anyone, + a command and its output, the code path, the quoted rule, a byte-identical diff, so a decline + resting on anything weaker is not one of these. An assertion ("this is fine") does not close a + finding, and outcome 3's value call is the maintainer's, so that thread stays open until they + answer it. +3. **Real, fixable here, but deliberately left as is, a value call rather than a scope + boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is + ruled out, since a scope boundary declines on its own evidence and never needs this outcome at + all. State the finding and why the fix is unwanted, and get an explicit answer in the same + turn, before moving to other work. A plan to ask later is resolution by silence the moment + attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open + and say so, rather than treating the intention to ask as the asking. +4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral + noted only in a thread is lost the moment the PR merges. File it in the repository where the + fix has to land, which for a finding against carried content is the repository that authors + that content rather than the one carrying it, since an issue filed where nobody may make the + fix is a deferral nobody can close. +5. **Keeps recurring although the class was swept, so the rule is what needs fixing.** A finding + raised repeatedly against correct code means the code is not communicating something: add the + comment, sharpen the name, narrow the interface, or fix the rule if the rule is wrong. + Bouncing the same point across rounds is the signal to escalate the rule itself, not to keep + re-arguing it. This is not where the class sweep lives, outcome 1 already owing that on the + first instance, and reaching here means the sweep ran and the finding came back anyway. + +**A disposition decided on one PR does not carry to the next.** The same finding shape recurring +on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own +evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior +instance's outcome is context for the new one, never a standing answer to reuse in its place. + +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + +<!-- /include --> + +## Mechanics Live Elsewhere + +- Review loop mechanics, the Merge Gate, and `scripts/pr_review.py`: pr-review-conduct. +- Branch rules, never delete develop, the EOL-only conflict, issue-closing keywords belonging on + the promotion PR: branching-and-release-model. +- Worktree isolation and post-merge cleanup: repo-worktree. + +## Stop and Ask, Beyond the How-Far Question + +- A genuine design trade-off, a recurring finding pattern, or an architectural redesign proposal + each escalate per pr-review-conduct's own list, restated there, not duplicated here. +- An unrecognized review shape blocks the gate on its own, file an issue naming it and ask, never + guess what new wording probably meant. diff --git a/.github/skills/fleet-code-review/SKILL.md b/.github/skills/fleet-code-review/SKILL.md new file mode 100644 index 0000000..42d3cda --- /dev/null +++ b/.github/skills/fleet-code-review/SKILL.md @@ -0,0 +1,73 @@ +--- +name: fleet-code-review +description: >- + Reviews a pull request or change set against the repository's contracts, with explicit diff + coverage and no suppressed findings. Use this whenever asked to review code, a pull request, a + patch, or a proposed change, and whenever GitHub Copilot performs code review. Triggers even + when the diff is documentation-only or workflow-only, because the review must load the + applicable general, language, documentation, and workflow skills before judging the change. This + skill judges a diff: disposing of a pull request's findings is `pr-review-conduct`, and the + pre-push pass over this branch is `local-strict-review`, which reuses this skill's criteria. +--- + +# Fleet Code Review + +## Establish the Contract + +1. Read the root `AGENTS.md` and the sections it routes to for the changed paths. +2. Read the complete diff and enumerate every changed file before forming findings. +3. Load every applicable sibling skill from the current skill distribution: + - `comment-and-doc-style` for Markdown, prose, comments, commit messages, and PR titles. + - `dotnet-codestyle` for C# and .NET changes. + - `python-codestyle` for Python changes. + - `shell-codestyle` for shell changes. + - `workflow-ci-contract` for GitHub Actions and CI/CD changes. +4. Treat a missing executable on `PATH` as no evidence that its check is unavailable. Read the + repository's documented local invocation before reporting a check as skipped. + +Do not substitute a familiar convention for the repository's written contract. Report a +conflict between instructions instead of silently choosing one. + +## Review the Change + +Review for correctness, regressions, security, compatibility, error handling, concurrency, +resource lifetime, tests, and contract drift. Follow data and control flow beyond the edited +lines when the behavior depends on unchanged callers or consumers. + +For each candidate finding: + +1. Verify it against the current head tree, not an unfetched checkout or the base branch. +2. Identify the concrete failing behavior and the conditions that reach it. +3. Confirm that the repository does not already prevent it elsewhere. +4. Prefer one root-cause finding over several symptoms of the same defect. +5. Omit pure preferences that no repository rule or user-visible risk supports. + +Review carried fleet content by intent and fidelity. A byte-locked reference to a path that one +downstream repository does not carry is not a broken link. A substantive defect in canonical +content remains a finding, with the fix located at its canonical source. + +## Publish Every Finding + +Never suppress or hide a finding because confidence is low. Investigate until it is supported +or discard it. Publish every supported finding as an inline review comment when a changed line +can anchor it. Use the review body only when no valid inline anchor exists. + +Each finding states: + +- A concise imperative title with a severity. +- The file and smallest useful line range. +- The behavior that fails and the input or state that triggers it. +- Why the change causes the failure. +- A bounded direction for the fix when one is known. + +Do not report a clean review until every changed file has been read. End the review body with +exactly one ASCII marker, replacing the numbers with measured counts: + +```text +<!-- fleet-review: reviewed=N changed=N findings=N --> +``` + +`reviewed` is the number of changed files actually reviewed. `changed` is the total number of +changed files. `findings` is the number of published findings, including body-only findings. +Never emit `reviewed=changed` as a placeholder. If full coverage is impossible, emit the actual +counts and explain the limitation in the review body. diff --git a/.github/skills/git-commit-conventions/SKILL.md b/.github/skills/git-commit-conventions/SKILL.md new file mode 100644 index 0000000..5c1454f --- /dev/null +++ b/.github/skills/git-commit-conventions/SKILL.md @@ -0,0 +1,167 @@ +--- +name: git-commit-conventions +description: >- + Governs how an agent stages, commits, signs, and pushes in a ptr727/ProjectTemplate fleet repo: + default-to-staging vs. explicit commit authorization, why "commit" means commit-and-push, the + mandatory signed-commit and noreply-identity checks, never force-pushing, how a history rewrite + must re-identify a commit that is not the agent's own, and the destructive-git-command ban. Use + this whenever about to run git add/commit/push, whenever authorization to commit is ambiguous + ("fix this" versus "commit this"), whenever about to configure or verify commit signing or + git user.email, whenever a merge conflict or a stale branch tempts a force-push or a hard reset, + and whenever rewriting history (filter-repo, an interactive rebase equivalent) touches a commit + authored or committed by someone else. Triggers even when the task looks like routine + housekeeping, such as "clean up this branch" or "just push it", because a scope-widened commit + authorization, an unsigned commit, a fabricated identity, or a force-push are each easy to do by + habit and each one is a hard-to-reverse mistake on a shared branch. +--- + +# Git Commit Conventions + +## Why this exists + +These are the fleet's mechanical git rules for producing a commit, kept in one place instead of +re-derived per repo or per session: whether to commit at all, what committing implies, how +signing and identity are verified rather than configured, and which commands are never run +without being asked. None of these are style preferences. Branch protection enforces several of +them at push time, and the rest guard against damage a rejected push does not undo (a +scope-widened commit, a rewritten shared history, a destructive reset). + +## Staging versus committing + +- **Default to staging, not committing.** Stage with `git add` and leave `git commit` to the + developer unless the developer has explicitly authorized committing for the current ask ("commit + this", "open a PR"). Authorization is scope-bound: it covers the commits that specific task + needs, not a blanket license for the rest of the session. +- **Stage by explicit path, never `git add -A` or `git add .`.** A blanket add stages whatever + else happens to be in the tree, and what it sweeps in is another task's uncommitted work, + landing in a commit whose subject never mentions it, committed by a session that never saw it. + That sweep has happened, which is why task isolation exists (the `repo-worktree` skill), and + isolation makes a shared tree rare rather than impossible. Name the files this task changed, + and let anything else stay unstaged. +- **"Commit" means commit and push.** An authorization to commit carries the push to the feature + branch the work belongs on, because nothing reviews a local commit. The Copilot review loop, the + required status checks, and the maintainer all read the remote, so stopping at `git commit` + leaves the review unstarted and the branch's state private to one machine, which reads as + progress while none of the gates have run. Push to the feature branch, never to a protected + branch, and never with `--force`. Holding a commit locally is the narrower case: it happens when + the developer asks for it, not by default. +- **Check `git status` before committing, and treat any change this session did not make as a + stop.** The maintainer hand-edits files live, often `README.md`/`HISTORY.md`, sometimes with an + editor's LF -> CRLF flip on top, and a sibling agent session sharing the tree leaves its edits + the same way. Whoever the author is, a change this session did not make is never bundled: ask + whether to include it, or leave it unstaged and say so, rather than committing half-finished + work or stranding it in an unrelated commit. An unexpected change in the tree is also the + signal to re-check isolation per the `repo-worktree` skill, since it may mean another task is + live in this checkout. + +## Signing, verified not configured + +- **Every commit must be cryptographically signed (SSH or GPG).** Branch protection enforces this + on every fleet branch, and an unsigned commit is rejected on push. Signing depends on + environment configuration (`commit.gpgsign`, `user.signingkey`, `gpg.format`), but none of those + values prove signing actually works: `gpg.format=ssh` can sign straight from a key file with no + `ssh-agent` running at all (the common case on Git for Windows), just as GPG can sign + agent-backed or straight from a keyring. **Probing agent liveness (`ssh-add -L`, a `gpg-agent` + check) is not a valid test and must not be used.** It tests one specific delivery path, not + whether a commit actually ends up signed, and a host that signs straight from a key file fails + that probe while signing correctly. +- **Verify with a real scratch commit, read back with git's own verdict, not a text grep.** This + single probe is tech-agnostic (SSH agent-backed, SSH key-file, GPG agent-backed, and GPG keyring + all exercise the same code path) and doubles as the identity check below. Run it once before the + first agent-authored commit of a session. Don't assume a prior session left config correct. The + commit below is plain, deliberately no `-S`: forcing it would still succeed on a host where + `commit.gpgsign` is unset or false, which is the exact default-config gap this probe exists to + catch, since every real commit an agent makes is plain too: + + The probe is one physical line, not backslash-joined ones, so it copy-pastes cleanly into a + shell: + + ```sh + d=$(mktemp -d "${TMPDIR:-/tmp}/sign-check.XXXXXX") && ( trap 'rm -rf "$d"' 0; email=$(git config --global --get user.email) && git init -q "$d" && git -C "$d" commit --allow-empty -q -m check && out=$(git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>') && echo "$out" && ae=$(git -C "$d" log -1 --format='%ae') && ce=$(git -C "$d" log -1 --format='%ce') && case "$out" in sig=G\ *|sig=U\ *) true ;; *) false ;; esac && case "$email" in *@users.noreply.github.com) true ;; *) false ;; esac && [ "$ae" = "$email" ] && [ "$ce" = "$email" ] ) + ``` + + PowerShell equivalent: + + ```powershell + $d = Join-Path $env:TEMP ([guid]::NewGuid()) + try { + $email = git config --global --get user.email + git init -q "$d" ` + && git -C "$d" commit --allow-empty -q -m check + $out = git -C "$d" log -1 --format='sig=%G? author=%an <%ae> committer=%cn <%ce>' + $out + $ae = git -C "$d" log -1 --format='%ae' + $ce = git -C "$d" log -1 --format='%ce' + if ($out -notmatch '^sig=[GU] ' -or $email -notmatch '@users\.noreply\.github\.com$' ` + -or $ae -ne $email -or $ce -ne $email) { + throw "signing/identity check failed: $out" + } + } finally { + if (Test-Path "$d") { Remove-Item -Recurse -Force "$d" } + } + ``` + + `sig` must read `G` (good signature) or `U` (good signature, unrecognized signer). For GPG, `U` + is a valid signature from a key whose trust level is merely undefined, common right after + generating a new key. For SSH, it's a valid signature from a key not found in the local + `allowed_signers` file, which doesn't affect whether GitHub itself verifies the commit, only + local `git verify-commit` output. `sig` is git's own verdict char. Don't grep localized + "Good" text, since that varies by git version and locale. Anything else, or the commit failing + outright, means **do not commit**: surface the actual error to the developer and stop at + `git add`. Nothing else is contrary evidence: not an unreachable agent, not a config value, not a + signature type you can't otherwise explain in past history (see below). +- **A mix of SSH- and GPG-signed commits in history is structural, not a host to track down.** + `git log --pretty='%G? %GK'` shows two distinct shapes, not two health states: a commit committed + by the PR's own author carries that host's own signature type, while a commit committed by + `GitHub <noreply@github.com>` is a squash-merge: GitHub creates and signs that commit itself, + server-side, with GitHub's own GPG key, regardless of what the PR author signed with locally. + Every commit on `develop`/`main` past its first squash-merge shows `GitHub` as committer and a + GPG signature. That's expected on every fleet repo, on every host, and is not evidence anything + is misconfigured. Check `commit.committer.name` before treating a differing signature type as a + clue worth chasing. +- **Signing must be live before the *first* commit, not retrofitted.** Turning on a + require-signed-commits rule against a branch that already carries unsigned commits forces a + rewrite of that entire history to re-sign it, changing every commit SHA and making whoever does + the rewrite the committer and signer of every commit in it (a rebase preserves `author` but not + the original signatures, and one contributor cannot sign for another). During new-repo setup, + never create commits until signing is verified. + +## Identity, verified not set + +**Commit under the committing account's own GitHub `noreply` identity, never a private, personal, +or invented address.** `author` and `committer` on every agent-authored commit are the GitHub +`noreply` address of the account whose key signs the commit, in `username@users.noreply.github.com` +or `ID+username@users.noreply.github.com` form. **Verify it, do not set it**: the scratch commit +from the signing check above already proves this end-to-end. Read its `author=`/`committer=` +output rather than trusting `git config --get user.email` alone, since a global config value +doesn't prove what actually lands on a commit object, and read both rather than the author alone +since a rebase, amend, or cherry-pick can rewrite the committer while leaving the author +untouched. Match both against that address before committing, rather than +writing a repo-local override. The identity is host configuration set globally once, so a repo-local +`user.email` is redundant where the global is right and a silently-shadowing wrong identity where +it is not. A mismatch is a host fault to surface to the maintainer, not to patch per repo, because +a local override hides a broken host that then commits wrong in every other repo on that machine. +A wrong identity is not cosmetic: a private email trips GitHub's email-privacy push protection, and +an invented author pollutes history. It is also a distinct failure from signing (a wrong author +does not by itself fail the signature check), though the ad-hoc identities that produce one are +typically also unsigned, which the signing rule above then rejects independently. + +## Never force push + +Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force +pushing rewrites shared history and can cause data loss. This holds regardless of how confident +the rewrite looks, a rejected push is recoverable, a force-pushed one is not. + +## History rewrites re-identify only what changed + +**Do not rewrite a commit that does not need to change.** A history rewrite (e.g. `git filter-repo` +to strip PII) re-signs every touched commit with the rewriter's key. If that commit is still +committed by a bot (`dependabot[bot]`, `github-actions[bot]`) or GitHub's own web-flow, the +signature will not match the committer and the require-signed-commits rule rejects it. Scope the +rewrite to only the commits that must change. Set `committer` (and `author`) to the rewriter's +identity on any non-own commit that must be modified. Verify with `git log --show-signature` after +any rewrite. See `references/history-rewrite.md` for the full two-gate rule. + +## Never run destructive git commands without being asked + +`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`, and other commands that discard work require explicit developer instruction. Never use them as a convenience inside a larger task. One narrow cleanup exception applies to `git branch -D <exact-task-branch>` after a squash merge. It requires live proof that the pull request for that exact branch merged and a clean worktree at the verified head SHA, per `repo-worktree`. The exception never applies to `develop`, an unmerged branch, an unresolved pull request, or a branch with uncommitted work. diff --git a/.github/skills/git-commit-conventions/references/history-rewrite.md b/.github/skills/git-commit-conventions/references/history-rewrite.md new file mode 100644 index 0000000..b331a55 --- /dev/null +++ b/.github/skills/git-commit-conventions/references/history-rewrite.md @@ -0,0 +1,24 @@ +# History Rewrites: Re-identification Rules + +**A history rewrite includes only the commits that must change, and re-identifies any commit it +rewrites that is not the agent's own.** Filtering history (`git filter-repo` or an equivalent, for +example to strip PII) re-signs every commit it touches with the rewriter's own key, while the +tooling preserves each commit's original `author`/`committer` unless told otherwise. GitHub +verifies a signature against the commit's `committer` identity, so a signature from the rewriter's +key over a commit still committed by a bot (`dependabot[bot]`, `github-actions[bot]`) or GitHub's +own web-flow does not match its committer and lands `unknown_key`/unverified, which a +require-signed-commits rule then rejects. + +Two gates keep committer and signature aligned: + +1. **Scope the rewrite to only the commits that must be modified.** By default those are the + rewriter's own, whose committer already matches, so a commit that needs no change stays out of + the rewrite entirely and its identity and signature are never touched. +2. **If a commit that must change is not the rewriter's own, set its `committer` to the rewriter's + own signing identity before re-signing** (and its `author` too, since a rewrite that alters + content should not keep attributing it to the bot). The original bot attribution is deliberately + given up as the cost of having to rewrite it. + +Never leave a signature over a commit committed by another identity. Verify after any rewrite that +every rewritten commit is signed and committed under the correct identity +(`git log --show-signature`). diff --git a/.github/skills/local-strict-review/SKILL.md b/.github/skills/local-strict-review/SKILL.md new file mode 100644 index 0000000..65373a9 --- /dev/null +++ b/.github/skills/local-strict-review/SKILL.md @@ -0,0 +1,172 @@ +--- +name: local-strict-review +description: >- + Runs a read-only, adversarial review pass against this branch's current diff against its + target branch, full file context included, on the strongest model tier the session can reach, + before a unit of work is pushed toward a pull request or claimed done. Use this whenever staged, + committed, or untracked work is about to be pushed on a PR-bound branch, and whenever + `agent-conduct`'s "about to claim work is done, verified, green, or fixed" trigger fires for + PR-bound work. Triggers even when the change looks small or the same session already judged its + own diff ready, because a self-review pass judging its own diff inherits its own blind spots, + the exact gap this skill exists to close before a PR-hosted reviewer closes it instead. Reuses + `fleet-code-review`'s "Review the Change" criteria rather than restating them, and owns only this + local, pre-PR moment. Once a pull request exists, `pr-review-conduct` and `drive-pr` own + triaging and disposing of what a PR-hosted reviewer finds. Also triggers whenever the periodic + canonical sweep is worked, which names the carried units whose text has moved past the pass + that read them together with a bounded slice of those nothing has read at all, because that + content reaches a reviewer whole only when a repository carries it for the first time, and a pass reading each named unit's whole text is what moves that read + into the repository that can act on what it finds. Editing such content owes no pass of its + own, so a change that moves a unit pushes and merges like any other. +--- + +# Local Strict Review + +## Why This Exists + +A coding agent that finishes a unit of work, judges it ready, and opens the pull request is judging its own diff with the model, and often the blind spots, that wrote it. CodeRabbit, Qodo, and Copilot routinely find real defects that a local pass missed, and each round costs review latency and, for a rate-limited reviewer, shared account-wide quota. A local, full-file-context adversarial pass before the pull request exists catches the same class of defect for a fixed, smaller cost, the same reasoning that already runs local lint before a push instead of waiting for CI. + +## What It Does + +Dispatches one read-only subagent against this branch's full diff since it forked from its target branch. Resolve `<target>` once, `develop` unless `repo-worktree`'s base-branch rule put this branch on `main` instead, then fetch it, `git fetch origin <target>`, and diff against the merge-base, `git diff "$(git merge-base origin/<target> HEAD)"`. Stop and report a failed fetch rather than running the merge-base or diff commands anyway: an existing local `origin/<target>` ref can still resolve after a failed fetch, and reviewing against it silently trades the current target for a stale one. Use the same resolved `<target>` in every command below, never a literal `develop` alongside it. Naming the target branch explicitly matters: the branch's own `@{u}` tracking ref points at the branch's own remote once it has been pushed, not at the branch it targets, so anchoring there silently narrows a later run to only the diff since the last push instead of the full accumulated diff. That merge-base diff covers every commit already on the branch plus whatever is currently staged or unstaged, so it never reviews only the latest increment, at any of the moments this skill is invoked from. An empty diff is not the same as nothing to review, and it is never the signal to stop: it reports no untracked file at all, and it reports nothing for content a commit carries that the working tree has since put back. The untracked-file list below covers the first of those. The second is why the diff pass commits before reviewing, the sweep's own passes below running against uncommitted content instead on the change that carries their ledger, since a removal or a restore that is committed leaves no net content to miss, and why the engine reads HEAD rather than this diff, its change set coming from the merge base against HEAD, the index and the working tree, so the two answer different questions. A fresh review of the full accumulated diff is what catches what per-push review misses, the exact evidence this skill exists to act on. + +`git diff` never reports a path `git add` has not touched, so a newly created file sitting untracked would otherwise go unread. List it explicitly, `git ls-files --others --exclude-standard`, and read each result in full alongside the diff, the same as any other file the diff touches. + +The subagent reads the full content of every file the diff and the untracked-file list touch, not just the hunks, since cross-file and whole-file context is exactly what incremental review misses. It reports findings only. It never fixes, stages, or commits anything. + +Review criteria are `fleet-code-review`'s "Review the Change" section, reused rather than restated here, plus three traps worth calling out explicitly for a pass that runs before a human or a PR-hosted reviewer ever sees the diff: unguarded type coercions, TOCTOU/race conditions, and platform-specific behavior differences. `fleet-code-review`'s separate "Publish Every Finding" section does not apply here: this skill has no PR to post a comment on and no coverage marker to close a review with, so its own report contract below replaces that section rather than extending it. + +## Running It + +Follow `AGENTS.md` "Context and Delegation Discipline"'s subagent briefing shape: + +```text +Task: adversarial review of this branch's diff against its merge-base with its target branch, + read full surrounding files where the diff hunks alone do not give enough context. +Paths: the files `git diff --name-only "$(git merge-base origin/<target> HEAD)"` and + `git ls-files --others --exclude-standard` list, mandatory floor. Reading a specific + unchanged caller or consumer beyond that list is in bounds only where a candidate finding's + proof actually depends on it, per fleet-code-review's own "follow data and control flow beyond the + edited lines" instruction below, never as an open-ended exploration. +Rules that bind this task: quote `fleet-code-review`'s "Review the Change" section into the prompt, + plus flag unguarded type coercions, TOCTOU/race conditions, and platform-specific behavior + differences explicitly. Do not quote "Publish Every Finding", this task's report contract is + the Return line below, not a PR comment or a coverage marker. +Return: one finding per line, file:line, the concrete failure scenario, no severity theater. +Bounds: read-only. No edit, no stage, no commit, no push, no PR-hosted write of any kind. +<AGENTS.md's own unresolved-rule closing line, quoted verbatim from "Context and Delegation Discipline", not restated here> +``` + +Before dispatching, grep the tree for other statements of each rule the diff adds or changes, and add each file holding one to the `Paths:` floor, so a statement the diff has put in disagreement is read rather than missed. + +**Model tier:** the strongest tier this session can reach, per `AGENTS.md` "Match the model tier to the judgment" and "Never tier down the seat holding the judgment", applied here to the reviewer rather than the author. Run the pass on the same tier that authored the change when only one tier is reachable, a second, adversarially-prompted look still catches what the authoring pass's own "looks ready" judgment did not. + +"This session can reach" means the tier this session can name when it dispatches the reviewer, rather than the tier this session is itself running on. A session deliberately tiered down for execution work, a worker dispatched by an orchestrator being the ordinary case, names a stronger tier for the reviewer where its harness lets it, since tiering down the author is the reason the reviewer must not follow it down. What a given harness and account actually permit varies, so treat this as the tier to ask for rather than one to assume. Where a dispatch reaches several tiers but exposes no way to name one, take what it gives and run the pass, on the same reasoning as the single-reachable-tier sentence above. A seat that cannot dispatch a subagent at all cannot perform this pass. Instead of pushing, it reports that it could not run the pass, to whoever dispatched it, or to the maintainer where nobody did. Either way it is a push that does not happen rather than a pass quietly skipped. The headless `run --backend` route under "Recording the Pass" is not the substitute: it runs a vendor CLI against its own review, which never carries the brief above, so it satisfies the rule this section states only where that separate route is what a capture point asked for. + +## Recording the Pass + +`scripts/local_review.py` is what makes this rule checkable rather than something each session has to remember. For the pass above, the engine only records that it happened, keyed on the content the reviewer actually saw, and its `run --backend <name>` subcommand is the separate case where a headless backend performs the review and records its own count. That receipt is what a capture point reads, the hub's own `.husky/pre-push` hook being the only one today, and a repository having none unless it adds one, since no manifest entry carries it. + +Commit first, then read the digest, then dispatch the subagent, then hand that same value back. Nothing may change the tree between the read and the record. Staging a modified tracked file is such a change, moving the digest although the content did not, and a commit can move it too, since HEAD decides which paths are in the change set at all. Reading after the commit is what leaves neither of them between the read and the record. + +```sh +engine="<hub-checkout>/scripts/local_review.py" # in the hub itself, scripts/local_review.py +python3 "$engine" status --target '<target>' # JSON, take contentDigest +# run the pass above, then: +python3 "$engine" record --reviewer agent-skill --target '<target>' --expect-digest '<digest>' [--findings N] +``` + +Every subcommand here, `run --backend <name>` included, runs with the repository under review as the working directory, whichever repository that is. The engine takes no `--repo` and reads whichever repository it is run in, so the path names where the script lives and the working directory names what it measures. + +`<target>` is the same branch "What It Does" resolved for the review, passed to both commands. Leaving it off defaults them to `develop`, and on a `main`-based branch that computes the digest against a merge base the reviewer never read, so the receipt would attest to a change set nobody looked at. A receipt is only valid against the target it names, so the two have to agree. + +`--expect-digest` is required rather than optional, and binding it to the earlier read is the whole point. A format-on-save or a hook autofix between the review and the record would otherwise be stamped as reviewed by a pass that never saw it. A refusal there is the content having moved, so the answer is another pass over the current content rather than another read of the digest. + +Record the pass whatever it found, including nothing. The key covers the net content the branch introduces against its target rather than the commit series, so a rebase that leaves the tree alone keeps the receipt valid while the fork point holds, and changing one byte invalidates it. The key holds that fork point too, so rebasing onto a target that has moved retires the receipt although no file changed. + +**Why the commit comes first**, rather than being an ordering that could equally run the other way. A push delivers the commit, and the hook's tree check refuses a push whose tracked content differs from HEAD, so the record has to describe what HEAD holds. A commit that leaves the tree alone usually does not move the receipt's key, so diligence done before it still describes the same content, and a commit putting a path back to its base state drops it from the change set and does move it. Two reasons make the order matter anyway: staging a modified tracked file moves the key even though its content did not change, and a commit made after the record can carry content the pass never read. Reviewing earlier than this is still worth doing as ordinary diligence, and it does not substitute for the recorded pass: the digest read and the record bracket a window in which the tree holds still, and a commit inside that window ends it. + +The engine is hub-hosted per `GOVERNANCE.md` "Hub-Hosted Tooling", so a downstream repository reaches a hub checkout's copy rather than carrying one, which is what the path above is for. + +## The Carried-Content Sweep + +A second pass under the same rule, run in the repository that authors canonical content other repositories carry, which in this fleet is the hub. `GOVERNANCE.md` "Verification Discipline" states the rule and why the ordering it corrects is a defect, and is not restated here. What it requires of a run is below. + +**No change owes this pass.** Editing a carried unit refuses no push and fails no pull request. The passes are worked instead from the sweep's own issue, which a scheduled workflow in the authoring repository files with the units it asks for this round, and working that issue is the moment this section is for. What it produces is an ordinary pull request, carrying the ledger and whatever the passes had you fix, driven the ordinary way. + +**The unit is what a reviewer reads whole**, and `spec/files.json` rather than the document decides which, down to which files carry units at all. `canonical_review.py list` names the whole set and is the authority on it, so the rules are not paraphrased here, where a paraphrase can only drift from them. In the ordinary case a unit is one level-two section of a carried Markdown canonical, and `sweep` names each one it wants exactly as `record` takes it. The pass reads that unit's whole current text rather than the diff that moved it, because reproducing the carrier's read is the entire point, and a diff with surrounding context is a different read the pass above has already done. + +Run it at the same model tier and in the same delegation shape as the pass above. The brief, the engine, and its flags each differ, and all three are below. + +```text +Task: adversarial review of one canonical unit, read as a repository carrying it for the first + time reads it, whole, knowing nothing about what this branch changed in it. +Paths: <the unit key, substituted here>, read in full out of the file that key names. + Read the whole unit, never a diff of it. +Rules that bind this task: <quote fleet-code-review's "Review the Change" section>, and judge the text + as a reader who has only this unit: a claim it makes about a tool, a path, a command, or + another rule is a defect wherever that claim is false, stale, or unverifiable from the unit + itself, and an instruction it gives is a defect wherever following it literally fails. +Return: one finding per line, the sentence quoted, and what is wrong with it. No severity theater. +Bounds: read-only. Report a rule that looks incomplete rather than guessing at what it meant. +<AGENTS.md's own unresolved-rule closing line, quoted verbatim from "Context and Delegation Discipline", not restated here> +``` + +```sh +python3 scripts/canonical_review.py sweep # the units it asks for this round, with their digests +# exit 1 where it named any, which is the sweep working rather than the command failing +# run the pass above over each unit it named, then, per unit: +python3 scripts/canonical_review.py record --reviewer agent-skill --target develop --findings '<count>' --unit '<key>=<digest>' +``` + +These run in the authoring repository itself, which is the only repository this pass ever runs in, so the engine path is the plain one and there is no downstream side needing the `<hub-checkout>/` form the pass above shows for its own reach. Both resolve the repository from the working directory rather than from where the script sits, so the directory a command runs in is what decides which tree it measures, while the unit model and the manifest reader come from the checkout the script itself lives in. Running one checkout's copy against another's tree therefore measures the second tree by the first's rules, so run them in the tree being measured. `record` additionally stamps each pass with the merge-base against `--target`, which is provenance rather than coverage. The line above names it rather than leaning on the default, since a sweep's own branch is based on `develop`, and a `main`-based branch passes `--target main` instead. + +The digest is bound to the read for the same reason `--expect-digest` is above: recording a unit by name alone would stamp whatever the file holds at record time, so an edit between the review and the record would be attested to by a reviewer who never saw it. Record each unit whatever the pass found, including nothing. Fixing a finding is itself such an edit, so `record` then refuses the digest you were holding: that refusal is the content having moved rather than a fault in the record, and the answer is a read of the unit's new text, which is what a carrier will actually receive, recorded at its new digest. + +**The ledger this writes is tracked content, so the commit has to carry it**, where the receipt the pass above writes never can be. Record each unit, commit the ledger together with whatever the passes had you fix, then read the digest, run the diff pass over that commit, record its receipt, and push. That is why the two records sit on opposite sides of the one commit. + +**A unit nothing has read here yet reaches the list a slice at a time.** `sweep` names every unit whose text has moved past a pass, and beside them a bounded number of the never-read ones, ordered by how recently the file each sits in was last committed, so recently authored content comes ahead of text that has sat unread for months rather than waiting behind the whole backlog. The key is the file rather than the unit, so committing to a file lifts every unread unit in it, and a unit becomes carried without being lifted wherever the manifest is widened on its own, the manifest being a file of its own. Declaring a section in the same commit that writes it lifts it like any other. `canonical_review.py report` renders that backlog in full, and working more of it off than the sweep asked for is worthwhile and is its own change. + +## Disposing of Findings + +Each bullet is a rule down to its `Why:` line, which is rationale rather than rule, so a stale rationale is a cleanup rather than a defect. + +- **Every finding ends in one of the outcomes that `pr-review-conduct` "Every finding ends in one of five outcomes" enumerates, reached here with no thread to reply in.** + - `Why:` a local finding and a PR-hosted one deserve the same dispositions, and one home for the list is what stops two copies of it drifting apart. +- **The agent disposing of a pass's findings classes each one `style`, `introduced`, or `pre-existing`, in that order.** `style` is a preference between defensible forms. `introduced` is any other finding on text this change wrote, rewrote, or removed, on text this change should have written, on a precondition this change left false elsewhere, or load-bearing for a decision this change puts to the maintainer. `pre-existing` is every other finding. + - `Why:` the reviewer is asked to omit preferences and returns some anyway, and `style` is classed first so that a preference on text this change wrote is not owed a fix. +- **Another round is owed only while an `introduced` finding is open.** Unless evidence disproves it, an `introduced` finding is fixed within the budget below, or escalated where `pr-review-conduct` "Escalate to the maintainer when" says so, a `pre-existing` one is filed once and blocks nothing, and a `style` one is declined with evidence, per `pr-review-conduct` "Every finding ends in one of five outcomes", the evidence being `fleet-code-review` "Review the Change"'s own rule to omit preferences. + - `Why:` a finding count over prose never reaches zero, so a loop closing on "did it find anything" does not close, where one closing on the false claim, the unfollowable instruction, or the wrong behavior this change put there does. +- **Two rounds of edits answer a pass, one budget per push and one per sweep issue.** Where an `introduced` finding is still open after the second round, editing stops and what remains goes to the maintainer with its counts per class, per `pr-review-conduct` "Escalate to the maintainer when". + - `Why:` past the second round nearly every finding is against text the previous round's fix wrote, so the rounds are producing the defects they find rather than removing them. +- **The pass is mandatory, and the count it records gates nothing.** A pass is recorded whatever it raised, so the record attests that a review ran rather than that the content is clean. + - `Why:` a gate reading the count would make a pass raising nothing the cheapest way through it, the opposite of what recording one is for. + +## When to Run It + +- Before the first push toward a pull request, the push that opens it in `drive-pr` "The Drive Loop" and in `pr-review-conduct` "Expected review loop". +- Before pushing a fix for a reviewer finding, the same self-review blind spot applies to a fix as to the original diff (the fix outcome of `pr-review-conduct` "Every finding ends in one of five outcomes", which `drive-pr` "Disposing of Every Finding" carries). +- Whenever `agent-conduct`'s "about to claim work is done, verified, green, or fixed" trigger fires for work that will become, or already is, a pull request. +- When the canonical sweep's issue is worked, over each unit it names, per "The Carried-Content Sweep" above. Editing such content is not itself one of these moments, and nothing refuses a push over it. + +In the hub, `.husky/pre-push` checks the receipt at the push itself, so the moments above are where the pass is run rather than the only place it is noticed. A blocked push usually means it was skipped. That hook is the hub's own, and a repository carrying this Skill has none until one is carried to it, which is what makes the moments above the layer that actually binds everywhere. The hook is a backstop under this skill and not a replacement for it: it fires only in a clone that enabled `core.hooksPath`, it says nothing about a repository that carries no such hook, and it is bypassable by design, `--no-verify` being the documented route for a genuine pickle rather than for a diff nobody read. That route is not open in every seat. A Claude Code session running the fleet's agent-safety hook has the flag denied unconditionally, so where the rows below say a bypass is the answer, the answer in that seat is to report the state and hand the push to the maintainer rather than to force it. No capture point anywhere gates the carried-content sweep: the hub's `.github/actions/validate` composite action renders the coverage burn-down into every run's job summary and fails no pull request over what that rendering shows, which is the sweep being periodic rather than enforced at a push. That step does still fail where the engine could not read what it needs at all, which is a boundary rather than a verdict about coverage. + +**Read the refusal itself, which names its own case.** Some of the rows below are cleared by running a pass and some are cleared by nothing of the kind, and each row says which, so no count of either is kept here to go stale against the table. Some the hook decides before the engine runs, so there is no engine message under them, and the rows say where each one's detail comes from. + +| The refusal says | What it means | What clears it | +| --- | --- | --- | +| No local review covers this branch's current content | The ordinary missing pass: no recorded receipt covers what this push delivers, either because none was recorded or because the content moved after one was | One pass over the branch's whole diff, recorded per "Recording the Pass" above | +| Tracked content differs from HEAD | A push delivers HEAD while a receipt covers the index and working tree, so the receipt does not describe this push. The hook prints the same headline for an unresolved merge and for a `git update-index --refresh` that exited above 1, naming each on its own line | Commit what is being pushed, then the pass, then the record. Where the change also carries the canonical ledger, record those passes before that commit, per "The Carried-Content Sweep" above, since a ledger written after it leaves the tree differing from HEAD again. Resolve the merge first where the hook names one, and run `git status` first where it names the refresh, since the content may not differ at all | +| The commit is not this worktree's HEAD | Any pushed branch ref carrying an object id that is neither this worktree's HEAD nor the all-zero id of a delete, which a push from a checkout sitting elsewhere reaches and so does a multi-ref push such as `git push --all` | Push one branch, the one this worktree holds. Where another branch is the one wanted, check it out in its own worktree first, per `repo-worktree` | +| Any wording saying the gate did not or could not run | An execution boundary rather than a verdict, which blocks because a gate that waves a push through when it could not run has stopped gating. The cause is named in that same message or in the engine error printed above it, and it is a missing Python interpreter, an unresolvable target, an unreadable receipt, a git command that failed, or any unexpected failure | Whatever the message names, most often installing an interpreter per `docs/host-setup.md` or fetching the target branch. Never another pass | +| The recorded pass was run against X and this check measured Y, printed under the missing-pass headline | The hook reads `develop` and nothing else, so a branch based elsewhere is measured against `develop` whatever the pass targeted, and the engine deliberately prints no record command, since the one it would print records a pass over a diff nobody read | One more pass against the branch this work actually targets, where it does target the measured one. Where it does not, the gate cannot judge the branch at all and the bypass is its answer | + +This table is the fleet's one enumeration of these, and every other surface states the principle and routes here rather than listing the shapes. That is deliberate: every review round that added a shape also left a restatement of it somewhere else, and keeping one table is what stops the next round doing the same. + +## Mechanics Live Elsewhere + +- Review criteria: `fleet-code-review`. +- Delegation shape and model-tier discipline: `AGENTS.md` "Context and Delegation Discipline". +- Branch base rule (`develop` unless the task is explicitly `main`-only): `repo-worktree`. +- Finding disposition once a pull request exists, the Merge Gate, `scripts/pr_review.py`: `pr-review-conduct`, `drive-pr`. +- The receipt's key, its backends, and the three-valued exit contract a capture point folds: `scripts/README.md` "`local_review.py`". +- The unit model, the coverage ledger, and the burn-down report: `scripts/README.md` "`canonical_review.py`". diff --git a/.github/skills/merge-and-release/SKILL.md b/.github/skills/merge-and-release/SKILL.md new file mode 100644 index 0000000..fcf432f --- /dev/null +++ b/.github/skills/merge-and-release/SKILL.md @@ -0,0 +1,237 @@ +--- +name: merge-and-release +description: >- + Merges a ready develop -> main promotion PR for any ptr727/ProjectTemplate fleet repo and, when + asked, dispatches the release, in this hub always refreshing this machine's installed Skills + from the newly promoted content as part of that release step, never as a separate ask. Use this + whenever asked to merge main, ship a release, cut a release, or finish a promotion once its PR + is already green and fully resolved (produced by drive-pr or by hand). When the request does not + say how far ("merge main", "ship it"), ask once whether to merge only or merge and release, + rather than guessing which the maintainer wants this time. Triggers even when the phrasing is as + short as "merge main and release", because that already states the scope and is itself the + explicit, current go-ahead this skill acts on without asking again, though it never substitutes + for the pr-review-conduct Merge Gate, a promotion PR that is not actually green and fully + resolved gets reported and stopped on, not merged. Where a merge or dispatch is actually + performed this skill wins over `branching-and-release-model`, which supplies the policy it + follows, and an `unattended-handoff` run invoked with scope main or release is the one standing + go-ahead it accepts in place of asking. +--- + +# Merge and Release + +## Why This Exists + +Once drive-pr (or a maintainer by hand) leaves a promotion PR ready, the same two steps follow +every time: merge it, and usually dispatch the release it unblocks. In this hub a promotion can +also change `.agents/skills` content this very session depends on, so the release step always +carries a Skills refresh with it there, never a separate branch to ask about, an ambiguous "merge +and release" on the hub must not leave the maintainer unsure whether Skills got refreshed. One +skill covers all of it, scoped down by what the maintainer actually asks for. + +## How Far to Go + +- Read the invocation for an explicit scope first. "Just merge" or "merge only" means stop after + the merge. "Merge and release", "ship it", or "cut a release" means also dispatch, and in this + hub also refresh Skills as part of that same step. Act on either without asking. +- When the request names no scope ("merge main"), ask once, before merging: merge only, or merge + and release. Recommend "merge and release" as the default on a release-model repo, a promotion + merged without its release is the more common regret there. Recommend "merge only" as the + default on an operational repo (registry `workflowModel: operational`), where a release is a + separate, deliberate dispatch rather than an automatic follow-on to a promotion, per + branching-and-release-model's "Operational repositories" delta. +- Detect the hub automatically, `git remote get-url origin` or `gh repo view --json + nameWithOwner` naming `ptr727/ProjectTemplate`. There the release scope silently includes the + Skills refresh, a downstream repo never sees it, it has no `.agents/skills` of its own to + refresh. + +## What Invoking This Skill Authorizes + +- Naming this skill, and answering its how-far question, is the maintainer's explicit, current + go-ahead to merge the promotion PR and to perform the scope chosen, for the one repo and PR in + front of the agent. It is never a standing mode carried to the next PR. +- The one standing grant is an `unattended-handoff` run the maintainer invoked with scope `main` + or `release`, which names in advance each promotion that run's workers make, in that session + only. A worker handing a promotion here under it asks no how-far question, since the scope + states it, and the Merge Gate is still re-verified per promotion. +- It is never permission to merge a PR that fails the Merge Gate. Re-verify the gate at + invocation time, a check from earlier in the session can be stale. + +## The Procedure + +1. Identify the open develop -> main promotion PR for this repo, stop and report if none is open. +2. From a hub checkout, `scripts/` is not carried into downstream repos, run `scripts/pr_review.py + status [number] --repo owner/repo` on it and confirm the pr-review-conduct Merge Gate. Stop + and report exactly what is missing rather than merging on a partial gate. +3. `gh pr merge [number] --merge --repo owner/repo`. Never `--delete-branch`, the promotion PR's + head is `develop`. +4. Confirm the merge landed, `mergedAt` set, `main`'s tip matching the merge commit. +5. When the chosen scope includes a release, first bring the hub checkout used for this procedure + current, `git fetch origin main`, and read this repo's `releaseTrigger` from that fetched tip + rather than a possibly-stale working tree copy, relevant when the target repo is the hub itself + and this exact promotion changed its own registry entry. Select the one matching entry + explicitly, falling back to the registry's own default when that entry sets no + `releaseTrigger` of its own, and stop and report rather than guessing when selection is not + exactly one match, on a non-1 count exit non-zero rather than returning empty with success, an + ambiguous or missing match must fail loud, not read as an empty value still safe to act on: `git + show origin/main:registry/repos.json | jq -r --arg name '<repo-name>' '(.repos | map(select(.name + == $name))) as $m | if ($m | length) == 1 then ($m[0].releaseTrigger // .defaults.releaseTrigger) + else error("expected exactly one registry entry for \($name), got \($m | length)") end'`. Two + cases, `none` versus anything else. When it + reads `none`, report that no + release is configured, dispatch and run-correlation (step 6) do not apply. Otherwise, dispatch + explicitly, `gh workflow run publish-release.yml --ref main --repo owner/repo`, or `--ref + develop` only when the maintainer explicitly asked for a prerelease dispatch instead. +6. Correlate the specific run this dispatch produced rather than assuming the newest one is it. + `gh run list --repo owner/repo --workflow publish-release.yml --branch main --event + workflow_dispatch --json databaseId,createdAt,headSha` (or `--branch develop` for a prerelease + dispatch), matched by `headSha` against the dispatched ref's tip (`main`'s tip confirmed in + step 4, or `develop`'s current tip for a prerelease) and by `createdAt` against the dispatch + time. `gh run list` can momentarily omit a just-created run, so a single query reporting zero + candidates is not yet "never started". Poll the list itself, within a bounded interval, until + exactly one candidate matches. A concurrent run of a different event on the same branch must + never be mistaken for this one, more than one candidate is as inconclusive as zero. A run whose + `headSha` does not match the expected tip at all, rather than simply being absent, means the + dispatched ref moved between step 4's confirmation and the dispatch itself, report that + distinctly, the ref changed mid-dispatch, rather than folding it into an ordinary absent-run + timeout. Report and stop rather than guessing once the interval elapses with zero or more than + one candidate still matching. Only once exactly one candidate is confirmed, poll that one run + id to completion in one further bounded background wait with an explicit, finite timeout, + 2700 seconds (45 minutes, matching `scripts/pr_review.py`'s own default) unless the maintainer + states a different bound for this specific release: `timeout 2700 gh run watch <run-id> --repo + owner/repo --exit-status` on a host with GNU `timeout`, or the equivalent bounded-wait + mechanism enforcing the same bound on a host without it (macOS without coreutils, native + Windows). Never pipe `gh run watch` into another command unless the shell running it sets + `pipefail`, since without it a pipeline reports its last stage's exit status and + `gh run watch ... | tail` reports whether `tail` succeeded rather than whether the run + did. Read the watch's own exit status, or read the conclusion back with + `gh run view <run-id> --repo owner/repo --json status,conclusion`. + Report a timeout separately from a completed run's own conclusion, the tag or + version it produced. A run that fails, times out, or never starts is reported, never silently + retried. +7. In the hub, when the chosen scope includes a release, bring this checkout to the merged + content without discarding or mixing in anything local. First assert `git status --porcelain + --untracked-files=all --ignored -- .agents/skills/ .claude-plugin/` is empty, and stop and + report rather than proceeding over any uncommitted content there, tracked, untracked, or + gitignored, since `skills_install.py` reads both paths: `shutil.copytree()` installs each + `.agents/skills/` skill directory for Codex/opencode, and `claude plugin marketplace add` + installs from `.claude-plugin/` for Claude Code, so a gitignored stray file under either rides + along the same as any other, and the plain porcelain form (silent on ignored paths) would pass + this preflight while one still rides into an install. Scoped to those two paths rather than + the whole tree, matching `skills_install.py`'s own `source_ref()` dirty check (`watched = + [SKILLS_SRC, CLAUDE_PLUGIN_DIR]`), since an ignored file elsewhere in the checkout (a build + cache, a lockfile) is not this preflight's concern and should not block the refresh on it. + Then `git fetch origin main`, `git checkout main` + (or `git checkout -b main origin/main` the first time this checkout carries no local `main` at + all, `checkout` rather than `switch` since the fleet's own `git` floor is undeclared and + `checkout` needs no minimum version for this), and `git merge --ff-only origin/main`. + `checkout` still refuses a `main` checked out in another worktree, and `--ff-only` refuses + anything but a clean fast-forward, so either stops and reports on top of what the preflight + already ruled out, per Repository Boundaries and Write Safety. `--ff-only` does not fail when + local `main` is already ahead of `origin/main`, since a strict superset needs no fast-forward + and reports up to date, so assert `git rev-parse main` equals `git rev-parse origin/main` + afterward and stop and report on a mismatch, a local-only commit this checkout never pushed is + exactly the case a bare "up to date" would hide. `skills_install.py` stamps and installs from + whatever this checkout's HEAD already is, so running it against a stale, unrefreshed, or + locally-diverged `main` skips the refresh silently. Only then run `python3 scripts/skills_install.py --report`, then + `python3 scripts/skills_install.py` to install, and confirm `--report`'s snapshot now reads + current. The two channels hold different things. The Codex and opencode copy keeps the + revision it was taken from, the promoted `main` here. The Claude Code channel loads the + registered checkout in place, the one `--report` names under `live`, and serves whatever it + holds at read time. Where that is this checkout, once step 8 returns it to `develop`, Claude + Code sessions on this machine load `develop`. `--report` exits on the snapshot alone. This step runs whether step 5 + or 6 dispatched, skipped, or failed a release, since it is gated only on the chosen scope, + never on the release outcome. This refreshes only the machine running + this session, per skill-lifecycle, every other machine still refreshes on its own next run or + `docs/host-setup.md` "Fleet Skills Install" cadence. +8. Run cleanup regardless of how steps 5 through 7 ended, no release configured, a dispatch + failure, an ambiguous run match, a timeout, a failed run, or a hub Skills refresh all still + reach this step, the merge in step 3 already landed by then. Two parts, both required, neither + optional: + - The promotion PR's own worktree: fetch and prune, remove the worktree, then fast-forward the + base clone to `develop`. Removing first, not after, matters: the base clone cannot check out + `develop` while the promotion worktree still has it checked out, one branch checked out in + two worktrees at once is refused outright. Never delete `develop`, it is the promotion PR's + own head, and the repo's auto-delete-head-branches setting is kept off fleet-wide for exactly + this reason, so nothing does this automatically. + - A defensive sweep for anything drive-pr's own cleanup should already have removed but might + not have, an interrupted loop, a fix landed by hand outside that skill, or a maintainer + merge in the GitHub UI. `git worktree list` for any worktree still registered under this + task's feature branches, `git branch -vv` for any local feature branch, `git ls-remote + --heads origin` for any matching remote feature branch. For each, verify it finished by + reading GitHub's own state with the exact fields this check needs, not a bare listing, and + stop and report rather than guessing when selection is not exactly one match, on a non-1 + count exit non-zero rather than returning empty with success, an ambiguous or missing match + must fail loud, not read as an empty value still safe to act on: `gh pr list --head + "<branch>" --state merged --repo owner/repo --json + number,baseRefName,mergedAt,headRefOid,headRefName,headRepository --jq 'if length == 1 then + .[0] else error("expected exactly one merged PR for this head, got \(length)") end'`. + `--head` is expected to match exactly (verified against `gh` 2.97.0 on this repo, a bare + prefix of a real branch name returned nothing), but confirming `headRefName` equals `<branch>` + costs one field and is cheap insurance against a future `gh` behavior change, not a workaround + for a known partial-match case. Confirm `headRepository` is non-null and its `nameWithOwner` equals + `owner/repo`, the owner alone is not enough, a same-owner PR against an identically named + branch in a different repository must never pass this check either. Confirm `baseRefName` + is `develop` (a different merged pull request can share the same head branch name against a + different base, and that is never this sweep's target) and `mergedAt` is set. Compare tips + only where a remote branch actually exists. `git ls-remote --heads --exit-code -- origin + "refs/heads/<branch>"` is the exact-match form and must be, in that argument order. `--heads + origin "<branch>"` alone still tail-matches, a bare `topic/x` pattern also returns an unrelated + `other/topic/x` if one exists. `--` placed after `origin` instead of before it is not + equivalent either, verified empirically: with a `refs/heads/other/--` ref present, `--heads + origin -- "refs/heads/<branch>"` matched both that ref and the intended one, while `--heads -- + origin "refs/heads/<branch>"` matched only the one intended. Exit status is a tri-state, not a + stdin-emptiness check: `--exit-code` makes exit `2` mean query succeeded, branch gone, most + likely a prior cleanup attempt got interrupted after the remote delete but before the local + one, so skip straight to the local-tip check below and never attempt the remote delete a + second time. Exit `0` means it matched. Anything else is a failed query, a network or auth + problem, and stops and reports rather than being read as absence, an unreachable remote and a + genuinely gone branch both print nothing to stdout, only the exit code tells them apart. + Where the remote branch does exist, its tip must match that exact pull request's `headRefOid` + before its own delete proceeds, proving nothing landed on it since. Where a local branch + still exists too, its tip (`git rev-parse --verify "refs/heads/<branch>"`) must independently + match `headRefOid` before its own delete proceeds. Neither side needs the other to exist, a + prior interrupted attempt may have deleted one side already and left only the other, so + verify and delete whichever side is still there and skip whichever already is not, never + block one side's cleanup on the other side's absence. No `--` on `rev-parse`, verified + empirically: `git rev-parse -- "<branch>"` treats the argument after `--` as a path rather + than a revision and never resolves a SHA at all. The fully-qualified form needs no `--` + regardless, since `refs/heads/<branch>` never itself starts with `-`, and `--verify` fails + loudly rather than guessing when it does not resolve. Every branch or + worktree-path placeholder below is the real value, substituted as its own quoted argument + (a shell variable expansion such as `"$branch"`, or an argv element), never handed to `eval` + or `sh -c` for a second round of shell parsing, the only way an embedded `$()` or backtick + would actually run. A valid ref can start with `-` or carry a shell metacharacter, which is + why it stays quoted regardless. `--` marks the + end of options wherever a command supports it. + `git merge-base --is-ancestor <branch> develop` must never be used for either tip check, a + squash merge (drive-pr's own merge method) never makes the feature tip a literal ancestor of + `develop`, so the check reports every already-finished branch as unmerged. Only once GitHub + confirms it, and only when a local worktree or branch is still there to remove, remove the + worktree by its exact path (a dirty worktree stops cleanup rather than discarding uncommitted + work), `git worktree remove "<worktree-path>"`, `git worktree list` names it, then delete the + local branch. `git branch + -d` has the identical squash blindness as `git merge-base --is-ancestor` and refuses too, so + use `git branch -D -- "<exact-branch>"` here, safe only because the GitHub-state check just + proved that exact branch finished, the narrow post-squash exception git-commit-conventions + describes, never applied to an unverified branch. Then, only when the remote branch still + exists, delete it the same way, `git push origin --delete -- "<branch>"`. + Never `--force-with-lease` here, git-commit-conventions + forbids it unconditionally, the GitHub-state check just completed is the verification gate, + not a compare-and-swap at delete time. Never apply this sweep to `develop` or `main` + themselves, only to feature branches a drive-pr loop created. + +## Mechanics Live Elsewhere + +- The Merge Gate itself: pr-review-conduct. +- Never delete develop, no-op republish, the operational repos' dispatch-only model: + branching-and-release-model. +- What the dispatch actually builds and publishes: workflow-ci-contract. +- Skills install and report semantics: skill-lifecycle. +- Cleanup mechanics: repo-worktree. + +## Stop and Report, Never Guess + +- A merge conflict, a newly failing check, or a gate item that regressed since drive-pr finished + are each a stop, report the exact state, never force or retry blindly. +- `gh pr merge` or `gh workflow run` failing is reported with its actual output, never + suppressed, never assumed harmless on the agent's side alone. diff --git a/.github/skills/pr-review-conduct/SKILL.md b/.github/skills/pr-review-conduct/SKILL.md new file mode 100644 index 0000000..6f7e367 --- /dev/null +++ b/.github/skills/pr-review-conduct/SKILL.md @@ -0,0 +1,305 @@ +--- +name: pr-review-conduct +description: >- + Governs opening, driving, and merging a pull request review loop in a ptr727/ProjectTemplate + fleet repo: requesting a review after a push, triaging findings, replying and resolving threads, + and deciding whether a PR is actually mergeable. Use this whenever about to open a PR, + immediately after creating one, about to merge a PR, enable auto-merge, ask the maintainer for + merge permission, push a fix and move on without re-checking review state, or judge a PR "green" + or "clean" from CI or mergeStateStatus alone. Triggers even when the request sounds routine, + such as "open a PR," "merge this," or "it's all green, go ahead," because mergeStateStatus: + CLEAN can go clean once checks pass and every known thread is resolved, while still saying + nothing about whether the review covered the current head SHA, read the full diff, or left a + suppressed low-confidence finding, which opens no thread at all, unanswered. Also triggers when + a review loop looks stuck (no review landing, findings that keep reappearing) or when deciding a + finding is real, false, deferred, or a deliberate decline, or when a reviewer looks missing or + skipped. This skill is the contract that `scripts/pr_review.py`, `drive-pr`, and + `merge-and-release` implement: running the loop hands-off is `drive-pr` and merging main is + `merge-and-release`, each winning for its own action while this skill still binds the gate. +--- + +# PR Review Conduct + +## Why this exists + +`mergeStateStatus: CLEAN` reflects required status checks and any review thread the ruleset's +conversation-resolution requirement already tracks as resolved. It says nothing about whether the +review that resolved those threads actually covered the **current** head SHA, whether it read the +full diff rather than part of it, or whether a suppressed low-confidence finding, which never +opens a thread for the ruleset to see, was ever answered. A PR that looks done, green checks, no +visible comments, routinely still carries a finding nobody has answered. Treating "green" as +"mergeable" is the single most common way this loop gets skipped. + +## Merge Gate, check this before merging or enabling auto-merge + +**Do not merge, and do not enable auto-merge, unless ALL of these hold:** + +1. Required status checks are green, and where they are not, the reason is **read**, never + inferred. `BLOCKED` covers a failed check, a required check nothing is running, an unresolved + thread, and a missing approval alike, and the response differs by cause. +2. A review is confirmed on the **current head SHA**, matched by commit SHA rather than assumed + from a green merge-state. A push makes checks go green *before* the re-review lands, and the + matched review is **read**, not just counted. A review can carry the head SHA and still decline + the PR outright, or say it read only part of the changed files. Where the round covering the + head states no coverage at all, the newest round that does state some stands in for it, and + only where the pull request changes the same set of files at both commits, since a statement + about a diff this head no longer has says nothing about this one. A head round's own + statement always wins, and `pr_review.py` refuses the carry where it cannot read that set at + both commits. The coverage this item + requires is Copilot's, and CodeRabbit and Qodo are advisory, since the hub's + `docs/pr-reviewer-evaluation.md` "Status" names Copilot the incumbent and says no candidate is + a required reviewer: an advisory reviewer's absence blocks nothing, while its findings owe + item 3 exactly as Copilot's do. `pr_review.py`'s `review_on_head` names Copilot's own coverage + specifically, not "no review of any kind covers this head": an advisory reviewer carrying the + exact head under `other_reviewed`, with an empty review body and no new threads, is its own + ordinary "reviewed, nothing to flag" shape, not a missing review. + A refusal is not that coverage, so this item stays unsatisfied under one, and the loop clears + it where it can. A file-count refusal is cleared by splitting the pull request, which is the + only cause on record that the loop can clear. `pr_review.py wait` exit `46` is the one nothing + the loop does clears, an account-quota refusal carrying the current head, which is the case + "Which Reviewers a Repository Actually Has" below states. Exit `47` is that same account state + read from the reviewer's activity elsewhere when this head carries none of its own, and exit + `41` holding across several heads with no cause its body names reaches it the slower way. + Those three are `wait`'s alone: `status` exits 0 over a refusal, carrying it as `refusal=` in + the digest line instead, so reading that exit code as the absence of one would falsely satisfy + this item on the exact state it exists to catch. That is where the + unsatisfied item goes to the maintainer, with the coverage the other reviewers gave that head + read rather than counted and named to them, and their permission under item 5 is what allows + the merge. Item 2 is never waived, and a merge over an unsatisfied one is theirs to authorize. +3. **Every** finding on that head SHA is closed: threads resolved, issue-level comments (which + have no resolve action) triaged and replied to, **and** the low-confidence findings collapsed + in the review body investigated and answered. Those appear in no thread, so polling threads + alone reports a clean pass while they stand. The same holds for CodeRabbit's own + "outside diff range" comments (`cr_outside_diff` in `pr_review.py`'s digest) and for Qodo's + comment-only findings (`qodo_open`): neither opens a `reviewThreads` entry either, so + give each one the same triage the low-confidence findings above already get. Copilot's own + section for findings against code the pull request did not change, `Previously missed` in the + review body and `previously_missed` in the digest, is a fourth such class and takes that same + triage. It raises no thread for the same reason the others do not, and a finding it holds is + raised outright rather than withheld, so "the branch did not touch that code" is a reason to + decline one with evidence rather than a reason to leave it unanswered. Qodo's own + `Resolved`/`Dismissed` self-tracked badge is a fast pre-triage signal, not a substitute for + reading the finding, spot-verify against `gh pr diff` rather than trusting it outright. + Copilot's second review-body format states its own finding total, which the digest reads as + `overview=T/M` beside the number of review threads that round opened. A total larger than that + thread count is usually findings that format withheld, the same blind spot under a different + name, and it is sometimes the digest undercounting instead, where the round's enumeration + carries findings earlier rounds raised. Reading the review body is what tells the two apart, and + nothing else does, so read it and dispose of what it holds as this section's outcomes require. + `T` reads `?` where no total was found, which is a round stating none and equally one the digest + could not locate, so a `?` leaves this item unsatisfied and sends you to the body exactly as a + shortfall does. + The body read in that format so far carried no `Suppressed comments` heading, so `suppressed=` + finds nothing in it and there is no collapsed block to quote a count from: the digest's + shortfall and the body's own prose are what an answer cites instead. A later body that does + collapse one may have those findings counted by `suppressed=` and again in the shortfall, or may + have them counted once each, depending on whether its stated total includes them, which no body + read so far says. Answer what the body holds rather than what the two numbers add up to. + What closing a finding owes turns on whether it is `pre-existing`. A finding on text inside a + canonical Markdown unit, one the hub's `scripts/canonical_review.py list` names, classed + `pre-existing` by the classes `local-strict-review` "Disposing of Findings" defines for a + local pass, applied here to a PR-hosted finding, is outcome 4 of "Every finding ends in one + of five outcomes" below applied once per unit rather than once per finding: the round gathers + that unit's such findings onto the unit's tracker, an open hub issue whose title carries the + unit key, retitled by the change that moves the key and filed by whichever round first needs + it, and answers each finding with that issue's link, resolving a thread on that reply, so a + `pre-existing` remark on a sentence the change never touched costs one link rather than a + decline or an issue per finding. The batch runs in the hub, which authors the text of every + verbatim unit. A carrying repository routes a finding on a verbatim unit by fidelity rather + than by class, since a resync writes the whole text there: it declines the finding under + that section's outcome 2, ownership sitting elsewhere, and files it on the same tracker, + while a finding on an intent unit is filed there too, the carrier adapting its own copy + meanwhile, since the defect is still fixed at the source. Every other finding, a `style` + remark on untouched text included, takes its own outcome in that section. +4. Nothing in the review was a shape the tooling could not read (an unrecognized heading, a moved + section, an unfamiliar coverage wording). An unrecognized shape blocks the gate on its own. + File an issue naming it and quoting the body, rather than guessing what the new wording + probably meant. +5. The maintainer has given **explicit** permission to merge. + +The agent never merges on its own. A green or CLEAN PR with one open finding is not mergeable, +full stop, whatever the merge-state field says. + +## Which Reviewers a Repository Actually Has + +Whether a reviewer covers a repository at all is decided by product terms this fleet observes +rather than sets, and whether it reviewed this pull request is decided by those terms together +with configuration a repository commits itself. So respond to what the reviewers actually did on +the pull request in front of you, rather than deciding from a repository property what a reviewer +must have done. + +- **A reviewer that posted a skip notice is available for the asking.** It says it did not review + automatically, which is not the same as not reviewing at all. Comment `@coderabbitai review`, or + Qodo's `/review`, and wait for the result as with any other requested review. The agent driving + the loop posts that comment itself, on the same standing as requesting a review after a push. +- **A notice naming when the reviewer can next run is a rate limit, and asking does not clear + it.** It reads like the skip notice above and is the opposite case: the trigger returns the same + notice rather than a review, so a loop that keeps asking waits on something no amount of asking + produces. Wait for the time it names, or proceed on the reviewers that did run, since an advisory + reviewer blocks nothing. +- **Silence is not evidence, and is never read as one on its own.** A reviewer that has posted + nothing may not have started yet, may not cover this repository at all, or may have reviewed and + had nothing to say, which Merge Gate item 2 describes as its own ordinary shape and which posts + no comment to read. Read the reviews themselves rather than the comments alone, since the third + case appears only there. +- **Copilot's absence blocks, and is answered elsewhere.** Merge Gate item 2 requires Copilot's own + coverage of the current head, and the loop's own re-request step below is where a missing one is + answered, on the terms stated there. A refusal naming the account quota is its own case rather + than a review: it covers no head, so the gate stays unsatisfied, and nothing the loop does + clears it, since the refusal names no time to wait for and re-requesting returns it again. That + one goes to the maintainer, rather than into a wait with no stated end. + +Where a reviewer's behavior still surprises you after reading what it posted, the hub's +`docs/pr-reviewer-reference.md` records what each one does, what shapes it, and which repositories +its plan covers. + +## Expected review loop + +Open every fleet-owned pull request ready for review. Draft state delays the loop and causes +reviewers to skip, so it has no place in the internal feature-to-develop or develop-to-main +workflow. The separately documented `upstream-contribution-workflow` may use a draft while a +third-party contribution is still being prepared for upstream review. + +Opening a pull request starts this loop by default. Creating the PR is not a terminal handoff. +Only an explicit maintainer instruction may stop, defer, or alter the loop. Silence or a request +that says only "open a PR" is not such an instruction. + +Run every `scripts/pr_review.py` command below from a hub checkout. The script is hosted there and +is never carried into a downstream repository. + +Run `local-strict-review` against the branch's current diff before every push this loop makes, the one that opens the pull request in step 1 and each one after it, whatever finding it answers and whether or not the branch was reviewed once already. A push that delivers content no pass has read is the case the rule is about, so a re-push of a tree a recorded pass already covers needs no second pass, the receipt being keyed on the branch's net content rather than on its commit series. A rebase that leaves the tree alone keeps it only while the merge base holds: rebasing onto a target that has moved retires the receipt although no file changed, and that push owes a pass like any other. Follow that skill's own ordering and record each pass, which is what a capture point reads, the hub's own `pre-push` hook being one and a repository having none until such a hook is carried to it. A push that hook refuses, where one is present, is the gate working rather than an obstacle to route around, and that skill's refusal table says what each refusal means and what clears it. + +1. Push changes to the PR branch and open the pull request when it does not exist. +2. Run `scripts/pr_review.py status <number> --repo <owner>/<repo>` once in the foreground and read its output. +3. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it + explicitly, which step 4's `wait` is what does, though it skips the request where a review + already covers the head, where the answer came outside a formal review, where it detects + drift, and where something is already in the request set, which is the condition the recovery + below clears. Requesting in the pull request UI is the maintainer's route rather than this loop's. +4. Run a bounded `scripts/pr_review.py wait <number> --repo <owner>/<repo>` in a background process and read its terminal output. + A completed review raising **no findings** is a valid terminal outcome, so do not re-trigger it + or read silence as a missing review. A review whose body says it declined to review is the one + exception, and it is terminal the other way. Nothing follows it, and re-requesting the same + head only repeats the decline. +5. Triage findings (see below). +6. Apply fixes or write a rationale for declines. +7. Reply to each thread, and resolve what was addressed and what was declined on evidence the + reviewer could check for itself, per outcome 2 below. +8. Re-run the loop after every fix push until the checks are green and no finding remains open. + +The review effort setting is user-controlled. The workflow never selects or changes it. `status` reports `effort=lite`, `effort=balanced`, or `effort=max` when the completed review exposes that metadata, lowercased, and names an inherited setting apart from a chosen one in a separate `effort_source=default|explicit` field, both reading `unknown` when no effort line parses. Missing effort metadata reports `unknown` and does not change coverage or completion. A pending effort-labeled request can complete without a `copilot_work_started` timeline event, so absence of that event never proves the request is abandoned. The bounded timeout reports `PENDING` when no review or terminal answer arrives. `requested=yes` reports that the request was accepted rather than that a round is coming. An accepted request can sit unpicked, printing the same digest as one about to be served, so a driver reading that field as progress is waiting on evidence it does not hold. After a timeout carrying it, rerun `wait` for another bounded interval by default, because the request may still be active. Where a second bounded wait times out as well, read the pending set, and clear it only where no human or team reviewer is requested alongside the bot, because the clear replaces that set rather than adding to it and nothing restores a request it drops. A stall on a pull request that has a human or team reviewer requested goes to the maintainer instead, and so does one still pending after the wait that follows a clear. The clear leaves the next `wait` nothing outstanding to defer to, so that run requests afresh, and its own auto-request line is what says so, since `wait` reads the reviewer's node id out of the repository's recent reviews and polls without requesting where it finds none. The hub's `docs/pr-reviewer-reference.md` carries the mutation, and an agent seat can run it, where removing and re-adding the reviewer in the pull request UI is a step only the maintainer can take. This recovery replaces only the review request and never changes the effort setting. + +Drive to green, a review confirmed on the latest head SHA and every actionable finding closed, +then apply the Merge Gate above. **Never exit this PR-hosted loop early.** Its pre-push +counterpart is bounded instead by `local-strict-review` "Disposing of Findings". A round count +is not a stopping condition here, and neither is patience running out. Reporting only that the +PR was opened is an early exit unless the maintainer explicitly instructed the agent not to +monitor or drive its review. + +After an authorized merge, run the `repo-worktree` post-merge cleanup procedure unless the user explicitly asks to retain the checkout or branch. The pull request loop is incomplete while its finished worktree or local task branch remains. It is also incomplete until the base clone returns to fetched and fast-forwarded `develop`. + +## Every finding ends in one of five outcomes + +1. **Real, so fix it, and fix the class rather than the instance.** A reviewer samples rather + than enumerates, so sweep for the finding's siblings before replying and fix each one sitting + in a file the diff already touches or that this change itself made wrong, filing the rest, per + `GOVERNANCE.md` "Verification Discipline". That sweep is owed the first time the finding is + raised, not once it recurs. Take the fix through `local-strict-review` the same way the push + that opened the pull request went, per `pr-review-conduct` "Expected review loop", then reply + with the fixing commit SHA. A branch already reviewed once has not been reviewed for the fix, which + is the round the `local-strict-review` pass gets dropped on and the churn `local-strict-review` + exists to stop. For a finding on platform-specific code (PowerShell, a macOS- or WSL-only + path), "fixed" means executed on that platform, per + `agent-conduct` "Before Claiming Done": a fix reasoned out by analogy to a tested equivalent + elsewhere is not yet fixed, and the reply says so rather than claiming the SHA closes it. +2. **Not real, or real but structurally out of scope, so decline in the thread with evidence.** + Disprove a wrong finding with the command and its output, the code path that makes it + impossible, or the rule that governs it. A finding that is factually correct but not this + repo's to fix (a verbatim-fidelity manifest entry byte-locking the section, ownership that + sits elsewhere) declines the same way: name the boundary and cite what proves it. Either shape + closes the thread on its own evidence, and the agent resolves such a thread itself rather than + leaving it for the maintainer. What makes that safe is the evidence being checkable by anyone, + a command and its output, the code path, the quoted rule, a byte-identical diff, so a decline + resting on anything weaker is not one of these. An assertion ("this is fine") does not close a + finding, and outcome 3's value call is the maintainer's, so that thread stays open until they + answer it. +3. **Real, fixable here, but deliberately left as is, a value call rather than a scope + boundary, so it is the maintainer's, not the agent's.** Reach for this only once outcome 2 is + ruled out, since a scope boundary declines on its own evidence and never needs this outcome at + all. State the finding and why the fix is unwanted, and get an explicit answer in the same + turn, before moving to other work. A plan to ask later is resolution by silence the moment + attention moves elsewhere. If the maintainer is not reachable right now, leave the thread open + and say so, rather than treating the intention to ask as the asking. +4. **Real and worth doing later, so file the issue first, then reply with its link.** A deferral + noted only in a thread is lost the moment the PR merges. File it in the repository where the + fix has to land, which for a finding against carried content is the repository that authors + that content rather than the one carrying it, since an issue filed where nobody may make the + fix is a deferral nobody can close. +5. **Keeps recurring although the class was swept, so the rule is what needs fixing.** A finding + raised repeatedly against correct code means the code is not communicating something: add the + comment, sharpen the name, narrow the interface, or fix the rule if the rule is wrong. + Bouncing the same point across rounds is the signal to escalate the rule itself, not to keep + re-arguing it. This is not where the class sweep lives, outcome 1 already owing that on the + first instance, and reaching here means the sweep ran and the finding came back anyway. + +**A disposition decided on one PR does not carry to the next.** The same finding shape recurring +on a sibling repo or PR, even within one batch or one session, gets its own outcome: its own +evidence-backed decline (outcome 2) or its own explicit maintainer answer (outcome 3). A prior +instance's outcome is context for the new one, never a standing answer to reuse in its place. + +`pr-review-conduct` "Every finding ends in one of five outcomes" keeps the full rule, and the +`drive-pr` Skill carries it whole as a generated include, applying it while driving. + +## Triaging findings + +**A low-confidence (suppressed) finding is not a low-value one.** Judge each against the code, +never against its confidence label. Classify before responding: + +- **Bug**, wrong behavior, missing coverage, a real code or doc divergence. Fix it. +- **Style or convention**. If the cited rule matches the existing tree, fix the code. If the rule + contradicts the tree or industry norm, **fix the rule, not the code**, and take it to the + maintainer (outcome 5) rather than bouncing the same code across rounds. +- **Architectural opinion**, a proposed redesign. Surface it with a recommendation, never apply + it unilaterally. + +## Answering a suppressed finding + +A suppressed finding has no thread and no resolved or unresolved state, so an answer needs to +carry its own context: quote the finding (with its `file:line` anchor and enough of the +reviewer's own words to identify it), give one bold verdict per finding (`Fixed in <SHA>`, +`Disproven`, or `No change needed`), state the `(N)` count the block gave so answers can be +checked against findings, and link the review round. **Read every round, not only the head.** A +suppressed finding does not retire when a later push supersedes it, it just stops showing up in a +head-scoped query while still unanswered. Post the answer with `scripts/pr_review.py comment <number> --repo <owner>/<repo> --body <text>` +from a hub checkout. Do not use a provider connector or reconstruct the GitHub mutation. + +## Escalate to the maintainer when + +- A genuine design trade-off surfaces (fail-open vs. fail-closed, refactor scope). +- A finding keeps recurring. Bring the pattern and a recommended fix (rule change or code + change), don't keep silently re-declining it. +- A finding is judged real but should not be fixed. That decision is never the agent's alone. +- An architectural redesign is proposed rather than a bug fix. + +An agent that cannot reach the maintainer directly, a dispatched subagent being the ordinary case, +escalates to whoever dispatched it and stops that unit of work there. It never substitutes its own +judgment for the escalation because asking is inconvenient from where it sits, and it never resolves +the thread to keep moving. A dispatcher receiving one puts it to the maintainer at the point that +work stopped, per `GOVERNANCE.md` "Communicating with the User", and deciding it instead so the +dispatcher's own work keeps moving is the same resolution by silence this skill's own +ask-the-maintainer outcome forbids, one seat further from the maintainer. The escalation may travel through several seats, and what stays stopped is the +escalated unit of work, in whichever seat holds it, until the answer arrives. A dispatcher's other +work is not stopped by it. + +## Mechanics Live Elsewhere + +This skill is the provider-agnostic contract. Use `scripts/pr_review.py` from a hub checkout for +the GitHub-specific API operations, each taking `<number> --repo <owner>/<repo>`. `claims` checks the pull +request description against the branch it describes, catching a commit or `uses:` ref the head no +longer carries. `status` reports coverage, threads, body-only findings, and +shapes in one call. `wait` requests and polls in-process. `comment` posts a PR-conversation +answer after it reads the PR node ID. `reply` answers a thread by matching the finding's own +words instead of a line number a fix push can move, and resolves it only when `--resolve` is +given. The repository's +`.github/copilot-instructions.md` bootstraps Copilot into the `fleet-code-review` skill and its stable +coverage marker. Do not reconstruct the API operations by hand. diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md new file mode 100644 index 0000000..160bf0b --- /dev/null +++ b/.github/skills/python-codestyle/SKILL.md @@ -0,0 +1,194 @@ +--- +name: python-codestyle +description: >- + Governs Python code style for ptr727/ProjectTemplate fleet repos: the build-versus-lint-only + profile split, the uv/ruff/pyright/mypy/pytest toolchain, src layout, formatting and linting, + comment and docstring conventions, type hints, naming, imports, patterns to avoid, test + conventions, and versioning. Use this whenever writing, reviewing, or editing a .py file, a + pyproject.toml, or a uv.lock, whenever running or choosing a Python formatting, lint, type-check, + or test command, whenever deciding whether a Python subtree is a shippable project or a lint-only + scripts tree, whenever choosing pyright versus mypy for a repo's CI gate, or whenever writing or + reviewing a Python test. Triggers even when the task looks like a small local fix ("just add a + helper function", "silence this lint warning", "add a dependency") or verification step ("run + the tests"), because choosing pytest before reading the profile turns an intentional unittest + suite into a false missing-dependency diagnosis. Applies only to a repo's Python side, a repo + with no Python has no use for this Skill. +--- + +# Python Codestyle + +## Why this exists + +This is the Python-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. CODESTYLE.md's General section still owns the rules every +language shares (clean-compile verification as a concept, the suppression-scope order, tooling +casing in prose), this Skill is everything specific to a Python project on top of that: the two +profiles, the toolchain, layout, and the language-level conventions. + +## Two profiles + +Read the repo's `OPERATIONS.md` local-verification commands before substituting a generic command. +Then read the `pyproject.toml` shape and pick the profile before running Python tooling or tests: + +- **build** (Project): `[project]` + `[build-system]` + committed `uv.lock`. Uses `uv run`, pytest, + pyright strict (or mypy where the repo requires it). +- **lint-only** (Scripts): no `[project]`, no lockfile. Uses `uvx` for third-party tools, unittest + for tests, and mypy as the CI gate. Do not run pytest or diagnose its absence as an environment + defect. Use the repository's exact coverage command and unittest scope from `OPERATIONS.md`. + +For the full profile specification and per-repo adaptation axes (type checker, dependency +declaration, versioning, VS Code config), see `references/profiles.md`. + +## Toolchain + +| Tool | Role | Config | +|---|---|---| +| [uv][uv-link] | env, deps, build, publish (build/publish only where the repo ships a package) | `pyproject.toml` `[dependency-groups]` or `[project.optional-dependencies]`, `uv.lock` | +| [hatchling][latest-link] | build backend (published packages) | `pyproject.toml` `[build-system]` | +| [ruff][ruff-link] | lint + format + import sort | `pyproject.toml` `[tool.ruff]` | +| [pyright][pyright-link] | type checker (the default, a strict baseline) | `pyproject.toml` `[tool.pyright]` | +| [mypy][mypy-link] | additional/alternate type checker (optional, the CI checker in a mypy-in-CI repo, required for Home Assistant) | `pyproject.toml` `[tool.mypy]` (or per home-assistant/core) | +| [pytest][docs-link] | test runner (build profile only, lint-only uses `unittest`) | `pyproject.toml` `[tool.pytest.ini_options]` | + +**Type checking targets strongly typed, deterministic code.** pyright in strict mode is the +default baseline on first-party code (a repo may instead run mypy in CI and keep pyright +editor-only via Pylance, per the next paragraph): `[tool.pyright]` `strict = ["src"]`, or the +integration package for a Home Assistant repo, with tests run in standard mode. pyright is the +anchor because Pylance embeds it, so the editor and the CLI/CI (`uv run pyright`) run the same +engine and never disagree. The standalone `ms-pyright.pyright` extension stays in +`unwantedRecommendations` because Pylance covers it. Relax strictness on third-party code only +when a dependency has no usable types and no alternative (e.g. `pandas`): a targeted, commented +`# pyright: ignore[...]` or a scoped `[tool.pyright]` override, never a blanket relaxation. + +**mypy is allowed, and required where the ecosystem demands it, it is not banned.** Running more +than one checker is normal when each serves a purpose (the .NET side pairs CSharpier and +`dotnet format` the same way), and pyright's inference and mypy's plugin ecosystem (e.g. +`pydantic.mypy`) catch different classes of error. A Home Assistant integration runs +`mypy --strict` because the platinum `strict-typing` quality-scale tier requires it, and a +pydantic-heavy library may opt in for the plugin. When a repo uses mypy it runs in CI and the +editor (the `ms-python.mypy-type-checker` extension) so the two stay consistent, and its mypy +command joins the clean-compile. A repo with no such need stays pyright-only, which is lighter and +inherently consistent. + +## Local development loop + +From inside a **build**-profile Python project directory. A **lint-only** Scripts profile has no +`uv.lock` to sync and no pytest to run, substitute `uvx` per tool and `unittest` per the Two +Profiles section above: + +```sh +uv sync # creates .venv, installs deps + dev group +uv run ruff format # auto-format +uv run ruff check --fix # auto-fix lint +uv run ruff check # verify lint clean +uv run ruff format --check # verify format clean +uv run pyright # verify types +uv run pytest # run tests +uv build # produce wheel + sdist in ./dist (published packages only) +``` + +The **build**-profile Python clean-compile is `uv run ruff format` + `uv run ruff check` + the +repo's type checker: `uv run pyright`, or `uv run mypy src` where mypy is the CI checker, or both +where the repo runs both (see Type checking above). Run it, plus `uv run pytest`, before +committing. A **lint-only** profile's clean-compile substitutes its `uvx` and `unittest` +equivalents, per Two Profiles above, and has no such command to run before committing beyond +those. These are documented commands, and the hub's `vscode-tasks-python.json` snippet carries the +VS Code tasks mirror that the fleet baseline expects. Every command-executing task in it is +`type: process`, and every aggregator is `dependsOn`-only. Neither chains with `&&`, so the mirror +runs the same on any task shell. CI runs the same clean-compile commands as the authoritative +backstop. A repo that keeps no .NET tool manifest declaring Husky.Net, or one that prefers the +`pre-commit` framework, wires its local hook from the canonical `catalog/snippets/pre-commit/` directory, +hub-local and not carried into every fleet repo. Any repo may instead wire an equivalent hook of +its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing. That path and +`.pre-commit-config.yaml` are the two the audit reads. The runner is bounded by the toolchain the +repo already keeps rather than by the languages the hook checks, so a repo keeping a Husky.Net +manifest may run these same Python checks from the `catalog/snippets/husky/` shape instead. Each +shape carries whichever language checks its own repo keeps. The `pre-commit` directory's own +README names the second file to copy alongside the config. +GOVERNANCE.md's hub-only "Running the Linters Locally (Known-Working Invocations)" section carries +the obligation itself, what the hook must cover, its audit treatment, and the per-clone enablement +steps. + +A restricted executor gives each task a cache directory under a writable temporary root. Point +`UV_CACHE_DIR`, `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR`, and `COVERAGE_FILE` into that directory before +running the applicable tools. This keeps their generated state outside both the home directory +and the checkout. Do not change `HOME` or an agent configuration directory. A denied network +request means the tool did not run, so preserve the denial and rerun through the executor's scoped +approval mechanism. + +## Layout + +`src` layout, which keeps the package out of the repo root and prevents accidental imports of +unbuilt code: + +```text +<python-project>/ + pyproject.toml + README.md + uv.lock # committed for reproducible CI + src/ + <package_name>/ + __init__.py + _version.py # published packages; a source-only repo uses a static version instead + <modules>.py + tests/ + __init__.py + test_<module>.py +``` + +## Code style + +Key rules for every Python task: + +- **`ruff format` is authoritative.** Don't argue with the formatter. Configure in `pyproject.toml` + `[tool.ruff]`, not via inline `# fmt:` directives. +- **Run `ruff check --fix` before committing.** The configured rule families are in + `[tool.ruff.lint]` `select`. Add new rule families project-wide, not scattered inline `# noqa`. +- **`# noqa` is a last resort.** Scope it narrowly (`# noqa: E501`) with a comment. Recurring + false positives belong in `[tool.ruff.lint]` `ignore` or `per-file-ignores`. +- **All public APIs are typed.** Use modern syntax (`list[int]`, `X | None`). Don't add + `# type: ignore` without an explaining comment. +- **Don't add backward-compat shims.** Just delete unused code. Git history is the audit trail. +- **Don't add error handling for impossible cases.** Trust internal code. Validate only at boundaries. + +For comments, docstrings, full type-hint rules, naming, imports, and all patterns to avoid, see +`references/code-style.md`. + +## Tests + +`uv run pytest` for a build profile, `unittest` for a lint-only Scripts profile (see Two Profiles +above). One test file per module (`test_<module>.py`). A build profile prefers fixtures over +`unittest`'s `setUp`/`tearDown` lifecycle hooks. A lint-only profile uses those hooks directly, +since `unittest` has no fixture-injection mechanism of its own. Fakes over mocks either way. Test +the docstring's contract, not implementation details. See `references/testing.md` for the full +build-profile conventions, and `references/profiles.md` for the lint-only `unittest` conventions. + +## Versioning + +Published packages use `_version.py` with `__version__ = "0.0.0"` as a placeholder. Wire +`hatch-vcs` or equivalent to increment, publish with `skip-existing: true`. Source-only repos use +a static `version` in `[project]` with no `_version.py`. See `references/profiles.md` for details. + +## Linter cleanliness + +Before pushing or opening a PR: + +- VS Code's Problems pane should be quiet for the files you touched. The relevant linters are ruff + (via the `charliermarsh.ruff` extension) and pyright (via the `ms-python.python` extension's + bundled Pylance). +- The **build**-profile CI gate is `uv run ruff check`, `uv run ruff format --check`, the repo's + type checker (`uv run pyright` or `uv run mypy src`), and `uv run pytest`, the same commands as + the local loop above, run from the Python project directory (invoked as separate steps, not + `&&`-chained, so the runner shell is irrelevant). A **lint-only** profile's CI gate is its `uvx` + equivalents plus its `unittest` suite, per `references/profiles.md`. +- Markdown in this directory follows CODESTYLE.md's repo-wide Markdown and Spelling rules, + packaged as the `comment-and-doc-style` Skill. + +<!-- External --> + +[docs-link]: https://docs.pytest.org/ +[latest-link]: https://hatch.pypa.io/latest/ +[mypy-link]: https://mypy-lang.org/ +[pyright-link]: https://microsoft.github.io/pyright/ +[ruff-link]: https://docs.astral.sh/ruff/ +[uv-link]: https://docs.astral.sh/uv/ diff --git a/.github/skills/python-codestyle/references/code-style.md b/.github/skills/python-codestyle/references/code-style.md new file mode 100644 index 0000000..9ad17f2 --- /dev/null +++ b/.github/skills/python-codestyle/references/code-style.md @@ -0,0 +1,93 @@ +# Python Code Style: Full Reference + +## Formatting and linting + +- **`ruff format` is authoritative.** Don't argue with the formatter, and if it reformats your + code, that's the final form. Configure (line length, target version) in `pyproject.toml` + `[tool.ruff]`, not via inline `# fmt:` directives. +- **Run `ruff check --fix` before committing.** Most ruff lint rules have safe autofixes, let the + tool handle them. The configured rule families are listed under `[tool.ruff.lint]` `select`. Add + new rule families project-wide rather than scattering inline `# noqa` markers. +- **`# noqa` is a last resort.** When you must use one, scope it narrowly (`# noqa: E501`, not + bare `# noqa`) and add a short comment on the same line explaining why. False-positive patterns + that recur across the codebase belong in `[tool.ruff.lint]` `ignore` or per-file + `[tool.ruff.lint.per-file-ignores]`, with a comment. Porting an existing codebase is not a + license to add `ignore` / `per-file-ignores` blocks to mute newly surfaced lint. Fix it. + +## Comments + +- **Inline `#` comments**: keep tight and local. One line is preferred, but multi-line is fine + when you need to document a non-obvious implementation constraint, a local trade-off, or + coupling that future edits could easily break. Keep that rationale next to the affected block so + the reviewer/maintainer sees it at edit-time. +- **Don't explain what the code does.** Well-named identifiers handle that. Don't reference the + current task ("added for X", "used by Y"), which belongs in the PR description. + +## Docstrings + +- Follow [PEP 257][pep-0257-link]. Focus docstrings primarily on the behavior contract (what + callers and tests can rely on), public semantics, and edge-case expectations. + Implementation-local rationale belongs in inline `#` comments, not docstrings. +- A short one-liner is fine for trivial functions and tests with self-documenting names. +- For non-trivial behavior (non-obvious test scenarios, contracts a test pins, edge cases callers + must know about, design trade-offs that are load-bearing for future maintainers), write a + one-line summary, blank line, then a details paragraph. Multi-paragraph docstrings are fine when + the contract earns it. +- Design notes belong in the code (docstrings or inline comments). They do NOT belong in + `HISTORY.md`, which is end-user release notes, not a design log. + +## Type hints + +- **All public APIs are typed.** The repo's configured type checker runs on `src/` (pyright strict + via `[tool.pyright]` `strict = ["src"]`, or mypy where that is the CI checker), and tests run in + the checker's looser/standard mode. +- **Use modern syntax**: `list[int]` not `List[int]`, `dict[str, X]` not `Dict[str, X]`, + `X | None` not `Optional[X]`, `from __future__ import annotations` only when needed for forward + references. +- **Don't hedge that syntax for an older interpreter.** `pyproject.toml` pins `target-version` / + `python_version` to 3.13 for every Python profile in this repo, and `spec/host-tools.json` + carries that as the host floor `scripts/host_gate.py` enforces, so 3.10+-only syntax (`X | None`, + `match`, etc.) needs no quoting, no `typing.Union` fallback, and no `from __future__ import + annotations` guard on that account alone. Add that import only when a real forward reference + needs it, per the bullet above. Three named exceptions carry a lower floor on purpose and say so + themselves: `scripts/skills_install.sh` and the `install-skills.*` bootstrap scripts, which must + run on whatever interpreter a host already has before this floor's toolchain exists to install + one, and `spec/resolve_description.py`, which `repo-config/configure.sh`'s own bootstrap probe + accepts down to 3.7 for the same reason, and which carries `from __future__ import annotations` + for exactly that purpose rather than out of habit. No other `spec/` code has a reason to hedge, + so that import or a quoted annotation appearing anywhere else in `spec/` is a sign this one + exception got copied rather than a pattern to follow. +- **Don't add `# type: ignore` to silence pyright errors without a comment** explaining the + constraint. If a recurring false positive needs suppression, configure it project-wide in + `[tool.pyright]`. A new port doesn't change this, fix freshly surfaced type errors rather than + muting them. + +## Naming + +- `snake_case` for functions, methods, variables, modules, package directories. +- `PascalCase` for classes, type aliases, type vars, enum members. +- `UPPER_SNAKE_CASE` for module-level constants. +- Single leading underscore for module-private, double leading underscore for name-mangled (rare, + and usually means rethink the design). + +## Imports + +- **Let ruff sort imports.** `[tool.ruff.lint]` `select` includes the `I` rule family + (isort-equivalent). Don't hand-sort. +- Standard library first, then third-party, then first-party (the project itself), each block + separated by a blank line, which ruff enforces automatically. +- Avoid wildcard imports (`from x import *`) outside `__init__.py` re-exports. + +## Patterns to avoid + +- **Don't add backward-compat shims, `# removed` markers, or rename-to-`_` for unused vars**, just + delete. Git history is the audit trail. +- **Don't add error handling for impossible cases.** Trust internal code, and validate only at + boundaries (user input, parsed config, external APIs). +- **Don't use exceptions for expected control flow.** Exceptions are for unexpected states. +- **Don't suppress errors silently** (`except Exception: pass`). Either handle the specific + exception and document why it's safe, or let it propagate. + +<!-- External --> + +[pep-0257-link]: https://peps.python.org/pep-0257/ diff --git a/.github/skills/python-codestyle/references/profiles.md b/.github/skills/python-codestyle/references/profiles.md new file mode 100644 index 0000000..556c3d2 --- /dev/null +++ b/.github/skills/python-codestyle/references/profiles.md @@ -0,0 +1,72 @@ +# Python Profile Details + +## Adapt before propagating + +The rules in `SKILL.md` describe the default Python profile: a package that publishes to PyPI, +type-checked by pyright in strict mode, dependencies in `[dependency-groups]`. A derived repo +often differs, and when it does, adapt these fields to match the repo's actual toolchain rather +than copying verbatim (a verbatim copy that misdescribes the repo is inaccurate and gets rejected +in review). The axes that commonly vary per repo: + +- **Type checker in CI**: pyright strict, mypy in CI with pyright editor-only (Pylance), or both. + Whichever runs in CI is the one the clean-compile and the CI gate invoke. +- **Dependency declaration**: `[dependency-groups]`, or PEP 621 `[project.optional-dependencies]` + (dev tools installed with `uv sync --extra <group>`). +- **Versioning / publishing**: a published package (`_version.py` plus a version source, + `uv build`, and a PyPI publish step), or a source-only repo with a static `version` and no + publish step (see Versioning below). +- **VS Code config home**: editor settings/extensions may live in `.vscode/*.json` or the + `<Repo>.code-workspace`, while tasks/launch/debug configs can only be external `.vscode/*.json` + (they cannot live in the workspace file). The repo's own `tasks.json` sits wherever it keeps it, + and the canonical task definitions it is written against are the hub `vscode-tasks-python.json` + snippet, which resolves the same way from every repo. + +## Two profiles: full specification + +A repo's Python is one of two shapes, declared as the `build` or `lint-only` profile and validated +against the `pyproject.toml` shape. Most of the `SKILL.md` rules (uv project, `uv.lock`, `uv run`, +src layout, pytest coverage) describe the Project shape (the `build` profile). The two differ by +whether the Python has third-party runtime dependencies, which shows up structurally in +`pyproject.toml`, so the fleet's audit reads the shape there: + +- **Project** (the `build` profile): the Python has third-party runtime dependencies, or is the + repo's deliverable. It is a PEP 621 uv project: `[project]` with `dependencies` (dev tools in + `[project.optional-dependencies]` or `[dependency-groups]`), a `[build-system]`, and a committed + `uv.lock` (pinned LF, per GOVERNANCE.md's "Line Endings" section). CI runs `uv sync --frozen` + + `uv run <tool>`, so the lockfile pins tool versions. +- **Scripts** (the `lint-only` profile): stdlib-only utility scripts embedded in a non-Python repo + (e.g. a Python tooling subtree of a `csharp` app). Run the tools with `uvx` (no project install, + no lockfile): the `pyproject.toml` carries only tool config (`[tool.ruff]`, `[tool.mypy]`, and + an optional `[tool.pyright]` editor block), with no `[project]`, no `[build-system]`, and no + `uv.lock` (that metadata would misrepresent it as a shippable package). mypy is the type-check + gate (there is no first-party package for pyright strict to anchor on), and a `[tool.pyright]` + block in standard mode keeps Pylance quiet in the editor, the same mypy-gate/pyright-editor + split the build profile uses. There is no lockfile, and a `uvx <tool>@<ver>` pin in a `run:` + step is not something Dependabot tracks, so CI runs `uvx ruff@latest` / `uvx mypy@latest` rather + than a manual pin that would silently go stale. The fleet rule is to pin only what Dependabot + auto-updates (SHA-pinned actions, package deps) and otherwise run latest, so the VS Code tasks, + README, and CI all run the unpinned latest here. `.py` files follow the repo's LF line-ending + default (per GOVERNANCE.md's "Line Endings" section). There is no pytest suite, and `unittest` is + the runner instead. A script that carries a gate still earns tests, written with the standard + library's `unittest` so they run under bare `python3` with nothing installed, as + `test_<script>.py` under a `tests/` directory beside the scripts it exercises + (`<scripts-dir>/tests/`), kept apart so a test never reads as a tool. Within the scripts + directory the name carries the kind: a gate that checks and exits non-zero on a finding takes a + `_lint` or `_gate` suffix, and a utility that does work takes none. Any repo carrying Python + carries the Python tooling in CI, coverage included, this profile too: `uvx ruff@latest check`, + `uvx ruff@latest format --check`, `uvx mypy@latest`, and the unittest suite under + `uvx coverage@latest run -m unittest discover -s <scripts-dir>/tests` with `coverage report`, + informational with no threshold adopted. A co-present `csharp` type still carries `codecov.yml` + for its own tests. + +## Versioning + +**Published packages.** `_version.py` ships with `__version__ = "0.0.0"` as a placeholder. Until +you wire `_version.py` to something that increments (the usual options are `hatch-vcs`, a +version.json bridge, or manual bumps), no new PyPI versions will land, and publishing with +`skip-existing: true` keeps a stuck placeholder version from failing the run. + +**Source-only repos** (no PyPI publish, with a source-release on dispatch or no release at all) do +not need `_version.py`: keep a static `version` in `pyproject.toml` `[project]`, or let the +release pipeline's version source (e.g. NBGV plus `version.json`) own the tag. There is no publish +step to guard, so `skip-existing` does not apply. diff --git a/.github/skills/python-codestyle/references/testing.md b/.github/skills/python-codestyle/references/testing.md new file mode 100644 index 0000000..b4368a7 --- /dev/null +++ b/.github/skills/python-codestyle/references/testing.md @@ -0,0 +1,19 @@ +# Python Testing Conventions + +This covers the **build** profile. A **lint-only** Scripts profile has no `uv.lock` and does not +use pytest, its testing conventions (`unittest`, `uvx coverage@latest run -m unittest discover`) +are in `references/profiles.md`. + +Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: +`uv run pytest`. + +**Coverage.** A build-profile repository with tests declares **`pytest-cov`** among its test dependencies, a dev dependency group where the repository is a uv project and a `requirements*.txt` entry where it is on pip, and selects the coverage source in its own `pyproject.toml`, an `addopts` entry of `--cov=<package>` in practice. CI adds `--cov-report=xml` to the invocation, so the repository owes the dependency and the selector rather than that flag. Both halves are load-bearing and they fail differently: without the dependency the CI run exits non-zero on an unrecognized argument, and with the dependency but no selector it measures nothing, writes no file, and exits zero. Leave the report at the repository root as `coverage.xml`, the one path CI names. `WORKFLOW.md` D1.6 owns the pipeline half, the upload and the check that fails when no report was written. + +- One test file per module under test, named `test_<module>.py`. +- Test functions named `test_<scenario>_<expected_behavior>`, descriptive and not numbered. +- Use fixtures (defined in `conftest.py` for shared ones, or per-test for narrowly-scoped) instead + of setup/teardown methods. +- **Avoid mocking when fakes work.** Hand-rolled fakes that implement the protocol you depend on + are usually clearer and break less than `unittest.mock` magic. +- **Test edge cases that the docstring promises**, not implementation details. If the test breaks + when you refactor without changing behavior, the test is asserting on an implementation detail. diff --git a/.github/skills/repo-worktree/SKILL.md b/.github/skills/repo-worktree/SKILL.md new file mode 100644 index 0000000..3a5888e --- /dev/null +++ b/.github/skills/repo-worktree/SKILL.md @@ -0,0 +1,250 @@ +--- +name: repo-worktree +description: >- + Mandates and mechanizes task isolation in ptr727/ProjectTemplate fleet repos: every task, + including a continuation of a prior session's task, creates its own git worktree on its own + feature branch before its first file edit, based on the branch work starts on (develop on both + fleet workflow models unless the task is explicitly about main-only content, never whichever + branch a tool defaulted to), preferring a registered worktree in the fleet layout and using a + standalone-clone fallback only when the executor cannot write to either the standard worktree + location or its Git metadata, and no available approval route grants access. Also wraps the + mechanics: + creating a worktree with git worktree add, the fleet layout convention, listing what is in + flight, preparing Husky.Net or Python pre-commit hooks in the new tree, and removing a + worktree and its branch after merge. Use this whenever about to create or edit files in a + fleet repo, whenever starting or resuming a task, whenever the task's branch is already + checked out in a shared checkout, and whenever creating, listing, or removing a worktree. + Triggers even when the session was launched in the primary checkout or the change looks like + a one-line fix, because the primary checkout is the maintainer's own surface and the incident + this guards against was two sessions sharing one checkout, each session's blanket add + committing the other's uncommitted files. +--- + +# Repo Worktree + +## Why This Exists + +Two agent sessions once ran concurrently in the same primary checkout, on the same feature +branch, neither knowing the other was in the tree. One session's commits swept in the other +session's uncommitted files, so two commits landed carrying work their subjects never mention, +committed by a task that never saw it. No rule fired at the moment it was violated, which is the +first file edit: the commit-time and review-time skills all run after a sweep has already +happened. This skill is that missing task-start surface. `GOVERNANCE.md` "Repository Boundaries +and Write Safety" keeps the isolation law and wins on any disagreement, and the mechanics below +are this skill's own content. + +## The Mandate + +- **Every task isolates into its own worktree before its first file edit.** All new work begins + by creating a unique worktree (or clone) on its own feature branch. The primary checkout is + the maintainer's own surface, so a session launched there isolates before writing rather than + after noticing contention. +- **A continuation re-isolates.** A session resuming prior work finds its branch already checked + out somewhere and naturally resumes there, and that instinct is the hazard: a branch sitting + checked out in a shared tree is exactly how two sessions end up in one checkout. Create a + fresh worktree for the continuation and check the branch out there. +- **The moment is the first file edit, not the commit.** By commit time another task's + uncommitted work can already be swept into the staging area, so isolating late protects + nothing. Reading anywhere is fine, and the worktree exists before the first write. +- **Someone else's tree stays theirs.** A branch that changes when nothing you did changed it, + or an edit of yours reverted with no conflict, means another task is live in that tree, and + the response is to stop rather than to re-apply the edit, per `GOVERNANCE.md` "Repository + Boundaries and Write Safety". + +## The Base Branch + +Base the worktree on the branch work starts on for the repository's model, not on whichever +branch a tool defaulted to. GitHub's own "default branch" setting reads `main`, but on both +fleet workflow models work starts on `develop`, so a worktree defaulted to "the default branch" +lands on `main` and silently misses everything merged to `develop` but not yet promoted. Branch +from `develop` unless the task is explicitly about `main`-only content, per `GOVERNANCE.md` +"Branching Model". Fetch immediately before creating and base on the remote ref, because a clone +is whatever it last fetched rather than the branch it names. + +The base clone is a fetch source, not a place to do task work. `fetch` and `worktree add` run +against it for that purpose, and outside two steps below, nothing else mutates its working tree, +index, or HEAD while a task is in progress: never `checkout`, `pull`, `reset`, `commit`, or any +other such command. Those two are "Listing and Cleanup"'s own terminal step and "Creating a +Worktree"'s return of the base clone to its own working branch where a continuation finds the task +branch checked out there. That distinction is the one a real incident missed, where an agent +reused a primary checkout as the working directory itself rather than only as the source a +worktree is created from. On a machine carrying the hub's agent-safety install, Claude Code also +makes this a mechanical stop for most of that list. `merge --ff-only`/`pull --ff-only` stay exempt +even there, and so does a `checkout <ref>`/`switch <ref>` naming exactly one positional that +resolves as a ref, with no force flag and no `--` separator, which is the shape both of this +skill's own steps below run. A `checkout -- .` or a `switch -c <new>` is denied, being neither. +Prose remains the only enforcement for a non-Claude-Code agent, for a machine without that +install, and for the shapes the hook itself exempts. + +## Creating a Worktree + +The fleet layout convention keeps every base clone and every in-flight task visible in one +place: + +```text +~/repos/<Repo> base clone, on its default/working branch +~/repos/worktrees/<Repo>-<task-slug> one worktree per in-flight task, own branch +~/repos/upstream/<owner>-<repo> clone of a repo under another owner, not a fork +``` + +The top level carries no owner segment because everything in it is the fleet owner's own, an +original repo and a fork alike. A fork is named `<upstream-owner>-<upstream-repo>` at fork time, +so a fork of `acme/core` is `acme-core`, and its name identifies the upstream project and stays +unique in the flat namespace without an owner segment of its own. A repository adopted as the +owner's own work rather than kept as a fork is detached from its parent and keeps a plain name, +`widget` rather than `initech-widget`, since it no longer tracks anything upstream. + +A clone of a repository under another owner is neither of those, and flattening one collides +rather than merely reading oddly: `acme/core` joined the way a fork is joined **is** the fork's +name, `acme-core`, while reduced to a bare `core` it names no project and collides with the next +`core` cloned from any other owner. Those clones live one level down under `upstream/`, named by +that same join, so `upstream/acme-core` sits beside the fork it would otherwise land on. The +segment states the relationship rather than the owner, so a reference checkout is told from a +working repo without a `git remote` call, and the names under it never compete with the flat +namespace above. The join is ambiguous in the abstract, since a hyphen in either half means +`acme-labs/core` and `acme/labs-core` produce one name, and it is kept anyway because it is the +fork convention's own join: the ambiguity is inherited from the flat namespace above rather than +introduced here, and it surfaces at clone time as a directory that already exists, where the +second clone takes a hand-picked name. A worktree off one of them keeps the flat worktrees path +under the same name, `~/repos/worktrees/<owner>-<repo>-<task-slug>`. Contributing a change from +such a clone is never a push out of it: fork the upstream first, per the +`upstream-contribution-workflow` skill, and that fork's own clone then belongs in the flat +namespace above, under the name this one already has. + +```sh +git -C ~/repos/"<Repo>" fetch origin develop +git -C ~/repos/"<Repo>" worktree add ~/repos/worktrees/"<Repo>-<task-slug>" -b "<task-branch>" origin/develop +``` + +The registered worktree above is the normal path. It keeps the task visible in `git worktree +list`, the fleet worktree directory, and IDE worktree discovery. It also leaves the task in a +durable location the maintainer can inspect after the agent session ends. + +Before running that command, inspect the executor's active write boundaries. A linked worktree +requires write access to both of these locations: + +- the intended `~/repos/worktrees/<Repo>-<task-slug>` worktree directory +- the base clone's `.git/worktrees/` administrative directory, which holds the index and locks + +A writable worktree directory does not make the administrative directory writable. When the +executor has an approval mechanism, request scoped approval for the standard `git worktree add` +command. A declared sandbox boundary is the reason to request that approval, not by itself the +reason to skip the registered worktree. + +Use the standard layout when both locations are writable or the executor approves the scoped +write. When approval for the worktree path is unavailable or denied, create a standalone clone +under a writable temporary root. Name it `<temporary-root>/<Repo>-<task-slug>`, fetch immediately, +and create the task branch from `origin/develop`. On Claude Code that clone needs a grant of its +own before its git steps will run, per the paragraph below, so ask for both together rather than +meeting the second after the clone exists. A standalone clone keeps its worktree and Git +administrative directory under the same writable root. It therefore supports edits, explicit-path +staging, commits, and branch updates without sharing the base clone's index. + +On Claude Code, a standalone clone is structurally a primary checkout to `gh-write-guard.py`'s own +rule 6 test (`--git-dir` equals `--git-common-dir` there too, since it is not a linked worktree of +anything), so the hook denies the git commands this fallback exists to run. Its rules classify git +and gh commands only, so a plain file edit is never denied there and only the git steps need the +grant, though a compound command is judged whole, so an edit chained to a denied git write does +not run either. That grant is `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, the same escape hatch +`host-setup/agent-safety/README.md` in the hub already documents as its requirement 6, not a +repo-relative link since that path is hub-local and not carried into every fleet repo. The +maintainer sets it before the session starts, and an agent cannot set it for itself: the guard +reads it from the environment the session was launched with, never from an inline `VAR=x` prefix +or an `export` the agent runs, so ask for it rather than trying to set it mid-session. This +fallback is exactly the narrow, already-approval-gated case that grant exists for. + +A temporary standalone clone is a degraded handoff, not an equivalent location. The base clone +does not register it, `git worktree list` does not show it, and an IDE opened on the base clone +does not discover its changes. The maintainer must navigate to it manually, and the operating +system may reap it as temporary data. State the absolute path as soon as the fallback is chosen +and again in the handoff. Do not present work there as ordinarily reviewable from the primary +workspace. + +```sh +TASK_ORIGIN="$(git -C "<base-clone>" remote get-url origin)" +git clone --no-checkout "$TASK_ORIGIN" "<temporary-root>/<Repo>-<task-slug>" +git -C "<temporary-root>/<Repo>-<task-slug>" fetch origin develop +git -C "<temporary-root>/<Repo>-<task-slug>" switch -c "<task-branch>" origin/develop +``` + +Do not use a linked worktree under the temporary root when the base clone's Git metadata is +read-only. If an existing linked worktree must be kept, index operations require the executor's +scoped approval for that administrative path. Use the standalone clone only after the standard +registered path and its approval route are unavailable. + +A continuation attaches the task's existing branch rather than forking a fresh one: + +```sh +git -C ~/repos/"<Repo>" fetch origin "<task-branch>" +git -C ~/repos/"<Repo>" worktree add ~/repos/worktrees/"<Repo>-<task-slug>" "<task-branch>" +``` + +When the base clone holds only the remote-tracking ref, the same command creates the local branch +tracking `origin/<task-branch>` through git's ordinary checkout guessing, so a fresh clone needs +no separate branch setup. Git refuses to attach a branch that is already checked out somewhere +else, and that refusal is the mandate working, since the branch sitting checked out in a shared +tree is the hazard the continuation rule exists for. `git worktree list` names the checkout +holding it and prints the base clone first, and what to do there depends on which checkout that is. + +The base clone holding it is the case "The Base Branch" excepts. Return it to its own working +branch when its tree is clean, `git -C ~/repos/<Repo> checkout develop`, and then create the +worktree. Stop when it is not clean, because a dirty shared checkout is the signal that another +task may be live there. + +A previous session's own worktree holding it is retired rather than switched. It normally sits at +the very `~/repos/worktrees/<Repo>-<task-slug>` path the command above wants, so `worktree add` +aborts on the existing directory whatever its branch is. Remove it when it is clean, and stop when +it is not, because a dirty tree there may be uncommitted work. `backlog-burndown` calls this +retire-then-dispatch and cites this skill for it. + +A machine not yet migrated to this layout still isolates exactly the same way, since the mandate +is the isolation rather than the path: create the worktree beside whatever layout the machine +has, and note that the base clone may live elsewhere than `~/repos/<Repo>`. + +## Agent-Specific Worktree Tools + +Provider-specific mechanics stay separate from the general creation procedure above: + +- **Claude Code:** its `EnterWorktree` tool acts only on an explicit instruction from the user or + project instructions. Given a `name`, it creates the worktree under `.claude/worktrees/` and + bases it on the GitHub default branch. Both differ from the fleet path and base. Create the + worktree with `git worktree add`, then attach with `EnterWorktree` `path:`, not `name:`. +- **Codex:** no provider-specific creation override applies. Use the general `git worktree add` + procedure above. Its host-specific writable-root setting lives in `docs/host-setup.md` "Agent + Worktree Access". +- **opencode:** no provider-specific creation override applies. Use the general + `git worktree add` procedure above. + +## Preparing Git Hooks + +A new worktree holds tracked hook configuration but not every generated hook runtime. Prepare +the hooks immediately after creating or attaching the worktree, before the first commit. A +shared `core.hooksPath` value does not make generated files such as `.husky/_/husky.sh` appear +in the new tree. + +- **Husky.Net:** When `.husky/pre-commit` sources `.husky/_/husky.sh` and the local .NET tool + manifest declares Husky.Net, run `dotnet tool restore`, then `dotnet husky install` from the + worktree root. +- **Python pre-commit:** When `.pre-commit-config.yaml` exists, run `uv tool install pre-commit` + once per host if not already installed, then `pre-commit install` from the worktree root. + `pre-commit` is never a project dependency, so this is the same regardless of profile. +- **Repository override:** Follow a repository's explicit hook-setup instructions when they + differ from these standard cases. Do not infer a replacement command from the language alone. + +Treat hook preparation as worktree setup, not as recovery after a rejected commit. If setup +fails, report that boundary and fix the setup. Never bypass the hook to make the commit succeed. + +## Listing and Cleanup + +- `git worktree list`, run in any checkout of a repo, names that repo's base clone and every + worktree with its branch. On the convention layout, one `ls ~/repos/worktrees/` reads what is + in flight across the whole fleet. +- **Cleanup after merge is the default terminal step.** Run it after a squash merge into `develop`. Run it again after a merge-commit promotion into `main`, unless the user explicitly says to retain a checkout or branch. A merge or release handoff is incomplete while finished task, conflict-resolution, installer, or release worktrees remain registered. +- **Verify before removing.** Read the pull request's merged state and head SHA from live GitHub state. Confirm the worktree is clean and resolves to that head. A dirty worktree stops cleanup because force-removing it would discard work. A detached helper worktree needs no pull request, but its commit must be contained in the branch whose completed operation created it. +- **Remove the exact finished worktree, then its local task branch.** Use `git worktree remove <exact-path>`. Try `git branch -d <exact-branch>` after a merge commit. A squash merge does not make the feature tip an ancestor of `develop`, so `-d` cannot recognize it as merged. After the live merged-PR and clean-worktree checks prove that exact branch finished, use `git branch -D <exact-branch>` under the narrow post-squash exception in `git-commit-conventions`. Never apply that exception to an unverified branch or to `develop`. +- **Remove temporary standalone clones and detached helper worktrees too.** Remove the exact `<temporary-root>/<Repo>-<task-slug>` path after confirming it is clean. The remote feature branch follows the repository's normal pull request cleanup policy. Never delete `develop` after a promotion because it is the permanent integration branch. +- **Return the base clone to current `develop`.** Fetch and prune `origin`, confirm the base clone is clean, switch it to `develop` when needed, and fast-forward it with `git merge --ff-only origin/develop`. A completed promotion or release does not leave the base clone on `main`. Stop and report a dirty base clone or a non-fast-forward instead of switching or reconciling it. +- **Prove the cleanup.** Finish with `git status --short --branch` in the base clone and `git worktree list`. The expected result is a clean base clone at `origin/develop` and no worktree belonging only to the completed task. +- A worktree that refuses removal is dirty, and force is not the fix: look at what is + uncommitted in it first, since discarding uncommitted work runs only on explicit instruction, + per the `git-commit-conventions` skill. diff --git a/.github/skills/resync-a-repo/SKILL.md b/.github/skills/resync-a-repo/SKILL.md new file mode 100644 index 0000000..82588b8 --- /dev/null +++ b/.github/skills/resync-a-repo/SKILL.md @@ -0,0 +1,97 @@ +--- +name: resync-a-repo +description: >- + Drives RESYNC.md's procedure for bringing a ptr727/ProjectTemplate fleet repo that is already + stood up back into line with the current hub, run from a hub checkout against a named target + repo. Use this whenever asked to resync, sync, converge, or bring a specific repo up to date + with the hub, or to run a conformance sweep against a named repo and apply what it finds. Needs + a hub checkout and a named target repo to mean anything, so it does not usefully trigger from + inside a downstream repo's own session with no target named and no hub checkout present, that + case is check-this-repo instead. Triggers even when the request sounds routine, such as "just + copy AGENTS.md over" or "make repo X match the hub," because that phrasing is exactly how the + AGENTS.md-overwrite incident happened. It co-fires with `carried-instruction-file-guard` and + `copilot-instructions-keeper`, which guard each carried file's merge, rather than replacing + them. +--- + +# Resync a Repo + +## Why this exists + +RESYNC.md's own apply order already sequences the remedies so the rules land before the files +they govern and a deletion lands before the re-vendor that would otherwise refresh it. The +AGENTS.md-overwrite incident happened inside that same procedure, on the step that looked most +routine. This skill exists so the mandatory check survives contact with a real, time-pressured +resync instead of depending on an agent remembering to run it unprompted. It is a driver over +RESYNC.md, not a replacement for it. Read RESYNC.md itself for the deletion sweep, the +letters-versus-drift routing, the settings and ruleset step, and everything else that does not +change from one resync to the next. + +## Confirm the procedure before starting + +Read RESYNC.md section 0. A repo with no instruction set at all, or a partial one, is not this +skill's job, it is STANDUP.md sections 1A and 2 instead, since an absent carried file is a +baseline that never arrived rather than drift to converge. Run `python3 spec/audit.py <RepoName>`, +the target's `registry/repos.json` `name` field rather than an `owner/repo` slug or a checkout +path, and read whether the findings are letters (absent) or drift (present but stale) before doing +anything else. The finding kind names the procedure the repo is owed. + +## Reach the hub and measure before changing anything + +Fetch a hub checkout of your own immediately before reading it, per the hub-only `RESYNC.md` +section 1, a file fetched with the hub rather than present in a carrier, since a stale clone +answers confidently instead of failing. Never do the resync's own work in a checkout another task +may be using, the maintainer's own primary checkout included, even one that looks current: for the +hub and for the target repo alike, create a worktree of your own off that base clone and work +there, per `repo-worktree`. On a machine carrying the hub's agent-safety install, Claude Code also +refuses a mutating git command run directly in a primary checkout, though its rules classify git +and gh commands only, so a plain file write is never denied and the re-vendoring this skill exists +to govern goes uncaught. A compound command is judged whole, so a copy chained to a denied git +write does not run either. Prose is the enforcement here, and following it is not optional. Verify +the host with `python3 scripts/host_gate.py --repo <path-to-target-worktree>`, run from your hub +worktree, since `scripts/` is hub-hosted and no carrier holds it. Then run the audit end to end, +`RESYNC.md` section 2. `python3 spec/audit.py <RepoName>` measures the target's ground-truth +branch, which the registry's `groundTruthBranch` names and which is `main` for every cataloged +repo today, never `develop`. That is the invocation "Confirm the procedure before starting" +already ran, run again here. Convergence lands on a ref `main` does not hold yet, so a run that +previews in-flight work names that ref: `python3 spec/audit.py --branch <ref> <RepoName>`. Use it +to check whether a fix landed, since a run against `main` still reports what the in-flight ref +already fixed. Section 2 carries two further commands that neither of these replaces. A finding is +a snapshot, so quote the run stamp in anything derived from it and re-run before acting on a +finding read earlier in the session. File any hub defect this work exposes against +`ptr727/ProjectTemplate`. Examples include bugs, conflicting sources, unclear or incomplete +instructions, missing capabilities, and Copilot findings about any of them. Search open and closed +issues first, then update the matching issue or file a new one. Preserve the evidence `RESYNC.md` +section 2 requires, and do not leave the finding only in chat, a review thread, the downstream +repo, or agent memory. + +## Apply, in this order + +1. **The instruction set first.** `CLAUDE.md`, then `AGENTS.md` and `GOVERNANCE.md` verbatim sections, then `CODESTYLE.md` and `WORKFLOW.md`, including the `AGENTS.md` skill-dependency pointer paragraph (naming `scripts/skills_install.py` and where the fleet's Skills live) as one more verbatim unit carried in this same step, not a separate pass. `CLAUDE.md` is the single `@AGENTS.md`-import file that gets `AGENTS.md` into a Claude Code session's context at all, a separate baseline entry from `AGENTS.md` itself, so carrying one without the other still leaves that provider unconfigured. **Before touching `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, or `WORKFLOW.md` in this step, run the `carried-instruction-file-guard` skill's distinctive-phrase probe against the target file, every time, without exception, regardless of whether the update is a verbatim re-vendor or an intent-fidelity edit.** This is not advisory language to weigh against how routine the diff looks, a diff that looks routine is exactly the shape the AGENTS.md-overwrite incident took. Do not proceed to the re-vendor until the probe has run and any local addition it finds has a destination, per that skill's own procedure. `CLAUDE.md` is outside that guard's scope: it carries no mixed or repo-specific content by design, so its re-vendor is an ordinary verbatim-fidelity copy, no probe needed. +2. **Deletions second, before any re-vendor.** Only a `retire` disposition in + `spec/divergences.json` authorizes removing a file, and the removal is swept tree-wide, per + RESYNC.md section 4, before the deletion counts as done. +3. **Verbatim re-vendors** for `CLAUDE.md` and everything else the probe in step 1 cleared. A + finding classified modified rather than stale gets its diff read before being overwritten, + since it may be an improvement the hub should adopt instead of a mistake to erase. +4. **Interface workflows.** Honor the named contract, required jobs, the ruleset-bound check name, + the artifact-name handoff, rather than copying bytes. +5. **Settings, rulesets, and secrets.** Run + `repo-config/configure.sh check "<owner>/<repo>" release` (substitute `operational` for an + operational repo) from the hub at `main`, then `apply` for what it reports, never from a + carried copy. Run `python3 spec/audit.py <RepoName>` from the same checkout for secrets. +6. **Intent files last, and by hand,** since nothing mechanical judges these. + +Reconcile the registry entry (`status`, `types`, `releaseTrigger`, `workflowModel`, +`driftNotes`) in the same pass, and delete a `driftNote` describing work this pass just finished +rather than leaving it standing. + +## Ship it + +One focused pull request per drift class, branched from the target's `develop`, never a direct +push to a protected branch and never a hand edit outside a pull request. Close the review loop, +per the `pr-review-conduct` skill, before asking the maintainer for merge permission. The +maintainer merges, the agent drives to green and stops. Re-run the audit after the merge and, +from the hub checkout, commit the report under the hub's own `reports/` once authorized, per +`AUDIT.md` section 8 and `git-commit-conventions`. A session resyncing its own repository +leaves the report to a hub-side audit instead. Done means measured, not applied. diff --git a/.github/skills/session-handoff/SKILL.md b/.github/skills/session-handoff/SKILL.md new file mode 100644 index 0000000..d0504cb --- /dev/null +++ b/.github/skills/session-handoff/SKILL.md @@ -0,0 +1,323 @@ +--- +name: session-handoff +description: >- + Writes and resumes the ptr727/ProjectTemplate fleet's session handoff, a link in a chain of + issues rather than a file: one open issue per track per repository, carrying the `handoff` label + and naming its predecessor, which is then commented on and closed. Use this whenever ending a + session or a round of work, whenever starting or resuming one, whenever asked for a handoff or + for what the previous round did, whenever the maintainer says only "resume the handoff" or names + none, which stands for the whole attended procedure this skill states, and whenever about to + re-attempt something a previous round may already have tried, the moment that earns this skill, + since a session that does not know a chain exists never goes looking for one. Every session + writing one, except an `unattended-handoff` seat, ends in the same order: lessons recorded, the + link, the parked decision queue presented, then memories saved last. Triggers even when the + session feels too short to be worth a handoff, because the rounds that produce nothing worth + writing down are exactly the rounds a later session repeats. The rule is `AGENTS.md` "Session + Scope" and the mechanics are the hub's `scripts/handoff.py`. Where a sibling skill owns the + moment, it wins: `backlog-burndown` owns a multi-round run's reporting, `unattended-handoff` + owns a run with no maintainer present, and `repo-worktree` owns the worktree the handoff names. + `agent-conduct` co-fires at the parked-decision obligation and neither defers to the other. +--- + +# Session Handoff + +## Why This Exists + +A handoff written to a scratch file fails three ways the chain closes. The file is not found where +the next session looks. More than one candidate file is found and nothing says which one is +current. And there is no history at all, so a later round re-runs a path an earlier round already +tried and already wrote down. The value of a handoff is precisely the record of what has already +been attempted, and the shape the handoff actually had, one overwritten file on one machine, is a +shape that cannot hold it. + +The chain closes each of those rather than making it impossible. The label and the metadata block +are what make a link findable from any machine, the one-open-per-track invariant is what answers +which link is current, and every closed link stays readable to every session after it. Where the +invariant is broken the chain refuses and says so, which is a state a reader can act on rather than +one that reads as an answer. + +The rule is `AGENTS.md` "Session Scope", which keeps it, and it names the handoff's sections and +sets the size rule over them. This skill is the judgment that rule cannot state: what actually earns +a place in each of those sections, what belongs somewhere durable instead, and how to read a handoff +without trusting the parts of it that have gone stale. + +## What a Handoff Is, and Is Not + +- **It points at the durable record rather than restating it.** A defect becomes an issue, a rule + becomes documented rule text, and a lesson becomes governance prose, per `GOVERNANCE.md` "Durable + Knowledge and Self-Improvement". The handoff names each of those in a line and a link. The one + exception is what a round attempted and what that cost, which has no home outside the chain, so + there the chain is the durable record rather than a pointer to one. +- **It records work to do next, and it is not itself backlog work.** It carries none of the labels + that classify work to be done, and every enumeration that ranks or counts the open backlog filters + the `handoff` label out, since a track in use always has an open link and counting it inflates the + backlog by one per track forever. +- **It is not a place to ask a question.** A question waiting on the maintainer is an issue carrying + the `decision` label. The handoff records that queue, and "The Parked Decision Queue" below + requires the session to put the questions themselves to the user in the same act, since a + question recorded and never asked is the failure that section exists for. + +## The Chain + +- **One open handoff issue per track per repository.** A track is a short kebab-case slug naming a + lane of work, and a session that names no track uses `default`. Parallel lanes each name their + own, so each closes its own predecessor and neither reads the other's state as current. +- **The title is for humans**, shaped `Session Handoff [<track>]: <subject>`, which `new` composes + from the track and the subject it is given, so what a session writes is the subject alone. + Nothing parses the title, so a maintainer is free to rename one. +- **The chain is machine-readable from one HTML comment on the body's last line**, rendered + invisible, so a retitled or hand-edited issue still chains: + `<!-- handoff: v1 track=<slug> round=<n> previous=<issue number or none> -->`. +- **Closing the loop runs in one order**: create the new issue, comment the forward link on the + previous one, then close the previous one. Creating first means a failure at any later step leaves + a discoverable new issue rather than a closed chain with no successor, and `link` is what finishes + a run that stopped between those steps. +- **The invariant a reader checks** is exactly one open issue carrying `handoff` for a given track in + a given repository, read from each issue's metadata block rather than from the label, since an + issue carrying the label and no block belongs to no readable track and blocks the count rather + than joining it. Two or more on one track is a defect to report, never one to resolve by picking, + and a run interrupted between the three steps above is the one shape of it that `link` settles + rather than a human. +- **Zero open on a track is two different states.** The track has never had a handoff, or its lane + was closed out and its newest link is closed. `new` chains onto the newest link either way, open + or closed, because starting a second chain beside one that exists orphans every link already + written. +- **The handoff lives in the repository holding the work the next session resumes.** A session that + spanned repositories writes it there and names the others in the state section "What Goes in the + Body" below describes, rather than filing one handoff per repository. + +## What Goes in the Body + +A fixed section order and fixed section names, the bold name opening each of items 1 through 8 +below being the heading the handoff writes. Item 9 is an HTML comment the tool writes rather than a +section anyone types. Fixed names are what let a reader find a given fact in the same place every +time and what let one round's section be compared against another's. `AGENTS.md` "Session Scope" sets the size rule, and it is per section: an entry +earns its place by being specific enough to change a later session's behavior, and a section ranks +what it keeps and drops whatever does not meet that bar. `handoff.py` warns above 12 KB and refuses +above 60 KB, the refusal being a backstop against a body GitHub would reject and the warning a hint +that the per-section rule broke several sections earlier. Neither is the rule. + +1. **Next steps, in priority order.** The most valuable section, and first for that reason. Each item + names what to do and what "done" looks like. At most a handful, ranked. A step blocked on + something says so and names the blocker below. +2. **External blockers.** Anything the next session cannot resolve on its own: a maintainer decision, + a third-party quota or outage, an upstream release, a credential, a reviewer bot that is not + running. Each names who must act, what unblocks it, and what is safe to do meanwhile. +3. **Internal dependencies.** The ordering constraints among the next steps, each stated as the + dependency and its reason, so a later session can re-rank when circumstances change rather than + following an order it cannot audit. +4. **State.** Branch, worktrees and whose they are, open pull requests, what merged, whether a + release was dispatched, whether the primary checkout is clean, and any other repository the + round touched, named so a resume knows to look there too. These are facts a resume re-derives, + listed so the resume knows what to re-derive. +5. **The parked decision queue.** The count and the ranked list "The Parked Decision Queue" below + requires, which also requires the questions to be presented in the same act rather than only + recorded. +6. **What the last round did.** Issues closed and filed, pull requests landed, and the peripheral + issues filed along the way. +7. **What not to repeat.** The section a file could never carry: paths explored that led nowhere, + approaches that failed and why, problems discovered during execution, and the cost of each where + it is known. An entry here is a claim about a specific attempt rather than a general lesson. +8. **New learnings.** Only what is not already durable somewhere else, each with a pointer to where + it was recorded. A lesson that belongs in governance goes to governance and appears here as one + line and a link. +9. **The metadata block**, which `scripts/handoff.py` writes and no author types. + +Sections 7 and 8 are what the chain exists for, and they are also the two most likely to be padded. + +A handoff issue outlives the session that wrote it and is readable by everyone who can read the +repository, which on a public one is everyone, so it quotes no data observed in the maintainer's +environment, per `GOVERNANCE.md` "Representative Data in Agent-Authored Text". That rule binds on a +private repository exactly as it does on a public one, and the visibility only changes who the +audience is. The routine case here +is an absolute home path, since a handoff naturally wants to name a worktree, and the rule for it is +to name the worktree by its branch and its repository-relative role rather than by its path. A next +session re-derives the path from `git worktree list` anyway, which section 4 already tells it to +do. + +## Resuming + +**Re-derive live state, and do not trust the handoff's copy of it.** Branches, worktrees, open pull +requests, and issue counts are read again from git and from GitHub at resume. The handoff's state +section is the pointer to what to read rather than the answer, and `AGENTS.md` "Session Scope" +already says stale context is worse than absent. A handoff is context by construction, so this is +that rule applied to the one artifact built to outlive the session that wrote it. + +Read the current link's comments too, with `gh issue view "<n>" --comments`, since `resume` prints +only the body, and a parking comment or a closing session's answers land in the comments. + +Read the chain before re-attempting anything. `resume` prints the current body and indexes the +closed links behind it, and `chain --grep` searches the bodies it walks for a regular expression, +which is how a session answers whether a path has already been tried without reading every round. +Three things bound that answer. The walk starts at one track's newest link and follows each +marker's `previous=` from there, which is normally that one lane and is in fact whatever the markers +name, so every link prints its own track and a predecessor on another lane shows up rather than +passing unseen. The walk stops at `--limit` and names the link it stopped short of, so a capped +search never reads as an exhaustive one. And `--grep` takes a regular expression, so a literal +string carrying a regex character is escaped or it matches something other than what was typed. On +a track with no open handoff `resume` refuses rather than printing an empty body, and `chain` falls +back to that track's newest closed link, so the read side of a lane that was closed out is `chain` +rather than `resume`. + +Re-derive a count rather than copying one, and read it with an explicit page size. `gh issue list` +returns 30 rows unless told otherwise, and a truncated count reads exactly like a repository with +30 issues, which is worse than an absent count because it gets stated. + +## The Attended Session + +A maintainer resuming work says little, often only "resume the handoff", and that phrase stands for +the whole procedure below. Run every step without being reminded of any of them. + +1. **Pick the link.** Where the maintainer names an issue, take it. Where none is named, read + `gh issue list --label handoff --state open --limit 100 --json number,title,labels,updatedAt`, + since `tracks` prints neither labels nor exact update times. Take a link carrying `blocked` + first, newest update first among them, since its blocker is a decision only the maintainer can + make and the maintainer is now present. Otherwise take the newest update among the rest. An + `auto-*` link not carrying `blocked` is skipped unless named, since an `unattended-handoff` + worker may hold it right now, and where one is named, confirm with the maintainer that no + unattended run is live before working it. Where every open link is skipped, say so and ask the + maintainer which to take. Say which link was picked in one line before anything else, so a wrong pick costs one reply + rather than a round. +2. **Resume it** per "Resuming" above, comments included, re-deriving live state rather than + trusting the body. +3. **Ask what it is blocked on first.** Where the link carries `blocked`, the parking comment names + a `decision` issue. Read it first, since the maintainer may have answered it there already. + Otherwise that question goes to the maintainer before any work starts, since the rest of the + link waits on it. Once answered, record the answer on the decision issue, take its `decision` + label off, and close it where it held nothing but the question. Leave `blocked` on the handoff + while this session works the link, so a running `unattended-handoff` loop does not pick it up + underneath the session, and settle it in step 6. +4. **Work the next steps** in a worktree of the session's own, per `repo-worktree`, and drive each + pull request with `drive-pr` to a mergeable develop -> main promotion pull request. The phrase + already states that target, so `drive-pr` does not ask how far. Merging that promotion pull + request and dispatching a release stay `merge-and-release`, each on an explicit go-ahead asked + for as a prompt whose option names the action. +5. **Ask every question as a dialog.** Where the interface has a prompt mechanism, each decision is + its own question with its answers as the options, the recommended one first and marked as the + recommendation, and every one carrying its reason, per "The Parked Decision Queue" below. The + numbered list is the fallback where no prompt exists. +6. **Close the session** per "Closing a Session" below, which records lessons, writes the next link + with `new`, presents the parked decision queue, and saves memories last. An `auto-*` lane is the + exception, since it holds one issue. Where that issue is done, comment the outcome on the link + and close it with no successor, as `unattended-handoff` closes such a lane out. Where work + remains, write its next link on the same track without `blocked`, its next steps naming the + decision issue and the answer, which hands it back to the unattended loop. Either way the + `blocked` label left on in step 3 goes with the link it was on, and the parked decision queue + is still presented, since the exception covers only the link. + +## Closing a Session + +Every session that writes a handoff ends in this order, except an `unattended-handoff` seat, which +closes as that skill states. Each step leaves the chain whole if the session is interrupted after +it. + +1. **Record what was learned** per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement", which + says where a lesson goes, before the link is written, so its "New learnings" section points at a + record that exists. +2. **Write the link**, per "Running the Chain" below, or close the lane out where its work is done. + The link is written before the closing questions are put, since a prompt blocks until someone + answers and an unanswered one must not cost the round its handoff. +3. **Present the parked decision queue** in the same act, per "The Parked Decision Queue" below. + Record each answer given on its issue, where one was asked and answered. Where a link was + written, comment those answers onto it together with the queue's count and list after them, + since the body keeps the count it was written with and a resume reads the link's comments. +4. **Save memories last.** Where the host keeps a per-user memory, what goes there is the + environment-specific nuance "Durable Knowledge and Self-Improvement" leaves to memory, such as a + quirk of this machine or this account, saved as the session's final act. Never the round's + state, which the link holds, and never a lesson, which step 1 already recorded. + +## The Parked Decision Queue + +The rules below are `GOVERNANCE.md` "Communicating with the User", carried whole so this skill works +in isolation. Two of them state the obligation: the one opening "A question filed as an issue is +parked rather than asked" and the one opening "The session that writes a handoff presents the parked +queue in the same act". Three more state the form the questions take, the one opening "Ask every +question through the interface's prompt, never in prose", which also names the numbered list as the +fallback where no prompt exists, the one opening "Raise work blocked on the user as a direct +interactive prompt", which shapes the options, and the one opening "Lead every choice with a +recommendation and its reason", which binds every question put either way. A reader who stops after +the obligation has it with no shape to put it in. + +Recording the queue is not asking it. The handoff's "The parked decision queue" section is the +recording half, and what the other half is depends on what the session can reach: a prompt where one +is available, the numbered list where a user is present and no prompt is, and nothing at all where +no user is present, in which case the handoff names the whole queue by issue number and the asking +falls to the next session that has one. The rules below settle which case applies. + +<!-- include: GOVERNANCE.md > Communicating with the User --> + +- **Reference every pull request as a clickable link.** When you mention a PR on a surface that renders Markdown (chat, a summary, a report), render it as a Markdown link to the PR (`[#N](https://github.com/OWNER/REPO/pull/N)`), never a bare `#N`. The same applies to issues and commits. **The form follows the surface.** Some surfaces link neither a Markdown link nor a bare URL, an interactive prompt's question and option text among them, and pasting a full URL into one of those does not rescue it, since the reader gets a string to copy, which is the outcome this rule exists to prevent. There the reference is a bare `#N`, and the clickable link goes in the message that comes **before** the prompt rather than merely alongside it, because the prompt blocks on an answer and a message emitted after it is read once that answer is already given, which is the one moment the link is no longer any use. The test is whether the reader can click it where it is read, not whether it was written in the syntax that works elsewhere. +- **Ask every question through the interface's prompt, never in prose.** When you need the user to decide, answer, or approve anything, put it to them through the interface's own prompt mechanism, whether or not work is blocked on it, and an offer to do more work ("want me to file that?") is a question like any other. A question written into a message is lost in the report around it however short it is, and one closing a long report sits where the reader is least likely to reach it. Where no prompt mechanism is available, the questions go as a numbered list opening the message rather than closing it, so the user can reply per number. This bullet settles how a question is put once it is asked, and whether a question is asked now or recorded for later is the parking bullet's to settle, the one opening "A question filed as an issue is parked rather than asked". +- **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it through the interface's own prompt mechanism, at the point the work stops. Never leave it as prose in a summary: a handoff buried in a paragraph is a handoff that did not happen, because a summary reads as a report of finished work and the one line still waiting on the user is the easiest in it to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves**, and the one that unblocks the work names the action it authorizes ("squash and merge it"), so selecting it is the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing, since a prompt whose every choice is inaction reports the block rather than clearing it, and where the agent may not perform the authorized action itself, the option says who does it. Where no interactive prompt is available, the numbered list the bullet above names is the fallback. +- **Lead every choice with a recommendation and its reason.** Wherever the user is asked to choose, in a prompt or in a numbered list, the option the agent recommends comes first and is marked as the recommendation, and every option states the reason for it. A user who takes the recommendation then reads one line, and one who does not sees what the other options trade away. A question with no defensible recommendation says so rather than inventing one. Where an open question has no options and the agent does have an answer, that answer and its reason go in the question's text, never as an invented option. +- **A question filed as an issue is parked rather than asked, and it stays owed.** Where the work cannot continue without the answer, the interactive-prompt bullet governs and the question is asked at the point the work stops. Wherever the question is recorded rather than asked, whether because the work can continue without the answer or because the question was asked once and deferred, recording it is the right thing to do and recording it is still not asking it, so the issue carries the fleet's `decision` label and, when the filing session knows the choices the question is between, states them, which is what lets a later session, one that was not there when the issue was filed, find the question and put it to the user without inventing its answers. **The parked queue is the failure, not the parking.** An issue holding a question the user has never seen reads to every later session as tracked work rather than as a block, so each session files correctly and moves on, and presenting the accumulation is the step nobody owns. +- **The session that writes a handoff presents the parked queue in the same act.** This binds at the moment the session writes the handoff that `AGENTS.md` "Session Scope" defines, rather than at the moment the session ends, because a session also ends by interruption, where no agent acts at all and no rule reaches it. The session enumerates this repository's open issues carrying that label, states how many issues the queue holds, and puts the highest-ranked of them to the user as questions, **one question per parked decision, carrying that decision's own answers as its options**, which is the interactive-prompt bullet's own shape applied per decision rather than a single prompt whose options are topics. An issue that states no choices is put as the open question it is, never as invented options, since the label marks every decision waiting on the user rather than only the ones filed under this rule, and the issues that already carry the label predate the rule. Rank them longest-waited first. Every issue records how long it has waited, so any session can reproduce that order. Promote one ahead of that order where it blocks work in flight, and say in the handoff that it was promoted, since two sessions order the same queue differently where the key is subjective and nobody records it. Where more are parked than one round of questions can carry, the stated count still covers every one of them and the handoff names the remainder by issue number in that order, a list of numbers rather than of questions, which is what keeps the handoff inside the size rule that `AGENTS.md` "Session Scope" sets for it. **A count nobody states is a queue nobody can see**, which is how a backlog reported as healthy hides the questions inside it. Where a user is present but no prompt mechanism is available, the questions go as the numbered list the interactive-prompt bullet already names as its fallback, which reaches a reader who is there to read it. Where no user is present at all, the session asks nothing, names the whole queue by issue number in the handoff, and leaves the asking to the next session that has one. An item leaves the queue when the user answers it, and also at triage where a later change has already answered or overtaken its decision. Either way it leaves by losing the label, with the answer or the reason recorded on the issue, and the issue itself closes only where it held nothing but the question, since the queue holds issues that outlive their decision, and closing such an issue just to clear the queue discards the other work that issue tracks. + +`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` and `session-handoff` Skills at `.agents/skills/agent-conduct/SKILL.md` and `.agents/skills/session-handoff/SKILL.md` in the hub, not repo-relative links since those paths are hub-local and not carried into every fleet repo, each carry it whole as a generated include and surface it at its own decision moment. + +<!-- /include --> + +## Running the Chain + +The mechanics are the hub's `scripts/handoff.py`, run from a hub checkout, per `GOVERNANCE.md` +"Hub-Hosted Tooling". Every subcommand takes `--repo`, with no default, because an issue number +resolves in every repository and a chain read out of the wrong one is well formed. `current`, +`resume`, `chain`, and `new` each take an optional `--track` defaulting to `default`, and `link` +and `tracks` take none, since a pair of issue numbers and a whole-repository survey each name +their own scope. A session working a named lane passes `--track` on each of the four that accept +it. Omitting it does more than read the wrong chain, since `new` then files onto `default` and +comments on whatever link that lane has, closing it too where it was open. + +- **`current` and `resume`** answer where a track stands, `resume` adding the body itself and an + index of the closed links behind it. +- **`chain`** starts at one track's newest link and walks `previous=` backwards from there, + following whatever the markers name and saying so where that leaves the track, `--grep` + filtering the listing by a regular expression over the bodies while the notices print + regardless. +- **`tracks`** surveys every open handoff, one carrying no metadata block included, and it is the + one command that reports that state rather than refusing over it. That state is settled by a + hand edit, adding the block to the issue's body or taking the label off it, since no read can + place an issue on a track until its own block says which track that is. +- **`new`** performs as many of the chain's three steps as the track has links for. On a track + with no link at all it creates and stops, since there is nothing to comment on or close. On one + whose newest link is closed it creates and comments, since the close already happened. Only a + track with an open head takes all three. **`link`** finishes a run that stopped between them, + and separately repairs a successor whose block names no predecessor, so its own writes are the + comment, the close, and that body edit. The edit reaches only the repair case, since `new` + embeds the predecessor in the block at create time, so a run interrupted after the create + already names it. + +```sh +python3 scripts/handoff.py current --repo OWNER/NAME --track "<slug>" +python3 scripts/handoff.py resume --repo OWNER/NAME --track "<slug>" --history 5 +python3 scripts/handoff.py chain --repo OWNER/NAME --track "<slug>" --grep "an escaped regex" +python3 scripts/handoff.py new --repo OWNER/NAME --track "<slug>" --title "<subject>" \ + --body-file "<path>" --dry-run +python3 scripts/handoff.py link --repo OWNER/NAME --new "<successor>" --previous "<predecessor>" +python3 scripts/handoff.py tracks --repo OWNER/NAME +``` + +Read `--dry-run` output before the first real `new` of a session, since the run can close an issue. +It is accepted by `new` and `link`, the two subcommands that write, and by no other. + +Exit `0` is success, `1` a refusal the caller can act on, a usage error included, and `2` the +command not having run to an answer, so a refusal and a failure to reach one never share a code. A +repository missing the `handoff` label is a refusal rather than a degraded empty answer, and it +names the command that applies the fleet label set except where the label read filled its window, +which is the one case where the label's absence is unproven rather than established. + +Creating an issue, commenting on one, closing one, and editing a body are each outward-facing +writes. `new` creates, comments, and closes, the label riding inside the one create call rather than +being a write of its own. `link` edits a body, comments, and closes. Each of them is bound by +`GOVERNANCE.md` "Repository Boundaries and Write Safety" exactly as any other write is. Point them +at the repository `AGENTS.md` "Session Scope" sends the link to, the one holding the work the next +session resumes, and at no other. `link` also reaches an issue this chain never created, since the +caller names both numbers and its refusals ask for a block and a label to be added by hand first, so +the two issues it is given are chosen deliberately rather than swept up. + +Where the caller names an issue, which is `link` alone, it reads both live before writing and +writes only what those reads returned. Every other identifier a write targets is captured from a +read in the same run, and the one identifier no read could have supplied, the new issue's own +number, is parsed from the create's confirmation rather than constructed. No write's output is suppressed or forced to success, and a close is +confirmed by reading the state back, because a write that appears to have failed may have succeeded +on the server. diff --git a/.github/skills/shell-codestyle/SKILL.md b/.github/skills/shell-codestyle/SKILL.md new file mode 100644 index 0000000..46fbaec --- /dev/null +++ b/.github/skills/shell-codestyle/SKILL.md @@ -0,0 +1,63 @@ +--- +name: shell-codestyle +description: >- + Governs Bash/shell script style for ptr727/ProjectTemplate fleet repos: when a bootstrap or + host-tool script may be shell instead of Python, the mandatory set -Eeuo pipefail header, the + pipefail-versus-early-reader pitfall, self-locating scripts, the shellcheck-plus-shfmt + clean-compile, and the why-not-what comment rule. Use this whenever writing, reviewing, or + editing a shell script (a `.sh` file, or an extensionless bash/sh shebang script), whenever + deciding whether a new script should be Bash or Python, or whenever a pipeline built from + `curl`/`grep`/`jq`-style commands looks like it silently swallowed a failure. Triggers even when + the task looks like a one-line tweak to an existing script, because a missing `-e`/`pipefail`, + or a reader piped straight from a producer that closes the pipe early, are each invisible until + the exact failure mode they guard against actually happens. Fleet-wide: a shell script can + appear in any repo (a bootstrap that installs the interpreter, a host tool that must run before + a toolchain exists), not only a repo whose primary language is shell. +--- + +# Shell Codestyle + +## Why this exists + +This is the shell-specific half of the fleet's code style guide, kept in one place instead of +re-derived per repo or per session. Shell is the fleet's exception language, reached for only +where Python cannot run yet, so its rules exist to keep that narrow surface safe rather than to +cover general scripting style. + +## When shell, not Python + +Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot +be written in it, and a host tool that must run before a development toolchain exists cannot +depend on Python either. Everything else is Python, with a test under its own scripts tree's +`tests/` directory. + +## Rules + +- **`shellcheck` is the linter and `shfmt` the formatter.** The clean-compile is `shellcheck` + clean at default severity plus `shfmt -d`, both reporting nothing before a commit. CI enforces + both, per `GOVERNANCE.md`'s hub-only "Running the Linters Locally (Known-Working Invocations)" + section, and the hub's `scripts/docker_lint.py` runs the same pair headless. Neither is scoped + to the `*.sh` glob alone: a tracked, extension-less script whose shebang names bash or sh (the + shape a script meant to run as a bare command takes) joins the target list too. +- **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above + it, as the hub's own `repo-config/configure.sh` and `host-setup/` scripts do, since what matters + is that nothing executes unguarded rather than which line number it lands on. Without `-e` a + failed command in the middle of a sequence lets the rest run against a state nobody checked, and + without `pipefail` a pipeline reports the exit of its last stage, so a fetch that failed reads + as an answer when a parser downstream succeeds on an empty input. `-E` carries an `ERR` trap + into functions and command substitutions, so a script that later adds one is not surprised by + where it does not fire. +- **A reader that stops early needs its producer read first.** Under `pipefail`, a producer + writing to a closed pipe exits non-zero, so `curl ... | grep -q` reports a successful fetch as a + failure whenever the match is found early enough. Capture the output, then search it. +- **Self-locating, never dependent on the caller's directory.** A script resolves its own + directory from `BASH_SOURCE` and references its payloads through it, since the working + directory at invocation is not a property of the script. +- **`shellcheck` clean, and a deliberate exception carries its reason inline.** A + `# shellcheck disable=SCxxxx` names why the rule does not apply here, so the next reader can + tell a considered exception from an unread warning. The hub's own `repo-config/configure.sh` is + the worked example, carrying `SC2016` disables where a single-quoted `jq` program must stay + unexpanded, each with its reason on the same line. +- **Comments say why, never what.** The code states what it does. A comment restating it goes + stale silently, where a comment carrying a reason fails visibly when the reason stops being + true. diff --git a/.github/skills/skill-lifecycle/SKILL.md b/.github/skills/skill-lifecycle/SKILL.md new file mode 100644 index 0000000..e433685 --- /dev/null +++ b/.github/skills/skill-lifecycle/SKILL.md @@ -0,0 +1,52 @@ +--- +name: skill-lifecycle +description: >- + Governs the lifecycle of the fleet's own skills in ptr727/ProjectTemplate: creating, changing, splitting, and retiring a skill under .agents/skills/, the source-versus-generated split with .github/skills/ and .claude-plugin/, the regenerate and --check semantics of scripts/build_dist.py, the include regions it fills from a rule's home so a skill carries the rule's text without a copy, the install and stamp semantics of scripts/skills_install.py, the doc-packaging pattern that keeps a law doc and its skill in agreement, and the trigger-description conventions that make a skill fire. Use this whenever about to create, edit, move, or delete anything under .agents/skills/, .github/skills/, or .claude-plugin/, whenever packaging a doc or a doc section as a skill, and whenever deciding whether a topic deserves a skill at all. Triggers even when the edit looks trivial, such as fixing a typo in one SKILL.md, because the generated distributions desync the moment the source changes without a build_dist.py run, and CI fails the pull request on exactly that. Hub-context only, since .agents/skills/ exists only in the hub. +--- + +# Skill Lifecycle + +## Why This Exists + +The agent most likely to get a skill wrong is the one editing a skill, and before this skill existed nothing watched that moment: the regenerate and install semantics lived in `scripts/` docstrings and scattered prose, so the procedure was rediscovered per session. The two standing hazards are mechanical and silent. A hand-edit to the generated `.claude-plugin/` tree is overwritten by the next regenerate, and a source edit without a regenerate ships a plugin that no longer matches its source, which the CI `--check` gate fails rather than anyone noticing in review. + +## The Pipeline + +- **`.agents/skills/<name>/SKILL.md` is the only tree a skill is authored in**, with optional `references/` and `scripts/` directories beside it, and the one part of it not written by hand is the text inside an include region, described below. Codex and opencode read this tree directly, project-local, and also read the global `~/.agents/skills/` copy the installer materializes. +- **Generated distributions serve GitHub Copilot and Claude Code.** `scripts/build_dist.py` generates `.github/skills/` for GitHub Copilot and a Claude-plugin-compatible copy at `.claude-plugin/fleet-skills/`, published through `.claude-plugin/marketplace.json`. Neither generated tree is hand-edited, and `build_dist.py --check` exits non-zero when either tree differs from `.agents/skills/`. +- **The skill set is implicit.** Every `.agents/skills/<name>/` directory carrying a `SKILL.md` is a skill, and the generated `plugin.json` derives its list from those directories, so adding or retiring a skill edits no manifest by hand. `marketplace.json` names the plugin, not the skills, and is untouched by ordinary lifecycle work. +- **A rule's text reaches a skill as a generated include, never as a copy.** A region opened by a line holding only `<!-- include: <path> > <heading> -->` and closed by a line holding only `<!-- /include -->`, each indented at most three spaces, is filled by `build_dist.py` with the body under that heading. The key is the root-relative path, spelled as the tree spells it, then ` > `, then the heading text at any level from two, matched case-insensitively. The body runs to the next heading at that level or above, level one included, so a key naming a level-two heading carries every subsection under it. The fill lands in `.agents/skills/` itself, since Codex and opencode read that tree directly and a region left empty there is a skill with a hole in it, and the generated trees mirror the filled source. A region is filled only in a file the generator walks, which is the `*.md` files under each `.agents/skills/<name>/` directory carrying a `SKILL.md`, plus the files `build_dist.py`'s `INCLUDE_DESTINATIONS` tuple declares: a Markdown file sitting in `.agents/skills/` but not under such a directory, `.agents/skills/README.md` among them, is walked no more than a file elsewhere is. That tuple is how a hub surface that is not a skill carries a rule's text rather than restating it, each entry declared by hand and held to the same path rules a source is held to, and a file `spec/files.json` carries content from is refused. It is empty today, because the surfaces that would qualify restate a rule in their own words rather than copying it and a pointer is what each of those needs, so `scripts/tests/test_build_dist.py` rather than any shipped declaration is where the shape of one is exercised. A source is any regular file under the repository root outside the two generated trees and reached through no symlink, so a key may name a `GOVERNANCE.md` section, an `AGENTS.md` subsection, or a section of a sibling skill, and a region filled from a file carrying regions of its own reads that file's filled text. Regenerating after a source edit changes the bytes of every skill unit including it, and `--check` fails the pull request until that regenerate runs. Those units then read as moved past the pass that covered them, which is the periodic sweep's work rather than this change's, per `GOVERNANCE.md` "Verification Discipline". +- **`--check` holds every region to its source.** It fails when a region differs from what its source renders now, so a hand edit inside one and a source edit nobody regenerated for both fail the pull request the same way a stale mirror does. `python3 scripts/build_dist.py`, with no flag, is what clears either. A failure regenerating cannot repair is exit 2 rather than 1, whether or not a region is what it is about: a key with no ` > ` or an empty heading, a path naming no file, a heading that no longer resolves or that recurs in its source, a body with nothing in it or leaving a code fence open, a region in a file the generator does not walk, reached through a key, a declared destination refused by the same path rules a source is held to, or carried by the manifest, a `spec/files.json` that cannot be read or parsed while a destination is declared, since a check that cannot run must not wave one through, a symlink at or under a skill's own directory, a region that opens inside another or never closes, a close marker with no region open, a cycle, a path that is empty, absolute, carries a `..` component, or is spelled otherwise than the tree spells it, one outside the root, through a symlink, or under a generated tree, a file that is not UTF-8 wherever the run decodes one, a file holding a region while mixing line endings, and a line outside a code block that begins like a marker and matches neither form, which read as content would leave a region unfilled. An unreadable file is exit 2 as well, and it is the one cause that is not a refusal: `--check` catches the operating system's own error beside the refusals, so a permissions failure or a file removed mid-run is reported rather than read as a stale result. Every code here is `--check`'s. The no-flag run reports a refusal as exit 1. +- **`scripts/skills_install.py`, run from a hub checkout, installs both forms per machine**: an overlay copy into `~/.agents/skills/` for Codex and opencode, marked per skill so a retired skill is removed on the next run and a foreign skill is never touched, and a user-scope plugin install for Claude Code via the `claude` CLI. Each run stamps the hub commit into `~/.agents/skills-install-stamp.json`, and `--report` answers the two channels separately: which commit the copy was taken from, judged against the promoted `main`, and which branch and commit the checkout Claude Code loads in place is serving now. It exits non-zero when the copy is not current. The install is global per user, and per-repo pinning is a settled non-goal (`docs/fleet-map.md` "Skills Install Model"). + +## Deciding a Topic Deserves a Skill + +A skill surfaces at a trigger moment. A rule that binds every action all the time, or a short reference section a task reads once, gains nothing from being one: the always-on layer is the carried instruction set (`AGENTS.md` and the sections it maps), and packaging it as a skill duplicates it and spends the tokens the delegation rules exist to save. The `AGENTS.md` "Where the Rules Live" map records the disposition either way, a skill annotation on the row or the deliberate absence of one, so a topic with no skill reads as a decision rather than an oversight. + +## Creating a Skill + +1. **Name the directory in kebab-case** and set the frontmatter `name:` to the same string. +2. **Write the `description:` to carry the trigger**, since it is the only part an agent reads before deciding to load the skill: state what the skill governs, then the concrete moments it applies ("Use this whenever..."), then the routine phrasings that precede the failure it guards against ("Triggers even when..."), naming a real incident where one exists. Disambiguate against sibling skills by name, the way `standup-a-repo`, `resync-a-repo`, and `check-this-repo` each state which of the three a session is in. +3. **Author the body per the `comment-and-doc-style` skill**: LF (the repo default), present tense, ASCII tiers, no semicolon in prose. Name hub paths as plain code spans rather than repo-relative links, because an installed copy resolves no repo path, and say "from a hub checkout" for anything the reader must run. +4. **Split bulk into `references/`** when the source doc is large: the SKILL.md carries the summary and the binding rules, and each `references/*.md` carries one topic read on demand, the shape `comment-and-doc-style` uses. +5. **Apply the doc-packaging pattern below in the same change** when the skill packages a law doc or one of its sections. +6. **Regenerate and commit all trees together**: `python3 scripts/build_dist.py`, then, once authorized, commit the source and both generated trees in one commit, per `git-commit-conventions`. CI runs `--check` on every pull request and fails a desynced distribution. `python3 scripts/tests/test_build_dist.py` covers the generator itself. +7. **Record the surfacing**: annotate the `AGENTS.md` "Where the Rules Live" row when the skill packages a GOVERNANCE section, or its closing paragraph when the skill is new content, so the map stays the one place coverage is read from. +8. **Refresh the machines after promotion**: re-run `python3 scripts/skills_install.py` per machine, from a freshly fetched `main`, the cadence `docs/host-setup.md` "Fleet Skills Install" states. Until then each machine's Codex and opencode copy holds the previous skill set, which `--report` says, while Claude Code serves whatever the hub checkout holds. + +## Changing or Retiring a Skill + +- **Edit only the source tree, and outside its include regions.** Any skill-content change under `.github/skills/` or `.claude-plugin/` that did not come from a `build_dist.py` run is a defect, whatever it fixes. The text inside an include region is generated too, so a change there is made at the region's source and regenerated, never typed into the region. +- **Retiring is deleting the source directory and regenerating.** A region in a sibling keyed on the retired skill stops that regenerate, since its key no longer resolves, so re-key or remove it first. The derived `plugin.json` list shrinks with it, and the installer's per-skill markers remove the retired skill from `~/.agents/skills/` on each machine's next run. +- **A deletion sweeps the prose that references the skill**, in the same change rather than as follow-up: the `AGENTS.md` map row or paragraph naming it, any law-doc packaging pointer to it, and any sibling skill that disambiguates against it. A law-doc section that had moved its full rules into the skill takes them back, or is retired with it, so no rule is silently lost with the skill that carried it. +- **Renaming is a retire plus a create** as far as the installer's markers and the plugin list are concerned, so sweep references the same way. An include key spelling the old path is such a reference, and one left behind fails `--check` as a region it cannot render rather than as a stale mirror. + +## The Doc-Packaging Pattern + +Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has three shapes, and each pairing states which it uses: + +- **Moved content.** The law-doc section keeps a summary and the skill holds the full rules (`git-commit-conventions`, `comment-and-doc-style`, `pr-review-conduct`). The section ends with the standard pointer sentence: packaged as the named skill at `.agents/skills/<name>/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, read the skill for the full rules. +- **Kept authority.** The source doc keeps the full rules and the skill is the summary that routes to them (`audit-a-repo` over `AUDIT.md`, `workflow-ci-contract` over `WORKFLOW.md` outside sections 3, 4, and 5). The skill states per topic which doc section owns it. +- **Included content.** The doc keeps the full rules and the skill needs them whole to work in isolation, so it carries the section as a generated include rather than as a summary or a copy, declared with the region markers "The Pipeline" above describes and keyed on the doc's section (`agent-conduct` over the three `GOVERNANCE.md` sections it surfaces, `workflow-ci-contract` over `WORKFLOW.md` sections 3, 4, and 5). The doc side states the shape with one sentence naming the skill that includes the section, and the skill side is the region itself. A section carried this way is read outside its own document, so it names a sibling section by document and heading rather than as above or below, and it links to no file by a relative path, since the path would resolve against the skill's directory rather than the doc's. The doc wins by construction, since `scripts/build_dist.py` writes the region from it and its `--check` reports a region that differs from it as stale. + +In every shape the doc is the authority when the two are found to disagree, the moved-content shape included: the doc's summary says what the rule is, and the skill's full text is what gets corrected. A deliberate change to a packaged rule is not such a disagreement. It lands where the full text lives, and in the same change the author either edits the other side's summary to match, since a summary has no mechanical check, or regenerates the include, which has one. A rule stated fully in both places by hand is the drift this pattern exists to prevent, and an include is the one full second statement that cannot drift undetected. diff --git a/.github/skills/standup-a-repo/SKILL.md b/.github/skills/standup-a-repo/SKILL.md new file mode 100644 index 0000000..61fc1e0 --- /dev/null +++ b/.github/skills/standup-a-repo/SKILL.md @@ -0,0 +1,105 @@ +--- +name: standup-a-repo +description: >- + Drives STANDUP.md's procedure for taking a ptr727/ProjectTemplate fleet repo from nothing (or a + partial state) to operational against the fleet ground truth, run from a hub checkout for a + named target repo the maintainer is standing up. Use this whenever asked to stand up, create, + bootstrap, or onboard a new fleet repo, or to onboard a new repo type. Needs a hub checkout and + a target repo, new or partially started, to mean anything, so it does not usefully trigger + inside an already-operational downstream repo's own session with no hub checkout present, that + case is resync-a-repo for drift or check-this-repo for a self-check instead. Triggers + even when the request sounds like "just copy the template over" or "spin up a quick repo," + because skipping the ordered signing, branch, and instruction-set steps below is exactly how a + repo ends up unsigned, unrecoverable, or authored against unknown rules. +--- + +# Stand Up a Repo + +## Why this exists + +STANDUP.md's own section order exists because several of its steps close a window that cannot be +reopened cheaply: commit signing has to be correct before the first commit, the long-lived +branches have to exist before any standup commit lands on one, and the instruction set has to be +carried before anything else is authored against it. This skill exists so that order survives +contact with a real, time-pressured standup instead of depending on an agent remembering to run +each gate unprompted. It is a driver over STANDUP.md, not a replacement for it. Read STANDUP.md +itself for the full text of every step, the onboarding-a-new-repo-type procedure, and the +cold-start self-test. + +## Before starting + +Read STANDUP.md section 0A first. Nothing in this procedure creates the GitHub repository, its +App, or its secrets, each an outward-facing write that needs the maintainer's explicit permission +and inputs, so hand that checklist over before step 1 rather than discovering the gap partway +through. A repo with no remote is not partially stood up, it is not started, and only the +maintainer can supply what section 0A lists. + +## Apply, in order + +1. **Signing, before the first commit.** STANDUP.md section 0: verify, never set, the inherited + `--global` commit identity and signing configuration, and the host tool floors via + `python3 scripts/host_gate.py`. The window closes at the first commit, since a repo committed + under the wrong identity or unsigned cannot be cleanly repaired afterward. + +2. **Branches, before the first standup commit.** STANDUP.md section 0B: create `main` and + `develop` empty, off one signed empty root commit, then run every step below on a feature + branch off `develop`. Never commit standup work directly onto `develop`. `non_fast_forward` on + both branch payloads, or the missing blocking rule on an operational repo's `develop` ruleset, + makes that mistake either unrecoverable or silently unprotected. + +3. **Classify and catalog.** STANDUP.md section 1: resolve the repo's type(s) against `AUDIT.md` + section 2, then write or repair its `registry/repos.json` entry and confirm it with + `spec/validate.py`. + +4. **The instruction set, before authoring anything.** STANDUP.md section 1A: carry `CLAUDE.md`, + `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, `WORKFLOW.md` and `AUDIT.md`, adapted rather + than cloned for the ones that describe a repo, plus `.markdownlint-cli2.jsonc` and + `cspell.json`. `CLAUDE.md` is the fixed, verbatim `@AGENTS.md`-import file that gets + `AGENTS.md` into a Claude Code session's context at all, a separate baseline entry from + `AGENTS.md` itself, so carrying one without the other still leaves that provider unconfigured. + Read `CODESTYLE.md` and the `GOVERNANCE.md` documentation-style rules before writing any repo + content of your own, the same window-closes shape as signing in step 1. + +5. **Capture the source, if one exists.** STANDUP.md section 1B, only when the repo's content + replaces a live external system: capture it and verify the capture against the source before + anything is scaffolded from it, since the source is not under version control and cannot be + re-derived once it stops serving. + +6. **The baseline files.** STANDUP.md section 2: copy every `spec/files.json` entry whose + `appliesTo` matches the repo's selector set, adapted rather than cloned, and choose + `version.json`'s version floor deliberately rather than propagating the template's. Carry + `AGENTS.md`'s skill-dependency pointer paragraph, naming `scripts/skills_install.py` and where + the fleet's Skills live, as one more verbatim unit in this same step, not a separate pass, the + identical requirement `RESYNC.md` places on a repo already stood up. + +7. **The workflows.** STANDUP.md section 3: implement the Actions `WORKFLOW.md` requires for the + repo's type, reusing `catalog/snippets/workflows/` as the reference implementation rather than + inventing a shape. + +8. **Settings, rulesets, and secrets.** STANDUP.md section 4: confirm the remote and the GitHub + repository agree before running anything else here, then run + `repo-config/configure.sh check owner/repo release` (substitute `operational` for an + operational repo) from the hub at `main`. A non-zero exit there means drift was found, not a + command failure. Review what it reports. Then run the same command's `apply` subcommand, which + idempotently reconciles the repo to the full committed configuration regardless of what `check` + reported, never from a hand-built or carried copy. + +9. **Verify with the audit.** STANDUP.md section 5: run `AUDIT.md` end to end. The repo is stood + up only when it passes for its type, or its residual deltas are tracked in + `reports/<repo>/audit.md` plus an issue. + +## Onboarding a new repo type + +When a repo matches no existing type in `spec/project-types.json`, that is a type to onboard, not +a repo to force into the nearest existing one. STANDUP.md's "Onboarding a New Repo Type" section +covers the manifest additions (`spec/project-types.json`, `spec/files.json`, `spec/secrets.json`, +`spec/scope-model.md`, `spec/type-model.md`, and the `registry/repos.schema.json` target enum for +a new publish destination) and the cold-start self-test that proves the result usable by a +context-free agent, not just by the one that wrote it. + +## Ship it + +One pull request per standup, branched from `develop` per step 2 above, into `develop`, never a +direct push to a protected branch. Close the review loop, per the `pr-review-conduct` skill, +before asking the maintainer for merge permission. The maintainer merges, the agent drives to +green and stops. diff --git a/.github/skills/unattended-handoff/SKILL.md b/.github/skills/unattended-handoff/SKILL.md new file mode 100644 index 0000000..b6bf958 --- /dev/null +++ b/.github/skills/unattended-handoff/SKILL.md @@ -0,0 +1,250 @@ +--- +name: unattended-handoff +description: >- + Runs a ptr727/ProjectTemplate fleet repository's handoff chain with no maintainer present: a + lean orchestrator loops, dispatching a picker subagent that returns one handoff whose work needs + no maintainer decision, creating that handoff from the open backlog where none is waiting, then + a worker subagent that resumes the handoff, fixes it, drives its pull request as far as the + invocation's scope allows, and closes the lane out, or parks it when a decision turns up, + leaving the branch open, a state comment on the handoff, a `decision` issue for the maintainer, + and the `blocked` label on the handoff. Use this whenever asked to run the handoff loop + unattended, work the auto-resolvable issues while the maintainer is away, keep going until + nothing is left that needs no decision, or run handoffs overnight. Triggers even when the + backlog looks small, because the failure it guards against is an orchestrator that reads issues, + diffs, and review threads itself and exhausts its context after a few rounds. Scope is named at + invocation: develop by default, main to also merge each promotion pull request, release to also + dispatch the release. Distinct from `backlog-burndown`, which runs parallel groups with the + maintainer reachable, and from `session-handoff`, which owns the chain's shape and the attended + "resume the handoff" session this loop's parked links return to. Its invocation scope is the + explicit go-ahead `merge-and-release` otherwise asks for. +--- + +# Unattended Handoff + +## Why This Exists + +The attended session, `session-handoff`'s "The Attended Session", needs the maintainer for every +decision it meets. Most open issues need none, so a loop can work those while the maintainer is +away and hand back only the ones that do. Two things make that loop hard. Its orchestrator runs for +hours, so any detail it reads is paid for again on every later round, and a loop that reads issues +itself dies of its own context long before the backlog is empty. And a worker that meets a decision +must stop without losing its work, in a state the maintainer's next attended session picks up with +no reminder. + +## The Three Seats + +- **The orchestrator** is the session this skill is invoked in. It resolves the repository once, + then only dispatches, reads one line back, and dispatches again. It reads no file, issue, diff, + or review, writes no handoff, and saves no memory. Everything it would learn by looking belongs + to a subagent that starts empty and is discarded after one round. Its cost stays flat because of + what it holds, whatever tier it runs on. +- **The picker** is a subagent dispatched once per round. It chooses one handoff whose work needs + no maintainer decision, creating one where none is waiting, and returns its number and track. +- **The worker** is a subagent dispatched once per round on the handoff the picker returned. It + does the work in its own worktree and ends the handoff as done or parked. + +One worker runs at a time, so no two rounds claim the same file and no round needs the grouping +`backlog-burndown` does. + +## Invocation and What It Authorizes + +The maintainer names the scope when invoking the skill, and the scope is the whole grant. + +| Scope | A worker may merge | +| --- | --- | +| `develop`, the default | its feature -> develop pull request | +| `main` | that, then the develop -> main promotion pull request that follows it | +| `release` | both, then dispatch the release that promotion unblocks | + +The default keeps every main merge the maintainer's. `main` promotes each fix alone, since many +develop merges queued behind one promotion make that promotion too large to review. `release` +exists because a merge to main with no release never exercises artifact creation. + +- **The grant is bounded by the session it was named in**, as `backlog-burndown`'s "What Invoking + This Skill Authorizes" bounds its own. A run resumed in a new session needs the scope named again. +- **Under `main` or `release` it is the one standing promotion grant** `merge-and-release` + recognizes, covering each promotion this run's workers make, since naming the scope is naming + every such merge in advance. +- **The grant answers the explicit-permission item of the `pr-review-conduct` Merge Gate for the + merges its scope names, and nothing else.** A pull request with an open finding still does not + merge, and no scope authorizes closing an issue on judgment, changing repository settings, or + touching another repository. +- **The orchestrator passes the scope into every worker brief.** A brief is not a grant, per + `drive-pr`, and what makes the merge authorized is the maintainer having named the scope in this + session. + +A run may also be given a round cap. Where none is named, it is 20. + +**One run per repository at a time.** The picker reads an open `auto-*` handoff not carrying +`blocked` as a lane whose worker died, which only holds while no other run is live, so the +maintainer starts a second run on a repository only once the first has ended. + +## The Loop + +The orchestrator first resolves `<owner>/<repo>` from the checkout's `origin`, or takes it from the +invocation, which is the one command it runs. From then on it holds exactly four things: the scope, +the round count, the handoff numbers dispatched so far, and the one-line outcome of each round. +Each round runs: + +1. **Dispatch the picker** with the brief below, and wait on the dispatch mechanism's own + completion signal rather than polling. +2. **Read its one line.** `NONE` or `STOP` ends the run. A handoff number already dispatched in + this run ends it too, since a handoff a worker neither closed nor parked means the worker failed + in a way this seat must not investigate. +3. **Dispatch the worker** on that handoff and track, at the tier the picker named, and wait the + same way. +4. **Record its one line** and start the next round. `STOP` ends the run, and so does a reply that + is not one of the lines below, rather than being read further. + +The run ends at `NONE`, at `STOP`, at the round cap, or at the repeat stop above. Its final message +lists every round's outcome line, then the count of parked handoffs, and names the attended session +(`session-handoff`, "resume the handoff") as where they get answered. It writes nothing else +and asks nothing. + +### The Briefs + +Pass these verbatim, filling the angle brackets. They name this skill rather than restating it, +which keeps the orchestrator's own context to the brief's length. + +```text +Load the `unattended-handoff` skill and act as its picker for <owner>/<repo>, +scope <develop|main|release>. Reply with exactly one line, in the picker return form that skill states. +``` + +```text +Load the `unattended-handoff` skill and act as its worker on handoff #<n>, track <track>, +in <owner>/<repo>, scope <develop|main|release>. The maintainer named that scope when +invoking the run. Reply with exactly one line, in the worker return form that skill states. +``` + +### Return Lines + +| Seat | Line | Means | +| --- | --- | --- | +| picker | `PICK #<n> track=<track> tier=<tier>` | work handoff `#<n>` on that model tier | +| picker | `NONE <reason>` | nothing left that needs no decision | +| worker | `DONE #<n> <pull request numbers>` | merged as far as the scope allows, lane closed out | +| worker | `PARKED #<n> decision #<d>` | parked on decision issue `#<d>` | +| either | `STOP <reason>` | a condition no later round can clear, so the run ends | + +`STOP` is for a state of the repository or the session rather than of one issue: a missing label, +an exhausted reviewer quota, a push the executor refuses, or a promotion pull request already +waiting on an open `decision` issue, since every later round would meet that same decision. + +A promotion carries whatever develop holds, since that is what a develop -> main pull request is. +Under `main` or `release` every round promotes, so each one ordinarily carries one fix, and a change +another session merged to develop meanwhile rides along with it. Naming the scope accepts that. + +## Auto-Resolvable + +An issue needs no maintainer decision when every one of these holds. Where any is unclear, it +does not qualify, since skipping one costs nothing and a guess costs a revert and a review round. + +- **The right outcome is determined** by the issue together with the committed rules, and the issue + leaves no choice open between alternatives it names. +- **Nothing on it waits on the maintainer.** It carries none of `decision`, `blocked`, `handoff`, or + `canonical-sweep`, the last being an issue a workflow owns rather than one a pull request closes, + and no comment asks the maintainer something still unanswered. +- **The fix stays inside this repository's tree.** It changes no repository setting, ruleset, + visibility, secret, or release condition, and needs no credential, account, or host the session + lacks. +- **It reverses no settled decision** recorded in an issue, a handoff, or the rule text. +- **Nothing has worked it or is working it.** The track `auto-<issue>` has no link, open or closed, + which `handoff.py chain --track "auto-<issue>" --limit 1` answers with its refusal naming no + handoff on that track. Any other refusal from it is a `STOP` rather than a yes. No open pull + request names it, and no pull request whose squash commit is in `origin/main..origin/develop` + names it anywhere in its body, since a fix merged to develop leaves its issue open until it is + promoted, whoever merged it. No open handoff on any track names it in its next steps, and no + comment on it claims it for a `backlog-burndown` group, since both mark work that has no pull + request yet. + +## The Picker + +1. **Check the promotion first** under `main` or `release`. Where an open `decision` issue names + the open develop -> main pull request, return `STOP` before picking anything, since every worker + this run dispatched would meet that same decision after merging its own work to develop. +2. **Read the open handoffs** with labels and update times, `gh issue list --label handoff --state + open --limit 100 --json number,title,labels,updatedAt`, since `handoff.py tracks` prints + neither. Reach `scripts/handoff.py` from a hub checkout, per `session-handoff` "Running the + Chain". +3. **Prefer an open `auto-*` handoff not carrying `blocked`**, oldest first. That is a lane an + earlier run parked and the maintainer has since unblocked, or one whose worker died, and a live + link is work already framed. Handoffs on any other track belong to the maintainer's attended + lanes and are never picked. A picker never takes `blocked` off a handoff, even where the decision + issue it names has been answered, since an attended session may be working that lane and only + the session handing the lane back removes the label, per `GOVERNANCE.md` "Durable Knowledge and + Self-Improvement". +4. **Otherwise pick from the backlog.** Rank the open issues by `backlog-burndown`'s "Ranking" + criteria, keep the auto-resolvable ones, and take the top one. Read the list with an explicit + page size, since `gh issue list` returns 30 rows unless told otherwise. +5. **Create its handoff** with `handoff.py new --track "auto-<issue>"`, `--dry-run` first. The body + carries the sections `session-handoff` "What Goes in the Body" names, with the next steps naming + the issue and what done looks like. That skill's rules on the body bind it. +6. **Choose the worker's tier** by `backlog-burndown`'s "Choosing the Worker's Model Tier". +7. **Reply with one line.** A picker writes nothing but the handoff it creates, and returns `STOP` + where a read it needs cannot run. + +## The Worker + +1. **Resume the handoff** with `handoff.py resume --track "<track>"`, then read its comments with + `gh issue view "<n>" --comments`, since `resume` prints only the body and a parked lane's state + is in its parking comment. A lane handed back by an attended session has a closed predecessor + holding that comment, so read the predecessor's comments too. Where either names a decision + issue, read the answer recorded there and follow it, since it is what unblocked the lane. + Re-derive live state rather than trusting any of them, per `session-handoff` "Resuming". +2. **Isolate** in a worktree of its own, per `repo-worktree`, on the branch the handoff names or on + `feature/<track>`. +3. **Fix and drive.** Run `local-strict-review` before every push, and drive the pull request with + `drive-pr` to develop, its body carrying `Closes on promotion: #<issue>`. Under `main` or + `release`, continue to the promotion pull request, its body carrying a `Fixes` line for every + issue develop fixes, assembled per `backlog-burndown` "Assembling the Promotion Body", and hand + it to `merge-and-release`, merging only under `main` and merging and releasing under `release`. + Every Merge Gate item other than the permission still has to hold. +4. **Wait in the foreground.** Each wait is one bounded command such as `pr_review.py wait`, run in + the worker's own turn. A subagent receives no completion notification, so a wait handed to a + monitor or a background task never wakes it. +5. **Park at the first decision**, per "Parking" below, filing any lesson per step 6 before the + parking comment so the comment can name it. That includes a merge the harness refuses after one + retry, which is parked as ready to merge rather than routed around. +6. **File any lesson for the maintainer.** A lesson a future agent must honor is rule text, which is + the maintainer's to judge and no one is present to judge it, so file it as an issue carrying + `decision`, stating the proposed rule and where it would go, with the choices as its options in + the form `GOVERNANCE.md` "Communicating with the User" sets for any choice put to the maintainer. + The picker never takes a `decision` issue, so the loop cannot write a rule nobody has judged. + Once answered, the label comes off per `GOVERNANCE.md` "Communicating with the User". A declined + rule's issue closes, and an adopted one stays open as ordinary work, which the loop may then + take, since the maintainer has judged it. +7. **Close the lane out on done.** Comment on the handoff what merged, which issues it fixed, and + what it filed along the way, the lesson issue included, then close it. An `auto-*` lane holds one + issue, so its work is complete and it is the closed-out lane `session-handoff` "The Chain" names, + needing no successor. +8. **Save no memory**, since state lives in the chain where any session on any machine reads it. + Reply with one line. + +## Parking + +A worker parks rather than asks, since no one is present to answer. Park in this order, so that an +interruption part way leaves the work findable rather than lost. + +1. **Keep the work.** Commit it and push the branch under the ordinary push rules, and leave the + branch and any pull request open. Where the push cannot run, leave the worktree exactly as it + stands and name it in the comment below. +2. **File the question** as an issue carrying `decision`, per `GOVERNANCE.md` "Communicating with + the User". It states the question, the choices as its options in the form that section sets for + any choice put to the maintainer, what each choice would do to the parked work, the handoff it + belongs to, and every pull request the decision blocks, the open promotion included where it + blocks that, which is what lets a picker find a promotion already waiting on one. Where an open + `decision` issue already asks the same question about the same pull request, name that one + instead of filing another, commenting onto it this handoff, the effect on its work, and every + pull request the decision now blocks. It holds nothing but the question, so it closes once + answered. +3. **Comment the state on the handoff**, filing any lesson first per worker step 6 so the comment + can name it: what is done, the branch and pull request, whether the worktree was left standing, + what remains, and the decision issue it now waits on. This comment is what the next session on + the lane resumes from, so it is complete enough to continue with no other context. +4. **Label the handoff `blocked`**, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". + The picker skips it from then on, and the attended session takes it first. + +Each of these is an outward-facing write bound by `GOVERNANCE.md` "Repository Boundaries and Write +Safety": this repository only, every identifier read live in the same run, and no output +suppressed. diff --git a/.github/skills/upstream-contribution-workflow/SKILL.md b/.github/skills/upstream-contribution-workflow/SKILL.md new file mode 100644 index 0000000..6545861 --- /dev/null +++ b/.github/skills/upstream-contribution-workflow/SKILL.md @@ -0,0 +1,84 @@ +--- +name: upstream-contribution-workflow +description: >- + Governs how the maintainer contributes to a third-party repository he does not control (for + example esphome/esphome), distinct from the fleet's own internal branching model: a dirty work + branch on his own fork for the actual work and review iteration, squashed once clean to a second + branch that carries only the intended minimal history, that clean branch opened as the PR + against the upstream repo, and reviewer feedback applied to the dirty branch first, then + re-squashed into the clean one. Use this whenever about to open a pull request against a + repository outside the ptr727 fleet, whenever forking a third-party project to contribute a fix + or feature, whenever an upstream reviewer requests changes on a PR opened this way, and whenever + deciding which issue or PR template to use for a third-party repository. Triggers regardless of + the target repo's own type or workflow model, since this skill is about the shape of a + contribution to someone else's repo, not the target repo's own internal conventions, which this + skill does not attempt to state and are never assumed to match the fleet's. +--- + +# Upstream Contribution Workflow + +## Why this exists + +The fleet's own branching model (`branching-and-release-model`) governs repos the maintainer +controls end to end: squash-only feature branches, merge-commit promotions, signed commits under +his own identity. None of that applies to someone else's repository. A PR into a third-party +project answers to that project's own maintainers, on their own timeline, with their own review +cycles, and the history that lands there should read as a deliberate, minimal contribution, not as +the maintainer's own iteration log. This skill is that different shape, kept separate from the +fleet's internal model so the two are never conflated. + +## The two-branch shape + +1. **Fork the upstream repo**, if not already forked. +2. **Do the actual work on a dirty work branch**, on the maintainer's own fork. This branch is + allowed to be messy: false starts, fixup commits, back-and-forth in response to review, whatever + the real work looks like while it's happening. Open a PR from this branch into a branch on the + maintainer's **own fork** (not upstream), so all the iteration happens there, visible and + reviewable, without touching the upstream repo at all. +3. **Once the dirty branch is clean and the change is ready, squash it to a second branch** that + carries only the intended, minimal commit history, one commit (or a small, deliberate set) that + states what the change is, not how it was arrived at. +4. **Open the PR against the upstream repo from that second, clean branch.** This is the only + branch upstream ever sees. An upstream draft may be opened only after that clean presentation + branch exists and is published. When more preparation is needed, continue on the dirty branch, + re-squash it into the clean branch, and update the same draft by the step 5 procedure. Never + iterate directly on the published presentation branch. Mark the draft ready when preparation + finishes. Open it ready immediately when no preparation remains. +5. **If upstream reviewers ask for changes, apply them to the dirty branch first**, iterate there + the same way as step 2, then re-squash the updated dirty branch into the clean branch that + actually reaches upstream. Updating the same upstream PR rather than opening a new one each + round rewrites the clean branch's history, and pushing a rewritten branch that is already + published requires `git push --force-with-lease` (prefer it over a bare `--force`, it refuses + the push if the remote moved since the last fetch). **`git-commit-conventions`'s never-force-push + rule governs this fleet's own repos, where a branch is shared with bots, other branches, and + required-check history a rewrite would orphan, and it stays absolute there, with no exception. + It has no jurisdiction here**: this clean presentation branch lives on the maintainer's own + fork, outside the fleet entirely, and carries nobody's work but this squash. Force-with-lease + is scoped just as tightly regardless: only this one branch, only on the maintainer's own fork, + never the dirty work branch, which is the append-only iteration log this whole workflow exists + to preserve. If force-with-lease is ever refused or unavailable, open a fresh PR from a newly + named clean branch rather than fighting the push. + +**The dirty branch is always the working copy. The clean branch is always the presentation copy.** +Never reverse this: never iterate directly on the branch that's open against upstream, and never +skip the squash step because the dirty branch "looks clean enough." + +## Use the upstream repo's own conventions, not the fleet's + +Always use the upstream repo's own issue and PR templates, its own contribution guidelines, and +its own commit-message and code-style conventions when they differ from this fleet's. The fleet's +`comment-and-doc-style`, `git-commit-conventions`, and `pr-review-conduct` skills describe how +*this fleet* does things, and none of them are the target repository's own rules. Read the target +repo's `CONTRIBUTING.md` (or equivalent) and follow it. Where the target repo states no +convention of its own, matching the surrounding code's existing style in that file is the better +default, not falling back to the fleet's own convention by habit. + +## What stays governed by the fleet's own rules + +Signing commits and using the correct git identity are host configuration, not project +convention, so `git-commit-conventions`'s signing and identity rules still apply on both the dirty +and clean branches. They are properties of the committer, not of the target repository. The +write-safety rules (never write to a repository outside explicit authorization, never fabricate a +GitHub id) also still apply in full. A fork the maintainer owns is within scope to push to, and +the upstream repository itself is written to only through the PR the maintainer explicitly asked +for. diff --git a/.github/skills/workflow-ci-contract/SKILL.md b/.github/skills/workflow-ci-contract/SKILL.md new file mode 100644 index 0000000..c8a5e1a --- /dev/null +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -0,0 +1,50 @@ +--- +name: workflow-ci-contract +description: >- + Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: + the D1-D9 guarantees, the output seam by destination (a GitHub release file, a package-registry + push, an image-registry push, or a filesystem on a host the project owns), the artifact + lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test + methodology. Use this whenever writing or editing anything under .github/workflows/ or a + composite action under .github/actions/, editing version.json, adding or dropping a release + target, auditing a repo's workflows, or tracing which job, input, or condition made a publish + run or skip. This is the YAML half of the pipeline, and `branching-and-release-model` keeps the + git half, which branch a change targets and which events may publish at all. Triggers even when + the edit looks mechanical, such as bumping an action, renaming a job, or adding one upload step, + because SHA pinning, the ruleset-bound aggregator name, smoke gating on uploads, and + retention-days are each easy to break in a one-line diff that no build fails on. WORKFLOW.md + keeps authority, and GOVERNANCE.md's Workflow YAML Conventions and Release Model sections win + where those two overlap. +--- + +# Workflow CI Contract + +## Why This Exists + +`WORKFLOW.md` is the fleet's CI/CD behavioral contract. This skill is that contract's surface, so an agent editing workflow YAML has the contract in view. It carries the summary, and `WORKFLOW.md` sections 3, 4, and 5 are each carried whole in `references/` as a generated include. `WORKFLOW.md`'s own canonical-scope note says which of it and `GOVERNANCE.md` is authoritative where the two overlap. + +## How the Contract Is Read + +- **Outcomes, not bytes.** A workflow is judged against `WORKFLOW.md` section 4's expected inputs and outputs, never against a snippet byte for byte, per `GOVERNANCE.md` "Foundational Principles". +- **Applicability.** A guarantee, or a 5B scenario from `WORKFLOW.md` section 5, governing a construct the repo does not contain is N/A: recorded, excluded from the verdict, never a defect. A source-only pipeline is mostly N/A and that is fine. +- **Operational is binary.** Every applicable guarantee holds, or the workflow is not operational. A single applicable input-output mismatch is a defect regardless of how clean the YAML looks. +- **Reached, not carried.** A standard workflow whose job graph is identical across repos of a type is reached as a hub-hosted `workflow_call` task, per `GOVERNANCE.md` "Hub-Hosted Tooling". The repo's own surface is the caller stub, pinned to a hub release commit, and a composite-action hook at `.github/actions/<hook>` for what is its own. A hub task reaches its own actions and sibling tasks through `$/`, which resolves at that pinned commit. The merge-bot is the first, and `docs/reusable-workflows.md` in the hub carries the model, the hook contract, and the stage each workflow migrates in. Until a workflow's stage ships, its copy is graded against the same contract. +- **Two layers.** The pipeline splits into an orchestrator layer and a build-leaf layer, defined in `WORKFLOW.md` section 3's `Two Layers: Orchestration vs Build` and carried in `references/architecture.md`, while `WORKFLOW.md` section 1's `Two layers when auditing` maps which layer declares which input. Assert an input a guarantee names in the layer that declares it. + +## Style Rules + +`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and the `comment-and-doc-style` Skill keeps the line-ending policy, reached from `GOVERNANCE.md` "Documentation Style Conventions" under "Line Endings". Read both before editing a workflow or a composite action. + +## The Contract Text + +`references/architecture.md`, `references/d-guarantees.md`, and `references/test-methodology.md` carry `WORKFLOW.md` sections 3, 4, and 5 whole, each as a generated include, so the pipeline's architecture, a guarantee's exact wording, and the audit-trace-probe procedure are each one read away rather than restated in full here. A defect in an include region is fixed in `WORKFLOW.md` and regenerated, never edited in this skill, per the `skill-lifecycle` Skill. `WORKFLOW.md` keeps sections 1, 2, and 6 itself, the applicability rule, the style-rule pointer, and the per-project-type walkthroughs, which say which constructs each type adds, map each construct to the scenarios it reaches, and carry three rules for reading a row, one of which is about a repository declaring more than one type, so read those there. + +## After Any Workflow Edit + +A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. + +**actionlint discovers `.github/workflows/` recursively, both extensions, and opens no action file on its own.** It reaches a local action's `action.yml` only through a workflow's `uses: ./<path>`, wherever in the tree that path leads, and reports everything it finds there against the calling workflow: the caller's `with:` block against the action's declared inputs, the caller's `steps.<id>.outputs.<name>` against its declared outputs, and the action's own `name`, top-level `description` and `runs.using`. A missing per-input `description` and an unexpected top-level key are the metadata it does not reach that way. Pointing it at an action file directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. + +**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- <files>`. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. The schema and actionlint do not agree on every key: the schema accepts `runs.using: node16`, which actionlint rejects as an invalid runner, so a green schema run is not a statement about what GitHub currently accepts. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. + +**Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.github/skills/workflow-ci-contract/references/architecture.md b/.github/skills/workflow-ci-contract/references/architecture.md new file mode 100644 index 0000000..b03c335 --- /dev/null +++ b/.github/skills/workflow-ci-contract/references/architecture.md @@ -0,0 +1,112 @@ +# The Pipeline Architecture + +The section below is `WORKFLOW.md` section 3, whole. The D-guarantees it cites by number are `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. + +## The Architecture + +<!-- include: WORKFLOW.md > 3. Architecture --> + +### Branch Model + +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline `WORKFLOW.md` specifies: + +```mermaid +flowchart LR + feature[feature branch] -->|squash| develop + develop -->|merge commit| main + main -.->|no back-merge| develop +``` + +`operational` repos (live-service config, `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: + +```mermaid +flowchart LR + edit[direct signed commit] -->|advisory CI| develop + pr[pull request] -->|lint CI, reported not required| develop + develop -->|merge commit, enforced lint CI| main +``` + +The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in `GOVERNANCE.md` "Operational Repositories", which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (`WORKFLOW.md` section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. + +Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees in `WORKFLOW.md` section 4 that assume a build/test pipeline are **N/A** exactly as for `source-only` (`WORKFLOW.md` section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in `GOVERNANCE.md` "Branching Model" rather than in `WORKFLOW.md`. + +### Two Layers: Orchestration vs Build + +- **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned in shape: the leaf a target runs. A repo owning its release task hosts its leaves itself. A repo calling the hub-hosted task shapes a leaf through a composite-action hook of its own, which the task runs in place of its hub default where that file exists. For the .NET, NuGet, and PyPI leaves it is `.github/actions/<job>/action.yml`, named for the build job (`dotnet-publish`, `build-nuget`, `build-pypi`). For the Docker leaf it is `.github/actions/docker-prepare/action.yml`, which a `docker_matrix` the caller passes bypasses along with the default. A `docker-build-base` hook, which has no default, builds a shared base layer where the caller sets `docker_build_base`. +- **What the repo curates** (by design, not a leak): the *list* of targets. A repo calling the hub-hosted release task reaches it by pin and carries no copy, so adding or dropping a target edits the caller's own surface. That is the `enable_<target>` value its publisher passes, the publisher's `on.push.paths` entries where it carries a `push` trigger, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3). It is also the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-<target>` job for a package target. The release task's `enable_*` inputs, its per-target build jobs, and their `github-release` and `build-docker` `needs:` entries belong to the task, so a target the hub task declares no input for is a change to that task first (`WORKFLOW.md` section 6, the `library` row). A repo owning its release task edits those in its own file, where "verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the task's job list. + +### The Seam Contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset-<branch>-<target>`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset-<branch>-*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included. Switching to an `artifact-ids:` handoff forks the release download. + +```mermaid +flowchart LR + dotnet[dotnet-publish] -->|release-asset-BRANCH-dotnet-publish| store[(run artifacts)] + nuget[build-nuget] -->|release-asset-BRANCH-nuget| store + store -->|pattern + merge-multiple| rel["github-release job (D6)"] + nuget -->|nuget-build-BRANCH| pub["publish-TARGET job in the repo's own publisher"] + pypi[build-pypi] -->|pypi-build-BRANCH| pub + pub -->|push| registries[(registries)] + docker[build-docker] -->|push| registries +``` + +The diagram writes `BRANCH` and `TARGET` where the prose writes `<branch>` and `<target>`, because a mermaid label is sanitized as HTML at render and an angle-bracket placeholder is dropped as an unknown tag. This reaches node labels as well as edge labels, which is why the Release Model diagram below writes `X.Y.Z-g-sha` rather than bracketing its own placeholder. + +### Reusable-Task Parameter Contract + +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch` (the logical branch the caller passes). Artifact names carry the branch. + +### Versioning + +NBGV versions the branch being published. Each run builds a single branch (the trigger ref), so `GITHUB_REF` already names it and NBGV classifies it directly, and no `IGNORE_GITHUB_REF` override is required. The default branch is the public-release ref, so it builds clean `X.Y.Z`. Every other branch builds a prerelease `X.Y.Z-g<sha>`. `version.json`'s `version` is the major.minor floor. NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code**, since they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification. The .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs: the PyPI version is the `X.Y.Z` core of `SemVer2`, any prerelease or build segment dropped, with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. + +### Validate-at-Entry + +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. + +### Resource Lifecycle + +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the half of the consumption whose failure would leave it not yet redundant** (D5.2 names the two halves), and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. + +### Fast PR Feedback + +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets. A validation job always runs. Smoke builds compile/lint/test but upload nothing and push nothing. One required aggregator gates the merge. See D1. + +```mermaid +flowchart TD + pr[pull request] --> ch[changes paths-filter] + ch -->|target changed| sb[smoke-build changed targets] + ch -->|workflow-only or docs| skip[smoke-build skipped] + val[validation job] --> agg["Check pull request workflow status job (D1)"] + sb --> agg + skip --> agg + agg -->|success| ok[merge allowed] +``` + +### Release Model + +Each publish builds a **single branch**, the trigger ref (`main` a release, `develop` a prerelease), so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a `plan` job (`publish-plan-task.yml`) decides once, and a job downstream of it either gates on its outputs or inherits the skip through `needs:`. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it, and the publisher's own `on.push.paths` list also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE. Targets amend it with `release-asset-*` files, and a registry push contributes none, made by the Docker leaf for an image and by the separate `publish-<target>` job for a package. An unchanged version cuts no second release on a schedule or push trigger, while a dispatch refreshes it (D4.4). Docker re-pushes by design. + +```mermaid +flowchart TD + trig[main-only schedule / dispatch / paths-filtered push] --> one[build the one trigger branch] + one -->|main| vmain["version X.Y.Z stable (D3)"] + one -->|develop| vdev["version X.Y.Z-g-sha prerelease (D3)"] + vmain --> relm["github-release + registries: latest (D4)"] + vdev --> reld["github-release + registries: prerelease (D4)"] +``` + +### Output Seam by Destination + +Pick each output's path by **where the artifact goes**: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset-<branch>-<name>`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and uploads a build artifact (`nuget-build-<branch>` / `pypi-build-<branch>`), and a separate `publish-<target>` job in the **publishing repository's own** publisher consumes it and pushes. Both registries publish through OIDC Trusted Publishing, never a stored API key, and two things put that push outside the leaf. Trusted publishing validates the OIDC token's `job_workflow_ref` claim, which names the workflow the job actually ran from, so a push made from a reusable workflow a *different* repository hosts is rejected at the token exchange, NuGet.org answering `HTTP 401` with `does not start with <owner>/<repo>/.github/workflows/`. That alone rules out a leaf another repository hosts. A leaf this repository hosts clears the claim, and the split still applies to it, because a called job declaring no `permissions:` runs under the calling job's whole grant, so a push anywhere inside the release task would put `id-token: write` on every job in it rather than at the one entry point D7.2 requires. The registered trusted-publishing policy therefore names the publisher, `publish-release.yml`. PyPI additionally gates its publish job behind an environment. NuGet.org binds its policy to the workflow file rather than to an environment and needs none. NuGet's leaf also uploads a `release-asset-*` carrying the package, and PyPI contributes none. +- **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. +- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). +- **No file target via the release task** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The caller **MUST pass `expect_release_assets: false`** to the release task. A publisher with file targets retains the default `true`. This setting is caller-specific. The default `true` fails on `fail_on_unmatched_files` when no assets exist. A **source-only** repo also passes every `enable_*` input as false because it has no build leaf (see `WORKFLOW.md` section 6). + +`WORKFLOW.md` section 3 keeps the architecture, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/architecture.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + +<!-- /include --> diff --git a/.github/skills/workflow-ci-contract/references/d-guarantees.md b/.github/skills/workflow-ci-contract/references/d-guarantees.md new file mode 100644 index 0000000..1a39662 --- /dev/null +++ b/.github/skills/workflow-ci-contract/references/d-guarantees.md @@ -0,0 +1,87 @@ +# The D-Guarantees + +The section below is `WORKFLOW.md` section 4, whole. Which of its items bind a given repository is `WORKFLOW.md` section 1's applicability rule. The architecture these items govern is `WORKFLOW.md` section 3 and the methodology that checks them is `WORKFLOW.md` section 5, carried whole in `architecture.md` and `test-methodology.md` beside this file. + +## The Behavioral Contract + +<!-- include: WORKFLOW.md > 4. Behavioral Contract: Expected Outcomes --> + +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. + +### D1 - PR Fast-Feedback (Smoke) + +- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run. Unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped), and that entry lists paths rather than negating them, so a change matching no entry marks nothing and every smoke build skips. *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a validation job runs unconditionally and the aggregator `needs:` it. That job is the caller's own job reaching the reusable validator, named `validate` in every shipped stub, and that name is what the aggregator's `needs:` carries. The validator's internal jobs (`lint`, `unit-test` and `validate` in the hub's `validate-task.yml`) are not addressable from a caller, so a `validate` in a caller's `needs:` list always names the caller's own job rather than the validator's internal one of the same name. The validator detects the tree rather than the repo's language, running the doc and repo gates everywhere, the `dotnet test` path only where a `*Tests*.csproj` project exists, and the `pytest` path only where a root `tests/` directory sits beside a root `pyproject.toml` and a root `uv.lock` or `requirements*.txt`, so a non-.NET repo calls the same one rather than replacing it. A repo whose tests take another shape can run them from its own `.github/actions/validate/action.yml` hook, which the validator runs where that file exists. That hook receives no secret, so a repo whose other-shape tests owe D1.6's coverage upload is one whose validation the validator cannot express. A repo whose validation it cannot express **replaces** the call (not deletes it) with its own validator and re-points the aggregator's `needs:` to the replacement. `smoke-build` `needs:` the `changes` job rather than the validation job, so no second `needs:` moves with it. *Prevents: a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.* +- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* +- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* +- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-<guid>.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* + +### D2 - Input/State Validation at Entry + +- **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each cross-input/derived-state invariant and fails fast before builds. Downstream jobs `needs:` it. +- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts), and on a smoke build the **check exits early while the job still reports success** (a detached PR head always versions as prerelease). Read that as the validation being skipped rather than the job, because a job-level `if:` would skip the job itself, and a dependent whose `if:` carries no status-check function, an absent `if:` included, gets the implicit `success()` and skips with it. `validate-release` has such dependents, so a job-level skip there would skip their smoke builds with it. *Prevents: a non-default leg published as stable, a build-metadata false-positive, and the gate blocking every default-base promotion PR.* +- **D2.3 Publish only from main or develop.** Input: a dispatch publish. Output: a dispatch from any ref other than `main` or `develop` fails fast. *Prevents: cutting a release from an unintended branch.* +- **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* +- **D2.5 The release gate refuses a branch input git would not accept as a name.** Input: a release task taking a `branch` input, on any run. Output: the gate runs `git check-ref-format --branch` over that value and fails when git rejects it, ahead of D2.2's smoke exit, with an error echoing no part of the value. *Prevents: a `::` or `##[` in a caller's value forming a workflow command wherever a later step prints the branch, on a smoke run as much as on a publish.* + +### D3 - Versioning and Classification + +- **D3.1 One branch per run.** Input: a publish triggered on `main` or `develop`. Output: the run builds and versions that one branch, and `github.ref` names it, so NBGV classifies it directly (no `IGNORE_GITHUB_REF`). *Prevents: a cross-branch ref mismatch misclassifying the version.* +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`, and any other -> `X.Y.Z-g<sha>`. Every literal that keys default-branch behavior, `version.json`'s `publicReleaseRefSpec` among them, MUST name the repo's real default branch. The hub-hosted tasks and their default leaves write it as `main`, so a repo calling them has `main` as its default branch. +- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor. NBGV appends the git height as the patch, and the maintainer moves the version by bumping the floor. NBGV and `version.json` are retained even by a no-compiler repo (they own the tag). +- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g<sha>` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from the `X.Y.Z` core of `SemVer2`, any prerelease or build segment dropped, and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule). The develop build stays `pip install --pre`-selectable, `.dev0` being a PEP 440 development release. *Note: at a shared `version.json` floor, `M.N.P.dev0` sorts below `M.N.P` at equal height, so a develop build does not always lead the stable release. A develop-only floor bump leads on the floor whatever the heights.* *Prevents: a non-default leg published as a release.* +- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the tracker (the writer) ships without consumer wiring, so a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`. If the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* + +### D4 - Release / Publish + +- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing. A **human merge never auto-publishes**. A `plan` job (`publish-plan-task.yml`) decides once. A job downstream of it either gates on the plan's outputs or inherits the skip through `needs:`. A gate on an output compares it against `'true'` rather than testing it bare, since a job output is always a string. A publish runs on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`, with an Actions-only bump matching no release path and publishing nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch. +- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's `GitCommitId`), never a branch name or a separately re-resolved ref. *Prevents: the tag landing on the default branch instead of the built tree.* +- **D4.3 Release contents.** Output: every release contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the no-asset shape. This applies to Docker-only, PyPI-only, and source-only repos. A NuGet target is not among them, since its leaf uploads a `release-asset-*` carrying the package, so a NuGet-only caller keeps the default `true`. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged, on a schedule or push trigger. Output: no second release is cut, because the release-create step is skipped when `gh release view` finds a release for the tag, and the paired asset-delete is skipped with it. A **dispatch** re-run refreshes the release instead and runs that delete with it, which is why a dispatch-only publisher records this item's skip leg as unreachable rather than failed. Package-registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence. They run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success, and PyPI does the same under `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* +- **D4.5 A build failure blocks every publish target, within the Docker limit below.** Input: a real publish where one enabled build fails. Output: nothing publishes. The limit: where a Docker target builds a shared base or more than one image, its task pushes the base and each image leg as it builds, so a leg failing after the base or a sibling leg pushed leaves those images in the registry with no release. `github-release` needs every build and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push), while a **disabled** target, skipped rather than failed, still lets docker push. A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* +- **D4.6 Deploy verification names the release.** Input: a deploy to a filesystem on a host the project owns that completes without error. Output: a check against the running host asserts **which release is answering**, not merely that it answers. The artifact stamps its own version into the configuration it ships, and the check compares that against the version just installed, **waiting for convergence to a bounded timeout** rather than sampling once, because content goes live the instant a pointer moves while server rules wait on an asynchronous reload. The same check asserts **which environment** answered, since several environments serve a byte-identical artifact and a proxy rule aimed at the wrong one answers healthily under the right hostname. An unreachable host is reported distinctly from an HTTP status. *Prevents: a green deploy over a host still serving the previous release's configuration, a URL contract checked against the wrong environment, and a dead config watcher read as a routing fault.* + +### D5 - Resource Cleanup + +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete by the kind of consumer it follows.** Output: where the consumer is a conditional step (the GitHub release create), the delete carries that same condition, narrowed by `inputs.expect_release_assets`. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps.<download-step-id>.outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* +- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* + +### D6 - Seam / Architecture Conformance + +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset-<branch>-<target>`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. +- **D6.2 Branch drives config.** Output: a called workflow's branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3 Branch-named artifacts.** Output: every artifact name carries the branch, so a branch's artifacts do not collide with another branch's. +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_<target>` value the publisher passes to the release task, the publisher's `on.push.paths` entries where it carries a `push` trigger, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-<target>` job. A repo owning its release task also updates the target's build job there, `build-<target>` or `dotnet-publish` for the .NET target, and its `github-release` and `build-docker` `needs:` entries. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* + +### D7 - Concurrency, Permissions, Safety + +- **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* +- **D7.2 A called job's permissions block is validated before its `if:`.** Output: a reusable job declares `permissions:` only where **every** caller grants that scope at startup, and otherwise declares none and runs under whatever the calling job granted. A callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. *Prevents: a `startup_failure` on every caller that does not grant a scope only one target needs, including a smoke build under a read-only pull request token.* +- **D7.3 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.<name>` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs.<name> == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.<name>` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is always a string as well and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* + +### D8 - Bots / Automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `<prefix>-<base>` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. +- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. + +### D9 - Style / Static + +`GOVERNANCE.md` "Workflow YAML Conventions" names the tool D9.1 excepts and states the suffix rules D9.2 requires. + +- **D9.1** Every action or reusable workflow referenced from another repository is SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). A local (`./`) or self-repository (`$/`) reference names no ref and takes no pin. +- **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). +- **D9.3** Multi-line bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `<repo>:buildcache-<branch>` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `<image>:buildcache-<branch>` per image, the tag alone being unable to distinguish two images. +- **D9.5** Line endings follow `.editorconfig`. + +`WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + +<!-- /include --> diff --git a/.github/skills/workflow-ci-contract/references/test-methodology.md b/.github/skills/workflow-ci-contract/references/test-methodology.md new file mode 100644 index 0000000..f7690a0 --- /dev/null +++ b/.github/skills/workflow-ci-contract/references/test-methodology.md @@ -0,0 +1,59 @@ +# Testing a Repo's Workflows + +The section below is `WORKFLOW.md` section 5, whole. Its items and scenarios answer to the D-guarantees in `WORKFLOW.md` section 4, carried whole in `d-guarantees.md` beside this file. + +## The Test Methodology + +<!-- include: WORKFLOW.md > 5. Test Methodology --> + +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (`WORKFLOW.md` section 1): a guarantee or scenario for an absent construct is recorded N/A, not failed. + +### 5A. Static Audit (No Execution) + +Assert the structural fact each *applicable* D-guarantee implies, and record **pass**, **fail**, or **N/A** per item. This section says how an audit is run and recorded rather than what must hold: a guarantee names its own constructs, and the requirement is `WORKFLOW.md` section 4's item together with whatever that item defers to. + +Most of the evidence is in the workflow files and the composite actions they reach. Where a guarantee's evidence lies outside them, it is in practice the repo's branch ruleset, its Actions and Dependabot secret names, a workflow the repo only calls, a project or dependency file, or a committed file such as `version.json`, `.github/dependabot.yml`, `global.json`, `codecov.yml`, `.gitignore`, or `.editorconfig`. + +Cite what each verdict rests on. That is `file:line` for a file in the audited repo, its own name where a setting, a ruleset, or a secret name rather than a file is the evidence, and `<owner>/<repo>@<sha>` plus the `file:line` in that repo where the guarantee binds a workflow or composite action the audited repo only reaches, read at the SHA the caller pins. An **N/A** verdict names the absent construct instead, there being no line to cite. + +### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) + +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: + +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **succeeds**, its check exiting early on smoke per D2.2; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.2, D1.5 | +| S3 | PR changing only `.github/workflows/**` | the filter marks no target -> smoke-build **skipped**, validation runs, aggregator **success** | D1.2, D1.4, D1.5 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **succeeds** with its check exited early per D2.2, so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.5, D2.2, D3.2 | +| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | +| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | +| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; each package build-artifact (`nuget-build-*`, `pypi-build-*`) deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | +| S9 | re-run publish on a schedule or push trigger, version unchanged (a dispatch re-run refreshes the release instead, per D4.4) | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their download succeeded); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a per-branch bump PR -> the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3) -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1 | + +### 5C. Live Probe (Where Warranted) + +Every probe here that opens a pull request, dispatches a workflow, or re-runs a real publish is the maintainer's to run, with the agent preparing the command and reading the result back afterwards. A harness that refuses such a write is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (`GOVERNANCE.md` "Repository Boundaries and Write Safety"). + +- Open a trivial-change PR touching one target and confirm S1. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI read the built `dist/*` filenames out of the build job's log, `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted, with none left behind). +- **The deploy ref gate (S13) is verified only by tripping it.** Dispatch the production environment from a non-default ref and expect the run to fail at the gate. The evidence is four things, and each of them matters: the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded as **skipped** rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref. Capture all four, because a gate that fails open and a gate nobody tripped produce the same empty run history, so "we have never seen it fail" is not evidence about the one control standing between a mis-dispatch and the live site. **The agent prepares the command and reads all four back afterwards. It does not fire it.** The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. + +### Assessment + +Record the workflow **operational** when every *applicable* 5A item passes, every *applicable* 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: + +1. **Audit** with 5A, recording each item's verdict and its evidence in the form 5A sets out. +2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. +3. **Probe** with 5C where a live signal exists that the static trace cannot produce, running the probes that only read and preparing the writing ones for the maintainer: live version classification, registry state, the artifact lifecycle of a real run, and the deploy ref gate. +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, the list of items recorded N/A, and the 5C probes prepared but not run. + +`WORKFLOW.md` section 5 keeps the test methodology, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/test-methodology.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + +<!-- /include --> diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml deleted file mode 100644 index 0dcd61d..0000000 --- a/.github/workflows/build-docker-task.yml +++ /dev/null @@ -1,89 +0,0 @@ -name: Build Docker image task - -# Builds the image (multi-arch on main, amd64 on develop/smoke) and pushes it on any publish (main or develop). The Docker Hub -# overview is published separately, main-only, by publish-docker-readme-task.yml. -# Branch drives the moving tag: main => `latest`, else `develop`, plus the `:SemVer2` tag. Smoke builds amd64 only -# and never pushes; registry buildcache is branch-scoped. The orchestrator passes `branch` explicitly (the -# publisher builds one branch per run - the trigger ref - and smoke passes github.ref_name). -on: - workflow_call: - inputs: - # Push the built image. - push: - required: false - type: boolean - default: false - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch: main => `latest`, else `develop`. Required (see header). - branch: - required: true - type: string - # Smoke: build linux/amd64 only, never push, skip the shared cache-to. Fast PR feedback. - smoke: - required: false - type: boolean - default: false - # Version (SemVer2) from the orchestrator's single NBGV run, threaded so this task does not re-run NBGV on - # the detached commit (which could classify the branch differently). - semver2: - required: true - type: string - -jobs: - - build-docker: - name: Build Docker image job - runs-on: ubuntu-latest - env: - # Multi-arch (amd64+arm64) only on a full main publish; smoke and develop build amd64 only. - PLATFORMS: ${{ (!inputs.smoke && inputs.branch == 'main') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - - steps: - - - name: Checkout step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - - # arm64 is non-native on the amd64 runner, so install its QEMU emulator only when the build includes it. - - name: Setup QEMU step - if: ${{ contains(env.PLATFORMS, 'arm64') }} - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - with: - platforms: arm64 - - - name: Setup Buildx step - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - with: - platforms: ${{ env.PLATFORMS }} - - # Always login (even on smoke) for higher pull/cache-read rate limits; the credentials are in both the - # Actions and Dependabot secret stores so a Dependabot push CI run can log in too. Forks cannot push here. - - name: Login to Docker Hub step - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - - name: Docker build and push step - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - push: ${{ inputs.push }} - file: ./Dockerfile - tags: | - docker.io/ptr727/vscode-server-dotnetcore:${{ inputs.branch == 'main' && 'latest' || 'develop' }} - docker.io/ptr727/vscode-server-dotnetcore:${{ inputs.semver2 }} - platforms: ${{ env.PLATFORMS }} - # Read both branches' caches (near-identical layers), write only this branch's tag and only when pushing. - # mode=max caches the builder layers; ignore-error keeps a cache hiccup from failing a publish. - cache-from: | - type=registry,ref=docker.io/ptr727/vscode-server-dotnetcore:buildcache-main - type=registry,ref=docker.io/ptr727/vscode-server-dotnetcore:buildcache-develop - cache-to: ${{ inputs.push && format('type=registry,ref=docker.io/ptr727/vscode-server-dotnetcore:buildcache-{0},mode=max,ignore-error=true', inputs.branch) || '' }} - build-args: | - LABEL_VERSION=${{ inputs.semver2 }} diff --git a/.github/workflows/build-release-task.yml b/.github/workflows/build-release-task.yml deleted file mode 100644 index f0b4cec..0000000 --- a/.github/workflows/build-release-task.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Build project release task - -# Orchestrate one branch's release: version once (get-version), build the Docker image, then create the GitHub -# release. github/dockerhub gate the two publish targets; smoke builds the image, publishes nothing. -on: - workflow_call: - inputs: - github: - required: false - type: boolean - default: false - dockerhub: - required: false - type: boolean - default: false - # Git ref to check out / version (empty = default checkout ref). - ref: - required: false - type: string - default: '' - # Logical branch driving config, tags, and prerelease. Required (see header); the publisher passes the run's branch. - branch: - required: true - type: string - # Smoke: reduced, never-published build for fast PR feedback; hard-disables every push below. - smoke: - required: false - type: boolean - default: false - -jobs: - - # Validate the branch being published (lint), gating the build/publish below. Skipped on smoke: the PR runs - # its own validate job, so a smoke build does not double-validate. - validate: - name: Validate job - if: ${{ !inputs.smoke }} - uses: ./.github/workflows/validate-task.yml - secrets: inherit - with: - ref: ${{ inputs.ref }} - - get-version: - name: Get version information job - uses: ./.github/workflows/get-version-task.yml - secrets: inherit - with: - ref: ${{ inputs.ref }} - - # Build only when validation passed (success) or was skipped (smoke); never when it failed. - build-docker: - name: Build Docker job - needs: [get-version, validate] - if: ${{ !cancelled() && needs.get-version.result == 'success' && (needs.validate.result == 'success' || needs.validate.result == 'skipped') }} - uses: ./.github/workflows/build-docker-task.yml - secrets: inherit - with: - # Pin to the resolved commit so the image matches the release tag, and thread the single NBGV version. - ref: ${{ needs.get-version.outputs.GitCommitId }} - branch: ${{ inputs.branch }} - smoke: ${{ inputs.smoke }} - semver2: ${{ needs.get-version.outputs.SemVer2 }} - # Push to Docker Hub, never on a smoke build. - push: ${{ inputs.dockerhub && !inputs.smoke }} - - github-release: - name: Publish GitHub release job - # !smoke enforces "smoke never publishes" even if a smoke caller set github: true. - if: ${{ inputs.github && !inputs.smoke }} - runs-on: ubuntu-latest - needs: [get-version, build-docker] - - steps: - - # Check out the exact built commit so the release files match the tag even if the branch advances mid-run. - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ needs.get-version.outputs.GitCommitId }} - - # Backstop (main only): refuse to publish if the public version carries a prerelease '-' (NBGV mis-versioning). - # Strip '+buildmetadata' first; only a '-' in the core segment marks a prerelease. - - name: Verify public release version step - if: ${{ inputs.branch == 'main' }} - env: - SEMVER2: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - CORE_AND_PRE="${SEMVER2%%+*}" # drop +buildmetadata; a '-' here is the genuine prerelease separator - if [[ "$CORE_AND_PRE" == *-* ]]; then - echo "::error::Public (main) release version '$SEMVER2' carries a prerelease suffix; refusing to publish." - exit 1 - fi - - # Weekly re-runs may hit an already-released version; skip release-create when the tag exists (no-op republish). - - name: Check for existing release step - id: release-exists - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.get-version.outputs.SemVer2 }} - run: | - set -euo pipefail - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - echo "Release $TAG already exists; workflow_dispatch will refresh it." - else - echo "Release $TAG already exists; skipping release creation (no-op republish)." - fi - else - echo "exists=false" >> "$GITHUB_OUTPUT" - fi - - # The release is a version anchor (the image ships to Docker Hub): tag the built commit plus LICENSE + README. - # target_commitish pins the tag to the exact built commit (GitCommitId), not the moving branch ref. - # fail_on_unmatched_files catches a missing or renamed LICENSE/README. - - name: Create GitHub release step - if: ${{ steps.release-exists.outputs.exists == 'false' || github.event_name == 'workflow_dispatch' }} - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - generate_release_notes: true - tag_name: ${{ needs.get-version.outputs.SemVer2 }} - target_commitish: ${{ needs.get-version.outputs.GitCommitId }} - prerelease: ${{ inputs.branch != 'main' }} - fail_on_unmatched_files: true - files: | - LICENSE - README.md diff --git a/.github/workflows/get-version-task.yml b/.github/workflows/get-version-task.yml deleted file mode 100644 index 3ff3a3e..0000000 --- a/.github/workflows/get-version-task.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Get version information task - -# Run NBGV once and expose the version outputs. Checks out `inputs.ref` (default: the run's ref) and lets NBGV -# classify public-release from github.ref. Callers are expected to version the branch the run is for - the -# publisher builds the trigger ref - so github.ref matches the versioned branch and the classification -# (main = public clean version, any other branch = prerelease) is correct without overriding github.ref. -on: - workflow_call: - inputs: - # Git ref to check out / version (empty = caller's default checkout ref). - ref: - required: false - type: string - default: '' - outputs: - SemVer2: - value: ${{ jobs.get-version.outputs.SemVer2 }} - AssemblyVersion: - value: ${{ jobs.get-version.outputs.AssemblyVersion }} - AssemblyFileVersion: - value: ${{ jobs.get-version.outputs.AssemblyFileVersion }} - AssemblyInformationalVersion: - value: ${{ jobs.get-version.outputs.AssemblyInformationalVersion }} - # Full SHA NBGV versioned; pins the release tag to the exact built commit, not a moving ref. - GitCommitId: - value: ${{ jobs.get-version.outputs.GitCommitId }} - -jobs: - - get-version: - name: Get version information job - runs-on: ubuntu-latest - outputs: - SemVer2: ${{ steps.nbgv.outputs.SemVer2 }} - AssemblyVersion: ${{ steps.nbgv.outputs.AssemblyVersion }} - AssemblyFileVersion: ${{ steps.nbgv.outputs.AssemblyFileVersion }} - AssemblyInformationalVersion: ${{ steps.nbgv.outputs.AssemblyInformationalVersion }} - GitCommitId: ${{ steps.nbgv.outputs.GitCommitId }} - - steps: - - - name: Setup .NET SDK step - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 - with: - dotnet-version: 10.x - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - fetch-depth: 0 - - # Float nbgv on `master`, not SHA-pinned: its tag stream lags `master`, so Dependabot - # tag-tracking would only propose downgrades to stale tags (WORKFLOW.md D9.1). - - name: Run Nerdbank.GitVersioning tool step - id: nbgv - uses: dotnet/nbgv@master diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index b500ed0..71ef7dc 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,88 +1,27 @@ name: Merge bot pull request action -# Enable auto-merge once per PR on opened/reopened; disable it when a maintainer pushes to a bot branch. Merge -# method by base branch (develop = squash, main = merge). App token so the merge fires downstream workflows -# (GITHUB_TOKEN pushes don't) and so the disable job has write access on read-only Dependabot PRs. - -# `pull_request_target` (not `pull_request`): these jobs hold the App private key, so the workflow definition and -# its action SHAs must resolve from the trusted base branch, not the PR head. Safe because no job checks out PR -# code - each only runs `gh pr merge` against the PR by URL. +# Thin caller: the merge-bot is the hub's reusable merge-bot-task.yml, which every fleet repo reaches rather than carries. +# The trigger is pull_request_target so the called workflow resolves from the trusted base rather than the PR head, and no job checks out PR code. on: pull_request_target: types: [opened, reopened, synchronize] -# Per-PR group: under `pull_request_target` `github.ref` is the base branch, which would serialize every bot PR -# against that base; key on the PR number so each PR's events queue independently. `cancel-in-progress: false` so a -# follow-up synchronize doesn't cancel an in-flight `opened` run before it enables auto-merge. +# Concurrency keys on the PR number rather than on github.ref, which under pull_request_target is the base branch and would serialize every bot PR against it, so each PR queues independently. +# The cancel-in-progress setting is false so a follow-up synchronize does not cancel an in-flight opened run before it enables auto-merge. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number }} cancel-in-progress: false -jobs: - - merge-dependabot: - name: Merge dependabot pull request job - runs-on: ubuntu-latest - # In-repo Dependabot PRs only, on opened/reopened so the disable job stays sticky. Every tier - # auto-merges, semver-major included: the required checks are the gate, not the version bump. - if: >- - (github.event.action == 'opened' || github.event.action == 'reopened') && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.event.pull_request.head.repo.full_name == github.repository - permissions: - contents: write - pull-requests: write - - steps: - - - name: Generate GitHub App token step - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} +# Every write in the called workflow uses the App token, so GITHUB_TOKEN gets no scope. +permissions: {} - - name: Merge pull request step - run: | - set -euo pipefail - case "${{ github.event.pull_request.base.ref }}" in - develop) method=--squash ;; - main) method=--merge ;; - *) - echo "::error::Unsupported base branch: ${{ github.event.pull_request.base.ref }}" - exit 1 - ;; - esac - gh pr merge --auto --delete-branch "$method" "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - disable-auto-merge-on-maintainer-push: - name: Disable auto-merge on maintainer push job - runs-on: ubuntu-latest - # Fires when a maintainer pushes to a bot's branch (synchronize, actor != bot). Disables auto-merge so the - # maintainer's commits don't merge with the bot's; they re-enable it manually. The disable call is idempotent. - if: >- - github.event.action == 'synchronize' && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login == 'dependabot[bot]' && - github.actor != github.event.pull_request.user.login - permissions: - pull-requests: write - - steps: - - - name: Generate GitHub App token step - # App token because a Dependabot PR's GITHUB_TOKEN is read-only regardless of who triggered the event. - id: app-token - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }} - private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} +jobs: - - name: Disable auto-merge step - run: gh pr merge --disable-auto "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ steps.app-token.outputs.token }} + merge-bot: + name: Merge bot pull request job + uses: ptr727/ProjectTemplate/.github/workflows/merge-bot-task.yml@904096552cea4192d7a1c4db88a347fbb18272e9 # 2.0.718 + secrets: + CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + # A repo with a tracker outside the built-in codegen and upstream-version pairs adds a with: block carrying a rules JSON array of head or head-prefix plus base. + # A repo that keeps the repository-wide auto-delete off and still wants bot branches gone sets delete-branch true in the same block. diff --git a/.github/workflows/publish-docker-readme-task.yml b/.github/workflows/publish-docker-readme-task.yml deleted file mode 100644 index a3a1016..0000000 --- a/.github/workflows/publish-docker-readme-task.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Publish Docker Hub readme task - -on: - workflow_call: - inputs: - # Logical branch this run is for. The overview only updates on `main`; - # the publisher passes the branch so a develop leg is a no-op. - branch: - required: true - type: string - -jobs: - - publish-docker-readme: - name: Publish Docker Hub readme job - runs-on: ubuntu-latest - - steps: - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - # Check out the branch being published so the main leg pushes main's readme regardless of the triggering ref. - ref: ${{ inputs.branch }} - - # Push Docker/README.md as the Docker Hub repository overview, main only (one overview, no per-branch context). - - name: Publish Docker Hub readme step - if: ${{ inputs.branch == 'main' }} - uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - repository: ptr727/vscode-server-dotnetcore - readme-filepath: ./Docker/README.md diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 04ada17..dff626e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,56 +1,75 @@ name: Publish project release action -# Scheduled + on-demand publisher, one run = one branch (the trigger ref). The weekly schedule rebuilds -# `main` only (refreshing the `latest` image for base-image CVEs); a manual dispatch publishes the branch it -# is dispatched from (main => stable/`latest`, develop => prerelease/`develop`). Building only the trigger -# branch keeps github.ref aligned with the branch being versioned, so NBGV classifies it correctly with no -# matrix and no cross-branch ref leak. -# -# Merges do NOT publish: accumulated changes (mostly Dependabot bumps) ship in the next scheduled run, which -# also refreshes the base image (lscr.io/linuxserver/code-server). To release develop, dispatch this workflow -# from the develop branch. CI/validation runs separately on push (test-pull-request); this never runs on push. +# Thin caller: the release chain is the hub's reusable build-release-task.yml, which every release repo reaches rather than carries. +# Merges do not publish: the weekly schedule rebuilds main to pick up base-image updates, and a dispatch publishes the branch it runs from. on: workflow_dispatch: schedule: - # Weekly rebuild/publish of main (Mon 02:00 UTC) to pick up base-image updates. - cron: '0 2 * * MON' -# Global, ref-independent group so two publishes never overlap (a schedule + a manual dispatch, or back-to-back -# dispatches). cancel-in-progress: false so a publish is never cancelled mid-release. concurrency: group: ${{ github.workflow }} cancel-in-progress: false +# GITHUB_TOKEN gets no scope by default, and each job below grants only what its hub task writes with. +permissions: {} + jobs: - # Publish the trigger branch in full (Docker + GitHub release). The schedule always runs on the default - # branch (main); a dispatch runs on whatever branch it is started from. build-release-task validates, - # versions, and tags that one branch; the no-op guard skips release creation when the version is unchanged - # while the Docker push still refreshes the base image. + # Single source of the release-gate decision (publish or not, stable or not), reused by every job below. + plan: + name: Plan release job + uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@904096552cea4192d7a1c4db88a347fbb18272e9 # 2.0.718 + with: + event_name: ${{ github.event_name }} + actor: ${{ github.actor }} + ref_name: ${{ github.ref_name }} + + # The same reusable gate CI runs, on the branch tip, running only when a publish will happen. + validate: + name: Validate job + needs: [plan] + if: ${{ needs.plan.outputs.publish == 'true' }} + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@904096552cea4192d7a1c4db88a347fbb18272e9 # 2.0.718 + permissions: + contents: read + publish: name: Publish project release job - # Only the long-lived branches publish; a stray dispatch from a feature branch is a no-op. - if: ${{ github.ref_name == 'main' || github.ref_name == 'develop' }} - uses: ./.github/workflows/build-release-task.yml - secrets: inherit + needs: [plan, validate] + if: ${{ needs.plan.outputs.publish == 'true' && needs.validate.result == 'success' }} + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@904096552cea4192d7a1c4db88a347fbb18272e9 # 2.0.718 permissions: contents: write + actions: write + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} with: - ref: ${{ github.ref_name }} + ref: ${{ github.sha }} branch: ${{ github.ref_name }} smoke: false github: true dockerhub: true + enable_docker: true + enable_dotnet_publish: false + enable_nuget: false + enable_pypi: false + expect_release_assets: false + docker_image: ptr727/vscode-server-dotnetcore - # main-only: push the Docker Hub repository overview after a successful publish. One overview, no per-branch - # context, so a develop dispatch is a no-op; the task self-gates to main too. - docker-readme: + # The task pushes only for main, so a develop publish leaves the one overview untouched. + publish-docker-readme: name: Publish Docker Hub readme job - needs: [publish] - if: ${{ github.ref_name == 'main' }} - uses: ./.github/workflows/publish-docker-readme-task.yml - secrets: inherit + needs: [plan, publish] + if: ${{ needs.plan.outputs.publish == 'true' && needs.publish.result == 'success' }} + uses: ptr727/ProjectTemplate/.github/workflows/publish-docker-readme-task.yml@904096552cea4192d7a1c4db88a347fbb18272e9 # 2.0.718 permissions: contents: read + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} with: + ref: ${{ github.sha }} branch: ${{ github.ref_name }} + repositories: '["ptr727/vscode-server-dotnetcore"]' diff --git a/.github/workflows/test-pull-request.yml b/.github/workflows/test-pull-request.yml index 40101ad..886b0c1 100644 --- a/.github/workflows/test-pull-request.yml +++ b/.github/workflows/test-pull-request.yml @@ -1,15 +1,8 @@ name: Test pull request action -# CI for every branch. Runs on push so the reusable tasks resolve from the pushed head: a PR that edits a -# workflow tests its own copy. validate (lint) and smoke-build run on every push with no paths filter, so a -# reusable-workflow change is always exercised. The aggregator below is the ruleset required -# status check, produced here on the head SHA. CI never publishes (the publisher runs on schedule/dispatch). -# -# There is deliberately no pull_request trigger: a fork PR cannot push to this repo, so it produces no run -# and cannot satisfy the required check. A maintainer lands such a contribution on an in-repo branch (which -# pushes, and so validates) before merging. This is the documented exception (see WORKFLOW.md). +# Thin caller: the lint gate and the smoke build are the hub's reusable validate-task.yml and build-release-task.yml. +# CI runs on push to every branch with no paths filter, so a pull request tests its own head, and a fork contribution lands on an in-repo branch before merging. on: - # All branches, but not tags: release tags must not re-run CI. push: branches: ['**'] workflow_dispatch: @@ -18,32 +11,43 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: {} + jobs: - # `!github.event.deleted` skips a branch-deletion push (github.sha is all-zeros, so checkout/build fail). - # The same lint gate the publisher runs, so the PR gate and the publish gate are identical. + # A branch-deletion push carries an all-zero SHA that nothing can check out, so every job skips it. validate: name: Validate job if: ${{ !github.event.deleted }} - uses: ./.github/workflows/validate-task.yml + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@904096552cea4192d7a1c4db88a347fbb18272e9 # 2.0.718 + permissions: + contents: read - # Build the Docker image to prove it ships, publishing nothing. Docker smoke is amd64-only and seeds from - # the branch-scoped cache, so it stays fast. + # Never publishes and never pushes: smoke builds linux/amd64 only. smoke-build: name: Smoke build job if: ${{ !github.event.deleted }} - uses: ./.github/workflows/build-release-task.yml - secrets: inherit + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@904096552cea4192d7a1c4db88a347fbb18272e9 # 2.0.718 + permissions: + contents: read + # The task logs in to Docker Hub on every build for the higher rate limit. + secrets: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} with: smoke: true - # Never publish from CI. github: false dockerhub: false branch: ${{ github.ref_name }} + enable_docker: true + enable_dotnet_publish: false + enable_nuget: false + enable_pypi: false + expect_release_assets: false + docker_image: ptr727/vscode-server-dotnetcore - # Single required status check. Its name is the ruleset-bound context in repo-config/ruleset-*.json. Do - # not rename it without updating those in lockstep. Must succeed, not merely not-fail. Skips on a branch - # deletion (when validate/smoke-build also skip), so the deletion push does not fail this required check. + # Required checks cannot bind a conditional job, so this always-run aggregator is the ruleset-bound status check. + # Its name is the ruleset context: rename it and the ruleset together. check-workflow-status: name: Check pull request workflow status job runs-on: ubuntu-latest @@ -52,7 +56,7 @@ jobs: steps: - name: Check workflow results step run: | - set -euo pipefail + set -Eeuo pipefail for result in "validate:${{ needs.validate.result }}" "smoke-build:${{ needs.smoke-build.result }}"; do name="${result%%:*}" value="${result#*:}" diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml deleted file mode 100644 index 237eb9c..0000000 --- a/.github/workflows/validate-task.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Validate task - -# The validation gate: lint the docs and workflows (markdownlint, cspell, actionlint). No unit tests or C# -# formatting - this repo has no compiled code. Reused by test-pull-request (produces the required check) and -# the publish run, so the CI and publish gates are identical. -on: - workflow_call: - inputs: - # Git ref to validate (empty = default checkout ref). The publisher passes its trigger branch so the - # publish run validates the branch it publishes. - ref: - required: false - type: string - default: '' - -jobs: - - lint: - name: Lint job - runs-on: ubuntu-latest - - steps: - - - name: Checkout code step - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.ref }} - - - name: Lint markdown step - uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0 - with: - globs: '**/*.md' - - # Spell-check the user-facing docs; word list + exclusions live in cspell.json (shared with the editor). - - name: Spell check step - uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0 - with: - files: | - README.md - HISTORY.md - incremental_files_only: false - - # Lint workflow YAML (actionlint bundles shellcheck). - - name: Lint workflows step - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 - - - name: Check EditorConfig step - run: docker run --rm -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest diff --git a/.gitignore b/.gitignore index d1c4886..ab39361 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -**/.mount +**/.mount diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 4afb100..e433177 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -1,15 +1,26 @@ -{ - "config": { - // Prose paragraphs and data-heavy tables/URLs are intentionally long; - // reflowing at 80 cols hurts readability and churns diffs. - "MD013": false, - // Inline HTML is used for reference-link section dividers. - "MD033": false, - // Require fenced code blocks over the legacy 4-space-indented style. - "MD046": { "style": "fenced" }, - // MD060 (table column style) is not enforced - allow both compact - // (`|a|b|`) and padded (`| a | b |`) table pipe spacing. - "MD060": false - }, - "gitignore": true -} +{ + "config": { + // Prose paragraphs and data-heavy tables or URLs are intentionally long. + // Reflowing at 80 columns hurts readability and churns diffs. + "MD013": false, + "MD024": { "siblings_only": true }, + // MD033 (inline HTML) stays enabled so native Markdown wins. + // HTML comments, used as reference-link dividers, pass it. + // The details and summary elements are allowed for GitHub collapsibles, which have no Markdown equivalent. + // Every other element still flags. + "MD033": { "allowed_elements": ["details", "summary"] }, + // Require fenced code blocks over the legacy 4-space-indented style. + "MD046": { "style": "fenced" }, + // MD060 (table column style) is not enforced - allow both compact + // (`|a|b|`) and padded (`| a | b |`) table pipe spacing. + "MD060": false + }, + "gitignore": true, + // Declared here rather than only as the CI action's globs input, because the ignores entry below only subtracts, so without a positive glob a run lints nothing and exits clean. + "globs": ["**/*.md"], + // Third-party Markdown under node_modules is not authored prose, and a repository's .gitignore does not reliably exclude it. + // A bare `node_modules` entry would match the root copy only. + // This list is fleet-wide. A repository excluding a subtree of its own adds no entry here, and puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` beside that content instead, which a bare local run and CI both honor. + // Those `ignores` patterns resolve against the directory holding that file rather than against the repository root, so each entry names its target as seen from that directory and a repository-root path matches nothing there, reporting no error saying so. + "ignores": ["**/node_modules/**"] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b322a4c..936b35c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,51 +1,51 @@ -{ - // VS Code tasks. Lint group - the local full doc-lint surface: the same tools and scope as the CI lint set - // (these run Docker at :latest, so versions can drift from CI's pinned actions). Run on demand. Every repo - // carries these. This repo has no compiled language, so there is no build or clean group. - "version": "2.0.0", - "tasks": [ - { - "label": "Lint: EditorConfig", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/check", "-w", "/check", "mstruebing/editorconfig-checker:latest" ], - "problemMatcher": [], - "presentation": { "showReuseMessage": false, "clear": false } - }, - { - "label": "Lint: Workflows", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/repo", "-w", "/repo", "rhysd/actionlint:latest", "-color" ], - "problemMatcher": [], - "presentation": { "showReuseMessage": false, "clear": false } - }, - { - "label": "Lint: Markdown", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "davidanson/markdownlint-cli2:latest", "**/*.md" ], - "problemMatcher": [], - "presentation": { "showReuseMessage": false, "clear": false } - }, - { - "label": "Lint: Spelling", - "type": "process", - "command": "docker", - "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "ghcr.io/streetsidesoftware/cspell:latest", "--no-progress", "README.md", "HISTORY.md" ], - "problemMatcher": [], - "presentation": { "showReuseMessage": false, "clear": false } - }, - { - "label": "Lint: All", - "dependsOrder": "sequence", - "dependsOn": [ - "Lint: EditorConfig", - "Lint: Workflows", - "Lint: Markdown", - "Lint: Spelling" - ], - "problemMatcher": [] - } - ] -} +{ + // VS Code tasks. Lint group - the local full doc-lint surface: the same tools and scope as the CI lint set + // (these run Docker at :latest, so versions can drift from CI's pinned actions). Run on demand. Every repo + // carries these. This repo has no compiled language, so there is no build or clean group. + "version": "2.0.0", + "tasks": [ + { + "label": "Lint: EditorConfig", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/check", "-w", "/check", "mstruebing/editorconfig-checker:latest" ], + "problemMatcher": [], + "presentation": { "showReuseMessage": false, "clear": false } + }, + { + "label": "Lint: Workflows", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/repo", "-w", "/repo", "rhysd/actionlint:latest", "-color" ], + "problemMatcher": [], + "presentation": { "showReuseMessage": false, "clear": false } + }, + { + "label": "Lint: Markdown", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "davidanson/markdownlint-cli2:latest", "**/*.md" ], + "problemMatcher": [], + "presentation": { "showReuseMessage": false, "clear": false } + }, + { + "label": "Lint: Spelling", + "type": "process", + "command": "docker", + "args": [ "run", "--rm", "--pull=always", "-v", "${workspaceFolder}:/workdir", "-w", "/workdir", "ghcr.io/streetsidesoftware/cspell:latest", "--no-progress", "README.md", "HISTORY.md" ], + "problemMatcher": [], + "presentation": { "showReuseMessage": false, "clear": false } + }, + { + "label": "Lint: All", + "dependsOrder": "sequence", + "dependsOn": [ + "Lint: EditorConfig", + "Lint: Workflows", + "Lint: Markdown", + "Lint: Spelling" + ], + "problemMatcher": [] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 55215cd..38be5bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,190 +1,116 @@ -# Instructions for AI Coding Agents - -**VSCode-Server-DotNetCore** is a Docker image of [LinuxServer.io Code-Server](https://github.com/linuxserver/docker-code-server) with the .NET LTS and STS SDKs pre-installed (installed onto the code-server base via `dotnet-install.sh`). It ships a single release target: a multi-arch Docker image (Docker Hub `ptr727/vscode-server-dotnetcore`); consumers pull from Docker Hub on their own cadence. There is no application source in this repo - the Dockerfile is the only build input. - -This file is the canonical reference for cross-cutting AI-agent rules. The CI/CD workflow contract and conventions live in [`WORKFLOW.md`](./WORKFLOW.md); code-style conventions live in [`CODESTYLE.md`](./CODESTYLE.md). Copilot review *mechanics* are owned by [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) - this file delegates them there explicitly (see "PR Review Etiquette" below). High-level summaries in other docs (e.g. README's Contributing section) are allowed when they link back here; don't duplicate the rules themselves. The project's **project-specific conventions** also live here, **not** in `.github/copilot-instructions.md` - that file targets GitHub Copilot / VS Code specifically, while this file is the agent-agnostic one every coding agent reads, so any rule a reviewer must honor has to live here to be provider-independent. - -**Where rules live.** A durable project, code, or style rule belongs in this file (or `WORKFLOW.md` / `CODESTYLE.md` as appropriate), so it is versioned and read by every session and every agent. An agent's own session memory or scratch state is private and lost on restart, so it is never the system of record for a rule: when you learn or are corrected on a rule, write it into the right doc in the same change. Memory may also note it, but the committed docs are the source of truth. - -## Git and Commit Rules - -- **Default to staging, not committing.** Stage changes with `git add` and leave `git commit` to the developer unless the developer has explicitly authorized the agent to commit for the current ask ("commit this", "open a PR", etc.). Authorization is scope-bound - it covers the commits needed for that specific task, not a blanket commit license for the rest of the session. -- **All commits must be cryptographically signed (SSH or GPG).** Branch protection enforces this on both branches; unsigned commits are rejected on push. Signing depends on environment configuration - `git config commit.gpgsign true`, a configured `user.signingkey`, and a working signing agent (loaded `ssh-agent` for SSH, or `gpg-agent` for GPG). If signing is not configured in the environment, **do not commit** - surface the missing config to the developer and stop at `git add`. Verify before any agent-authored commit (`git config --get commit.gpgsign && ssh-add -L` or the GPG equivalent). **Signing must be live before the *first* commit, not retrofitted.** Turning on `Require signed commits` against a branch that already has unsigned commits forces a rewrite of that entire history to re-sign it - changing every commit SHA and making whoever does the rewrite the committer and signer of every commit (a rebase preserves the `author` field but not the original signatures; you cannot sign another contributor's commits for them). During new-repo setup, never create commits until signing is verified. -- **Commit under the committing account's own GitHub `noreply` identity - never a private, personal, or invented address.** The `author` and `committer` on every agent-authored commit are the GitHub `noreply` address of the account whose key signs the commit (above) - GitHub issues these in a `username@users.noreply.github.com` or `ID+username@users.noreply.github.com` form, and for this single-maintainer fleet it is the owner's `ptr727@users.noreply.github.com`. Do not set `user.name`/`user.email` to a fabricated persona, bot name, or product name, and do not commit under whatever identity the environment happens to carry: verify `git config --get user.email` is that GitHub `noreply` address before committing, and fix it if not. A wrong identity is not cosmetic - a private email trips GitHub's email-privacy push protection (GH007), and an unrecognized or invented author pollutes history. Identity is separate from signing: a wrong author does not by itself fail the signature rule, but the ad-hoc identities that produce it are typically also unsigned, which the signing rule above then rejects on push. -- **Never force push.** Do not run `git push --force` or `git push --force-with-lease` under any circumstances. Force pushing rewrites shared history and can cause data loss. -- **Never run destructive git commands** (`git reset --hard`, `git checkout .`, `git restore .`, `git clean -f`) without explicit developer instruction. - -### Git and Commit Rules - Repo-Specific Notes - -- **The `develop -> main` release merge is maintainer-only.** Drive `feature -> develop` PRs end-to-end when authorized (commit, push, Copilot review loop, squash-merge), but never self-merge a release to `main`. - -## Branching Model - -- `develop` is the integration branch. Feature branches -> `develop` is **squash-only**; develop is kept linear. -- `develop` -> `main` is **merge-commit only** (no squash, no rebase). Merge commits preserve develop's commit list as a real second-parent reference on main, which lets the release model attribute releases to the develop commits that produced them (relevant to the weekly publish - see "Release Model" below). Branch protection enforces this: the develop ruleset allows only `squash`, the main ruleset allows only `merge`. -- All commits on both branches must be cryptographically signed (SSH or GPG). Squash and merge commits created via the GitHub UI are signed by GitHub's web-flow key. -- **`develop` is forward-only - no `main -> develop` back-merges.** The develop ruleset's squash-only setting physically blocks merge commits on develop. Historical back-merge commits visible in `git log` predate this rule and must not be repeated. -- **Both rulesets intentionally omit "Require branches to be up to date before merging" (`strict_required_status_checks_policy: false`), for two distinct reasons:** - - *Main* - the check is graph-based; it asks whether main's tip commit is reachable from develop, not whether the two branches have the same content. After any develop -> main release, main's tip is a brand-new merge commit that develop's history doesn't contain. Forward-only develop never adds it (no back-merge of main into develop), so the check would fail on every subsequent release. - - *Develop* - bot auto-merge incompatibility. When two bot PRs against develop land in the same minute (e.g. two grouped Dependabot PRs from the same daily run), the first to merge pushes the second into `mergeStateStatus: BEHIND`. GitHub's auto-merge will not fire while the strict flag is on, and nothing in the workflow set auto-updates a bot branch in that window - the merge-bot enables auto-merge via `gh pr merge --auto` but never rebases a stalled branch onto base (see [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)). Real file-level conflicts are still caught textually (`mergeable: CONFLICTING` blocks merge regardless); semantic-but-not-textual conflicts that combine cleanly are caught by the post-merge develop CI run rather than pre-merge. Do not reintroduce the strict flag on develop thinking it's hygiene - it breaks bot auto-merge. -- **Dependabot targets both `main` and `develop` in parallel.** [`.github/dependabot.yml`](./.github/dependabot.yml) duplicates every ecosystem entry (one per branch). Each branch absorbs its own bot PRs independently, so neither falls behind, and the forward-only rule still holds (nothing is back-merged from main to develop - both branches receive their updates directly). Parallel auto-merge across same-batch bot PRs is race-proof only because both rulesets have the strict "up to date" flag off (see bullet above). The merge-bot ([`.github/workflows/merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml)) dispatches `--squash` or `--merge` from each PR's base ref via a `case` statement so the form matches the ruleset on either base. Dependabot **security** PRs (CVE-driven) always open against the repo default branch (`main`) regardless of `target-branch` - the same `case` statement covers them. This repo has only the `github-actions` ecosystem (no compiled code), and every Dependabot bump auto-merges on green regardless of tier (semver-major included) - the required checks are the gate, not the version bump. -- **Maintainer-pushed commits on a bot PR auto-disable auto-merge.** The merge-bot's `merge-dependabot` job only fires on `opened` / `reopened` events (auto-merge is enabled exactly once per PR, for Dependabot-authored PRs that originate from this repository, not forks). When a maintainer pushes commits to the bot's branch (a `synchronize` event with a non-bot actor), the `disable-auto-merge-on-maintainer-push` job fires and calls `gh pr merge --disable-auto`; the maintainer's commits stay in the PR but won't auto-merge with the bot's content. Re-enable manually (`gh pr merge --auto <PR>`) when ready. The merge-bot is on `pull_request_target` with per-PR concurrency; it carries only `merge-dependabot` + `disable-auto-merge-on-maintainer-push`. -- **App-token workflows use Client ID, not App ID.** `actions/create-github-app-token` deprecated the numeric `app-id` input in v3.0.0; the merge-bot uses `client-id: ${{ secrets.CODEGEN_APP_CLIENT_ID }}` (with `private-key: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }}`). The App token - not `GITHUB_TOKEN` - is required so the merge push is committed by the App and fires downstream workflows (`GITHUB_TOKEN` pushes are blocked from triggering further runs by GitHub's recursion guard). When adding new App-token call sites, use the same form - do not reintroduce `app-id`. -- **Why parallel dual-target rather than develop-only with eventual flow-through:** consumers pull the Docker image from `main` directly. A develop-only model would leave `main` running stale code during long-running develop features, so both branches receive their own bot updates on their own cadence and each stays current. -- **Mirror to `develop` any change that lands on `main` outside the feature -> develop -> main flow.** "Mirror" means landing the same fix directly on `develop` via a follow-up PR targeting `develop` - never a `main -> develop` back-merge, which the forward-only rule forbids. A reconciliation-branch fix made to resolve a `develop -> main` promotion conflict, or a security PR that merges only to `main`, leaves `develop` behind on that content - and forward-only `develop` never back-merges to catch up (the same parallel-target principle as the bots). Before basing new work on `develop`, or diagnosing a defect from it, compare content and not commit history: run `git diff origin/main origin/develop` and inspect its `-` lines - the `main`-side of each difference, to check for staleness. A `-`/`+` pair within one hunk is usually just `develop` modifying that code as normal unpromoted work (occasionally `develop` is reworking a `main`-side fix differently - worth a glance). The stronger staleness signal is a deletion-only hunk (`-` lines, no `+` lines): content on `main` that `develop` lacks entirely, i.e. a `main`-only fix `develop` never received, so the defect may already be fixed on `main`. Prefer this over a commit-log check like `git log origin/develop..origin/main`, which is noisy here because it also lists routine promotion merges and the `main`-direct bot commits whose content `develop` already carries via its own parallel bot PRs. -- **Put issue-closing keywords (`Closes #N`) where they fire on merge to the default branch (`main`).** GitHub closes an issue from a *PR description* only when that PR merges to `main`, so a `Closes #N` in a PR that targets `develop` never fires - put it in the `develop -> main` promotion PR instead. A closing keyword in a *commit message* does close the issue once that commit reaches `main` via promotion, but that is fragile across squash-merges, so prefer the promotion PR's description or close the issue manually once the fix lands on `main`. - -## Release Model - -The publish behavior - the **scheduled + on-demand** publisher (one branch per run: the weekly schedule rebuilds `main`, and a dispatch publishes the branch it is started from - a multi-arch Docker image + a GitHub release that anchors the version), branch-scoped versioning (`main` = stable / `latest`, `develop` = prerelease / `develop`), and the rule that **merges do not publish** (changes accumulate and ship in the next scheduled run, which also refreshes the `lscr.io/linuxserver/code-server` base image; release `develop` by dispatching from `develop`) - is specified in [`WORKFLOW.md`](./WORKFLOW.md), the canonical CI/CD guide. Do not duplicate those rules here. - -Versioning is the one release rule that is a **human process**, not a workflow outcome, so it lives here: - -- The `version` (major.minor) in [`version.json`](./version.json) is the version floor; NBGV appends the git height as the SemVer patch. `main` (the public release ref, `publicReleaseRefSpec = ^refs/heads/main$`) builds a stable `X.Y.<height>`; `develop` builds a prerelease `X.Y.<height>-g<sha>`. NBGV needs only `version.json` and git history, so it versions correctly although the repo builds no .NET assembly. The maintainer edits `version.json`; *routine* dependency bumps, CI/workflow fixes, and doc edits leave it untouched. -- **Bump `version.json` only by maintainer instruction**, for a functional change (a new feature, a behavior change, a breaking change) or a significant one-time overhaul of the build/release process (such as a CI/CD migration), in the PR that introduces it (typically on `develop`). Do not bump on a cadence, for routine CI/workflow or dependency or doc edits, or mechanically after a release. -- **No post-release bump; no develop-ahead requirement.** NBGV advances the patch on every commit, so a release always gets a fresh build version with no `version.json` edit and there is no `bump-version-X.Y` PR after a release. A `develop -> main` promotion carries whatever `version.json` is current. -- **`dotnet/nbgv` is consumed via `@master`, never SHA-pinned.** Its tag stream lags `master` such that Dependabot tag-tracking would only propose downgrades to stale tags; this is the sole WORKFLOW.md D9.1 exception (rationale inline in the workflow). Do not SHA-pin it. - -## Pull Request Title and Commit Message Conventions - -### Format - -- Imperative subject summarizing the change, <=72 characters, no trailing period. ("Add 24-hour PM2.5 average sensor", not "Added X" or "Adds X".) -- Optional body, blank-line separated, explaining *why* the change is being made when that's non-obvious. The diff shows *what*. - -### Rules - -- Don't write `update stuff`, `wip`, or other vague titles. (Dependabot's default `Bump X from Y to Z` titles are fine - keep them.) -- Don't add `Co-Authored-By:` lines unless the developer explicitly asks. -- Don't put release-bump magnitude in the title - no "minor", "patch", "release v0.2.0", etc. Nerdbank.GitVersioning computes the next release version from `version.json` + git history. Dependency versions in dependency-bump titles are fine and expected. -- Use US English spelling and match the existing heading style of the file you're editing: title case with lowercase short bind words (a, an, the, and, but, or, of, in, on, at, to, by, for, from); hyphenated compounds capitalize both parts unless the second is a short preposition (*Built-in*, *EPA-Corrected*, *24-Hour*). - -### Examples - -```text -Install the .NET STS SDK alongside LTS -Pin softprops/action-gh-release to commit SHA -Refresh the LinuxServer.io code-server base image -Bump docker/build-push-action from 6.19.2 to 7.2.0 -Clarify container usage in README -``` - -## Documentation Style Conventions - -### Markdown - -- Use reference-style links for any URL referenced more than once or appearing in lists; alphabetize the reference definitions block. -- Inline single-use relative links (e.g. `[CODESTYLE.md](./CODESTYLE.md)`) are fine. -- One logical paragraph per line; no hard-wrap line-length limit. For an intentional hard line break within a block - stacked badges, status, or license lines - end the line with a trailing backslash (`\`); this explicit form is preferred over trailing whitespace and is not treated as a paragraph split. -- Headings follow the title-case-with-short-bind-words rule from the PR-title section. -- **Write docs in the current state, not as a change from a prior one.** The reader has no memory of the previous behavior, so describe what *is*: "X does Y", never "X *now* does Y", "X *no longer* does Z", or "changed/switched/restored to Y". Before/after framing belongs in changelogs, commit messages, and PR descriptions - not in `README.md` or other living docs. - -### Comments - -Applies to code and workflow (`#`) comments alike. - -- Comment only when the code is non-obvious or important. Self-evident code needs no comment. -- Judge "obvious" in context, not line by line. A note that reads as redundant on its own line can be essential in the larger flow - a comment marking a workflow step's exit condition, for example, even though the line itself plainly does a `return` or `exit`. -- State the non-obvious *why*, not what the code already shows. No cross-project references (do not name other repos), no historic or design narrative, no rule citations - governance lives in this file, not echoed inline. -- **One line if it fits in ~120 columns.** Do not wrap a comment at 75-80 columns; a short two-line comment that would fit on one line looks sloppy - collapse it. Go multi-line only when the content genuinely exceeds ~120, filling each line rather than narrow-wrapping. For a multi-point comment, prefer short structured lines or `-` bullets over one prose paragraph. -- **Workflows: prefer one short summary description at the top of the file** over scattering rationale across steps; comment an individual step only when its purpose is non-obvious. -- **Do not accumulate comments.** When you change code or a comment, rewrite the whole comment fresh; never bolt a new comment onto an existing one or layer explanations across edits. Comment volume should stay flat or shrink over time, not grow. -- **Leave human-authored comments and emojis exactly as written** - do not reword, trim, reflow, or "clean" them, even if they seem to bend a rule. Revise only agent-authored comments, and match the surrounding voice when you do. - -### Character Set - -- **Write ASCII in all agent-authored text** - documentation, code, comments, commit messages, and PR descriptions. The agent does not introduce non-ASCII characters. Replace typographic Unicode with its ASCII equivalent on sight: - - em dash (U+2014) and en dash (U+2013) -> hyphen `-` (use a spaced ` - ` for an em-dash-style clause break) - - right arrow (U+2192) -> `->`; double arrow (U+21D2) -> `=>` - - less-than-or-equal (U+2264) -> `<=`; greater-than-or-equal (U+2265) -> `>=` - - curly quotes (U+2018/U+2019/U+201C/U+201D) -> straight `'` and `"`; ellipsis (U+2026) -> `...` -- **Allowed non-ASCII (two narrow exceptions):** - - **Scientific or technical symbols with no clean ASCII equivalent** - e.g. ohm, micro, degree, pi. Keep the symbol; do not approximate it away. - - **Unicode the developer deliberately typed** - emoji used for emphasis or as callout markers (for example the warning/info markers a maintainer placed in `README.md`). Preserve it; never strip the developer's own characters. This carve-out is for developer-authored text, not a license for the agent to add emoji. - -### Line Endings - -- [`.editorconfig`](./.editorconfig) defines the correct ending per file type (CRLF for `.md`, XML/`.csproj`/`.props`, non-workflow `.yml`/`.yaml`, `.json`, `.cmd`/`.bat`/`.ps1`; LF for `.sh`, `Dockerfile`, and workflow YAML), and [`.gitattributes`](./.gitattributes) (`* -text`) stops git from normalizing. Carry both files whole even where a rule covers a file type this repo does not ship (the `[*.cs]` block is inert without `.cs` files) so a future re-sync stays a clean whole-file overwrite. -- **Workflow YAML (`.github/workflows/*.{yml,yaml}`) is pinned LF** in [`.editorconfig`](./.editorconfig) - Dependabot and Actions rewrite it with LF, so declaring LF keeps it consistent instead of mixed on every bump; other YAML stays CRLF. git still leaves endings alone (`* -text`), so CI enforces it: the `Check EditorConfig step` in [`validate-task.yml`](./.github/workflows/validate-task.yml) runs editorconfig-checker (config in [`.editorconfig-checker.json`](./.editorconfig-checker.json), EOL-only). -- **Editing an existing file: preserve its current line endings** - do not reflow them as a side effect of a content change, even if the file is already non-compliant. After any programmatic edit, verify with `git diff --stat` (only changed lines) and `file <path>` (expected ending). Bring a non-compliant file to its `.editorconfig` ending only as a deliberate, isolated EOL-only change. - -### Quantitative Claims - -- Any quantitative claim in `README.md` (counts, sizes, version floors, supported platforms) must be verified against current code. If a doc number is derived from a code constant, mark the dependency in a source-code comment so the next editor knows to update both. - -## PR Review Etiquette - -> This "PR Review Etiquette" section is the provider-agnostic review-loop *contract*; the [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook" implements its mechanics. Without both in-repo, an agent has no pointer to the reliable Copilot mechanics and falls back to ad-hoc (and known-broken) behavior. - -The repo runs a review loop on every PR: local agent iteration plus remote automated review (GitHub Copilot is the configured reviewer). Treat this as a contract regardless of which local agent authored the changes. - -### Merge Gate (read this first) - -**Do not merge - and do not enable auto-merge - unless ALL of these hold:** - -1. Required status checks are green (`mergeStateStatus: CLEAN`), **and** -2. A Copilot review is confirmed on the **current head SHA** (not an earlier push), **and** -3. **Every** Copilot finding on that head SHA is closed out - all review threads resolved, **and** any issue-level Copilot comments (which have no resolve action) triaged and replied to - so zero outstanding findings remain, **and** -4. The maintainer has given **explicit** permission to merge. - -`mergeStateStatus: CLEAN` reflects **only** required statuses - it never reflects open bot review comments, so `CLEAN` alone is **never** sufficient to merge. A green/`CLEAN` PR with an unresolved Copilot finding fails this gate; treat it as "not mergeable" no matter what the merge-state field says. The agent never merges on its own (consistent with "default to staging"; merging is maintainer-authorized). - -**Merging is not releasing.** A merge to a release branch does **not** by itself publish; publishing is a separate, explicitly configured step in the repo's release pipeline (e.g. a scheduled run, a manual dispatch, or an opted-in publish-on-merge trigger), not an automatic consequence of merging. Never describe a merge as cutting a release, and never trigger a publish without explicit maintainer instruction. - -### Expected Review Loop - -1. Push changes to the PR branch. -2. Re-request a review for the **current head SHA**. Auto-trigger is unreliable, so request it explicitly via the `requestReviews` GraphQL mutation (now reliable end-to-end - see the runbook); the UI is only a fallback. -3. Wait for review activity on that head. A completed review that raises **no findings** is a valid terminal outcome for that head - proceed; do not re-trigger it or treat the absence of comments as a missing review. -4. Triage findings. -5. Apply fixes or write a rationale for declines. -6. Reply to each thread and resolve what was addressed. -7. Re-run the loop after every fix push until no actionable findings remain. - -Drive the loop to green - review confirmed on the latest head SHA and every actionable finding closed - then stop and apply the **Merge Gate** above: all four preconditions must hold, and `mergeStateStatus: CLEAN` alone never satisfies it. - -For provider-specific mechanics (how to request review, query review state, post replies, resolve threads), see the **GitHub Copilot Review Runbook** in [.github/copilot-instructions.md](./.github/copilot-instructions.md). This file owns the contract; that file owns the mechanics. - -### Triaging Review Comments - -For each comment, classify before responding: - -- **Bug** - wrong behavior, missing test coverage, or a real divergence between code and docs. Fix it. Reply with the fixing commit SHA when done. -- **Style/convention** - the comment cites a rule from this file or a language-specific style guide. Two cases: - - The cited rule matches what the existing codebase already does -> fix the offending code. - - The cited rule contradicts what's in the tree, or industry norm -> **update the rule instead of the code**. The rule is wrong, not the code. Bouncing the same code across rounds is the symptom of a wrong rule. Heuristic: three rounds on the same style category means the rule needs adjusting and the user should authorize the rule change. -- **Architectural opinion** - the comment proposes a different design ("constrain this to disabled-by-default", "move it elsewhere", "add a runtime guardrail"). This is judgment, not a bug. Surface it to the user with a recommendation; don't apply unilaterally. - -### Responding and Resolution Expectations - -Reply inline with either the fixing commit SHA (for accepted issues) or a concise rationale (for declines). Resolve review threads when addressed or intentionally declined with rationale. Issue-level comments (those at `repos/.../issues/<N>/comments` rather than tied to a specific line) have no resolution action - acknowledge with a reply if needed and move on. - -After the final push on a PR, sweep older threads from earlier rounds whose code paths no longer exist; otherwise stale unresolved markers remain in the review UI. - -### Escalating to the User - -Bring the user in when: - -- **Genuine design trade-off** surfaces (fail-open vs fail-closed, narrow vs broad refactor scope, "should we add a guardrail or trust the docstring"). Triage, recommend, ask. -- **Repeated friction** across rounds without convergence - that's the rule-needs-updating signal. Stop, summarize the pattern, and let the user authorize the rule change. -- **Architectural redesign** is requested rather than a bug fix. Surface with a recommendation; never apply unilaterally. - -Anti-pattern: don't keep flipping the code on the same style point. Flip the rule once and stick to the rule. - -## Shared Configuration and Tooling - -- **Config files.** [`.editorconfig`](./.editorconfig) (per-file-type EOL plus the inert C# / ReSharper style block), [`.gitattributes`](./.gitattributes) (`* -text`, with the `*.sh` and `Dockerfile` LF pins), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), and [`CODESTYLE.md`](./CODESTYLE.md) hold the repo's formatting, linting, and code-style rules. Keep [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) narrow (Copilot / VS Code review mechanics plus the commit/PR-title summary); project-specific conventions live in this file. -- **Spell check.** The cspell word list and path exclusions live in [`cspell.json`](./cspell.json), the single source shared by the editor and CI. Do not keep a parallel word list in the `.code-workspace` file. -- **Release notes.** Keep a short summary in [`README.md`](./README.md) and the full history in [`HISTORY.md`](./HISTORY.md); update both when cutting a release. - -## Workflow YAML Conventions - -The conventions for everything under [`.github/workflows/`](./.github/workflows/) - action pinning, file/workflow/job/step naming, concurrency, shells, conditionals, boolean inputs, permissions, artifact handling, Docker layer cache, and release tagging - are specified in [`WORKFLOW.md`](./WORKFLOW.md), the canonical CI/CD guide. New and modified workflows must respect it; do not duplicate those rules here. - -## Project Structure - -- **`Dockerfile`** - the only build input. Layers the .NET LTS and STS SDKs onto the `lscr.io/linuxserver/code-server:latest` base via `dotnet-install.sh` (installed to `/usr/share/dotnet`), built multi-arch (`linux/amd64` + `linux/arm64`). Takes a single `LABEL_VERSION` build arg; nothing is compiled from local source. -- **`version.json`** - the NBGV version floor (major.minor) and `publicReleaseRefSpec`. NBGV derives the build/tag version from this plus git height; no .NET assembly is produced. -- **Docs** - [`README.md`](./README.md) (also the Docker Hub overview), [`HISTORY.md`](./HISTORY.md) (release history), this file, [`WORKFLOW.md`](./WORKFLOW.md) (CI/CD contract), [`CODESTYLE.md`](./CODESTYLE.md) (code style), and [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) (Copilot review runbook). -- **`.github/workflows/`** - the branch-scoped CI/CD pipeline (see [`WORKFLOW.md`](./WORKFLOW.md)); **`repo-config/`** - the branch rulesets, settings, and secret audit as code. -- **Config** - [`.editorconfig`](./.editorconfig), [`.gitattributes`](./.gitattributes), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), [`cspell.json`](./cspell.json), and the [`.code-workspace`](./VSCode-Server-DotNetCore.code-workspace). +# Instructions for AI Coding Agents + +**VSCode-Server-DotNetCore** is a Docker image of [LinuxServer.io Code-Server](https://github.com/linuxserver/docker-code-server) with the .NET LTS and STS SDKs pre-installed onto the code-server base with `dotnet-install.sh`. It ships a single release target, a multi-arch Docker image published to Docker Hub as `ptr727/vscode-server-dotnetcore`, which consumers pull on their own cadence. There is no application source in this repo, and the [`Docker/Dockerfile`](./Docker/Dockerfile) is the only build input. + +Past that description, the sections below are the only three things this file holds: the bootstrap that says where the canonical rules live and which procedure to follow for the state this repository is actually in, the rules for managing context and delegation, which apply to every task, and a map of where every other rule lives. The rule text itself is in [`GOVERNANCE.md`](./GOVERNANCE.md), one section per topic. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), and how this repository is run day to day, its release and publish specifics included, in [`OPERATIONS.md`](./OPERATIONS.md). + +Treat this file and `GOVERNANCE.md` as authoritative for cross-cutting rules, and do not restate their rules elsewhere. This repository's own conventions live in its topical docs, never in [`.github/copilot-instructions.md`](./.github/copilot-instructions.md), which targets GitHub Copilot and VS Code specifically. An undeclared section here is drift to reconcile rather than a local liberty. + +## Fleet Bootstrap + +This repository is governed by a shared template, and the canonical rules, machine-readable spec, and procedures live in `github.com/ptr727/ProjectTemplate`, the repository these rules call the hub. Fetch that repository before acting on anything about conformance, carried content, repository settings, or standing a repository up, because a carried copy here can be stale or absent and the hub is the only authority on what this repository is supposed to hold. This section is byte-locked across every repository in the fleet, so it reads identically wherever it is found, and it is the entry point whenever nothing else present says where the rules are. + +Route by what this repository currently holds rather than by what it is expected to hold, since the two differ exactly when this section matters most. + +```mermaid +flowchart TD + state["what does this repository currently hold?"] + state -->|"no repo, or a local tree with no remote"| standup["hub STANDUP.md, from section 0"] + state -->|"no carried instruction set, or a partial one"| standup2["hub STANDUP.md sections 1A, 2"] + state -->|"instruction set present, current or stale"| resync["hub RESYNC.md"] + state -->|"believes it is conformant"| resync2["hub RESYNC.md, run the audit anyway"] +``` + +- **No repository yet, or a local tree with no remote.** Follow the hub's `STANDUP.md` from section 0. That file is hub-only and deliberately not carried, because a repository needing it cannot be relied on to hold a current copy. Note that nothing in it creates the GitHub repository, which is an outward-facing write requiring explicit permission, so section 0A is the list handed to the maintainer before anything else starts. +- **A repository with no carried instruction set, or a partial one.** Carry the baseline per the hub's `STANDUP.md` sections 1A and 2, which resolve what this repository is owed from its declared types and workflow model. Absent files are not drift to re-vendor, they are a baseline that never arrived, and the two are fixed differently. +- **A repository with the instruction set, current or stale.** Follow the hub's `RESYNC.md`, which runs `AUDIT.md` end to end for the findings and then applies each one in an order that matters, since the rules govern what comes after them, a deletion must precede the re-vendor that would otherwise refresh the file, and only some findings are mechanically detectable at all. An audit that reports drift and stops is half the procedure. +- **A repository that believes it is conformant.** Run the audit anyway, because conformance asserted without a report is conformance nobody can check. The hub commits that report under its own `reports/`, since a report written by the repository it measures is a claim rather than evidence, so a session in the repository being audited fixes its own drift in its own repository, files its findings about the hub as issues, and leaves the report to a hub-side audit rather than opening a hub pull request to write its own. This is the same procedure as the case above and is listed separately only because it is the one most often skipped. + +Three rules bound every path above. **Read the hub's `main` branch as ground truth**, since that is the promoted and gated state, and read `develop` only to detect divergence. **Reach the hub as a checkout of your own and fetch it immediately before reading it**, because a clone is whatever it last fetched rather than the branch it names, and work only in that checkout rather than in one that another task is using, per [`GOVERNANCE.md`](./GOVERNANCE.md) "Repository Boundaries and Write Safety" and "Hub-Hosted Tooling". And **the audit is read-only**: it produces a report and never edits the repository it measures, so a fix is a separate, reviewable change. + +## Context and Delegation Discipline + +An agent session is billed on the context it carries, not the work it does. Every request re-reads the whole accumulated context, so a token added early is paid for again on every request after it. A long session therefore bills its last task for every earlier one. Most of these are cost rules. None of these rules licenses doing less work, skipping verification, or shipping something unreviewed. + +### Session Scope + +- **One deliverable, one session.** A session covers one branch and one deliverable, and ends when that work merges. A multi-step task is one deliverable and stays in one session. Two unrelated tasks are two sessions even when they run back to back. +- **End a session at any of these, without being asked:** the branch changes, the pull request merges, or the next task is unrelated to the last. A review round is none of them. A loop still producing findings is the deliverable in progress, and a round count is not a reason to leave one open. +- **A session orchestrating dispatched work is an exception, and a narrow one.** Its deliverable is the run rather than any branch, so it spans many branches and many merges by construction, and ending it at the first dispatched merge would end the run. The triggers above land on each dispatched task instead, one branch and one deliverable each, which is this rule applied rather than waived. What keeps the exception narrow is that such a session holds no branch of its own and authors none of the work it dispatches, so the file context every other session accumulates is context it never takes on, and it re-derives each round's state from live sources rather than holding it, per "Re-derive state, do not carry it" below. A session that starts editing the files a dispatched task would have edited is an ordinary one again and ends on the triggers above. +- **Hand off in an issue, never in a scratch file and never in context.** Close a session by filing the next link in the handoff chain of the repository holding the work the next session resumes, which for a session that stayed in one repository is this one, and a session that spanned several names the others in the handoff's state section. A track is a lane of work named by a short slug, `default` where a session names none, and a track in use holds exactly one open issue carrying the `handoff` label. The track and the predecessor are recorded in the issue body rather than in its title, so a retitled or hand-edited issue still chains and a reader can tell which lane an open issue belongs to. The new link names its predecessor that way, a forward-link comment then goes onto that predecessor, and the predecessor is closed last, in that order, so a failure part way leaves a discoverable new issue rather than a closed chain with no successor. Each of those three is an outward-facing write, so the write-safety rules in `GOVERNANCE.md` "Repository Boundaries and Write Safety" bind all three exactly as they bind any other write, the identifier rule most of all, since the link a comment and a close target is read live in the same run rather than remembered. The chain needs the `handoff` label to be findable at all, so a repository not carrying it cannot host one until the fleet label set is applied there, which is a change to that repository's configuration rather than anything a handoff writes. A session that cannot file a link reports that it could not hand off and leaves the previous link open, whether it is stopped by a repository carrying no such label, by a write it may not make, or by anything else. That is the one alternative this rule allows to a track still in use, and it is a report rather than a file, because a report says the round's record is missing while a file claims to be it. A track whose work is complete is closed out instead, its last link carrying the outcome as a comment and closed with no successor, which leaves the track no longer in use rather than breaking its chain. A scratch file fails three ways the chain closes. It is not found where the next session looks. More than one candidate is found and nothing says which is current. And it holds no history, so a later round re-runs a path an earlier round already tried and already wrote down, which is the one thing a handoff exists to prevent. The handoff carries the next steps in priority order, the external blockers and internal dependencies among them, the state a resume re-reads rather than trusts, listed so the resume knows what to re-read, the account of parked decisions that `GOVERNANCE.md` "Communicating with the User" requires, what the last round did, what not to repeat, and what was learned. That section states the account whole, and it requires the session to present those decisions as well as record them. **The size rule is stated per section.** An entry earns its place by being specific enough to change a later session's behavior, a section ranks what it keeps and drops whatever does not meet that bar, and what belongs somewhere durable goes there and appears here as one line and a pointer, a defect as an issue, a rule as rule text, a lesson as governance prose. The parked-decision account keeps the count and the ranked questions one round can carry, and names every issue past those by number alone, which is what keeps a queue larger than one round inside this rule. A summary held in context is re-billed until the session ends, a scratch file is read only on the machine holding it, and a closed link stays readable from any machine to every session after it. +- **Re-derive state, do not carry it.** "This session already has the context" is the signal to split, not to continue. Context that has gone stale is worse than absent, because a file read hundreds of requests ago no longer describes the file. +- **Compaction is a fallback, not the strategy.** It restarts context from a floor and climbs again, where a fresh session starts from zero. + +### Reading + +- **Map a large file, then read one range.** For anything over about 200 lines, list the headings with `grep -n '^## '` first and read only the range the task needs. Read the section, not the file that contains it. +- **Prefer an in-place edit to a whole-file rewrite.** Rewriting a file bills its full content again on top of what the read already cost. + +### Commands + +- **Bound output at the source.** Write every command so its output is the answer, not the haystack: a `--jq` projection on an API call, a count or files-only flag on a search, a summary flag on a diff, an explicit cap on anything unbounded. A command whose output you then skim is a command that should have been narrower. +- **Keep a long query in a file, not in the command.** A heredoc re-typed on every call costs its own length in context each time, often more than the answer it retrieves. +- **Keep generated caches outside the checkout when the executor restricts writes.** Give each task a cache directory under a writable temporary root. Point tools such as uv and ruff there through their own cache variables. Never repurpose `HOME` or an agent's configuration directory to make a tool run. +- **Report an execution boundary separately from a check finding.** A denied path, network request, or Docker socket says the check did not run. Preserve that failure, then use the executor's approval mechanism for the required rerun. Request the narrowest reusable command prefix the executor supports. Report the rerun's result as the verification evidence. + +### Delegation + +- **Delegate exploration, keep judgment.** A subagent starts from an empty context and returns only its conclusion, so a wide search, a multi-file audit, or a "which of these is affected" question costs a fraction of the same work inline. Delegate when the finding compresses to a short answer, and stay inline when the intermediate detail drives the next edit. +- **Match the model tier to the judgment, not to the diff size.** Mechanical work (a known-shape edit repeated across files, an extraction, a status check, a lint fix) runs on the cheapest model that does it correctly, at the lowest reasoning effort that holds. State the tier in the delegation itself rather than accepting the default. A change to a gate, a ruleset, a release condition, or a carried governance section is a design change however small it looks. +- **Never tier down the seat holding the judgment.** Governance wording, spec logic, rulesets, repository visibility, and the decision to decline a review finding are fleet-wide and durable when wrong. Tier the subagents, not the main thread. +- **Brief a subagent so it never needs a governance file.** A subagent inherits no context, so anything it must honor has to be in its prompt. Reading `GOVERNANCE.md` to find out costs it the same tokens the main thread would have paid. Brief on this shape: + +```text +Task: <the one question or edit, stated so the answer compresses> +Paths: <exact files or globs - never "find the relevant files"> +Rules that bind this task: <the specific rules, quoted, not a pointer to a doc> +Return: <the shape of the answer - a list, a diff, a yes/no with evidence> +Bounds: <what not to touch, and what to do when a rule looks incomplete> +If a rule you were given does not cover what you find, stop and report it. Do not guess, and do not read a governance file to resolve it. +``` + +- **Wait in a background process, not in a poll loop.** A review or CI wait is a sequence of near-identical requests, each billed for whatever context it happens to carry. Run the wait as one backgrounded command rather than as a sequence of turns. +- **A wait separates three outcomes, and says which one it reached.** The condition was met, it has not been met yet, and the wait cannot reach it at all are three different results, and a backgrounded wait that emits nothing renders all three identically. Run the command once in the foreground and read its output before backgrounding it, because a wait is only as good as the command inside it, and an unsupported flag on the installed tool version exits non-zero with an empty stdout that every naive test reads as "nothing yet". Never let a fallback stand in for a failed command, since `|| echo '[]'`, `|| true`, and `2>/dev/null` convert an error into that same reading, which is the suppression the write-safety rules already forbid on a mutation. Make the wait emit on failure as loudly as on success, so silence means "still running" and nothing else, and bound it, so a condition that is never coming ends in a report rather than in another wait. +- **Never write a wait as an unbounded shell loop.** This is a prohibition rather than a preference. A loop that waits for something, with no bound anywhere in the command that runs it, is forbidden. That holds in a tool call, in a script, and in a brief handed to a subagent. The bound goes inside that command. The wait then says which of three things it found: the condition met, the bound reached, or the check itself failing. **Prefer the mechanism that already signals.** Where the dispatch mechanism reports a subagent's completion itself, polling that subagent's output file is a second channel. The answer is already on its way. **The agent that starts a wait owns the process it leaves.** A process a tool call leaves running survives the turn, the subagent, and the run that dispatched it. So a run that dispatched workers does not report itself done while it cannot say what it left running. + +## Where the Rules Live + +Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md) unless its row says otherwise. Read the section the task needs. + +| Working on | Section | +| --- | --- | +| Why the rules are shaped this way | `Foundational Principles` | +| Recording a durable lesson, updating governance, or work here waiting on a fix in another repository | `Durable Knowledge and Self-Improvement`, surfaced at its decision moments by the `agent-conduct` Skill, and the section keeps the full rules | +| Any push, API mutation, comment, label, or merge, or which checkout the work happens in | `Repository Boundaries and Write Safety`, its task-isolation rule surfaced at the task-start moment by the `repo-worktree` Skill, and the section keeps the full rules | +| Quoting data into a comment, commit, test, or doc | `Representative Data in Agent-Authored Text` | +| Committing, signing, rebasing, force-pushing | `Git and Commit Rules`, packaged as the `git-commit-conventions` Skill | +| Branch choice, promotion, keeping branches in sync | `Branching Model`, packaged as the `branching-and-release-model` Skill | +| Releasing, version bumps, publishing | `Release Model`, packaged as the `branching-and-release-model` Skill | +| A live config repo rather than a code repo | `Operational Repositories`, packaged as the `branching-and-release-model` Skill | +| Onboarding a repo or running a conformance sweep | `Repository Onboarding and Conformance` (hub only, not carried). Standing up a new repo from a hub checkout is packaged as the `standup-a-repo` Skill, resyncing one already stood up the same way is `resync-a-repo`, and measuring a named repo against the fleet ground truth per `AUDIT.md` is `audit-a-repo`, all hub-context only | +| Running a fleet gate, the review digest, or the config script | `Hub-Hosted Tooling` | +| Running a lint or format check locally, or a lint tool missing from `command -v` | `Running the Linters Locally (Known-Working Invocations)` (hub only, not carried) | +| Running a test locally, or a test runner missing or failing to spawn | `Verification Discipline` | +| Writing a commit message or pull request title | `Pull Request Title and Commit Message Conventions`, packaged as the `comment-and-doc-style` Skill | +| Any prose, comment, doc, or line-ending change | `Documentation Style Conventions`, packaged as the `comment-and-doc-style` Skill | +| Proving work actually happened | `Verification Discipline`, surfaced at its decision moment by the `agent-conduct` Skill, and the section keeps the full rules | +| Editing rule text, a Skill, or any other content other repos carry | `Verification Discipline`'s carried-content rule, which asks nothing of the change itself, its passes being run by the `local-strict-review` Skill against the units a periodic sweep names and recorded by the hub-hosted `scripts/canonical_review.py` | +| Opening a pull request, or requesting, monitoring, answering, or closing a review | `PR Review Etiquette`, packaged as the `pr-review-conduct` Skill | +| Reviewing a pull request, patch, or change set | No section of its own: the `fleet-code-review` Skill, which routes to the applicable general, language, documentation, and workflow skills | +| Reporting progress or asking the user something | `Communicating with the User`, surfaced at its decision moments by the `agent-conduct` and `session-handoff` Skills, and the section keeps the full rules | +| Editing a workflow YAML file | `Workflow YAML Conventions`, surfaced with the full `WORKFLOW.md` contract by the `workflow-ci-contract` Skill, with that section keeping the style rules and `WORKFLOW.md` the contract | +| Choosing an OS, runtime, or toolchain target | `Supported Development Platforms` | +| The devcontainer | `Devcontainer` | +| Editor settings and tasks | `Editor and Tasks` | +| The About panel, description, or repo toggles | `Repository Details` | +| Where a file belongs in the tree | `Repository Layout` | + +A row above naming no Skill, or naming one only for part of its section, is doc-only by decision rather than by omission, and the reason differs by row. `Foundational Principles` is rationale read once rather than a procedure. `Repository Boundaries and Write Safety` and `Representative Data in Agent-Authored Text` are always-on law that binds whether or not a Skill fires, which is why the boundaries row names `repo-worktree` only for the one moment in it narrow enough to surface, isolating into a worktree at task start, on top of that law rather than instead of it. The `gh-write-guard` hook and the host-wide instruction blocks maintained by the hub's own agent-safety installer, hub-local at `host-setup/agent-safety/`, are the boundaries section's mechanical layer, while the data section has none, since no pattern decides it. `Running the Linters Locally (Known-Working Invocations)` is hub-only, so a carrier reaches it in a hub checkout rather than surfacing it. `Verification Discipline` carries its Skills on its other two rows. And `Hub-Hosted Tooling`, `Supported Development Platforms`, `Devcontainer`, `Editor and Tasks`, `Repository Details`, and `Repository Layout` are short reference sections a task reads at the moment it touches their subject, each already routed to by the procedures and Skills that need it. + +Some of the rules above are also packaged as Claude Code / opencode / Codex Skills, hand-authored at `.agents/skills/` in the hub (not a repo-relative link here, since that path is hub-local and not carried into every fleet repo), so they surface automatically instead of needing to be re-read every session. `scripts/` is hub-hosted and reached rather than carried, per "Hub-Hosted Tooling", so run the installer from a hub checkout: `python3 scripts/skills_install.py` (or the `.sh`/`.ps1` wrapper) once per machine, from `github.com/ptr727/ProjectTemplate`, installs them for every repo touched from that machine. `python3 scripts/skills_install.py --report`, also from a hub checkout, says whether this machine is current. A rule that keeps needing to be restated is a sign the install is missing or stale, not that the rule does not exist. Keeping a repo's own carried `.github/copilot-instructions.md` in sync with the hub, without losing that repo's own "Disproved Claims" ledger entries in the process, is `copilot-instructions-keeper`, a skill about maintaining that file rather than a rule extracted from it, since the file itself is read directly by the Copilot bot and stays fully intact everywhere it is carried. Checking, from inside this repo's own session with no operator watching, whether this repo and this machine are actually current against the hub is `check-this-repo`, new content rather than a rule extracted from a section, the counterpart to `resync-a-repo` that needs no standing hub checkout or named target beyond the repo the session is already in, even though its own check fetches a hub checkout to reach `scripts/skills_install.py`. Opening a pull request against a repository outside this fleet, one the maintainer does not control, follows a different workflow entirely, new content rather than a rule extracted from a section, packaged as `upstream-contribution-workflow` and independent of the target repo's own type or workflow model. Isolating a task into its own worktree before its first file edit, with the base-branch choice, the layout convention, and the cleanup mechanics, is `repo-worktree`, the task-start surface of the `Repository Boundaries and Write Safety` law, which keeps the rule. Writing the handoff that "Session Scope" above requires, and resuming from one, is `session-handoff`, new content rather than a rule extracted from a section, since deciding what actually earns a place in each of the sections that rule names is judgment rather than a shape. It carries `GOVERNANCE.md` "Communicating with the User" whole as a generated include, that being the parked-decision account the rule owes, and it names the hub's `scripts/handoff.py` for the chain's mechanics. Its widest trigger is the one that earns it, about to re-attempt something a previous round may already have tried, since a session that does not know a chain exists never goes looking for one. Creating, changing, or retiring one of these skills is itself packaged as `skill-lifecycle`, hub-context only, since `.agents/skills/` exists only in the hub and the generated plugin tree is never hand-edited. + +Adding or changing a managed host tool is packaged as `add-host-tool`. It keeps the cross-platform contract, installer, documentation, test, and native-verification surfaces together. + +Driving a pull request through its review loop, from a feature branch into `develop` and, when asked, on to a mergeable `develop -> main` promotion PR, disposing of every reviewer finding along the way per `pr-review-conduct`, is packaged as `drive-pr`, new content rather than a rule extracted from a section. Merging a ready promotion PR and dispatching the release it unblocks, refreshing this machine's installed Skills first when the repo is this hub, is `merge-and-release`, its own new-content package, invoked separately from `drive-pr` so the promotion merge and the release dispatch each keep their own explicit go-ahead. Working a whole open-issue backlog down by rounds, ranking the issues, grouping them so no two groups touch the same file, dispatching one subagent per group to drive its own pull request into `develop`, opening at most one `develop -> main` promotion pull request per round, and re-ranking from scratch afterwards because each round's reviews file new issues, is `backlog-burndown`, also new content rather than a rule extracted from a section. It orchestrates `drive-pr` rather than replacing it, and it scopes to the repository the session is in, and a fleet-wide issue sweep is a different request. Working the handoff chain with no maintainer present, a lean orchestrator dispatching one picker and one worker subagent per round, each worker merging only as far as the scope named at invocation and parking any handoff that meets a decision under the `blocked` label, is `unattended-handoff`, also new content, and its parked links return to the attended session `session-handoff` states. + +Running one read-only, adversarial review pass against a branch's current diff against its target branch, full file context included, on the strongest model tier the session can reach, before a unit of PR-bound work is pushed toward a pull request or claimed done, is packaged as `local-strict-review`, new content rather than a rule extracted from a section. `drive-pr`, `pr-review-conduct`, and `agent-conduct` each reference it at the moment they already govern, rather than restating what it does. The rule itself lives in [`GOVERNANCE.md`](./GOVERNANCE.md) "Verification Discipline", the hub-hosted `scripts/local_review.py` is the engine that records a pass so a capture point can check one, and a repository carrying a `.husky/pre-push` hook enforces it at the push itself, the skill staying the primary and agent-agnostic layer with the hook a bypassable backstop under it. That skill carries a second pass under the same rule, over canonical content this repository authors and others carry, read one whole unit at a time rather than as a diff, because a diff-scoped read leaves the first real review of a rule to whichever repository carries it next, which is the one repository that cannot act on what it finds. That one is swept on a schedule rather than owed by a push, since owing it at every change cost more than the fleet chose to keep spending there, so a change that edits such content pushes and merges like any other. `scripts/canonical_review.py` is that pass's engine, and the units the pass has yet to reach are listed in the burn-down that engine's `report` renders from the hub's `reports/canonical-review.json`, not a repo-relative link here since that path is hub-local like the Skills tree above. diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..6ddf448 --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,265 @@ +# AUDIT.md + +How an agent audits a repository against the fleet ground truth in the hub and reports drift. This is the procedure. The ground truth it checks against is [`registry/repos.json`][repos], the [`spec/`][spec] manifests, [`repo-config/`][repo-config], and the prose authorities ([`GOVERNANCE.md`][governance], [`CODESTYLE.md`][codestyle], [`WORKFLOW.md`][workflow]). The audit is read-only: it produces a report under [`reports/`][reports], never edits the target repo. + +The verdict vocabulary is [`WORKFLOW.md`][workflow]'s: **operational / not operational**, **N/A**, +**defect**, and the applicable/absent rule. Do not invent a parallel scheme. + +**This file measures. It does not decide the order a finding is applied in.** Section 10 states that converging is a separate phase and how a fix ships, and [`RESYNC.md`][resync] is that phase for a repository that is already stood up and has fallen behind, sequencing the remedies so the rules land before the files they govern and a deletion lands before the re-vendor that would otherwise refresh it. + +```mermaid +flowchart TD + s0m["0m: fleet membership, every owned non-fork repo has a registry entry"] --> s0["0: has the repo been stood up? if not, STANDUP.md"] + s0 --> s1["1: scope, ground-truth branch (main)"] + s1 --> s2["2: resolve the repo's type(s)"] + s2 --> s3["3: applicability gate, per item or check"] + s3 --> s4["4: per-dimension checks, letter and intent"] + s4 --> s5["5: assert Actions implement WORKFLOW.md"] + s5 --> s6["6: validate settings, rulesets, secrets"] + s6 --> s7["7: verdict model"] + s7 -->|"every applicable check passes"| operational["operational"] + s7 -->|"a letter miss, intent satisfied"| drift["drift finding"] + s7 -->|"letter and intent both miss"| defect["defect: not operational"] + s7 --> s8["8: report, under reports/"] +``` + +## 0. When to Run and What "Done" Means + +**Start here only if the repo already carries its instruction set.** This file measures a repo against the fleet ground truth, and measuring assumes the thing being measured arrived. A repo holding no carried files, or a partial set, has a baseline that never arrived rather than drift to report, so it goes to [`STANDUP.md`][standup] sections 1A and 2 first and comes back here afterwards. The `AGENTS.md` "Fleet Bootstrap" section states that routing in the repo itself, byte-locked, so an agent finds it without knowing this file exists. Running the audit against a repo with nothing to audit produces a report that is all absences, which reads as a catastrophic result rather than as a repo that was never stood up. + +This audit is not occasional. Run it whenever you **create, adopt, or materially change** a fleet repo, and on demand for any known repo: + +- **A full sweep opens with a fleet membership check, not a per-repo one.** `spec/audit.py`, run with no repo names, first lists every non-fork repository the registry `owner` actually owns on GitHub and diffs it against `registry/repos.json`. A repo that exists but carries no entry is invisible to every other check in this file, since all of them iterate the registry and never look past it, so this is the only place that gap is caught. The check also reconciles one field: a registry `status: "archived"` must agree with GitHub's own archived flag, in either direction. A name-filtered run or `--issue` skips it, since those are scoped to repos already known to the registry. Run this as `gh auth login` for the owner's own account: a fine-grained PAT scoped to "selected repositories" returns an incomplete list with no error, so the sweep would read clean while some repos were never inspected. +- **Onboarding a repo is complete only when it either passes this audit** (operational on every applicable check) **or carries a committed `reports/<repo>/audit.md` plus a tracking issue** enumerating every residual delta. A repo that is partially set up but never audited is itself a **defect**, the exact state this process prevents. The create-to-conformance counterpart is [`STANDUP.md`][standup]. Because both read the same manifests, a repo stood up by that file passes this audit by construction. +- **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. A carried `intent` unit gets one advisory beyond presence, a last-modified comparison: a hub canonical changing after the copy's own last commit marks the copy as possibly trailing, at **drift**, a hint rather than proof, since a copy touched without reconciling reads current and content is never judged. A content scan also looks for a version literal: a three-part version, a full 40-character commit SHA, or an abbreviated one of 7 to 12 characters mixing digits and letters, in `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, or `WORKFLOW.md`, outside the verbatim sections downstream and across the whole file in the hub, is a **drift** finding, since a pin's value in prose goes stale at the next bump and every other literal reads like one. + +**Verify the host before running any hub tool.** The tools carry version floors, and a host below one answers `--version`, looks healthy, and produces a wrong answer, so a clean audit run from a broken host is a clean-looking result rather than a result. + +```shell +python3 scripts/host_gate.py --repo "<path-to-target-checkout>" # run from a hub checkout, floors from spec/host-tools.json +``` + +Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory. Omitting it does not read the target's declaration at all, so every floor that repo adds goes unapplied, and the run reports nothing about the omission. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks. + +## 1. Scope and Ground-Truth Branch + +Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. Read `develop` only to detect divergence. A stale or diverged `develop` (behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat a `develop`-only file as present if it is absent on `main`. + +This holds for **both workflow models**. An `operational` repo commits directly to `develop`, but its ground truth is still `main`, the promoted and gated snapshot the promotion PR blesses. `develop` there is mid-flight by design (ungated direct pushes), so auditing it would measure work in progress: conformance scaffolding that has landed on `develop` but is not yet promoted is *un-promoted work*, not a conformance defect, and it counts when it reaches `main`. A registry `groundTruthBranch` naming `develop` therefore contradicts this section, for either model. + +## 2. Resolve the Repo's Type(s) + +Look up the repo in [`registry/repos.json`][repos]. An entry with status `archived` or `excluded` is out of scope for the rest of this procedure, so stop here rather than proceeding to section 3. `archived` means GitHub itself reports the repo archived, so no further conformance work applies. `excluded` means a maintainer decision took it out of audit scope, recorded in the entry's `exclusionReason`. Both still carry a registry entry precisely so the decision stays visible, per section 0's membership check, rather than the repo reading as an oversight. + +Otherwise read its `types[]`. If the entry is `classificationPending` (a backlog repo), classify it from the tree and propose a registry update: + +- `*.csproj` / `*.slnx` -> `csharp`, a `dotnet nuget push` workflow -> `nuget`, a `System.CommandLine` console -> `console`. +- `pyproject.toml` / `setup.py` -> `python`, a `pypa/gh-action-pypi-publish` workflow -> `pypi`. +- `Dockerfile` + a docker build/push workflow -> `docker`, an `upstream-version.json` tracker -> `upstream-wrapper`. +- `custom_components/*/manifest.json` + `hacs.json` -> `homeassistant`, a codegen workflow -> `codegen`, no `build-*` task -> `source-only`, governance-only -> `docs`. +- `hugo.yaml` / `hugo.toml` / `config/_default/hugo.yaml` -> `hugo`. A repo may carry it alongside `source-only`, since a site deploy leaf is not a `build-*` task and both declarations stay true. + +## 3. Applicability Gate + +Reuse [`WORKFLOW.md`][workflow] section 1, extended to this audit's own checks: an item or check that governs a construct the repo does not contain is **N/A**. Record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, and the artifact-lifecycle clauses on a source-only repo are all N/A. + +Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model). The scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. + +## 4. Per-Dimension Checks (Letter and Intent) + +For each applicable type in [`spec/project-types.json`][project-types] and every cross-cutting dimension, evaluate each check at its stated verdict tier: + +**Every check under a project type is judged by hand. The cross-cutting dimensions are only partly mechanized, and the line between the two halves is not where a reader assumes.** [`spec/audit.py`][audit-runner] evaluates **no** check belonging to a type in `spec/project-types.json`, and it reads that file for one purpose only, to resolve the id a registry `driftNote` names (section 8) against the catalog and against the repo's declared types. Resolving an id is not running the check it names. What the runner does mechanize is the deterministic subset in section 0, and that subset lands on several `crossCutting` checks without being organized by them: branch protection and the ruleset diffs, secret names, Dependabot ecosystems, the cspell single source, section presence, and `driftNotes` freshness. So read a clean run precisely. It is evidence for that subset, it is **no** evidence for any of a type's checks, and it is partial evidence across the cross-cutting dimensions. The three are easy to conflate, because adding a check under a type changes what an auditor must judge and changes no tool's output, so the check reports nothing until someone evaluates it, and silence from a tool that was never looking reads exactly like a pass. Cite the `file:line` each check was judged against, since that citation is the only durable record that the judgment happened. + +- **letter** - the exact file, section, config, or construct is present. +- **intent** - an equivalent outcome holds even if the form differs. + +A check with `intentRef`/`workflowRef` points at the prose section that owns the rationale, so read it to judge intent. The dimensions: + +- **csharp** - `.editorconfig` carries the shared `[*.cs]` rule block (letter), and analyzer severities are enforced, not relaxed (intent). +- **nuget** - `nuget.publish.oidc` (intent): publish uses OIDC Trusted Publishing with no stored `NUGET_API_KEY` secret, from a job in the publishing repository's own publisher, never inside a build leaf and never in a reusable workflow a different repository hosts, for the reason [`WORKFLOW.md`][workflow] section 3's `Output Seam by Destination` gives for both package registries. `nuget.publish.skipduplicate` (letter): the push carries `--skip-duplicate` and is gated on the publish decision, not on an existence check. `nuget.publish.job` (letter): that publish job declares `id-token: write` and `actions: write`, and consume-then-deletes `nuget-build-<branch>`. +- **pypi** - `pypi.publish.oidc` (intent): OIDC publish with no stored token, from a job in the publishing repository's own publisher under the same seam the **nuget** check names. `pypi.publish.environment` (letter): that job declares `environment: pypi` and `id-token: write`, with `skip-existing: true`. +- **python** - ruff and pyright present (intent), canonical in `pyproject.toml` (letter), and a standalone `.ruff.toml` / `pyrightconfig.json` is a drift finding. +- **dotnet-publish** - `dotnet-publish.smoke.subset` (letter): the smoke runtime matrix is a strict subset of the full set. `dotnet-publish.release.asset` (letter): the per-runtime outputs aggregate to one `release-asset-*`, gated `!smoke`. +- **docker** - registry layer cache (`buildcache-<branch>`, never `type=gha`), the size-limited Docker Hub README is published via the docker-readme task, and the image always re-pushes on publish. +- **hugo** - the build fails on a generator warning, the URL-parity gate asserts a length floor before comparing, the rendered output is untracked, the generator is pinned by version and checksum and declared once, a vendored tree records its upstream ref, and the deploy asserts what the host serves (the release id and the environment). Retention is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is confined write-only, so grade which shape the repo uses rather than looking for a prune step. Deploy credentials are per-environment, which `spec/secrets.json` cannot express, so a clean **repo-setup** verdict says nothing about whether the environments are configured. +- **branch-model** - `main` and `develop` both exist and are protected, and the live rulesets match [`repo-config/*.json`][repo-config] by normalized diff (below). +- **carried-scope** - the repo carries no file the hub hosts rather than carries. The set is derived, not listed: the hub's git-tracked paths minus the [`spec/files.json`][files] baseline, so a file dropped from the manifest starts being reported on the next run with no retirement list to remember to edit. The remedy is the opposite of every other file finding, a **deletion**, since the repo reaches the hub's copy per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. The match is on path alone, so a hit is a candidate and not a verdict: a repo's own content at a path the hub also uses matches while carrying nothing of the hub's, which the first fleet run showed twice, a KiCad tooling doc at `scripts/README.md` and per-repo formatting hooks at `.husky/pre-commit`. A [`spec/divergences.json`][divergences] `gaps` disposition decides which case a hit is, so only `retire` asserts a deletion, `accepted` closes a collision or a repo-owned file, and an untriaged hit is read before it is acted on. +- **verbatim-tree** - every applicable `trees[]` declaration in [`spec/files.json`][files] owns its target tree. The audit reports missing files as letter findings, stale or modified bytes as drift, and extra files under a pruned target as drift. An unreadable or truncated repository tree is undecided and produces drift rather than a clean result. +- **repo-setup** - every required secret for the repo's publish mechanisms is configured, and no forbidden secret is present (per [`spec/secrets.json`][secrets]). +- **runtime-secrets** - a repo-scoped runtime-secrets directory, when present, is named `.secrets/` (dotted, not a bare `secrets/`), a single opaque credential file carries no extension, and every real secret file has a tracked `<name>.example` beside it cataloged in `.secrets/README.md`. N/A for a repo with no such directory. See [GOVERNANCE.md "Repo-Scoped Secrets"][governance-repo-scoped-secrets]. +- **linter-parity** - one config per linter (`.markdownlint-cli2.jsonc`, `cspell.json`, ruff/pyright, editorconfig/csharpier, actionlint) drives the editor extension, the CLI, and CI, and CI runs each. That count is of the root config, so a nested `.markdownlint-cli2.jsonc` is not a second one. A local hook exists and runs at minimum the diff-scoped prose gate and the eol check via `hub-fetch-run.py`, or the hub's own local script copies for the hub repo itself (`parity.hooks`, intent). A repo with none wired is a defect, and one mid-convergence on the language-formatting half stays operational. +- **recurring-violations** - comments concise and non-narrative, ASCII only (no em-dash, no smart quotes), US spelling, line endings per `.editorconfig`. These are frequent regressions, so this dimension is high priority and always runs, and each check is grep-able (see below). +- **readme-structure** - the README follows [`spec/readme-structure.md`][readme-structure] (applicable sections, in order). Mechanically checked against the declared model in [`spec/readme-sections.json`][readme-sections]: required sections present, declared sections in their relative order, `License` last, the shields each deliverable implies, the license shield in the closing License section, and the tagline and its mirrors. A heading the model does not name is dropped before the order comparison, so a repo-specific section is never a finding. + +## 5. Assert the Actions Implement WORKFLOW.md + +Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions, reading a workflow it only calls at the SHA it pins: the 5A static audit (structural facts per applicable D-guarantee, each cited in the form 5A sets out) and the 5B trace scenarios (predicted run/skip + version + release + artifact-end-state vs expected). The contract in WORKFLOW.md section 4 is satisfied by **outcome**, not by matching the catalog snippets in [`catalog/snippets/workflows/`][workflows] byte for byte. Those are the reference implementation, not required bytes. Where a guarantee names a construct, D6.1's `release-asset-<branch>-<target>` and D9.2's ruleset-bound job `name:` among them, that name is the outcome and a divergence is a **defect** here. That is a separate judgment from the `verbatim` content hash section 0 describes, which classifies a mismatch as stale or modified and reports either at **drift**, since equivalence is intent-governed and a byte diff is a hint to review rather than a verdict. + +## 6. Validate Settings, Rulesets, and Secrets + +- **General settings, the registry description, labels, rulesets, the Dependabot security features, the fleet project link, and deployment environments** - fetch the hub and check out `main`. Run `repo-config/configure.sh check "<owner>/<repo>" "<release|operational>"` from that checkout. Pass the target repository and its registry `workflowModel` explicitly. The token needs project access as well as the admin the ruleset endpoints require, since a token without it cannot read the project link and the command reports that group as failing rather than as clean, and the command's own error names the scope to grant. The host needs a runnable `py -3` or `python3` too, since a run against a hub checkout resolves the registry description through [`spec/resolve_description.py`][resolve-description] and exits before the first check when neither interpreter answers. The command checks what `configure.sh apply` writes: the declared settings, the derived settings and the registry description, the declared labels, the Dependabot security features, the shared `main` ruleset, the `develop` ruleset the model selects, and the link to the fleet project the hub declares. It reports the live `bypass_actors` list without asserting it, because bypass authority is a per-repository human decision. It also checks one group apply never writes, the deployment environments the registry's `environments` declares for the repo: each one exists, its deployment-branch policy is the form declared, and under a `custom` policy the entries it allows are exactly the declared branches, so anything added by hand reads as drift. An environment the registry declares nothing about is reported rather than asserted, since GitHub creates some on its own. Neither this command nor the **Secrets** bullet below queries an environment's own secret and variable stores, where the API exposes a secret's name but not its value and a variable's name and value both, so a clean run says nothing about whether an environment holds what a deploy needs. + +- **Secrets** - from the same hub checkout, run [`spec/audit.py`][audit-runner] `"<RepoName>"` and read the findings it prints with a `secrets:` prefix. That argument is the registry entry name rather than the `<owner>/<repo>` form `configure.sh` takes, and an `<owner>/<repo>` argument prints `Not cataloged` and exits 2. The runner reads both the Actions and the Dependabot store on every repo, and a token that cannot read either one fails that repo's whole run with an error rather than reporting names as missing. It resolves what each store must hold from the hub's own [`spec/secrets.json`][secrets] plus the registry entry's `publish[]`/`types[]`/`requiredSecrets[]`. That file's `baseline` requires its names in both stores for every fleet repo, and a mechanism adds to a store only where the mechanism's own `stores` list names that store. A declared type claims nothing where the entry's profile for it reads `lint-only`, and claims no coverage token where the tree carries no tests for it, so a repo declaring a language can still owe nothing beyond the baseline. It reports three things: a required name missing from a store, a forbidden name present in either store, and a configured name no applicable mechanism claims. A forbidden name present draws the third as well as the second, since the claimed set is built from the required names alone. Names are read, never values. + +- **Dependabot ecosystem coverage** - for each ecosystem the repo's tree implies, `.github/dependabot.yml` must declare it: `github-actions` when `.github/workflows/` holds at least one `.yml` or `.yaml` entry (those workflows reference actions, and otherwise those versions go stale and a stood-up merge-bot has no action-update PRs to auto-merge), and `devcontainers` when a `.devcontainer` is present. The mechanical check (`spec/audit.py`) asserts each implied ecosystem's **presence**, and it runs at all only where `.github/dependabot.yml` exists and is non-empty. A missing `dependabot.yml` is a file-presence letter of its own, and an empty one is caught by neither check, since the file-presence check reports only absence, so an empty file is reported by hand. A tree-implied ecosystem that file declares nowhere is a **drift finding**. Then confirm **by inspection** that each declared ecosystem **dual-targets `main` + `develop`** per the [Branching Model][governance-branching-model], since the regex below cannot pair an ecosystem with its `target-branch`. Language ecosystems (`nuget`/`uv`/`npm`) are directory-scoped and audited by inspection too. + + ```bash + #!/usr/bin/env bash + # Save and run this as a script rather than pasting it into a shell, since it exits rather than returns. + # It prints one line per implied ecosystem, and its exit status reports whether the reads succeeded rather than whether an ecosystem is missing. + set -Eeuo pipefail + repo="<owner>/<repo>" + ground=main # The branch section 1 reads as ground truth. + + root_paths=$(gh api "repos/$repo/contents?ref=$ground" --jq '.[].path') + # A repo carrying no .github directory would 404 on the listing below, which is an absence rather than the read failure that exit code otherwise means. + github_paths="" + if grep -Fxq .github <<<"$root_paths"; then + github_paths=$(gh api "repos/$repo/contents/.github?ref=$ground" --jq '.[].path') + fi + has() { grep -Fxq "$1" <<<"$root_paths"$'\n'"$github_paths"; } + + if ! has .github/dependabot.yml; then + echo "dependabot.yml absent: a file-presence finding, and ecosystem coverage is not checked at all" + exit 0 + fi + # The raw media type answers with the file itself, so nothing here decodes base64 or reads a content key that can be absent. + dependabot_yaml=$(gh api "repos/$repo/contents/.github/dependabot.yml?ref=$ground" -H "Accept: application/vnd.github.raw") + # The mechanical check gates on a non-empty file, so an empty one skips here too rather than reporting every implied ecosystem missing. + if [ -z "$dependabot_yaml" ]; then + echo "dependabot.yml empty: the mechanical check skips it and the file-presence check reports only absence, so report this one by hand" + exit 0 + fi + # Anchor to the line start (optional list dash) so a commented-out '# package-ecosystem:' is not counted. + # Accept either quote or none, as the mechanical check does. + # The trailing class is the mechanical check's own, so a value carrying a digit is read whole rather than truncated to its leading letters. + eco_re="^[[:space:]]*-?[[:space:]]*package-ecosystem:[[:space:]]*[\"']?[[:alnum:]_-]+" + # A file declaring no ecosystem at all matches nothing, and grep exiting 1 would otherwise abort the run under the header above. + # The two greps are separate statements so that only a no-match is absorbed. + # Chained through a pipe they are not, because a failing first stage produces no output, the second stage then no-matches, and pipefail reports that exit 1 rather than the failure. + matched=$(grep -oE "$eco_re" <<<"$dependabot_yaml") || [ "$?" -eq 1 ] + decl=$(grep -oE '[[:alnum:]_-]+$' <<<"$matched" | sort -u) || [ "$?" -eq 1 ] + + if has .github/workflows; then + # The contents API answers with an object rather than an array where the path is a file, so the type is tested rather than assumed. + yaml_count=$(gh api "repos/$repo/contents/.github/workflows?ref=$ground" --jq 'if type == "array" then [.[] | select(.name | test("\\.ya?ml$"))] | length else 0 end') + if [ "$yaml_count" -gt 0 ]; then + grep -qx github-actions <<<"$decl" && echo "github-actions: present" || echo "github-actions: MISSING (.github/workflows/ holds a workflow file)" + fi + fi + if has .devcontainer; then + grep -qx devcontainers <<<"$decl" && echo "devcontainers: present" || echo "devcontainers: MISSING (.devcontainer present)" + fi + # Then read dependabot.yml and confirm each present ecosystem has both a main and a develop target-branch entry. + ``` + +- **Dependabot on self-hosted runners (owner setting)** - the owner-level toggle named `Dependabot on self-hosted runners`, which for a user-account owner sits at `https://github.com/settings/security_analysis`, routes Dependabot's own update jobs to a self-hosted runner pool. With none registered on the owner, those jobs queue for up to 24 hours, then get cancelled, and ordinary CI is unaffected throughout. GitHub routes only a private repo through this setting, which is why the fleet's public repos updated throughout while its private ones stalled, so read the audited repo's visibility first with `gh repo view "<owner>/<repo>" --json visibility` and treat a public one as N/A. On a private repo, read the newest Dependabot run's job: + + ```bash + #!/usr/bin/env bash + # Save and run this as a script rather than pasting it into a shell, since it exits rather than returns. + set -Eeuo pipefail + repo="<owner>/<repo>" + + # Dependabot's own update jobs run under the dynamic event rather than from a workflow file, so they are selected by path. + # Read the newest run rather than the history, since a repo whose account setting has since been turned off keeps every run cancelled while it was on. + # The dynamic event is shared with GitHub's other generated runs, Copilot's reviewer among them, which on an active repo fill whole pages, so the pages are walked rather than the first alone, within the 1000 runs this endpoint returns at most. + # The run's date is carried out with its id so the report can say when this repo's updates last ran, which the finding below does not turn on. + runs=$(gh api --paginate "repos/$repo/actions/runs?event=dynamic&per_page=100" --jq '.workflow_runs[] | select((.path // "") | startswith("dynamic/dependabot")) | "\(.id) \(.created_at)"') + if [ -z "$runs" ]; then + echo "no Dependabot run in this repo's dynamic run history" + exit 0 + fi + # The endpoint answers newest first and paginates in that order, so the first line is the newest Dependabot run whichever page it landed on. + run=$(head -n 1 <<<"$runs") + echo "newest Dependabot run: $run" + gh api "repos/$repo/actions/runs/${run%% *}/jobs" --jq '.jobs[] | "\(.conclusion) labels=\(.labels | join(",")) runner=\(.runner_name // "") steps=\(.steps | length)"' + ``` + + A job reading `labels=dependabot` with no runner name was routed to the pool, its empty step list saying it never started, where a job GitHub's own runners took reads `labels=ubuntu-latest` with a runner name and the steps it ran. Those four fields together are the finding: a newest run that was routed and then cancelled having run no step says this repo's Dependabot updates last failed that way and have not succeeded since, because a later success would be the newest run instead. Why they are still failing is not something the API answers, since the toggle's own state, whether a matching runner is registered and online, and whether the queue was ever restarted all sit outside it. The audit reports the repo and that run's date, and leaves the choice of remedy to the maintainer. Detection stops there, like the rest of this audit. Every remedy here is an owner-level or web-UI action no target-repo pull request can carry, so this is reported rather than converged under section 10: turn the toggle off, along with `Automatically enable for new repositories` beside it, or bring a matching self-hosted runner online instead. Disabling the toggle reruns nothing already queued, so an affected repo also needs its own manual `Check for Updates` click on its own Dependabot page, and a repo whose toggle is already off needs only that click. + +## 7. Verdict Model + +Per dimension, record `operational | not-operational | N/A`, each with a letter verdict and an intent verdict: + +- letter miss but intent satisfied -> **drift finding** (equivalent outcome in a non-standard form, worth fixing, not a break). +- letter and intent both miss -> **defect** (not operational). + +A repo is **operational** only if every applicable check passes. A single applicable defect makes it not operational, regardless of how clean the rest looks. N/A items are excluded, never counted as failures. + +## 8. Report + +Write `reports/<repo>/audit.md` from [`reports/_template.md`][template]: a dimension x {letter, intent, verdict, evidence} table with `file:line` citations (WORKFLOW.md 5A style), a drift section, and a list of proposed registry/spec updates (e.g. a resolved `classificationPending`). Rank findings most severe first. + +**The hub authors the report, and a downstream repo does not open a pull request against the hub to write its own.** `reports/` is the hub's evidence that it audited a repo, so a report written by the repo being audited is a claim rather than evidence, and the hub cannot adopt one without checking it. Checking the judgment dimensions **is** the audit, since confirming a verdict like "analyzers enforced" means reading the same files the audit reads, so a submitted report saves only the writing up and not the work. A submitted report is also stale by construction, because it is a snapshot of one hub revision arriving at a later one, and its claims then have to be reconciled against findings that did not exist when it was written. + +A downstream repo uses its context where it is worth most. It **files findings about the hub as issues against `ptr727/ProjectTemplate`** and **applies fixes to its own repo** per section 10. Filing an issue is the opposite of self-certification, and it is how several hub defects have been found. Hub findings include bugs, conflicting sources, unclear or incomplete instructions, missing capabilities, and Copilot findings about any of them. Search open and closed issues first, then update the matching issue or file a new one. Any pull request it opens against the hub follows the standard branching model. It targets `develop`, never `main`. + +**Findings are a point-in-time snapshot. Stamp them and re-verify before acting.** [`spec/audit.py`][audit-runner] prints a run stamp (`audit run <UTC> | hub <sha>`) and, per repo, the exact commit it read (`@ <branch>@<sha>`). Anything derived from a run (a report, and especially an **onboarding or conformance issue**) quotes that stamp, so a reader can tell whether it still applies. A convergence issue is generated from the audit, never composed by hand: `spec/audit.py --issue <repo>` emits a ready-to-file title and body from that repo's live findings (grouped into must-fix, converge, and could-not-verify), so the issue content cannot drift from what the audit actually found and regenerates as the repo changes. + +**Verify a convergence before it is promoted with `--branch`.** `spec/audit.py --branch <ref> <repo>` reads that ref instead of the repo's registry `groundTruthBranch`, so a repo can audit its own `develop` while the work is still in flight rather than discovering the gaps after `main` has moved. The registry is not edited, the run is still read-only, and the run stamp names the override so a finding cannot be mistaken for one against ground truth. A ref that does not resolve is a single error naming it, never a baseline's worth of file-absent letters. + +**Re-running the audit needs a full hub clone with git history.** The verbatim stale-vs-modified classification walks the canonical's history (`git log` / `git show` from the hub root), so a shallow clone or a files-only checkout cannot answer "matches a past hub revision" and those findings are unreliable there. A downstream agent verifying one finding without the full history can instead compare against the current hub canonical on `main` (the whole file for a file-level unit, or the named `## heading` block for a verbatim section), which decides current-match but not stale-vs-modified. An agent picking up such an issue **re-runs the audit first and acts on the live result, not the pasted findings**: a repo moves between filing and pickup, so a stale block leads an agent to "fix" what is already fixed (re-requesting secrets that exist, attempting a no-op forward-sync). State the findings as evidence for *why* the issue was filed, never as the current state. + +**Reconcile `driftNotes` in the same pass.** A registry `driftNote` records a *current* deviation from the baseline. Once the deviation is resolved the note is deleted, not left describing finished work, since hand-maintained prose drifts silently otherwise. `spec/audit.py` flags two shapes of note, neither of them gated on the rest of the audit being clean. A note asserting outstanding work in prose ("pending", "not yet", "missing", "behind", ...) is contradicted outright by a clean audit, and where findings are open it is raised as a question of which one it means, because gating the check on a clean audit meant one standing finding a repo could not clear exempted its whole note list, and the repo carrying open findings is where a stale note is most likely. A note naming the check that would retire it, as an id in parentheses and matched with them (`(hugo.generator.pinned)`, the bare id is not detected), is the mechanically checkable shape and is surfaced on **every** run: the audit resolves the id against the catalog and confirms the repo declares its type, then hands the check itself to the auditor, since section 4 above is judged by hand. **So a note naming a check id is retired by a person, not by a run.** Write it that way anyway. The id says exactly what would close the note, and the surfaced finding puts that decision in front of whoever runs the audit rather than leaving the note to sit until someone rereads it. + +## 9. Escalate + +Surface spec questions rather than resolving them silently, for example the Python config-placement canonicalization, or a new construct no type covers. A repeated letter miss that many repos share is a signal the spec (not each repo) needs adjusting, so raise it. + +```mermaid +flowchart LR + finding["a finding from 7: Verdict Model"] -->|"the same miss, many repos"| s9["9: Escalate, fix the spec instead"] + finding -->|"one repo"| s10["10: Converge, branch + fix + PR"] + s10 --> review["review loop to green"] + review --> merge["maintainer merges"] + merge --> reaudit["re-audit, the hub commits the report"] + s9 --> reaudit +``` + +## 10. Converge: Apply the Fixes + +Sections 0-9 (the audit and its report) are **read-only** and never touch the target. **Converging** is the separate follow-on phase: the drift the report found is **resolved by applying fixes to the target repo**, not left as a report. The convergence loop: + +- **Apply via a pull request on the target repo.** Branch from the target's `develop` (or `main` for a `main`-only repo), make the fix, and open a PR. Never push a fix directly to a protected branch, and never hand-edit a target outside a PR. +- **Drive the PR's review to green** - the same loop the hub runs (see [GOVERNANCE.md "PR Review Etiquette"][governance-pr-review-etiquette], and the [Copilot review runbook][copilot-runbook] in `.github/copilot-instructions.md` for that one reviewer's mechanics): request review on every push, address and resolve every thread from every reviewer the repo has configured, and confirm the review covers the head SHA. +- **Merge only with explicit maintainer approval.** The agent drives to green and stops. The maintainer merges. +- **One focused PR per drift class**, cross-referencing the audit finding. A sprawling all-drifts PR draws many review rounds and never feels done. +- **A `hub-only:` finding converges by deleting the file, not by updating it.** That prefix is how `spec/audit.py` reports the **carried-scope** dimension section 4 names. It is the one class where the fix removes content, so it is easy to convert into a re-vendor by reflex and end up refreshing a copy that should not exist. Delete the repo's copy and reach the hub's per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. Confirm the disposition is `retire` before deleting anything: an untriaged hit may be the repo's own content at a shared path, and deleting that destroys work the hub never owned. +- **Any deletion sweeps the inbound references to the path, and the sweep is part of the deletion rather than follow-up.** This governs every removal and not only a `hub-only:` one. Grep the path tree-wide and read every hit. Then read the files whose job is to say what the repo holds because a path search cannot find a description that names no path. Point a link at the hub's copy when that is the equivalent. Rewrite a runnable command to the invocation that works. Remove a mention with no equivalent and remove its reference definition in the same edit, per [GOVERNANCE.md "Documentation Style Conventions"][governance-documentation-style]. +- **Fix systemic drift in the hub, not per repo.** When many repos share a drift, fix the spec/rule (or add a machine check) here and let a re-audit re-flag it, rather than hand-patching each repo for the shared cause. + +The convergence model: the hub audits and the agent **applies** the fixes via target PRs, and the maintainer gates every merge. It supersedes any "the hub only reports; downstream operators apply by hand" framing. + +<!-- Workflow --> + +<!-- Repo --> +[audit-runner]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/audit.py +[codestyle]: ./CODESTYLE.md +[copilot-runbook]: ./.github/copilot-instructions.md +[divergences]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/divergences.json +[files]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/files.json +[governance]: ./GOVERNANCE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model +[governance-documentation-style]: ./GOVERNANCE.md#documentation-style-conventions +[governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling +[governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette +[governance-repo-scoped-secrets]: ./GOVERNANCE.md#repo-scoped-secrets +[host-setup]: https://github.com/ptr727/ProjectTemplate/blob/main/docs/host-setup.md +[project-types]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/project-types.json +[readme-sections]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/readme-sections.json +[readme-structure]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/readme-structure.md +[repo-config]: https://github.com/ptr727/ProjectTemplate/tree/main/repo-config +[reports]: https://github.com/ptr727/ProjectTemplate/tree/main/reports +[repos]: https://github.com/ptr727/ProjectTemplate/blob/main/registry/repos.json +[resolve-description]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/resolve_description.py +[resync]: https://github.com/ptr727/ProjectTemplate/blob/main/RESYNC.md +[scope-model]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/scope-model.md +[secrets]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/secrets.json +[spec]: https://github.com/ptr727/ProjectTemplate/tree/main/spec +[standup]: https://github.com/ptr727/ProjectTemplate/blob/main/STANDUP.md +[template]: https://github.com/ptr727/ProjectTemplate/blob/main/reports/_template.md +[validate]: https://github.com/ptr727/ProjectTemplate/blob/main/spec/validate.py +[workflow]: ./WORKFLOW.md +[workflows]: https://github.com/ptr727/ProjectTemplate/tree/main/catalog/snippets/workflows diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a2c84a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Claude Code Entry Point + +@AGENTS.md + +Claude Code reads `CLAUDE.md`, not `AGENTS.md`, so the import line above is what gets this repository's rules into a Claude Code session at all. See `AGENTS.md` for what is authoritative and why. This file carries no rule of its own, and adds none beyond the import line. diff --git a/CODESTYLE.md b/CODESTYLE.md index 5175a84..a18c456 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,38 +1,61 @@ -# Code Style and Formatting Rules - -This is the single code-style guide for the repo. The **General** section applies to every file. This repo ships no compiled application source - the Dockerfile is the only build input - so it carries the **General** section only; the per-language sections (.NET, Python) from the upstream template are omitted because nothing in those languages is built here. The carry-whole model still holds for config: [`.editorconfig`](./.editorconfig) keeps its inert `[*.cs]` block so a future re-sync stays a clean overwrite. - -Cross-cutting *process* rules (PR titles, branching, US English, markdown style, comments philosophy, workflow YAML, PR review etiquette) live in [AGENTS.md](./AGENTS.md) and are not repeated here. - -## General - -These rules apply to every file in the repo. - -### Tooling Names and Casing - -Use each tool's official casing in task labels, docs, and prose - `.NET` (not `.Net`), `Docker`, `actionlint`, `markdownlint`, `cspell`, `Nerdbank.GitVersioning` (NBGV). Don't invent personal variants. - -### Verification - -This repo compiles nothing, so there is no clean-compile task. The verification gate is the lint set plus the Docker smoke build, and it must report clean before a commit: - -- **Markdown** - `markdownlint-cli2` (see [Markdown and Spelling](#markdown-and-spelling)). -- **Spelling** - `cspell` over the user-facing docs. -- **Workflow YAML** - `actionlint` (bundles `shellcheck`) after any `.github/workflows/` edit. -- **Dockerfile** - the CI smoke build (`docker buildx build` for `linux/amd64`) proves the image still builds; CI runs it on every push. - -Run the relevant checks after every change; CI runs the same set as the authoritative backstop. Each linter has a known-working Docker image (`davidanson/markdownlint-cli2`, `ghcr.io/streetsidesoftware/cspell`, `rhysd/actionlint`) that auto-discovers its targets, so no local toolchain beyond Docker is needed. - -### Lint Diagnostics and Suppressions - -- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing a linter rule or muting a newly surfaced warning - fix it at the source. (The only brownfield allowance in this template is the one-time git-signing / line-ending migration described in [AGENTS.md](./AGENTS.md), which has nothing to do with linting.) -- **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**: an in-line annotation on the specific occurrence over a file-wide disable, and a file-wide disable over a root-config rule change. A root-config relaxation is justified only when the exception genuinely applies repo-wide. -- **Never blanket-disable a batch of rules** to get a file to pass. Rules the shared config deliberately disables (e.g. `markdownlint` `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them, and do not extend them without cause. - -### Markdown and Spelling - -These apply repo-wide, in every directory: - -1. **Markdown linting**: All `.md` files must be lint-clean (error and warning free) via the VS Code `markdownlint` extension. [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc) at the repo root is the single source of truth - the davidanson `markdownlint` extension and a command-line `markdownlint-cli2` run both read it, so the IDE and CLI stay in lock-step. Rules it deliberately disables (e.g. `MD013` line-length, `MD033` inline HTML) are **intentional** - do not "fix" them. Fix violations at the source rather than disabling rules. -2. **Spelling**: All spelling must be clean via the CSpell VS Code integration; words must be correctly spelled in **US English** (the repo-wide convention - see [AGENTS.md](./AGENTS.md)). Project-specific terms go in [`cspell.json`](./cspell.json), the single source shared by the editor and CI - not in a parallel `.code-workspace` word list. -3. **Spelling CI scope**: The enforced CI spell-check gate covers **`README.md` and `HISTORY.md` only** - these are the files every repo visitor sees, so they must be clean. It is deliberately **not** all `**/*.md`: repos carry many markdown files full of technical terms, and gating every one of them would mean endlessly padding `cspell.json` just to keep CI green. Broad, live spell-checking across any file (source, markdown, text) is the **cspell editor extension's** job, so typos still surface to whoever is editing. A repo owner **may** widen their own CI file list, but the template ships README + HISTORY as the default; keep every surface that runs cspell - the CI workflow and any local VS Code task or one-liner the repo has - on the same file list. The list is explicit (not a glob), so a repo that ships no `HISTORY.md` (e.g. one with no changelog) must drop it from all three surfaces and gate on `README.md` alone - cspell errors on a listed file that does not exist. Markdown *linting* (item 1) stays repo-wide `**/*.md` - it does not choke on technical terms. +# Code Style and Formatting Rules + +This is the single code-style guide for the fleet. The **General** section applies to every language. Each **language section** (.NET, Python, Shell) is self-contained: a repo follows only the section(s) for the languages it ships and ignores the rest. A repo keeps the whole file rather than trimming it. An unused-language section costs nothing, the same whole-file model as [`.editorconfig`][root], whose inert `[*.cs]` block a non-.NET repo keeps. + +Cross-cutting *process* rules (PR titles, branching, US English, Markdown style, comments philosophy, workflow YAML, PR review etiquette, and the verification discipline that defines the pre-push lint gate) live in [GOVERNANCE.md][governance] and are not repeated here. + +## General + +These rules apply to every language in the repo. + +### Tooling Names and Casing + +Use each tool's official casing in task labels, docs, and prose, per the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub (not a repo-relative link, that path is hub-local and not carried into every fleet repo). + +### Clean-Compile Verification + +Each language defines a **clean-compile** verification: the combination of build, formatter, linter, and code-analysis tools that must report clean before a commit. It is exposed as one or more **named** VS Code tasks (or, where a language ships no tasks, documented commands), and those definitions are the same across the fleet. The concrete names live in each language section below. + +- **Run it after every code change, and it is not the whole gate.** The relevant language's clean-compile must pass before you commit. CI runs those same language checks as a backstop **plus everything else its validation workflow runs**, and all of it reports into the one required status, so a green clean-compile does not predict a green CI. That remainder is at least the doc-lint set (markdownlint, cspell, actionlint, `editorconfig-checker`) and whatever spec, config, and script gates the repo carries, so read the workflow for the full list rather than assuming this sentence enumerates it. What has to pass before a push is the repo's **whole** lint gate, per [GOVERNANCE.md "Verification Discipline"][governance-verification-discipline]. Each linter's known-working invocation is in `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", a hub-only section read in a hub checkout rather than carried into every fleet repo. +- **The named task definition is the canonical spec** - its exact command sequence, arguments, and strictness. You may run it through the VS Code task **or** by invoking the equivalent native commands directly, and either is fine **only if the sequence, arguments, and strictness match exactly**. No shortcuts and no more-lenient options (for example, never drop `--verify-no-changes` or loosen a `--severity`). +- **A working local commit/pre-commit gate is strongly suggested, not the repo's free choice to skip.** The *mechanism* is bounded by the toolchain the repo already keeps rather than by which languages its checks cover, and two canonical shapes carry it. Husky.Net runs from a .NET tool manifest declaring it, so a repo that keeps no such manifest uses the `pre-commit` framework instead. Any repo may also wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing. Canonical shapes for both live in `catalog/snippets/` in the hub, not a repo-relative path since it is hub-local and not carried into every fleet repo. What that gate must cover, its per-clone enablement steps, and what its absence means for the audit, is `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", the same hub-only section, not restated here. Keeping a working gate is not drift. + +### Analyzer Diagnostics and Suppressions + +- **A new port is not a license to silence diagnostics.** Brownfield / just-ported status never justifies relaxing analyzer or linter severities or muting newly surfaced warnings. Fix them. (The only brownfield allowance is the one-time git-signing / line-ending migration described in [GOVERNANCE.md][governance] and [README.md][readme], which has nothing to do with code analysis.) +- **Suppress only genuine false-positives or deliberate, documented exceptions**, always at the **narrowest scope that fits**, in this order of preference: + 1. An **in-code annotation on the specific symbol**, with a justification, in the language's attribute/comment form, never a blanket pragma spanning a region. + 2. The **owning project's local config** when the exception is project-wide for one project (e.g. a test project's own `.editorconfig` / `pyproject.toml`). + 3. The **root / shared config** only when the suppression is genuinely applicable to **every** project in the repo. +- **Never blanket-relax a batch of rules project-wide** to get a port to build. The per-language mechanics (which attribute, which config key) are in each language section. + +### Markdown and Spelling + +These apply repo-wide, in every directory: Markdown lints clean via `markdownlint-cli2` against the shared config, spelling is US English via CSpell against the shared `cspell.json`, the CI spelling gate covers `README.md` and `HISTORY.md` only, `HISTORY.md` mirrors the README's opening, and "Markdown" is a proper noun in prose. A repo excluding a subtree of its own that it does not treat as authored prose, a committed data archive, a vendored theme, or a hand-maintained record, puts a `.markdownlint-cli2.jsonc` carrying its own `ignores` beside that content rather than editing the shared root config, whose contents are fleet-fixed. Those `ignores` patterns resolve against the directory holding them rather than against the repo root, so a repo-root-relative entry there matches nothing and reports no error saying so, and excluding through the CI workflow's own negated Markdown glob input instead is a CI-only fix that leaves the same files flagged for anyone running the linter locally. The full rules are in the `comment-and-doc-style` Skill referenced above. + +## .NET + +*This section applies only to the .NET side. A repo with no .NET projects still carries it (the file is carried whole) and ignores it.* + +The style guide for any .NET projects in this repo: the zero-warnings build policy and its three-task clean-compile chain, central `Directory.Build.props`/`Directory.Packages.props` configuration, C# language and naming conventions, XML documentation, analyzer suppression scope, the library-versus-application logging split, async and error-handling patterns, xUnit v3 + AwesomeAssertions testing conventions, the runner declaration, package references and version floor that an MTP-based test project needs under `WORKFLOW.md` D1.6, with the local diagnostic for a run that reports no tests, and AOT-compatible project configuration. + +This is packaged as the `dotnet-codestyle` Skill at `.agents/skills/dotnet-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the scope. Read the skill for the full rules, code examples, and mechanics. + +## Python + +*This section applies only to the Python side. A repo with no Python projects still carries it (the file is carried whole) and ignores it.* + +The style guide for any Python project(s) in this repo: the build-versus-lint-only profile split, the uv/ruff/pyright/mypy/pytest toolchain, `src` layout, formatting and linting, comment and docstring conventions, type hints, naming, imports, patterns to avoid, test conventions including the `pytest-cov` dependency and coverage selector a build-profile repo with tests owes under `WORKFLOW.md` D1.6, and versioning. + +This is packaged as the `python-codestyle` Skill at `.agents/skills/python-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the scope. Read the skill for the full rules and the profile-adaptation guidance. + +## Shell + +Bash, and only where a program cannot be Python: a bootstrap that installs the interpreter cannot be written in it, and a host tool that must run before a development toolchain exists cannot depend on Python either. Everything else is Python, with a test under its own scripts tree's `tests/` directory. The mandatory `set -Eeuo pipefail` header, the pipefail-versus-early-reader pitfall, self-locating scripts, the `shellcheck`-plus-`shfmt` clean-compile, and the why-not-what comment rule are packaged as the `shell-codestyle` Skill at `.agents/skills/shell-codestyle/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. Read the skill for the full rules. Run the clean-compile check itself per `GOVERNANCE.md` "Running the Linters Locally (Known-Working Invocations)", a hub-only section read in a hub checkout rather than carried into every fleet repo, not by probing `command -v shellcheck`. + +<!-- Repo --> + +[governance]: ./GOVERNANCE.md +[governance-verification-discipline]: ./GOVERNANCE.md#verification-discipline +[readme]: ./README.md +[root]: ./.editorconfig diff --git a/Dockerfile b/Docker/Dockerfile similarity index 100% rename from Dockerfile rename to Docker/Dockerfile diff --git a/Docker/README.md b/Docker/README.md index 8113622..1c502b7 100644 --- a/Docker/README.md +++ b/Docker/README.md @@ -1,30 +1,30 @@ -# VSCode Server with .NET Pre-Installed - -Docker image of [Code-Server](https://github.com/coder/code-server) with the .NET SDKs pre-installed, based on the [LinuxServer.io Code-Server][linuxserver-code-server] image. - -## License - -Licensed under the [MIT License](https://github.com/ptr727/VSCode-Server-DotNetCore/blob/main/LICENSE). - -![GitHub License](https://img.shields.io/github/license/ptr727/VSCode-Server-DotNetCore) - -## Project - -Code and pipeline are on [GitHub](https://github.com/ptr727/VSCode-Server-DotNetCore). Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/vscode-server-dotnetcore). Images are rebuilt weekly and on demand, picking up the latest upstream Code-Server and .NET SDK updates. - -## Docker Tags - -- `latest`: Latest `main` branch build. -- `develop`: Latest `develop` branch build. -- `<version>`: Specific `SemVer2` version (e.g. `1.1.0`). - -## Platform Support - -- `linux/amd64` -- `linux/arm64` - -## Usage - -Refer to the [GitHub README](https://github.com/ptr727/VSCode-Server-DotNetCore#usage) and the [linuxserver/code-server][linuxserver-code-server] documentation for configuration and environment variables. - -[linuxserver-code-server]: https://github.com/linuxserver/docker-code-server +# VSCode Server with .NET Pre-Installed + +Docker image of [Code-Server](https://github.com/coder/code-server) with the .NET SDKs pre-installed, based on the [LinuxServer.io Code-Server][linuxserver-code-server] image. + +## License + +Licensed under the [MIT License](https://github.com/ptr727/VSCode-Server-DotNetCore/blob/main/LICENSE). + +![GitHub License](https://img.shields.io/github/license/ptr727/VSCode-Server-DotNetCore) + +## Project + +Code and pipeline are on [GitHub](https://github.com/ptr727/VSCode-Server-DotNetCore). Docker images are published on [Docker Hub](https://hub.docker.com/r/ptr727/vscode-server-dotnetcore). Images are rebuilt weekly and on demand, picking up the latest upstream Code-Server and .NET SDK updates. + +## Docker Tags + +- `latest`: Latest `main` branch build. +- `develop`: Latest `develop` branch build. +- `<version>`: Specific `SemVer2` version (e.g. `1.1.0`). + +## Platform Support + +- `linux/amd64` +- `linux/arm64` + +## Usage + +Refer to the [GitHub README](https://github.com/ptr727/VSCode-Server-DotNetCore#usage) and the [linuxserver/code-server][linuxserver-code-server] documentation for configuration and environment variables. + +[linuxserver-code-server]: https://github.com/linuxserver/docker-code-server diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..f460929 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,283 @@ +# Fleet Governance Rules + +The cross-cutting rules every repo in the fleet follows. [`AGENTS.md`](./AGENTS.md) is the entry point agents read first and maps each task to the section here that governs it, and this file holds the rule text itself. Code style lives in [`CODESTYLE.md`](./CODESTYLE.md), the CI/CD workflow contract in [`WORKFLOW.md`](./WORKFLOW.md), and the day-to-day operational surface in [`OPERATIONS.md`](./OPERATIONS.md). + +Read the one section a task needs rather than the whole file. `grep -n '^## ' GOVERNANCE.md` lists them. + +## Foundational Principles + +The specific rules in this file implement a few governing principles. Read these first: they are the reason the branching, release, and versioning rules are shaped the way they are, and every rule below serves one of them. + +- **Distribution respects the user: pull by default, push only where the channel forces it.** Docker images, GitHub Releases, and NuGet/PyPI packages are **pull**: the user decides when to consume them. A few channels are **push**: HACS surfaces a new release to every installed user as a pending update they did not go looking for, and a consumer that vendors from `main` picks up its current state. Because a release can reach users who did not ask for it, releasing is a deliberate act that marks a real functional change, never mechanical churn. This is why a **human merge never auto-publishes**: a release is a deliberate `workflow_dispatch`, or a conditional auto-release when the App merges a code-affecting Dependabot/codegen PR to `main` (Docker also refreshes on a weekly schedule). That rule, the no-op republish guarantee, and maintainer-gated version bumps all hold the same line: a needless release spends the user's attention and, on a push channel, acts on their machine. +- **Both branches stay in sync, so a promotion never needs a back-merge.** Dependabot and codegen target `develop` and `main` in parallel, so neither branch drifts and a `develop -> main` promotion stays a clean forward merge by default. That is exactly what lets the model be **signed, linear, and free of back-merges**: forward sync removes any need to merge `main` back into `develop`, which the rules forbid. If sync is ever broken (a change lands on one branch only, or normalizes a file on one side), restore it forward-only, never back-merge. See "Branching Model". (These auto-publish rules describe `release` repos. **Operational** repos differ, with direct-to-`develop` commits and a dispatch-only release. See "Operational Repositories".) +- **Two version numbers, two jobs.** The 2-digit `major.minor` in `version.json` carries human meaning: the maintainer raises it only for a functional change (feature, behavior or API change, breaking change), at their discretion, while NBGV owns the patch position and always increments with git height, so every build is uniquely versioned with no edit. Human-facing docs name the 2-digit line, and the toolchain guarantees monotonic builds. See "Release Model". +- **Contracts state what, not how, and favor reuse.** [`WORKFLOW.md`](./WORKFLOW.md) fixes required outcomes, not a required implementation, so two repos may satisfy a guarantee with different YAML. Within that freedom, apply good engineering practice: minimize duplication and maximize reuse, which is why the pipeline splits a carried, generic orchestration layer from a repo-owned build layer. + +## Durable Knowledge and Self-Improvement + +- **Durable knowledge lives in the committed docs, not in agent memory.** Anything a future agent must honor (a rule, a contract, a hard-won gotcha, a pattern worth repeating or one to avoid) belongs in a committed governance file (`GOVERNANCE.md` for a cross-cutting rule, `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog the repository already keeps). Agent memory does not survive a new session, a new machine, or a new environment, so it holds only environment-specific nuance and in-flight session state, never anything whose loss on reset would matter. A durable lesson left only in memory is lost to the next agent. +- **Keep the governance current as you work.** When work surfaces something durable (a rule worth enforcing, a recurring gotcha, a positive pattern to repeat, a negative one to design out), record it in the governance docs as part of that change, rather than leaving it in a local note or routing around it with a one-off workaround. Where the governing doc is carried from a template this repo cannot edit directly, propose the change upstream rather than patching the local copy. A local patch leaves every sibling repo with the same trap. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. +- **A blocker filed in another repository is recorded in the repository whose work it blocks.** The binding moment is the one where the upstream issue is filed or, where it already exists, found, because that session is the one that knows what stopped and why. It owes a second issue in the repository that is waiting rather than only the first, and where the upstream issue already exists the local issue names that one and no second upstream issue is filed. The local issue states what this repository cannot do and why, in its own terms rather than as a pointer to read elsewhere, since a reader who has to open the upstream issue to learn whether it affects them opens every one of them. The local issue names the upstream one as its blocker, carries the `blocked` label, and carries whatever labels its own work would carry anyway. A comment on the upstream issue then names the local one in return, a comment rather than an edit to the body because a second repository may join the same blocker later and because the body is often not this session's to rewrite. That order, the upstream issue and then the local issue and then the backlink comment, leaves a partial failure as a record naming its blocker rather than as a blocker naming a record nobody wrote. **A handoff does not do this job.** It carries the blockers a round met, and it belongs to one track and closes with its successor, where the wait outlives every session that met it and belongs in the backlog the whole repository reads. +- **The blocker record is written under the ordinary write rules, and the label on it is taken off deliberately.** Filing an issue and commenting on another are state-changing calls, so "Repository Boundaries and Write Safety" binds each of them exactly as it binds any other write, which keeps the upstream issue inside this owner and makes a blocker under a different owner a matter of explicit permission rather than of this rule. The `blocked` label is what every reader of this record selects on, so a repository not carrying it cannot host one until the fleet label set is applied there, which is a change to that repository's configuration rather than anything this rule writes, and a session that finds it missing reports that. The label comes off when the blocker clears, and the session closing the upstream issue is best placed to take it off, since the backlink comments naming every waiting repository are on the issue it is closing, while any later session that finds it cleared takes it off instead. A fix can land well before either of those, so the label lags the fix rather than tracking it, which is why a session meeting a `blocked` issue reads the state of the issue that issue's body names rather than the label. The local issue stays open when the label comes off, because the work it records still has to be done and is ordinary backlog from that moment on. +- **A handoff parked on a maintainer decision carries the `blocked` label too.** Its blocker is a `decision` issue in this same repository rather than an issue elsewhere, and the parking comment on the handoff names it. A reader reads the named issue rather than the label, and the blocker clears when that issue loses its `decision` label. Unlike a cross-repository blocker, a session finding it cleared does not take the label off. It comes off only when a session hands the link back to be worked, so a loop running meanwhile never takes a link a present maintainer is still working. +- **A durable rule earns a mechanical hook only where a hook can actually decide it, otherwise it stays prose.** Three conditions together, not any one alone. The failure recurs even after the governing prose was demonstrably read and understood, so it is not a discovery or loading problem a structural fix (getting the rule into context at all) would already solve. The triggering shape is decidable from the tool call's own text, arguments, and working directory alone, with no semantic or contextual judgment required. And the failure is destructive or hard to reverse rather than a quality miss. A worktree-isolation lapse met all three (it recurred under prose the agent had already read, "is this command's target a primary checkout" is a plain directory comparison, and the harm is another task's swept or reverted work), so it was promoted to a `gh-write-guard` hook rule. A skill's own trigger going unread by the session at all, by contrast, is a loading problem, fixed by getting the rule into context (the `CLAUDE.md` importing `AGENTS.md`), not by a hook. And "was this review finding actually evidence-backed" fails the second condition outright: a hook sees only the command text, never the judgment call itself, so it can only ever nag, not decide, and that class of rule stays prose and a chained Skill trigger. Those three conditions gate promotion to a **host** hook, the involuntary layer that fires in every session under the maintainer's own credentials and that only the maintainer can grant an exemption from, which is why the bar there is destructive harm. A **committed** hook in the repository's own tree is a third layer between prose and that one, and it is earned on weaker grounds: it is opt-in per clone, visible in the tree, bypassable by design, and it therefore fits a rule whose harm is a quality miss rather than a destruction. The second condition still binds it, since a hook that cannot decide its own trigger is a hook that nags, so what earns the layer is finding the decidable half of a rule whose other half is judgment. The local-review rule under `GOVERNANCE.md` "Verification Discipline" is the worked example: whether a review's findings were rightly disposed of is judgment no hook can decide and stays prose, while whether a review pass ran over exactly the content being pushed is a receipt comparison, which the hub's own `.husky/pre-push` decides. + +`GOVERNANCE.md` "Durable Knowledge and Self-Improvement" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moments. + +## Repository Boundaries and Write Safety + +A state-changing GitHub call is the highest-blast-radius thing an agent does here: it runs under the maintainer's identity, so one wrong target writes to another owner's repository as the maintainer, an outward-facing and hard-to-reverse act. These rules bound every write (a git push, an API mutation, a comment, a label, a merge) on any platform, and they bound a write to a checkout on disk as well, since a blanket add or a hard reset in a working tree another task is using destroys work without ever reaching GitHub. Reads are unrestricted, and how far a local read can be trusted is governed under "Verification Discipline" rather than here. The bounds below are on writes. + +- **Write only within the owner of the current project's repository.** Every state-changing call targets this project's `origin` or another repository under the same owner, which is the fleet the maintainer already administers. A broad or logged-in identity is capability, not permission: a token that *can* reach another owner's repository does not authorize writing to it. Writing under a **different owner** needs explicit human permission naming that repository, granted deliberately rather than assumed from a token's reach, and a "harmless test" write is still a write, so there is no probe exception. That boundary is where the harm sits, since the incident this rule exists for was a stray comment on a stranger's repository, not work across the maintainer's own projects. Reads from anywhere are fine. +- **Provider connectors are read-only for fleet work.** Use a provider's GitHub connector for reads where it helps. Perform each GitHub mutation through the documented hub tool, or through authenticated `gh` where no tool owns the operation. This gives Codex, Claude, opencode, and a terminal session one write path with the same checks. It also avoids a connector mutation that predictably lacks repository authorization while the verified `gh` session already has it. A provider-specific instruction may explain how to reach the common path. It never replaces that path with its own mutation surface. +- **Never fabricate, guess, or reuse an identifier passed to a write.** Capture every identifier a state-changing call consumes from a live query in the **same** session. This includes node, numeric, thread, and comment ids. Pass the captured value directly. Do not hand-type an id, recall it from another session, or copy it from documentation or an example. Ids commonly resolve **globally**, so a wrong-but-valid id does not fail. It writes to the wrong target, in someone else's repository. Apply the same rule to an identifier embedded in outward-facing text. Read the complete URL from the live object. Never construct a plausible link from an unverified id. If a query returns no id or URL, stop rather than invent one to proceed. +- **A write is never a probe, and a write's output is never suppressed.** Never fire a state-changing call to see whether it works: decide it should happen, make it happen, and read the result. Never append output-discarding redirection or a force-success tail to a mutation (for example `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `|| true`, `|| :`, `|| echo`), because the write's output is exactly what must be read. A write that appears to fail is **verified, not assumed harmless**, because the operation may have succeeded on the server while the client reported an error, so confirm the actual state before retrying or moving on. The ban targets hiding a *failure*. An ad-hoc call's response is the only signal you get, so `>/dev/null 2>&1`, `|| true`, and `|| echo`, which swallow the error stream or force success, are never acceptable on one. A committed script under `set -e` is a narrow exception: it may send a write's *stdout* to `/dev/null` to drop the success-response noise, because stderr stays visible and a failed write still aborts loudly (the hub's own `repo-config/configure.sh` does exactly this, and a repository reaches it there rather than carrying a copy). The exception is stdout-only suppression inside a reviewed, fail-loud script, never `2>&1` or a force-success tail, and never an ad-hoc command. +- **A refused write is reported, never re-shaped, and the maintainer's say-so does not lift a refusal by the harness.** These are two different permissions and only one of them is the maintainer's to give. When the agent harness refuses a write, the maintainer authorizing it in conversation does not change the outcome, and the identical call is refused again, so a second attempt is not worth making and reading the second refusal as a flake is how an agent starts hunting for another shape of the same request. **That hunt is the failure this rule exists to stop.** Re-expressing a refused `gh` command as a raw `gh api -X POST` reaches the same endpoint with the same identity and the same blast radius, having defeated the one control that stopped it, and it is the more dangerous version because the agent believes it has permission. So a refused write is never re-attempted through a different API surface, a different tool, or a rephrasing, and it is never routed around by the agent writing itself a permission rule, which is self-authorization whatever the maintainer said. Two routes remain, both of them the maintainer's: they add the permission rule themselves, or they run the command themselves. Raise it as a blocked decision naming those two (see "Communicating with the User"), and where the work needs the result rather than the call, say what the agent will verify once the maintainer has run it. **A refusal is also a fact about the contract, not just about the session**: where a required verification can only be performed by a write the agent is refused, the document requiring it says so and names who runs it, since a check that is mandatory and unperformable is quietly dropped and then reported as done. +- **Each task runs in its own checkout, in its own directory, on its own feature branch.** The unit is the task rather than the agent, since one agent moving between two repositories meets the same hazard as two agents sharing one tree, and a rule written per agent permits exactly the case that goes wrong. The commands that cross the boundary are the ordinary ones rather than the reckless ones, and each is correct in isolation: a blanket `git add -A` sweeps another task's uncommitted work into the commit, a `git reset --hard` deletes it, and a branch switch carries it into an unrelated change. The mechanical habit that holds the rule up is that a mutating command takes an absolute path, or a `cd` to one in the same invocation, rather than the working directory it inherited, because a read in the wrong directory is a wasted call and a write there is damage. +- **A task isolates into its own worktree before its first file edit, and a continuation re-isolates.** All new work begins by creating a unique git worktree (or clone) on its own feature branch, based on the branch work starts on for the repository's model per "Branching Model", which is `develop` unless the task is explicitly about `main`-only content. The primary checkout is the maintainer's own surface, so a session launched there isolates before writing rather than after noticing contention, and a session resuming a prior task creates a fresh worktree rather than resuming wherever its branch happens to be checked out, since a branch sitting checked out in a shared tree is exactly how two sessions end up in one checkout. The moment this rule binds is the first file edit, because the commit-time and review-time checks all run after another task's uncommitted work can already be swept. The worktree mechanics, the layout convention, and the cleanup are packaged as the `repo-worktree` Skill at `.agents/skills/repo-worktree/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, and this section keeps the rule. For Claude Code sessions, the `gh-write-guard` hook now backstops a mechanical subset of this rule directly, denying a mutating git operation (`reset`, `add`, `commit`, and most of the rest, a documented handful of exemptions such as a fast-forward-only pull kept aside) run against a primary checkout rather than a worktree. Every other agent, and everything about isolation a hook cannot see (which checkout a read happens in, whether another task is live in the tree), still relies on this prose alone. The parenthetical "(or clone)" above names `repo-worktree`'s own standalone-clone fallback, used when a linked worktree is unavailable, and that fallback is itself structurally a primary checkout to the hook's own primary-vs-worktree test, so a session using it on Claude Code sets the hook's `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT` grant for that session, per `repo-worktree`'s own instructions, rather than being silently denied the commits the fallback exists to make. +- **A checkout another task is live in is left rather than shared, and a footprint already left there is undone deliberately.** Two signals say someone else is in the tree, a branch that changes when nothing you did changed it, and an edit of yours reverted with no conflict, and the response to either is to stop rather than to re-apply the edit, which is the instinct and the wrong one. Leaving and cloning your own costs about a minute against an incident that costs the better part of an hour, so it is the cheap move rather than the cautious one. Once you have written there, leaving it alone arrives too late, so save your work aside, restore only the files you touched, verify the tree is clean, delete your branch from that clone, and then say plainly what was touched, since a regenerated report left behind reads as the other task's own and is committed by whoever runs the next blanket add. + +## Representative Data in Agent-Authored Text + +Agent-authored text illustrates with data the agent constructed, never with data it observed in the maintainer's environment. This binds every surface an agent writes: pull request and issue comments, review replies, commit messages, code, tests, fixtures, and docs. Reading real data is unrestricted, and what is bounded is what an agent copies out of the environment into text that is committed or posted. The rule holds for a private repository as much as a public one, since a repository's audience changes with one settings toggle while the text stays exactly where it was written, and it holds where the data is the maintainer's own, since the exposure happens on their behalf before they can weigh it. + +- **Synthetic evidence is the better evidence, not a weaker substitute.** A case constructed to carry the defect demonstrates it exactly and any reader can re-run it, where observed data proves the same thing and can never be reproduced by anyone else. A filename built to contain a newline is a complete proof of a newline-handling defect, and the real directory it was found in adds nothing the proof needed. Reaching for observed data to make a finding more convincing inverts which of the two is the stronger evidence. Where observed data is what revealed the defect, name its shape, meaning the property that triggers the fault, and construct a case that carries that property. +- **The exposure is one-way.** A public comment is fetched, cached, and indexed the moment it posts, so editing it afterwards is mitigation rather than a fix, and the edit leaves the original readable in the comment's edit history to anyone who can read the repository. Text that has already landed is reported to the maintainer rather than quietly rewritten, since the decision on what to do about it, deletion included, is theirs. Do not quote the exposed data again while reporting or investigating it, because a transcript, an issue, or a commit message written about the exposure reproduces it somewhere new. +- **No checker closes this.** A pattern finds an absolute home path or a drive letter, and gating that subset is worth doing as a floor. The exposure this rule exists for was name-shaped, and a name is not pattern-detectable, so a search of the offending text for path-shaped strings returns nothing while the names sit in plain sight. A gate here catches the easy half, and mistaking it for the answer is what stops anyone looking at the other half, which is why this is a judgment an agent applies rather than a check it waits for. + +## Git and Commit Rules + +The fleet's mechanical git rules: default to staging rather than committing, stage by explicit path only and never with a blanket add, commit means commit and push, every commit is signed and carries the committer's own verified GitHub `noreply` identity, never force push, a history rewrite re-identifies only the commits it touches that aren't yours, and destructive git commands run only on explicit instruction. + +This is packaged as the `git-commit-conventions` Skill at +`.agents/skills/git-commit-conventions/SKILL.md` in the hub, not a repo-relative link since that +path is hub-local and not carried into every fleet repo. The summary above sketches the contract. +Read the skill for the full rules. + +## Branching Model + +Two workflow models, set per repo by the registry `workflowModel` field. Most repos are +`release`: squash-only feature branches into `develop`, merge-commit-only `develop -> main` +promotions, forward-only with no back-merges, and two promotion traps worth knowing before the +first one (never delete `develop`, resolve an EOL-only conflict by taking `develop`'s side). +**GitHub's own "default branch" repository setting reads `main`, but `develop` is where work starts and where in-flight content lives**, so a worktree or clone that defaults to "the default branch" lands on `main` and can silently miss content already merged to `develop` but not yet promoted. Branch from `develop`, on either workflow model, unless the task is explicitly about `main`-only content. +**Operational** repos differ substantially (direct-to-`develop`, advisory CI, dispatch-only +release), covered as a delta rather than a separate model. + +This is packaged as the `branching-and-release-model` Skill at +`.agents/skills/branching-and-release-model/SKILL.md` in the hub, not a repo-relative link +since that path is hub-local and not carried into every fleet repo. The summary above sketches +the contract. Read the skill for the full rules, including branch protection configuration, the +dual-target bot wiring, and the operational-repo delta in full. + +## Release Model + +The **two-phase model is the default**: PRs build fast, publishing is batched, a human merge +never auto-publishes on its own. See [`WORKFLOW.md`](./WORKFLOW.md) for the full CI/CD contract. +Publishing fires on a manual dispatch, a code-affecting bot push to `main`, or a `main`-only +weekly schedule (Docker), and versioning is semantic and maintainer-controlled (NBGV owns the build number, +the maintainer owns the `major.minor` floor). **Operational** repos differ, with a dispatch-only +release and no auto-publish bots. See "Operational Repositories" below. + +This is packaged as part of the `branching-and-release-model` Skill at +`.agents/skills/branching-and-release-model/SKILL.md` in the hub, not a repo-relative link +since that path is hub-local and not carried into every fleet repo. The summary above sketches +the contract. Read the skill for the full rules, including the release-target build layer, the +no-op republish guarantee, the recovery routes for a package push that fails after the release is +already cut, and wrapper-repo upstream-version tracking. + +## Operational Repositories + +The registry `workflowModel` field is `release` (the default) or `operational`. **Operational** +repos track a live service's running state rather than shipping versioned units of delivery +(live-service config such as Home Assistant, ESPHome, Vantage, and home automation): commits go +directly to `develop`, CI runs on the push as advisory feedback only, a PR still exists for a +change worth reviewing, the `main` promotion gate is unchanged, and release happens only by manual +dispatch. + +This is packaged as part of the `branching-and-release-model` Skill at +`.agents/skills/branching-and-release-model/SKILL.md` in the hub, not a repo-relative link +since that path is hub-local and not carried into every fleet repo. The summary above sketches +the contract. Read the skill for the full rules, including when a config change still earns a +pull request. + +Line-ending governance for an operational repo is in [Line Endings](#line-endings), where its `[*]` default follows the consuming app's native platform per the registry `lineEndings` field, not the fleet LF default. + +### Repo-Scoped Secrets + +A repo whose own stacks or scripts read local runtime credentials from disk, most commonly an operational repo's Docker Compose stack, keeps them in a dotted `.secrets/` directory at the repo root. This is the repo-scoped counterpart to the host-scoped `~/.secrets/` convention a repo's own `OPERATIONS.md` may document, and it is a different thing from `spec/secrets.json`, the CI/GitHub Actions secret-name registry `spec/audit.py` cross-checks. `spec/secrets.json` governs what a workflow reads from GitHub Actions. This convention governs what a repo's own process reads from its own checkout. + +- **The directory is named `.secrets/`, dotted, never a bare `secrets/`.** +- **A single opaque credential file carries no extension** (`homeassistant_db_password`, not `homeassistant_db_password.txt`), the same reason `README` and `LICENSE` carry none. It is a security property, read but never sourced, not a formatting preference. +- **A structured credential keeps its format's extension** (`.json` for structured config). +- **The shared env file is named for what it configures**, not a bare `.env` (`docker.env` for a repo whose stacks are Docker Compose), so a second env-shaped file added later stays unambiguous. +- **Every real secret file has a tracked `<name>.example` beside it**, and only the `.example` files plus a `README.md` catalog are un-ignored: + + ```gitignore + **/.secrets/* + !**/.secrets/*.example + !**/.secrets/README.md + ``` + + A fresh checkout then documents its own required shape without ever exposing a real value. This negation keeps a real secret file out of a **new** commit. It does not remove one already tracked: `.gitignore` has no effect on a path git already follows. A real secret file found tracked is removed from the index (`git rm --cached <path>`) and its credential is rotated, not just added to `.gitignore` going forward. +- **`.secrets/README.md` is a catalog**, one row per file naming what it holds and what consumes it, plus a short note on how the directory relates to `~/.secrets/` where the repo also touches that. + +## Hub-Hosted Tooling + +The fleet's tooling lives in the hub once and a repository runs it from there rather than holding a copy. A carried script is current only until the next fix to it, and a repository that misses the sweep does not fail loudly, it audits itself with an older gate while reporting the same command in its output. Removing the copy removes the sweep, the stale-copy detection, and the disposition each stale copy earns, all at once. The hub is the repository [`AGENTS.md`](./AGENTS.md) "Fleet Bootstrap" names, and that section is the entry point whenever nothing else present says where it is. + +**What a repository carries and what it reaches is decided by what the content is.** It carries the content it is audited against and the configuration that describes it, meaning its rule text and the files the manifest declares. It reaches machinery whose content is identical in every repository, meaning the prose and repository gates, the review digest, and the configuration script, because a file holding no per-repo content is a copy whose only future is to go stale. A tool named in a carried rule is therefore named as the hub's, since the alternative is a pointer to a path the reader does not have, and a pointer that resolves nowhere teaches the reader that a pointer in carried text is decorative. + +**Reaching it is a checkout of the hub rather than a copy of one file.** A tool reads the payloads, tables, and sibling modules beside it, so a single file lifted out of the tree runs against whatever the caller happens to have, which is the copy problem again in a shorter loop. Read `main`, the promoted and gated state, and fetch immediately before running, because a clone is whatever it last fetched rather than the branch it names, and a stale clone answers confidently instead of failing. Name the tool by its path in that checkout and name the target explicitly, since a tool that defaults to the current directory or the current repository resolves somewhere either way, and a result computed against the wrong repository is well-formed. Which directory the command runs in is the tool's own contract rather than a rule here, so a gate reading a working tree runs in the repository under test while a tool taking its target as an argument runs anywhere and is given one. What the rule forbids is letting a default decide which repository the answer is about. + +**A loader is outside this section rather than exempt from it.** The rule above governs a tool that reads hub content, because a tool reads the payloads, tables, and sibling modules beside it. A loader reads none of them: it obtains a tree and hands control to a tool inside that tree, on a host that cannot yet obtain one. The bound is what it may contain rather than who runs it, and it is one line: a loader references no path inside the tree it fetches except the single entry point it hands control to, and everything else it touches is the machine or the network. A loader that grows a second path into that tree has become a tool and is governed above. + +**A report or finding a hub tool produces names the hub commit it ran from.** The tool moves independently of the repository it measures, so a verdict carrying no hub commit cannot be re-run, and two runs that disagree cannot be attributed to the tree or to the tool. The obligation is the runner's rather than the tool's, since a tool reports on the repository it measures rather than on itself, so the commit is read from the hub checkout and written into the report beside the verdict. This is the same requirement "Verification Discipline" places on any claim that gets acted on. + +**CI reaches the same tooling as a pinned action or reusable workflow.** A runner holds no hub checkout, so a workflow consumes the hub's composite action or reusable workflow and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". A standard workflow whose job graph is identical across repos of a type is reached the same way, as a `workflow_call` task the hub hosts once, and the repository carries only the caller stub and a composite-action hook for what is genuinely its own. The pin is what makes a released repository's gate reproducible, since an unpinned consume lets a later hub commit fail a re-run of a change that already passed. Branch-dependent behavior belongs inside the consumed action, because `uses:` takes no expressions and a per-branch ref therefore cannot be selected in the workflow file. + +**An unreachable hub means the tool did not run, and that is the result reported.** A carried copy still works offline and a reached one does not, which is the cost this model trades away and the reason to state the failure rather than route around it. A check that cannot run reports itself as not run, never as clean, which is the silent-narrowing failure "Verification Discipline" names. A hand-rolled substitute is not the tool either: a reconstructed gate encodes its author's reading of the rule rather than the rule, agrees with no other repository, and is the duplicated effort this model exists to end, so an agent that cannot reach the hub says so and stops. + +## Pull Request Title and Commit Message Conventions + +A PR title and a commit message share one contract: an imperative subject, 72 characters or fewer, no trailing period, no vague titles like `update stuff` or `wip` (Dependabot's `Bump X from Y to Z` is fine as-is), no unsolicited `Co-Authored-By:` lines, and no release-bump magnitude in the title, since Nerdbank.GitVersioning computes the next version from `version.json` and git history. + +This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full format, rules, and examples. + +## Documentation Style Conventions + +The fleet's prose and formatting contract, applied to docs and code/workflow comments alike. It governs what a carried file may reference, Markdown link, heading, and tense structure, and the comment philosophy. It also holds the ASCII character-set tiers, the line-ending policy, the sentence-structure house style, the ban on naming an issue, a pull request, or a commit in a comment, a docstring, or an instruction document, and the rule keeping a quantitative claim honest. + +This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment-and-doc-style/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the full rules. + +### Comments + +The full comment philosophy, what earns one, structure, capitalization, growth discipline, is in the `comment-and-doc-style` Skill referenced above. + +### References + +No comment, no docstring, and no instruction document names an issue, a pull request, or a commit, and a commit message and a pull request body each carry theirs as usual. Two carve-outs. In a code or workflow comment, a URL naming an issue or a pull request on a public repository other than this one is a source citation and is permitted. And a record whose subject is the revision itself keeps it, which is what lets a disproved-claims entry in `.github/copilot-instructions.md` name the revision its proof was read against. Whatever neither carve-out affirmatively permits is banned by the sentence above them, which is the whole of the test. The reasons, the surfaces the ban reaches, the narrative files it leaves alone, and what to write instead are in the `comment-and-doc-style` Skill referenced above. Separately, `AGENTS.md`, `GOVERNANCE.md`, `CODESTYLE.md`, and `WORKFLOW.md` carry no three-part version and no commit SHA, full or abbreviated, whether it is a pin's value, an illustrative example, a minimum version, or a fixed constant. A pin lives in the workflow or manifest that uses it, where Dependabot moves it, so a copy in prose is stale at the next bump, and every other kind reads exactly like one, which is why a check cannot tell them apart. A two-part language or runtime version, such as a minimum Python minor, is how such a rule is stated and stays. Each file names the mechanism instead, such as a reference SHA-pinned to a hub release, writes an example with a placeholder, such as `1.0.N`, names the manifest or skill that holds a minimum version rather than the number, and describes a fixed constant, such as the all-zero placeholder version, rather than quoting it. Neither carve-out above lifts this ban. + +### Character Set + +The full ASCII tier system (never legitimate, legitimate next to a number, always legitimate, developer-typed Unicode) and the semicolon and spaced-hyphen rules are in the `comment-and-doc-style` Skill referenced above. + +### Line Endings + +The full CRLF/LF policy (`.editorconfig` and `.gitattributes` defaults and pins, choosing an ending for a new file type, operational-repo overrides, editing discipline, and auditing) is in the `comment-and-doc-style` Skill referenced above. + +### Sentence Structure + +ASD-STE100's structural half is the adopted house style: short sentences, one instruction per sentence, active voice, and imperative mood for procedure steps. Its controlled dictionary is deliberately not adopted. The full rules, the sentence word cap, and the opt-in `sentence-length` check that enforces the cap are in the `comment-and-doc-style` Skill referenced above. + +## Verification Discipline + +The checks that separate work actually done from work that merely reports success. A pattern that matches less still exits zero, and a gate that stops gating still reports success. + +- **Locate every check a change owes before running any of them, and CI's coverage is not that list.** The checks are read from what the repository declares, meaning its `OPERATIONS.md` "Local Verification" section alongside the workflows, rather than inferred from whatever the pipeline happens to run. Part of a repository's contract is routinely unreachable from a runner, a redirect no build serves, a deploy no pull request performs, hardware no runner holds, so the check covering that part lives in a document rather than in a workflow and is run by hand before the pull request opens. Green is then the precise signal that it was skipped, because the pipeline reports success over the half it reaches while saying nothing about the half it cannot. Reading a document's own description of itself is not how such a check is found, since a topical document is named for its most visible function, usually a post-merge one, and an accurate description of that function routes a pre-merge task away from the file holding the gate. The destination is declared fleet-wide for that reason, rather than left to how well each repository worded a pointer to it. A repository whose `OPERATIONS.md` carries no such heading, or carries no such file, is missing content it owes: read that file whole where it exists and the workflows beside it either way, and report what is absent rather than reading its absence as an answer that no local check applies. +- **A test runner failing to spawn is not evidence that no test coverage applies here.** `uv run pytest` failing to spawn in a lint-only Python Scripts profile is that profile working as intended, not a missing dependency, per the `python-codestyle` Skill's Two Profiles. Read the actual invocation from the same `OPERATIONS.md` "Local Verification" section the bullet above names, rather than guessing a generic test-runner command, and report that document's own command result, not the guessed command's failure. +- **A test must assert the mechanism it names, and a gate has to be watched failing.** Label each case by the behavior it proves, then write the case that reintroduces the fault and confirm the gate objects to it. A case that passes for an incidental reason, the right answer reached by the wrong path, is worse than no case, because it is later cited as evidence. A proof that restates the gated data instead of reading it proves only that the function works, so drive the real table or the real config. And a gate that finds nothing is indistinguishable from a gate with nothing to find, so assert a floor on what a healthy run covers. +- **Gates, filters, and gate-like watchers fail loud, never narrow quietly.** A pattern that silently matches less, an allowlist that silently stops matching, or a gate that silently stops gating all report success while doing nothing. When a construct exists to notice something, make the not-noticing case produce an error or an annotation. An identity allowlist used as a gate, for one, must raise an error when its list stops matching, not silently pass everything through. +- **Config with a uniqueness rule is validated on read, and its consumers assert what it promised.** A repeated key in a lookup table is not a precedence question to settle quietly, it is two answers to one question, and keeping whichever came last picks one of them where the reader sees no choice being made. Fail on the duplicate at the point the config is read, so the code downstream can rely on the invariant instead of re-deriving it. +- **Validate and read on the same normalized key.** A guard that compares stripped names while the join looks up the raw one passes a padded key and then matches nothing, so the exact fault the guard exists to stop is sitting inside the guard. Normalize once at the boundary and use that one value for both the check and the lookup. +- **Every push toward a pull request is preceded by a local adversarial review of the branch's whole diff, and the pass is recorded.** The rule binds every push rather than the first one, so a fix push answering a reviewer's finding owes a pass exactly as the branch's first push did, and that is the round it is actually skipped on: the fix looks small, the branch was reviewed once already, and what goes up is content no review has read. Skipping it does not save the round, it moves it, into the fix-commit and review-comment cycle that spends wall-clock, Actions runtime, and agent tokens finding what a local pass would have. The pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, and `scripts/local_review.py` records it keyed on the content the reviewer actually saw, so a capture point can ask whether a receipt still covers what is about to be pushed rather than trusting the rule to have been remembered. The pass is mandatory and its findings are advisory, which are opposite claims worth keeping apart: a pass is recorded whether it raised ten findings or none, and disposing of each one is judgment, per `GOVERNANCE.md` "PR Review Etiquette". +- **Canonical content one repo authors and others carry is read the way a carrier reads it, whole, in the repo that can fix it, and that read is swept periodically rather than owed by a push.** Such content is written and merged against a diff of a few lines, and reaches a reviewer as a new file, in full, only when a repo carries it for the first time, so the first real read of a rule happens where nothing can be done about the result: the tree is manifest-owned, the copy is compared against the authoring repo's, byte for byte wherever the declared fidelity is verbatim, and a local edit there is drift on the next fidelity check. Where the fidelity is intent the carrier may adapt its own copy, and the defect still has to be fixed at the source, since every other carrier holds it too. Every carrier after that re-discovers the same defect, and the finding arrives in a session holding no checkout of the authoring repo and no standing to test the claim. The unit is what a reviewer reads whole, and the carry manifest, `spec/files.json` in the hub, rather than the document decides which, down to which files carry units at all, so the engine that reads that manifest is the authority on the set rather than any restatement of its rules. In the ordinary case a unit is one level-two section of a carried Markdown canonical, which is the fidelity unit `spec/section-model.md` declares. The read is of the unit's whole current text rather than of the diff that moved it, and the pass itself, its delegation shape, and its model tier are the `local-strict-review` Skill's, exactly as they are for the pass above. **The read is swept because owing it at every push cost too much to keep owing it there.** Measured across this fleet's review rounds, the passes a push owed were a large share of what a pull request spent, and what they returned was never measured against that, so the read moves to a schedule on the cost alone rather than being owed by whichever change happens to touch a unit. A change that moves a unit is no longer refused over one, and no capture point asks a change for a pass of this kind, the diff pass the bullet above requires being owed by every push exactly as before. What replaces it is a schedule in the authoring repo, which gathers the work into one piece and files it where an agent session can run the passes and fix what they find. That work is every unit whose text has moved past the pass that read it, plus a bounded slice of the units nothing has read there at all, taken newest-committed first. The slice is what keeps a newly authored unit from waiting on a volunteer, since such a unit has no earlier pass to move past and would otherwise reach a carrier with nothing having asked to read it, which is the case this whole rule is about. A unit newly carried by widening the manifest alone is not reached that way, the order reading the unit's own file rather than the manifest, so it joins the backlog at that file's age, where a section written and declared in one commit leads like any other newly authored one. Bounding it is what keeps a long backlog from arriving as one week's work. `scripts/canonical_review.py` records each pass keyed on the content the reviewer saw and names both sets, so the sweep's list is read off that record rather than remembered, and its ledger is tracked content the change carrying it commits like any other. Like the pass above, a pass the sweep asks for is mandatory and its findings are advisory. +- **Another round of edits after either pass is owed only while a defect this change introduced is open, never by a finding count.** Which findings count as introduced, what each class owes, and how many rounds a push may spend are the `local-strict-review` Skill's. +- **Run the repo's whole lint gate before every push, not the parts that look relevant.** CI runs all of them, so a partial local run only defers the failure, and the tool most likely to catch a given change is often the one it seems least about (an edit that manipulates line endings is exactly when `editorconfig-checker` matters). The repo documents each linter's known-working invocation, and this rule is that **all** of them run. +- **Editing CRLF files programmatically: `.` matches `\r` in a regex**, so a captured line keeps its carriage return and rejoining with `\r\n` yields `CRCRLF`. Prefer literal replacement over regex reassembly. In Python the *default* path is a text-mode rewrite, which has the mirror failure: `Path.read_text()` decodes through universal newlines and `write_text()` translates each `\n` back to `os.linesep`, so a read-edit-write round trip rewrites every line ending in the file to the host's own while the edit itself looks correct. Work in bytes, or open the file explicitly with `newline=''` on both the read and the write, since a read that preserves the endings still hands them to a write that translates them. Use `open()` rather than `Path.read_text()`, which accepts that argument only on Python 3.13 and newer and raises `TypeError` below it. The corruption is worth naming because it is invisible in a rendered diff. +- **Scope a check by what the project declares, not by the file that prompted it.** A check written while editing one file tends to cover that file's language and stop, and then reports success on every other surface the rule governs. Read the declared types, or the config that enumerates them, and cover each one, then assert a floor per surface so a table that narrows fails loudly instead of passing quietly. A rule about comments means every comment syntax the project ships, and a format that carries comments in practice counts even where its specification says otherwise. +- **Never write source text carrying backslash escapes through a shell construct that interprets them.** A `printf` format string, a `printf` argument consumed by `%b`, `echo -e`, POSIX `sh`'s builtin `echo`, and `$'...'` each consume the escape and write an invisible control character in its place, so a `\b` inside a regex becomes a backspace and the pattern silently matches nothing while every test still passes. A quoted heredoc, `<<"EOF"`, is not one of those constructs and writes every backslash literally. An unquoted `<<EOF` is one of them, because it collapses `\\` to `\`, so source carrying `"\\bword\\b"` reaches the file as `"\bword\b"` and the pattern compiles to a backspace. Use a file-editing tool for such text where one is available, and a quoted heredoc where the work has to happen in a shell. When a check inspects text for control characters, use `str.isprintable()` rather than a codepoint floor, since DEL and the Unicode format characters sit above 32 and are equally invisible in a diff. That test calls `\n` and `\t` unprintable too, so apply it to the tokens the text is made of, or exempt the whitespace it legitimately carries, rather than running it over whole lines. +- **Never edit an active `.code-workspace` file.** A workspace file rewritten on disk can make VS Code reload the window, and a reload destroys the running agent session's context, so the work in flight is lost with nothing to catch it, and the trigger is not fully characterized (an agent's edit has caused the reload where a human's identical edit did not). Surface the needed change for the maintainer to apply by hand. +- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in the aggregated required check. When a job exists to exercise something, confirm from its log that it ran and produced the output it promises. +- **A local clone is not the branch it names, it is whatever that clone last fetched.** Reading a checkout on disk answers what that clone last saw, so a finding taken from one carries a date nobody stated, and two failures of exactly that shape are on record from one session: a repository reported as still drifted on a file whose fix had already merged, and a repository reported as missing a file it carries because the checkout sat on an older branch. Read the live ref through the API where the claim will be acted on, or fetch immediately before reading, and name the ref and the commit in any finding a local read produced. A clone stays the right tool for anything needing history or a build, which an API read cannot give. +- **A checkout already sitting on disk is not yours to trust for being there.** A clone or worktree this session did not create, found while looking around a machine, may belong to another concurrent session's task, sit on a stale fetch or a branch nobody expects, or hold uncommitted edits nobody has reviewed, and none of that is visible from the directory listing that found it. Running `git status`, `git remote -v`, or `git branch --show-current` against it, or reading a file inside it, answers for whatever that checkout happens to hold at that moment, not for the repository, and the found checkout is not the "local clone" the bullet above means, since this session never fetched it and has no basis for trusting what it last saw. Clone the repository fresh into a location this session controls, or read the live state through the GitHub API, rather than adopting a pre-existing checkout as ground truth. +- **A "does not exist" claim names the branch it was checked against.** A worktree or checkout answers for whichever ref it was built from, and that ref is not necessarily the one the content lives on: a `release`-model repo carries in-flight content on `develop`, per `GOVERNANCE.md` "Branching Model", well before it reaches `main`, so a worktree defaulted to the fleet's default branch can hold nothing while the repository holds everything. Before reporting a file, a directory, or a piece of content as absent anywhere in a repo, check it against the branch the repo's own model designates as current for that kind of content, not only whichever branch a worktree or checkout happened to default to, and name the branch the negative claim was checked against in the finding itself. +- **A raw-file fetch 404s the same way for a private repository as for a genuinely missing file.** `curl`ing `raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>` returns an indistinguishable 404 whether the repository is private, the ref does not exist, or the path is wrong, so an agent that treats that response as "the content does not exist" has made the same unstated-branch mistake the bullet above names, only over visibility instead of branch. Where a repository's visibility is not confirmed public, read its content through the contents API with the raw media type instead, which hands back the bytes themselves and leaves no decode step to fail quietly: `gh api -H "Accept: application/vnd.github.raw" "repos/<owner>/<repo>/contents/<path>?ref=<ref>"`. Take the base64 `.content` field only where something needs the JSON around it, and then read `.encoding` alongside it, because a blob over 1 MB comes back with `content` empty and `encoding` set to `none`: the call succeeds, `base64 -d` decodes the empty string successfully, and the result is the failed-fetch-read-as-an-empty-success this bullet exists to prevent. Either form is its own command whose exit status is read before its output is used, never a producer piped straight into a consumer that reports only its own status. `gh api` writes a failed call's error body to standard output, so an unchecked capture or redirect stores that error where the content was supposed to go, and merging the error stream in with `2>&1` puts it inside the payload rather than beside it. Verify the ref resolves (a commit SHA is unambiguous where a branch name may have moved, been deleted, or never existed on the remote) before reading either failure as an answer about the content itself. +- **A launched process is not a result, and a cause nobody observed is not a diagnosis.** "The watcher is armed" names a process rather than a finding, so what gets reported is the output that process produced, and where it produced none, that absence is the report. The failure it prevents is an agent standing still on a condition that was met half an hour earlier, having announced the wait and never read it. Naming an external cause for such a stall afterwards, a throttle or a quota that appears nowhere in the record, turns a local defect into a story about someone else and closes the investigation on the wrong party, so read the record for the cause before naming one, and where the record does not carry it, report the cause as unknown. +- **A workflow change is only fully exercised by CI.** Extracting a `run:` block and executing it locally validates the script and nothing else, because `secrets: inherit`, `permissions:`, `needs:` wiring, and reusable-workflow inputs resolve only in a real run. +- **Platform-specific code is "verified" only on the platform it runs on.** PowerShell on Windows, a macOS-only `mktemp`/`ssh-agent` behavior, a WSL-specific path quirk: an agent reasoning about such code from a different host, however carefully, has not executed it, and reasoning by structural analogy to an already-tested equivalent on another platform ("the POSIX version works, so the PowerShell version should too") is a plausible first pass, not verification. State it as exactly that, an unverified structural match, and never in the same words used for a tested fact. When no agent in the loop has access to the target platform, say so, and either defer the platform-specific portion to a human or an agent that has that access, or ship it clearly labeled unverified. +- **A review flags an instance, so a fix covers the class, bounded to what this change touched or broke.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, and the finding is being fixed, sweep for its siblings before replying, since reviewers sample rather than enumerate, and fix each sibling that sits in a file the diff already touches. A sibling the change itself put in disagreement is this change's to fix wherever it sits, because the change made it wrong. A sibling that was wrong before the change and sits in a file the diff does not touch is filed rather than folded in, because every file the diff grows into is one more that each round reads again, so a sweep that widens the diff widens the loop it was meant to close. + +`GOVERNANCE.md` "Verification Discipline" keeps the full rules, and the `agent-conduct` Skill at `.agents/skills/agent-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries it whole as a generated include and surfaces it at its decision moment. + +## PR Review Etiquette + +The provider-agnostic review-loop contract every fleet repo follows starts when a pull request opens. Open every fleet-owned pull request ready for review. Draft state is reserved for the separately documented upstream contribution workflow while a third-party contribution is still being prepared. Creating the pull request is not a terminal handoff. Run the review status once in the foreground. Then start the bounded review wait in a background process. Request a review on every push. Confirm it covers the current head SHA and the full diff rather than only part of it. Where the round covering the head states no coverage at all, read the newest round that does state some as covering this head only where the pull request changes the same set of files at both commits, a head round's own statement always winning over a carried one. Triage every finding, including low-confidence findings collapsed into the review body rather than threads. Reply to and resolve every addressed finding. Repeat after every fix until the checks are green and the current-head review leaves no finding open. Only an explicit maintainer instruction may stop, defer, or alter this default. Silence or a request that says only "open a PR" is not such an instruction. Never merge on a green or CLEAN merge state alone. That state does not prove the review covered the current head SHA and full diff. It also does not expose unanswered low-confidence findings that opened no thread. + +This is packaged as the `pr-review-conduct` Skill at `.agents/skills/pr-review-conduct/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo. The summary above sketches the contract. Read the skill for the merge gate, the expected loop, and how a finding is closed. + +The provider-specific mechanics this contract needs to actually drive GitHub Copilot, how to request a review, poll for it, read and answer the suppressed findings, verify coverage, and reply to or resolve a thread without a hand-typed id, are implemented by the hub's `scripts/pr_review.py`, per "Hub-Hosted Tooling" above. The runbook a repository carries for the same loop is [`.github/copilot-instructions.md`](./.github/copilot-instructions.md) "GitHub Copilot Review Runbook". + +## Communicating with the User + +- **Reference every pull request as a clickable link.** When you mention a PR on a surface that renders Markdown (chat, a summary, a report), render it as a Markdown link to the PR (`[#N](https://github.com/OWNER/REPO/pull/N)`), never a bare `#N`. The same applies to issues and commits. **The form follows the surface.** Some surfaces link neither a Markdown link nor a bare URL, an interactive prompt's question and option text among them, and pasting a full URL into one of those does not rescue it, since the reader gets a string to copy, which is the outcome this rule exists to prevent. There the reference is a bare `#N`, and the clickable link goes in the message that comes **before** the prompt rather than merely alongside it, because the prompt blocks on an answer and a message emitted after it is read once that answer is already given, which is the one moment the link is no longer any use. The test is whether the reader can click it where it is read, not whether it was written in the syntax that works elsewhere. +- **Ask every question through the interface's prompt, never in prose.** When you need the user to decide, answer, or approve anything, put it to them through the interface's own prompt mechanism, whether or not work is blocked on it, and an offer to do more work ("want me to file that?") is a question like any other. A question written into a message is lost in the report around it however short it is, and one closing a long report sits where the reader is least likely to reach it. Where no prompt mechanism is available, the questions go as a numbered list opening the message rather than closing it, so the user can reply per number. This bullet settles how a question is put once it is asked, and whether a question is asked now or recorded for later is the parking bullet's to settle, the one opening "A question filed as an issue is parked rather than asked". +- **Raise work blocked on the user as a direct interactive prompt.** When progress needs a decision, an authorization, or an answer only the user can give, ask for it through the interface's own prompt mechanism, at the point the work stops. Never leave it as prose in a summary: a handoff buried in a paragraph is a handoff that did not happen, because a summary reads as a report of finished work and the one line still waiting on the user is the easiest in it to skim past. The blocked item is the message, not a closing remark on a message about something else. **The options offered are the actions themselves**, and the one that unblocks the work names the action it authorizes ("squash and merge it"), so selecting it is the go-ahead rather than a note to act on later. Offering only ways to wait is the same failure in interactive clothing, since a prompt whose every choice is inaction reports the block rather than clearing it, and where the agent may not perform the authorized action itself, the option says who does it. Where no interactive prompt is available, the numbered list the bullet above names is the fallback. +- **Lead every choice with a recommendation and its reason.** Wherever the user is asked to choose, in a prompt or in a numbered list, the option the agent recommends comes first and is marked as the recommendation, and every option states the reason for it. A user who takes the recommendation then reads one line, and one who does not sees what the other options trade away. A question with no defensible recommendation says so rather than inventing one. Where an open question has no options and the agent does have an answer, that answer and its reason go in the question's text, never as an invented option. +- **A question filed as an issue is parked rather than asked, and it stays owed.** Where the work cannot continue without the answer, the interactive-prompt bullet governs and the question is asked at the point the work stops. Wherever the question is recorded rather than asked, whether because the work can continue without the answer or because the question was asked once and deferred, recording it is the right thing to do and recording it is still not asking it, so the issue carries the fleet's `decision` label and, when the filing session knows the choices the question is between, states them, which is what lets a later session, one that was not there when the issue was filed, find the question and put it to the user without inventing its answers. **The parked queue is the failure, not the parking.** An issue holding a question the user has never seen reads to every later session as tracked work rather than as a block, so each session files correctly and moves on, and presenting the accumulation is the step nobody owns. +- **The session that writes a handoff presents the parked queue in the same act.** This binds at the moment the session writes the handoff that `AGENTS.md` "Session Scope" defines, rather than at the moment the session ends, because a session also ends by interruption, where no agent acts at all and no rule reaches it. The session enumerates this repository's open issues carrying that label, states how many issues the queue holds, and puts the highest-ranked of them to the user as questions, **one question per parked decision, carrying that decision's own answers as its options**, which is the interactive-prompt bullet's own shape applied per decision rather than a single prompt whose options are topics. An issue that states no choices is put as the open question it is, never as invented options, since the label marks every decision waiting on the user rather than only the ones filed under this rule, and the issues that already carry the label predate the rule. Rank them longest-waited first. Every issue records how long it has waited, so any session can reproduce that order. Promote one ahead of that order where it blocks work in flight, and say in the handoff that it was promoted, since two sessions order the same queue differently where the key is subjective and nobody records it. Where more are parked than one round of questions can carry, the stated count still covers every one of them and the handoff names the remainder by issue number in that order, a list of numbers rather than of questions, which is what keeps the handoff inside the size rule that `AGENTS.md` "Session Scope" sets for it. **A count nobody states is a queue nobody can see**, which is how a backlog reported as healthy hides the questions inside it. Where a user is present but no prompt mechanism is available, the questions go as the numbered list the interactive-prompt bullet already names as its fallback, which reaches a reader who is there to read it. Where no user is present at all, the session asks nothing, names the whole queue by issue number in the handoff, and leaves the asking to the next session that has one. An item leaves the queue when the user answers it, and also at triage where a later change has already answered or overtaken its decision. Either way it leaves by losing the label, with the answer or the reason recorded on the issue, and the issue itself closes only where it held nothing but the question, since the queue holds issues that outlive their decision, and closing such an issue just to clear the queue discards the other work that issue tracks. + +`GOVERNANCE.md` "Communicating with the User" keeps the full rules, and the `agent-conduct` and `session-handoff` Skills at `.agents/skills/agent-conduct/SKILL.md` and `.agents/skills/session-handoff/SKILL.md` in the hub, not repo-relative links since those paths are hub-local and not carried into every fleet repo, each carry it whole as a generated include and surface it at its own decision moment. + +## Workflow YAML Conventions + +These conventions bind every workflow. Several of them [`WORKFLOW.md`](./WORKFLOW.md) section 4 also states as guarantees, and not only at D9, so where it does, a violation of an *applicable* one is a defect that makes the workflow **not operational**, on the same terms as any other. Each D-item names its own constructs, so read section 4 for which rule binds where rather than a mapping kept here. The target-state framing below settles *when* an unswept workflow is fixed rather than *whether* its violation counts: new and modified workflows respect these rules now, and the rest of the repo is brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. + +An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket precedence. This section keeps the full style rules and wins on them, stating each in more detail than the guarantee that carries it, while `WORKFLOW.md` wins on the architecture, the contract, and the test methodology. `WORKFLOW.md` section 2 points at this section rather than restating it. Throughout, a job is named by its id and a step by its `name:`. The `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/SKILL.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, surfaces it. + +- **Action pinning**: pin **every** action, first-party (`actions/*`) and third-party alike, to a commit SHA with a trailing `# vX.Y.Z` comment, so Renovate / Dependabot can still bump it but a tag swap can't change the executed code. This binds a `uses:` wherever it appears, in a workflow and in a composite action under `.github/actions/**` alike, except a local (`./`) or self-repository (`$/`) reference, which names no ref to pin. Use `# vX` (major-only) only when the upstream's floating major tag doesn't correspond to a specific patch/minor release SHA, since pinning to the floating-tag SHA still gives the SHA guarantee, the version comment just records the major line. Documented exception (no SHA pin at all): `dotnet/nbgv` is consumed via `@master` because the upstream tag stream lags `master` substantially and Dependabot's tag-tracking would propose a downgrade. **This applies to repo-owned build-layer leaves too**, since a leaf owning its build specifics is not a reason to use floating tags, and Dependabot still bumps SHA pins (updating the SHA + version comment). +- **Filename**: a workflow declaring `on: workflow_call` ends in `-task.yml`, **whatever else it is also triggered by**, since that is the half the suffix is about. A workflow without `workflow_call` is an entry point (`push`, `pull_request`, `pull_request_target`, `schedule`, `workflow_dispatch`) and takes no `-task` suffix, ending instead with what it does: `-pull-request.yml`, `-release.yml`. The suffix says the file is meant to be `uses:`-d, which stays true of a file that is also dispatchable. Composite actions are named by their path (`.github/actions/<name>/action.yml`), so these suffix rules do not reach them. +- **Workflow `name:`** (the top-level `name:` field): a workflow declaring `workflow_call` takes a name ending in **"task"** (e.g. `Build project release task`), matching the filename rule above and covering a file that is also dispatchable, and every other workflow takes one ending in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The suffix tells an orchestrator from a callee while reading the source tree, and on the runs list for an entry point. It does not do that in the Actions UI for a callee: a called reusable workflow's jobs appear nested inside the caller's run as `<caller job> / <callee job>`, and the runs list shows the caller's workflow name rather than the callee's own. +- **Job and step `name:` suffixes**: every job's `name:` ends in **"job"** and every step's `name:` ends in **"step"**, including the PR-gate aggregator, whose `name:` is a required-status-check `context:` in a branch ruleset (`Check pull request workflow status job` in `test-pull-request.yml`). A trailing parenthetical qualifier after the suffix is allowed and is the only exception (`Upload coverage to Codecov step (Python)`), and nothing enforces the rule mechanically. A ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, or required-status-check enforcement silently breaks. Every surface whose staleness breaks that enforcement moves in the same change, never one without the others. In a repository the surfaces are the live ruleset and its own workflow. A rename of the fleet-wide string additionally moves the hub's `repo-config/` payloads, its `spec/files.json` `requiredCheckName`, and each adopter-facing stub in its `catalog/` and `docs/reusable-workflows.md`, which exist only in the hub. Prose naming the old string goes stale rather than breaking, and follows behind. +- **Concurrency**: top-level workflows declare `concurrency: { group: '${{ github.workflow }}-${{ github.ref }}', cancel-in-progress: true }` so a fresh push supersedes an in-flight run on the same ref. `cancel-in-progress: false` queues instead of cancelling, and queuing is not ordering: GitHub holds at most one pending run per group and cancels the previously pending one when a newer run queues, so the guarantee it buys is that a **running** job finishes rather than that every event runs in arrival order. **Documented exceptions**, each recording its rationale inline in its own header comment: (1) a merge-bot workflow keys the group on the **PR number** (`-${{ github.event.pull_request.number }}` rather than `-${{ github.ref }}`, which under `pull_request_target` is the base branch and would serialize every bot PR against it) and takes `cancel-in-progress: false`, because cancelling mid-flight would leave auto-merge enabled or disabled inconsistently (`.github/workflows/merge-bot-pull-request.yml`). (2) A publisher uses a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) with `cancel-in-progress: false`, because it publishes shared ref-independent outputs (both branches' Docker tags and caches, and GitHub releases) and its triggers need not agree on a ref, so a ref-scoped group would let two runs double-push, and cancelling one can leave a partially pushed tag set or a half-created release (`.github/workflows/publish-release.yml`). (3) A deploy workflow keys the group on the **environment** it deploys with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and the pointer unflipped. No workflow in this repository implements it, the deploy task being reusable rather than top-level, so the rationale lives here rather than in a header comment. (4) A workflow whose only write is one repository-scoped issue keys on the **workflow alone** with `cancel-in-progress: false`, because nothing it writes varies by ref, so a ref-scoped group would separate a scheduled run, which always runs on the default branch, from a dispatch made anywhere else, and let each read no open issue and each file one, and a cancel between two writes can leave the first landed and the second unmade. The one workflow implementing it is hub-only, so a repository carrying this section holds no such file and finds the rationale here rather than in a header comment. +- **Shells**: every bash surface, a multi-line `run:` block and every committed bash script alike, starts with `set -Eeuo pipefail`: fail fast, fail on undefined vars, fail on a failed pipe segment, and let an `ERR` trap inherit into functions, subshells, and command substitutions (`-E`). A shebang naming bash makes a script one whatever its extension. A single-line `run:` block is outside the rule. A one-liner that pipes or chains commands takes a multi-line block instead, since without `pipefail` a failed producer reads as success. The `-E` is defense in depth: the fleet ships no `ERR` trap today, so a script that later adds one inherits the behavior instead of silently losing it. A deliberately POSIX `#!/bin/sh` surface, a git hook that must run before any toolchain exists being the case in practice, is not a bash surface: it takes `set -eu`, dropping `-E` and `pipefail`, which `sh` does not carry. +- **Conditionals**: multi-line `if:` uses the folded scalar `if: >-`, which joins the wrapped source lines back into one line. `WORKFLOW.md` D9.3 requires it. A literal block (`if: |`) evaluates the same, the expression lexer skipping newlines along with other whitespace, so this is a legibility rule rather than a correctness one, and it binds as a guarantee regardless. +- **Boolean inputs**: a workflow triggered both via `workflow_call` and `workflow_dispatch` declares each boolean input in *both* trigger blocks, since one declaration does not propagate to the other. Which context reads it then decides the comparison, and the two are not the same. The `inputs` context **preserves the declared boolean** on both paths, so `if: ${{ inputs.foo }}` is read directly. The `github.event.inputs` context delivers **every** input as a string whatever its declared type, so a read through it is compared against `'true'`. Comparing a `github.event.inputs` read against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs.foo == true` is false even on the run where the input arrived as `true`. A both-forms comparison on an `inputs` read is merely redundant. `WORKFLOW.md` D7.3 is the contract this bullet's rationale serves, and wins on any disagreement. +- **Validate input/state consistency at entry, fail fast**: when a workflow's inputs must satisfy a cross-input or input-versus-derived-state invariant (e.g. the release branch must match the computed version's prerelease status, or two inputs are mutually exclusive), assert it **once** at entry, before any expensive build or publish work, rather than as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Where later **jobs** depend on the assertion, it is a job of its own that they `needs:`, since `needs:` takes job ids and cannot name a step. Where the work it guards is in the same job, an entry step in that job is enough. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions), and the input-validating entry step in `publish-docker-readme-task.yml`'s own first job, which also resolves the repository list, so a consumer of it depends on that job rather than on the validation alone. +- **Reusable workflows**: job-level `permissions:` are validated *before* the `if:` evaluates, so even a skipped job needs valid permissions declared. A `release` job with `permissions: contents: write` and `if: ${{ inputs.publish }}` will still cause `startup_failure` on a caller that doesn't grant `contents: write`. So declare an inner block only where **every** caller grants that scope at startup, and otherwise omit it and run under the calling job's grant, declaring the scope at the call site. +- **Allowlist `success` and `skipped` explicitly** when chaining jobs across optional dependencies, since `!= 'failure'` lets `cancelled` through (timeout, runner failure, manual cancel). Use `(needs.X.result == 'success' || needs.X.result == 'skipped')`, and pair it with a status-check function. An `if:` carrying no such function has `success()` applied implicitly, and that implicit `success()` is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. Either `always()` or `!failure() && !cancelled()` serves, the explicit `success`/`skipped` allowlist beside it being what excludes a failed or cancelled dependency either way. They differ on a cancelled **run** and on a failed sibling `needs:` job, both of which `always()` still runs through. That is why `WORKFLOW.md` D1.5 requires `always()` of the pull request aggregator, which has to report a failed or skipped dependency rather than skip with it. +- **Artifact retention**: an explicitly uploaded workflow artifact is an intra-run handoff only, so it must not survive the run and accumulate against the small account-wide artifact-storage quota. **Clean up each transfer artifact surgically at its point of consumption**: the job that downloads it deletes it by exact name/pattern right after consuming it, under the **condition that made it redundant**, which is the half of the consumption whose failure would mean it is not redundant yet. Where the consuming step is conditional, that condition is the consumer's: the `github-release` job deletes `release-asset-<branch>-*` under the release-create step's own condition, narrowed by `inputs.expect_release_assets`, so a no-op re-run that skips the create skips the delete with it and leaves the freshly built assets alone. Where the consuming step always attempts once its job runs, the condition is the download's: a package repo's `publish-release.yml` deletes `nuget-build-<branch>` or `pypi-build-<branch>` in the `publish-<target>` job under `if: ${{ !cancelled() && steps.<download-step-id>.outcome == 'success' }}`, because the artifact has served its handoff once it has been downloaded, whether or not the push that followed succeeded. Recovering a failed push is a rebuild rather than a re-download, and "Release Model" above routes to what that costs. A delete left to the implicit `success()` would skip on exactly that failed push, and the `!cancelled()` suppresses that implicit `success()` the way any status-check function does. That implicit `success()` is the same mechanism the optional-dependency bullet above names, reached there by a skipped `needs:` job and here by a failed prior step. Deletion needs `actions: write` granted on that job, and for a reusable callee (e.g. `github-release` inside `build-release-task.yml`) the **caller** grants it (`publish-release.yml`'s `publish` job does). **Never blanket-delete the run's artifacts** (`gh api .../artifacts --jq '.artifacts[].id'`). That also destroys diagnostic/log artifacts and the build-records actions emit automatically (`docker/build-push-action`'s `.dockerbuild`), which are exactly what you need to debug a failed run, and which the `retention-days: 1` backstop below never reaches, since it is set on an explicit upload step and an auto-emitted record has none, so they fall back to the repository's own retention period. Set `retention-days: 1` on **every** explicit `upload-artifact`: it is the failure-path backstop, since a job that dies before its consumer runs leaves its artifact to be reaped within a day, so no separate terminal cleanup job is needed. A repo customizing these jobs must preserve the consume-then-delete shape. +- **Docker layer cache**: cache to and from a registry tag (`type=registry`, e.g. `<repo>:buildcache-<branch>` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. The cache is **asymmetric**: `cache-to` writes only on a push and only to the branch being built, so a pull request smoke run writes nothing at all and a `develop` publish writes only `buildcache-develop`, while `cache-from` reads both branches so a first build on a new branch still hits. A **multi-image** repo varies the cache **repository** rather than the tag, `<image>:buildcache-<branch>` for each image, since one tag cannot distinguish two images. It does not fall back to `type=gha` for the extra images. +- **Tag pinning on releases**: when using `softprops/action-gh-release` (or any tag-creating action), pass `target_commitish` explicitly, because without it GitHub's REST API defaults the new tag to the repository's default branch instead of the commit that built the artifact. Pin it to the **exact built commit's SHA** (the publisher uses NBGV's `GitCommitId` output), not `github.sha` (which may differ from the exact commit NBGV versioned) and not a branch name (a moving ref that a mid-run commit could advance past the built tree). + +## Supported Development Platforms + +- **Cross-platform by default: Windows + macOS + Linux.** Linux runs natively (a Linux desktop, or SSH/remote into a Linux host), through a devcontainer on Windows or macOS, or through WSL2 on Windows, where the devcontainer and WSL routes carry their own nuances (mounts, path translation, SSH-agent forwarding) but deliver the same toolchain. Editing is cross-platform through the GUI regardless of where code runs. Assume this default. +- **A repo's platform ceiling is set by its dependencies, not by tooling effort, so decide it per repo before writing dev tooling.** Narrow below the default only for a hard runtime ceiling, where the code can only execute or test on one platform (e.g. a Home Assistant integration is Linux-only: HA Core has POSIX-only dependencies and will not run natively on Windows, so even maximal tooling yields only lint-only there). The narrowing axis is where code *executes* for dev and testing (native, SSH-remote, container, or CI), never where editing happens. +- **Record a narrowed platform and its reason in the repo** (README/AGENTS) so the restriction reads as a deliberate dependency ceiling, not an omission. + +## Devcontainer + +Contributors commit to this repo with signed commits. This repo ships no committed devcontainer and builds no application source, so it needs no language toolchain. Docker with Buildx is the only build tool, for the multi-arch image the [`Docker/Dockerfile`](./Docker/Dockerfile) defines, and the document linters run from their official images too. The checks and the commands behind them are in [`OPERATIONS.md`](./OPERATIONS.md) "Local Verification". + +## Editor and Tasks + +- **VS Code is the primary IDE, and the experience favors it.** Prefer VS Code tasks and launch configurations for building, running, and testing over ad-hoc shell scripts. A script is the fallback, not the default. +- The `.code-workspace` file carries the shared editor settings and the recommended-extension set. **All VS Code settings and extension recommendations live only here, never in a standalone `.vscode/settings.json` or `.vscode/extensions.json`** (`.vscode/` holds only `tasks.json` and `launch.json`). A **standard set** of extensions applies to every repo (markdownlint, cspell, editorconfig, markdown-all-in-one, better-todo-tree, github-actions, actionlint, shellcheck, claude-code); **language-specific** extensions are added per project (.NET: csdevkit, csharpier; Python: python, pylance, ruff, mypy; Docker: the Docker extension). +- The Table of Contents is maintained by the Markdown All in One extension, and `markdown.extension.toc.levels` in the workspace sets which heading levels it includes (see the Markdown rules for the authoring convention and the `<!-- omit from toc -->` exclusion marker). +- **Agents: editing the active `.code-workspace` can reload the VS Code window and drop the agent's session.** Commit all state first, prefer opening the folder rather than the workspace while editing it, or leave workspace edits to the maintainer (a maintainer edit does not reload). + +## Repository Details + +Every repo's GitHub repository details (the About panel) follow a fixed convention so the fleet stays consistent and self-describing. + +- **Description** is one canonical sentence that carries to the README, the About panel, and (for a Docker repo) the Docker Hub short description alike, at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. Once a repo declares `registry/repos.json`'s optional `description` field, that field is the source, itself link-free plain text on one line for the same reason: `repo-config/configure.sh apply` writes it to the About panel directly, and the README's **tagline** (its first non-empty line after the `#` H1 heading) follows it rather than the other way around. A repo that has not adopted the field yet keeps the pre-existing convention, where the README tagline is the source of truth and the About panel is set from it by hand (`gh api -X PATCH repos/<owner>/<repo> -f description=...`). `spec/audit.py`'s `description_findings()` reports drift either way, falling back to the tagline when no field is declared. It is that one line and not the paragraph it opens: a README may carry further paragraphs below the tagline, and no mirror reads them. When the current description is *more specific* than the declared source (a chip revision or variant it omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the declared source so the other mirrors follow it. Docker Hub receives it from the About panel, which the docker-readme task reads at publish time, so an About panel left diverged from the canonical value is carried onward rather than corrected there. +- **Topics** are optional, and any that are present match the repo's actual content. Do not invent topics to fill the field. +- **Include in the home page**: Releases on, Deployments off, Packages off. These toggles are UI-only, since the REST and GraphQL APIs neither read nor write them, so they are set by hand and cannot be audited through `gh`. + +## Repository Layout + +- [`AGENTS.md`](./AGENTS.md): the agent entry point, carrying context and delegation rules plus the map to the sections above. +- [`CLAUDE.md`](./CLAUDE.md): imports `AGENTS.md`, since Claude Code reads `CLAUDE.md` and never `AGENTS.md` on its own. Carries no rule of its own. +- [`GOVERNANCE.md`](./GOVERNANCE.md), [`CODESTYLE.md`](./CODESTYLE.md), [`WORKFLOW.md`](./WORKFLOW.md), [`OPERATIONS.md`](./OPERATIONS.md), [`AUDIT.md`](./AUDIT.md): the governance, operations, and audit docs. This file is the cross-cutting-rules authority. +- [`Docker/Dockerfile`](./Docker/Dockerfile): the only build input. It layers the .NET LTS and STS SDKs onto the `lscr.io/linuxserver/code-server:latest` base with `dotnet-install.sh`, installed to `/usr/share/dotnet`. A `main` publish builds it for `linux/amd64` and `linux/arm64`, and a `develop` publish and the CI smoke build for `linux/amd64` only. It takes a single `LABEL_VERSION` build argument, and nothing is compiled from local source. +- [`Docker/README.md`](./Docker/README.md): the Docker Hub overview, pushed after a `main` publish. +- [`README.md`](./README.md) and [`HISTORY.md`](./HISTORY.md): the project description and its release history. +- [`version.json`](./version.json): the NBGV version floor. NBGV derives the build and tag version from it plus git height, and no .NET assembly is produced. +- [`.github/workflows/`](./.github/workflows/): the CI, release, and merge-bot workflows, whose contract is [`WORKFLOW.md`](./WORKFLOW.md). Each is a thin caller that reaches the hub's reusable tasks by a pinned commit, so this repo carries no task bodies. Repository settings and rulesets are applied from the hub's `repo-config/`. +- [`.github/skills/`](./.github/skills/): the fleet's Skills, carried verbatim from the hub and never hand-edited here. +- [`VSCode-Server-DotNetCore.code-workspace`](./VSCode-Server-DotNetCore.code-workspace) and [`.vscode/`](./.vscode/): the VS Code workspace, the primary IDE surface, and its tasks. +- [`host-tools.json`](./host-tools.json): the host tools this repo needs beyond the fleet declaration. +- [`.editorconfig`](./.editorconfig), [`.editorconfig-checker.json`](./.editorconfig-checker.json), [`.gitattributes`](./.gitattributes), [`.gitignore`](./.gitignore), [`.dockerignore`](./.dockerignore), [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), and [`cspell.json`](./cspell.json): the editor, line-ending, ignore, Markdown, and spelling configuration. + +After editing a doc, run the linters before commit, per [`OPERATIONS.md`](./OPERATIONS.md) "Local Verification". diff --git a/HISTORY.md b/HISTORY.md index 6ceee34..2685be2 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,13 +1,13 @@ -# VSCode-Server-DotNetCore - -Docker image of VSCode Server with the .NET LTS and STS SDKs pre-installed. - -## Release History - -- Version 1.1: - - Reworked the CI/CD pipeline to a branch-scoped self-publishing model: a weekly scheduled run (and manual dispatch) publishes both `main` (stable, Docker `latest`) and `develop` (prerelease, Docker `develop`) - the multi-arch Docker image plus a GitHub release that anchors the version - while merges accumulate until the next run. The installed contents (code-server plus the .NET LTS and STS SDKs) are unchanged. - - Version-tagged the container: images now also publish a `:SemVer2` tag (`X.Y.<height>`) alongside the moving `latest` / `develop` tags, and each version gets a GitHub release. - - Added `WORKFLOW.md` (the canonical CI/CD specification), `repo-config/` (rulesets and repository settings as code), and this `HISTORY.md`. -- Version 1.0: - - Docker image of [LinuxServer.io Code-Server](https://github.com/linuxserver/docker-code-server) with the .NET LTS and STS SDKs pre-installed via `dotnet-install.sh`. - - Multi-architecture images for `linux/amd64` and `linux/arm64`, published to Docker Hub as the moving `latest` (main) and `develop` tags and rebuilt weekly to pick up upstream base-image updates. +# VSCode-Server-DotNetCore + +This is a Docker image of VSCode Server with the .NET LTS and STS SDKs pre-installed. + +## Release History + +- Version 1.1: + - Reworked the CI/CD pipeline to a branch-scoped self-publishing model: a weekly scheduled run (and manual dispatch) publishes both `main` (stable, Docker `latest`) and `develop` (prerelease, Docker `develop`) - the multi-arch Docker image plus a GitHub release that anchors the version - while merges accumulate until the next run. The installed contents (code-server plus the .NET LTS and STS SDKs) are unchanged. + - Version-tagged the container: images now also publish a `:SemVer2` tag (`X.Y.<height>`) alongside the moving `latest` / `develop` tags, and each version gets a GitHub release. + - Added `WORKFLOW.md` (the canonical CI/CD specification), `repo-config/` (rulesets and repository settings as code), and this `HISTORY.md`. +- Version 1.0: + - Docker image of [LinuxServer.io Code-Server](https://github.com/linuxserver/docker-code-server) with the .NET LTS and STS SDKs pre-installed via `dotnet-install.sh`. + - Multi-architecture images for `linux/amd64` and `linux/arm64`, published to Docker Hub as the moving `latest` (main) and `develop` tags and rebuilt weekly to pick up upstream base-image updates. diff --git a/LICENSE b/LICENSE index 3f4167c..4c6af17 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2019 Pieter Viljoen - -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. +MIT License + +Copyright (c) 2019 Pieter Viljoen + +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/OPERATIONS.md b/OPERATIONS.md new file mode 100644 index 0000000..b2838d8 --- /dev/null +++ b/OPERATIONS.md @@ -0,0 +1,46 @@ +# Operations + +How this repository is run day to day. [`WORKFLOW.md`](./WORKFLOW.md) is the CI/CD contract, and [`GOVERNANCE.md`](./GOVERNANCE.md) holds the cross-cutting rules. + +## Local Verification + +This repo compiles nothing, so it has no clean-compile task. The gate is the document lint set plus a Docker smoke build of the image, and it reports clean before a commit. The `Lint: All` VS Code task in [`.vscode/tasks.json`](./.vscode/tasks.json) runs the lint set from the official images, which needs no local Node or Go install. The commands below are written for a POSIX shell or PowerShell, since `cmd.exe` expands neither `$PWD` nor the quoted glob: + +```shell +docker run --rm -v "$PWD":/work -w /work davidanson/markdownlint-cli2 '**/*.md' +docker run --rm -v "$PWD":/work -w /work ghcr.io/streetsidesoftware/cspell --no-progress README.md HISTORY.md +docker run --rm -v "$PWD":/work -w /work rhysd/actionlint +docker run --rm -v "$PWD":/check -w /check mstruebing/editorconfig-checker:latest +``` + +Run `actionlint` after any edit under [`.github/workflows/`](./.github/workflows/). After a [`Docker/Dockerfile`](./Docker/Dockerfile) edit, build the image for one platform to prove it still builds, which is what CI's smoke build does on every push: + +```shell +docker buildx build --load --progress plain --platform linux/amd64 --file Docker/Dockerfile --tag testing:latest . +docker run -it --rm --name testing testing:latest /bin/bash +docker run -d --rm -p 8443:8443 --name testing testing:latest +``` + +The first `docker run` opens a shell in the image. The second starts code-server and serves it on `http://localhost:8443/`. + +**What CI cannot exercise.** The Docker Hub push, the Docker Hub overview push, and the GitHub release run only on a real publish, so a pull request proves the image builds for `linux/amd64` and never proves it builds for `linux/arm64` or uploads. Whether code-server actually starts and the .NET SDKs work inside the running container is covered by no automated check. Start the image detached, as above, and open `http://localhost:8443/` to check it by hand when a change could affect either. + +## Runbooks + +**Cutting a release.** Merges do not publish. [`publish-release.yml`](./.github/workflows/publish-release.yml) runs on a weekly schedule and on manual dispatch, and each run publishes one branch, the trigger ref. The hub's release-plan task decides whether a run publishes, and the hub's validate gate runs on the branch tip before anything is built. The schedule rebuilds `main` only, which refreshes the `latest` image and picks up `lscr.io/linuxserver/code-server` base-image updates, so accumulated changes on `main` ship in the next scheduled run. A dispatch publishes the branch it is started from: `main` builds a stable release tagged `latest`, and `develop` a prerelease tagged `develop`. A `main` image is multi-arch, `linux/amd64` and `linux/arm64`, and a `develop` image is `linux/amd64` only. A dispatch from any other branch publishes nothing. The build, the Docker push, and the GitHub release are the hub's reusable tasks, pinned by commit in the workflow, and Dependabot bumps the pin. When a release for the version already exists, a scheduled run skips the GitHub release and still pushes the image, so a rebuild refreshes the base image without a new release, and a dispatch refreshes the existing release. The Docker Hub overview is pushed from [`Docker/README.md`](./Docker/README.md) after a `main` publish only. Update the release notes in [`README.md`](./README.md) and the full entry in [`HISTORY.md`](./HISTORY.md) in the change that ships the behavior. + +**Bumping the version floor.** [`version.json`](./version.json) carries the NBGV major.minor floor, and NBGV appends the git height as the patch. Raise the floor only on the maintainer's instruction, for a functional change or a one-time overhaul of the build or release process, in the pull request that introduces it, typically on `develop`. Routine dependency, workflow, and doc changes leave it alone. + +## Backup and Recovery + +## Logs and Debugging + +## Tool Usage + +## Configuration Layout + +**CI runs on push, on every branch.** [`test-pull-request.yml`](./.github/workflows/test-pull-request.yml) has no `pull_request` trigger and no paths filter, so the lint gate and the smoke build run on every push, and a pull request that edits a reusable workflow tests its own copy. A fork pull request cannot push here, so it produces no run and cannot satisfy the required check. A maintainer lands such a contribution on an in-repo branch, which pushes and so validates, before merging it. A branch-deletion push is skipped. + +**Workflows reach the hub.** Every workflow here is a thin caller of a hub reusable task, pinned to a hub release commit: the lint gate is `validate-task.yml`, the smoke build and the release are `build-release-task.yml`, and the merge-bot is `merge-bot-task.yml`. A change to a gate or to the build lands in the hub, and it reaches this repo when the pin moves. A publish-time failure inside a hub task is diagnosed from the run log against the pinned hub commit rather than against a file in this repo. + +**Dependabot** runs the `github-actions` ecosystem only, once per branch, so `main` and `develop` each receive their own update pull requests. A Dependabot security pull request always targets `main`, and the merge-bot selects the merge method from each pull request's base branch, so it merges either kind. Every Dependabot pull request that originates from this repository auto-merges once its required checks pass, semver-major included, through [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml). An action bump is not a shipped input, so it ships in the next scheduled publish rather than on merge. diff --git a/README.md b/README.md index 0c092c7..2f5a70d 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,134 @@ -# VSCode Server with .NET Pre-Installed - -This is a Docker image of VSCode Server with the .NET LTS and STS SDKs pre-installed.\ -Docker image is based on [LinuxServer.io Code-Server](https://github.com/linuxserver/docker-code-server), which is based on [Coder.com Code-Server](https://github.com/cdr/code-server).\ - -## License - -![GitHub License](https://img.shields.io/github/license/ptr727/VSCode-Server-DotNetCore)\ - -## Build Status - -[Code and Pipeline is on GitHub](https://github.com/ptr727/VSCode-Server-DotNetCore):\ -![GitHub Last Commit](https://img.shields.io/github/last-commit/ptr727/VSCode-Server-DotNetCore?logo=github)\ -![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/ptr727/VSCode-Server-DotNetCore/publish-release.yml?logo=github) - -## Container Images - -Docker container images are published on [Docker Hub](https://hub.docker.com/r/ptr727/vscode-server-dotnetcore).\ -Multi-Architecture images are created for `linux/amd64` and `linux/arm64` (`linux/arm/v7` is [not supported](https://www.linuxserver.io/blog/a-farewell-to-arm-hf) by LSIO).\ -Tags are `latest` for `main` branch and `develop` for `develop` branch, plus a `SemVer2` version tag (`X.Y.Z`) on every published image.\ -E.g. `docker pull ptr727/vscode-server-dotnetcore:latest`\ -E.g. `docker pull ptr727/vscode-server-dotnetcore:develop`\ -E.g. `docker pull ptr727/vscode-server-dotnetcore:1.1.0` - -Builds include the LTS and STS [supported versions](https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core) of .NET SDKs. - -Images are automatically rebuilt every Monday morning, picking up the latest upstream updates.\ -![Docker Pulls](https://img.shields.io/docker/pulls/ptr727/vscode-server-dotnetcore?logo=docker)\ -![Docker Image Version](https://img.shields.io/docker/v/ptr727/vscode-server-dotnetcore/latest?logo=docker) - -## Release History - -- Version 1.1: Branch-scoped CI/CD, version-tagged images (`:SemVer2` alongside `latest` / `develop`), and per-version GitHub releases. -- Version 1.0: Multi-arch code-server image with the .NET LTS and STS SDKs pre-installed. - -See [`HISTORY.md`](./HISTORY.md) for the full release history. - -## Usage - -Follow the [linuxserver/code-server](https://github.com/linuxserver/docker-code-server) instructions. +# VSCode-Server-DotNetCore + +This is a Docker image of VSCode Server with the .NET LTS and STS SDKs pre-installed. + +The image layers the .NET SDKs onto the [LinuxServer.io Code-Server][lsio-code-server-link] image, which packages [Coder Code-Server][code-server-link]. + +## Build and Distribution + +- **Source Code**: [GitHub][github-link] for source, issues, and CI/CD pipelines. +- **Versioned Releases**: [GitHub Releases][releases-link] for version-tagged source archives. +- **Docker Images**: [Docker Hub][docker-hub-link] for the published container images. + +### Build Status + +[![Release Status][release-status-shield]][actions-link]\ +[![Last Commit][last-commit-shield]][commits-link] + +### Releases + +[![GitHub Release][github-release-shield]][releases-link]\ +[![GitHub Pre-Release][github-pre-release-shield]][releases-link]\ +[![Docker Latest][docker-latest-shield]][docker-hub-link]\ +[![Docker Develop][docker-develop-shield]][docker-hub-link] + +### Release Notes + +**Version: 1.1**: + +**Summary**: + +- Branch-scoped CI/CD, version-tagged images (`:SemVer2` alongside `latest` and `develop`), and per-version GitHub releases. + +See [Release History][history] for complete release notes and older versions. + +## Table of Contents + +- [VSCode-Server-DotNetCore](#vscode-server-dotnetcore) + - [Build and Distribution](#build-and-distribution) + - [Build Status](#build-status) + - [Releases](#releases) + - [Release Notes](#release-notes) + - [Table of Contents](#table-of-contents) + - [Installation](#installation) + - [Usage](#usage) + - [Questions or Issues](#questions-or-issues) + - [3rd Party Tools](#3rd-party-tools) + - [License](#license) + +## Installation + +Images are published for `linux/amd64` and `linux/arm64`. `linux/arm/v7` is [not supported][lsio-armhf-link] by LinuxServer.io. + +- `latest`: the `main` branch build. +- `develop`: the `develop` branch build, `linux/amd64` only. +- `X.Y.Z`: a specific `SemVer2` version, published alongside the moving tag. + +```shell +docker pull ptr727/vscode-server-dotnetcore:latest +docker pull ptr727/vscode-server-dotnetcore:develop +``` + +Each build includes the LTS and STS [supported versions][dotnet-support-link] of the .NET SDK. The `latest` image is rebuilt from `main` every Monday, picking up the latest upstream Code-Server and .NET SDK updates. A `develop` image is published on demand. + +## Usage + +Follow the [LinuxServer.io Code-Server][lsio-code-server-link] instructions. + +## Questions or Issues + +Use [GitHub Discussions][discussions-link] for questions, and create a [GitHub Issue][issues-link] for problems with this image. Code-Server and base image issues belong with [LinuxServer.io][lsio-code-server-link]. + +## 3rd Party Tools + +The third-party tools, libraries, and actions this project depends on. + +| Tool | Role | +| --- | --- | +| [.NET SDK][dotnet-link] | Development platform installed in the image. | +| [actionlint][actionlint-link] | GitHub Actions workflow linter. | +| [Coder Code-Server][code-server-link] | VS Code running in a browser. | +| [cspell][cspell-link] | Spell checker. | +| [Docker][docker-link] | Container build and runtime platform. | +| [editorconfig-checker][editorconfig-checker-link] | Line-ending and whitespace linter. | +| [GitHub Actions][github-actions-link] | CI and automation runner. | +| [GitHub Dependabot][dependabot-link] | Dependency update bot. | +| [LinuxServer.io Code-Server][lsio-code-server-link] | Code-Server container base image. | +| [markdownlint-cli2][markdownlint-link] | Markdown linter. | +| [Nerdbank.GitVersioning][nbgv-link] | Version computation from git height. | + +## License + +Licensed under the [MIT License][license]\ +![GitHub License][license-shield] + +<!-- Shields --> + +[docker-develop-shield]: https://img.shields.io/docker/v/ptr727/vscode-server-dotnetcore/develop?label=develop&logo=docker +[docker-latest-shield]: https://img.shields.io/docker/v/ptr727/vscode-server-dotnetcore/latest?label=latest&logo=docker +[github-pre-release-shield]: https://img.shields.io/github/v/release/ptr727/VSCode-Server-DotNetCore?include_prereleases&logo=github&label=GitHub%20Pre-Release +[github-release-shield]: https://img.shields.io/github/v/release/ptr727/VSCode-Server-DotNetCore?logo=github&label=GitHub%20Release +[last-commit-shield]: https://img.shields.io/github/last-commit/ptr727/VSCode-Server-DotNetCore?logo=github&label=Last%20Commit +[license-shield]: https://img.shields.io/github/license/ptr727/VSCode-Server-DotNetCore?label=License +[release-status-shield]: https://img.shields.io/github/actions/workflow/status/ptr727/VSCode-Server-DotNetCore/publish-release.yml?logo=github&label=Releases%20Build + +<!-- Distribution --> + +[actions-link]: https://github.com/ptr727/VSCode-Server-DotNetCore/actions +[commits-link]: https://github.com/ptr727/VSCode-Server-DotNetCore/commits/main +[discussions-link]: https://github.com/ptr727/VSCode-Server-DotNetCore/discussions +[docker-hub-link]: https://hub.docker.com/r/ptr727/vscode-server-dotnetcore +[github-link]: https://github.com/ptr727/VSCode-Server-DotNetCore +[issues-link]: https://github.com/ptr727/VSCode-Server-DotNetCore/issues +[releases-link]: https://github.com/ptr727/VSCode-Server-DotNetCore/releases + +<!-- Repo --> + +[history]: ./HISTORY.md +[license]: ./LICENSE + +<!-- External --> + +[actionlint-link]: https://github.com/rhysd/actionlint +[code-server-link]: https://github.com/coder/code-server +[cspell-link]: https://cspell.org +[dependabot-link]: https://github.com/dependabot +[docker-link]: https://www.docker.com +[dotnet-link]: https://dotnet.microsoft.com +[dotnet-support-link]: https://dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core +[editorconfig-checker-link]: https://github.com/editorconfig-checker/editorconfig-checker +[github-actions-link]: https://github.com/actions +[lsio-armhf-link]: https://www.linuxserver.io/blog/a-farewell-to-arm-hf +[lsio-code-server-link]: https://github.com/linuxserver/docker-code-server +[markdownlint-link]: https://github.com/DavidAnson/markdownlint-cli2 +[nbgv-link]: https://github.com/dotnet/Nerdbank.GitVersioning diff --git a/VSCode-Server-DotNetCore.code-workspace b/VSCode-Server-DotNetCore.code-workspace index 856c018..f1795c2 100644 --- a/VSCode-Server-DotNetCore.code-workspace +++ b/VSCode-Server-DotNetCore.code-workspace @@ -1,31 +1,31 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "files.trimTrailingWhitespace": true, - "[markdown]": { - "files.trimTrailingWhitespace": false - }, - "[plaintext]": { - "files.trimTrailingWhitespace": false - }, - "diffEditor.ignoreTrimWhitespace": false, - "editor.renderWhitespace": "boundary", - "files.encoding": "utf8", - "git.alwaysSignOff": true, - "markdown.extension.toc.levels": "2..3" - }, - "extensions": { - "recommendations": [ - "davidanson.vscode-markdownlint", - "editorconfig.editorconfig", - "github.vscode-github-actions", - "ms-azuretools.vscode-docker", - "streetsidesoftware.code-spell-checker", - "yzhang.markdown-all-in-one" - ] - } -} +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "files.trimTrailingWhitespace": true, + "[markdown]": { + "files.trimTrailingWhitespace": false + }, + "[plaintext]": { + "files.trimTrailingWhitespace": false + }, + "diffEditor.ignoreTrimWhitespace": false, + "editor.renderWhitespace": "boundary", + "files.encoding": "utf8", + "git.alwaysSignOff": true, + "markdown.extension.toc.levels": "2..3" + }, + "extensions": { + "recommendations": [ + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "github.vscode-github-actions", + "ms-azuretools.vscode-docker", + "streetsidesoftware.code-spell-checker", + "yzhang.markdown-all-in-one" + ] + } +} diff --git a/WORKFLOW.md b/WORKFLOW.md index e9a395d..d4c2ff9 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,518 +1,308 @@ -# WORKFLOW.md - -The single guide for this repo's CI/CD **workflows** (GitHub Actions): **code style**, **architecture**, a -**behavioral contract** (expected inputs and outputs), and a **test methodology**. Source style lives in -[`CODESTYLE.md`](./CODESTYLE.md). This file covers everything under -[`.github/workflows/`](./.github/workflows/). - -It **describes required outcomes, not a required implementation.** A workflow is correct when it satisfies -the contract (section 4), whatever shape its YAML takes. Section 2 keeps workflows legible. Section 3 is -the model. Section 4 is what they must *do*. Sections 5 and 6 are how to verify it and the configuration it -assumes. Each guarantee names the **failure it prevents**, so the reason survives a reimplementation. - -## 0. The model at a glance - -VSCode-Server-DotNetCore ships **one target**: a **multi-arch Docker image** (Docker Hub) that layers the -.NET LTS+STS SDKs onto the LinuxServer.io code-server base. There is no compiled code in this repo - the -Dockerfile is the only build input. Two workflows do the work: - -- **CI** runs on **push to every branch**: it validates (lint) and smoke-builds the image, publishing - nothing. A pull request merges only when its required check is green. -- **The publisher** runs on a **weekly schedule and on manual dispatch** - never on a merge, and builds **one - branch per run** (the trigger ref). The **schedule** rebuilds **`main` only** (stable release + refreshed - `latest` image, picking up base-image CVEs). A **dispatch** publishes the branch it is started from: from - `main` -> stable / `latest`, from `develop` -> prerelease / `develop`. Merges accumulate; the next scheduled - run ships main's, and a develop release is cut by dispatching from `develop`. - -There is no publish-on-merge, no per-push release, and no two-branch matrix - building only the trigger branch -keeps `github.ref` aligned with the branch being versioned. A maintainer dispatches to release on demand. -Dependabot pull requests merge themselves once their checks pass. - -### Glossary - -- **Entry workflow** - has `push` / `schedule` / `workflow_dispatch` triggers. The orchestrator that an event or - a person starts. -- **Reusable workflow (task)** - a `workflow_call` workflow invoked through a `uses:` reference, never - triggered directly. File ends in `-task.yml`. -- **Target** - the one shipped output: the **Docker image** (`build-docker-task.yml`). - `build-release-task.yml` orchestrates it plus the GitHub release. -- **Smoke build** - a CI build that builds the image to prove it still ships, publishing and pushing nothing. - Driven by a `smoke: true` input. -- **Version-anchor release** - the GitHub release carries no build artifact: the image itself ships to Docker - Hub. The release tags the built commit and attaches the in-tree `LICENSE` + `README.md`, so the tag is - browsable and the version is recorded. *(There is no `release-asset` artifact seam - nothing is attached - from a build job.)* -- **Shipped input** - a file that changes what is shipped: the Docker build context (`Dockerfile`, the only - build input) or the version floor (`version.json`). GitHub-Actions bumps are **not** shipped inputs - they - ship in the next weekly publish, not on merge. -- **GitHub App token** - a short-lived installation token from `actions/create-github-app-token`, minted - from the App credentials (`CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY`). The merge-bot uses it, not - `GITHUB_TOKEN`: a `GITHUB_TOKEN` push does not trigger downstream workflows, and that token is read-only on - Dependabot pull requests. - -## 1. Purpose and how to use this document - -- **Contract, not implementation.** Conform to the *outcomes* in section 4 and the *architecture* in section - 3. Job names and file layout may vary; the input/output behavior may not. -- **"Operational" - the one definition.** The repo is **operational** when every applicable section-4 - guarantee holds, every applicable section-5B scenario's observed output equals its expected output - (corroborated by a 5C live probe where a live signal exists), and the section-6 configuration is in place. - Anything else is **not operational**. -- **Defect vs N/A.** An item is **N/A** only when this repo has no such concern (e.g. a fork-PR scenario, - since a fork cannot push here; or a build artifact, since nothing is attached to the release). A construct - required by an applicable guarantee but absent is a **defect**. -- **Default branch is `main`.** Guarantees say "default branch" portably. This repo writes the literal `main` - in the prerelease expression and the release-version backstop, and the anchored `^refs/heads/main$` in - `version.json`'s `publicReleaseRefSpec`. - -## 2. Workflow style conventions - -Legibility rules. Necessary but not sufficient: a perfectly styled workflow can still violate section 4. - -- **Action pinning.** Pin every action to a commit SHA with a trailing `# vX.Y.Z` comment. Use `# vX` only - when the upstream floating major tag has no specific patch SHA. A tool an action *installs* (e.g. the - actionlint binary behind `raven-actions/actionlint`) is not a `uses:` ref and is left unpinned to track latest. -- **Filename.** Reusable workflows end in `-task.yml`; entry workflows end in what they do - (`-pull-request.yml`, `-release.yml`). A `-task.yml` is `uses:`-d, never triggered directly. -- **Workflow `name:`.** Reusable names end in **"task"**, entry names in **"action"**. -- **Job and step `name:`.** Every job `name:` ends in **"job"**, every step `name:` in **"step"**, the - aggregator included (`Check pull request workflow status job`). A job name also bound as a ruleset - required-check `context:` is codified in [`repo-config/`](./repo-config/) and changed only **in lockstep** - with the live ruleset. -- **Concurrency.** Every entry workflow declares a `concurrency` group. CI uses - `group: '${{ github.workflow }}-${{ github.ref }}'`, `cancel-in-progress: true`. The publisher overrides - it: a ref-independent group with `cancel-in-progress: false`, so two publishes never overlap (a schedule and - a manual dispatch, or back-to-back dispatches) and none is cancelled mid-release. The merge-bot keys on the PR number with `cancel-in-progress: false` so each PR's events run to completion in order. -- **Shells.** Every multi-line bash `run:` starts with `set -euo pipefail`. -- **Conditionals.** Multi-line `if:` uses the folded scalar `if: >-`. -- **Boolean inputs.** A boolean used by both `workflow_call` and `workflow_dispatch` is declared in both - trigger blocks and compared against `true` and `'true'`. -- **Reusable-workflow permissions.** Job-level `permissions:` are validated before `if:`, so even a skipped - job needs valid permissions. Grant least privilege; a callee's extra scope is granted by the caller. -- **Allowlist `success` and `skipped` explicitly** across an optional dependency: use - `(needs.X.result == 'success' || needs.X.result == 'skipped')`, not `!= 'failure'`. -- **Line endings.** Workflow YAML follows [`.editorconfig`](./.editorconfig) (CRLF). Preserve on every edit. - -## 3. Architecture - -### Two workflows: CI on push, publishing on schedule/dispatch - -CI ([`test-pull-request.yml`](./.github/workflows/test-pull-request.yml)) and the publisher -([`publish-release.yml`](./.github/workflows/publish-release.yml)) are separate workflows with separate -concurrency, so they never race. CI re-tests every pushed tree and never publishes; the publisher releases -on its own cadence and never runs on push. *Prevents a merge from silently cutting a release, and a CI run -from racing a publish on the same ref.* - -### The publisher builds one branch: the trigger ref - -A publish builds exactly **one** branch - the run's trigger ref. The **schedule** always runs on the default -branch, so it rebuilds `main`; a **dispatch** runs on the branch it is started from (`main` or `develop`). The -single `publish` job passes `github.ref_name` as both `ref` and `branch`, so the branch built, versioned, and -tagged is always the run's own ref. *No matrix and no cross-branch ref mixing - `github.ref` is the branch -being published.* The job is guarded to the long-lived branches (`main` / `develop`); a stray dispatch from a -feature branch is a no-op. To release `develop`, dispatch the workflow from `develop`. - -Because the run's ref **is** the built branch, GitHub resolves the local `uses: ./...` reusable workflows from -that same branch's commit - so a `develop` dispatch runs develop's own task definitions, and the schedule runs -main's. There is no definition-vs-content split to reason about. - -### Versioning: compute once, thread everywhere - -NBGV runs once (in `get-version-task`), classifying from `github.ref` (see below), and its outputs (`SemVer2`, -`GitCommitId`) thread to every consumer via `outputs:` / `needs:`. The Docker build checks out a specific -commit to build it, but consumes the threaded version. `main` (the public ref, -`publicReleaseRefSpec = ^refs/heads/main$`) builds a clean `X.Y.<height>`; every other branch a prerelease -`X.Y.<height>-g<sha>`. *Keeps the built image's version label and the release tag in agreement.* NBGV needs -only `version.json` and git history, so it works although the repo builds no .NET assembly. - -NBGV classifies `publicReleaseRefSpec` from the `GITHUB_REF` environment variable. Because the publisher builds -the **trigger ref** (one branch per run), `GITHUB_REF` already equals the branch being versioned - a schedule -or `main` dispatch classifies as public (clean `X.Y.<height>`), a `develop` dispatch as prerelease -(`X.Y.<height>-g<sha>`) - so no `GITHUB_REF` override is needed. (`GITHUB_REF` is reserved and cannot be -reliably overridden anyway; the matrix publishers that build a non-trigger branch are the ones that need -`IGNORE_GITHUB_REF`.) The main-version backstop (D2.2) catches any misclassification. - -### Validate at entry - -A run that carries a cross-input invariant (e.g. `main` must not carry a prerelease suffix) asserts it once -with `::error::` before any publish. Downstream jobs `needs:` it. - -### Fast CI feedback, head-resolved - -CI runs on push to every branch, so GitHub head-resolves the reusable `./...` workflows from the pushed head: -a pull request that edits a reusable task tests its own copy. CI validates (the reusable `validate-task`: -`lint` only - there is no compiled code) and smoke-builds the image, pushing nothing. One aggregator job, the -ruleset-bound required check, gates the merge. A branch-deletion push (all-zeros `github.sha`) is skipped by a -`!github.event.deleted` guard on every job, so a deletion never runs a failing build. The publisher runs the -**same** `validate-task` against the branch it publishes (the run's ref is that branch), so the CI gate and the -publish gate are the identical definition applied to the same tree. - -### The single-target release - -`build-release-task` versions once, builds the Docker image, and creates the GitHub release. The Docker target -pushes multi-arch tags straight to Docker Hub (`latest` for main, `develop` for develop, plus `:SemVer2`). The -GitHub release is a **version anchor**: it tags the built commit and attaches the in-tree `LICENSE` + -`README.md` (no build artifact - the image ships to Docker Hub, not the release). The Docker Hub repository -overview is published separately, main-only, from [`Docker/README.md`](./Docker/README.md) by -`publish-docker-readme-task.yml`, since Docker Hub does not read the GitHub README. - -### Resource lifecycle - -No job uploads or downloads a workflow artifact: the image ships to Docker Hub and the release attaches files -straight from the checkout. There is therefore no artifact retention or cleanup to manage. *(If a future -target adds a transfer artifact, it MUST set `retention-days: 1` and never blanket-delete the run's set.)* - -### Self-sufficiency: automatic updates - -Every Dependabot pull request auto-merges once the required checks pass - any ecosystem and any tier, semver-major -included (this repo has only the `github-actions` ecosystem). The required checks are the safety net, not the -version bump. A merged bump does not itself publish - it ships in the next weekly publish. There is no codegen and -no upstream-version tracker. A person steps in only for a breaking change (a red check) or to dispatch a release. - -### Flow diagrams - -Three diagrams trace the architecture above: the pull-request gate, the scheduled/dispatched publisher, and -the bot automation. They depict the same outcomes that the section 4 contract specifies, drawn from the workflow YAML; if a -diagram and a guarantee disagree, one of them is a defect. Triggers are blue, gates yellow, -durable/published outputs green, and stop/skip outcomes red. - -**Pull request (CI) - `test-pull-request.yml`.** Every push (no `pull_request` trigger) head-resolves the -reusable tasks, runs the lint validate gate and a non-publishing amd64 Docker smoke build, and a single -aggregator produces the ruleset-bound required check (D1, D6). - -```mermaid -flowchart TD - T(["push: every branch<br/>(or workflow_dispatch)<br/>NO pull_request trigger"]):::trig - T --> D{"github.event.deleted?"} - D -- "yes: branch deletion" --> X(["all jobs + aggregator skip<br/>no failed run, no pending check"]):::stop - D -- "no" --> V["validate job<br/>(validate-task.yml)"] - D -- "no" --> S["smoke-build job<br/>build-release-task.yml<br/>smoke: true, github: false, dockerhub: false"] - subgraph VT ["validate-task.yml (lint only, no compiled code)"] - L["lint job<br/>markdownlint, cspell (README, HISTORY),<br/>actionlint"] - end - V --> VT - S --> SB["build-docker job (smoke)<br/>linux/amd64 only, head-resolved<br/>no push, no Docker Hub overview"] - VT --> A - SB --> A - A{"Check pull request workflow status job<br/>validate AND smoke-build succeeded?"}:::gate - A -- "yes" --> G(["required check passes<br/>merge unblocked"]):::pub - A -- "no" --> R(["required check fails<br/>merge blocked"]):::stop - classDef trig fill:#dbeafe,stroke:#2563eb,color:#1e3a8a - classDef gate fill:#fef9c3,stroke:#ca8a04,color:#713f12 - classDef pub fill:#dcfce7,stroke:#16a34a,color:#14532d - classDef stop fill:#fee2e2,stroke:#dc2626,color:#7f1d1d -``` - -**Publish - `publish-release.yml` -> `build-release-task.yml`.** There is NO push trigger and merges never -publish: the publisher runs only on the weekly `schedule` (rebuilding `main` for base-image CVEs) or a -`workflow_dispatch`. It runs the same lint validate gate, versions once with NBGV, backstops the main -version, builds and pushes the multi-arch image to Docker Hub, then cuts the version-anchor GitHub release -(D2, D3, D4). - -```mermaid -flowchart TD - SCH(["schedule: weekly Mon 02:00 UTC<br/>(rebuilds main only)"]):::trig --> PG - WD(["workflow_dispatch<br/>(branch it is started from)"]):::trig --> PG - PG{"publish guard<br/>ref in (main, develop)?"}:::gate - PG -- "no: feature-branch dispatch" --> PSKIP(["publish skipped (no-op)"]):::stop - PG -- "yes" --> RUN - subgraph BRT ["build-release-task.yml (github: true, dockerhub: true, smoke: false)"] - VAL["validate job<br/>(validate-task.yml, lint)<br/>!smoke"] --> BG - GV["get-version job<br/>(get-version-task.yml)<br/>NBGV @master, runs once<br/>SemVer2 + GitCommitId"] --> BG - BG{"build-docker gate<br/>get-version success AND<br/>validate success or skipped?"}:::gate - BG -- "no" --> BSKIP(["build + publish skip"]):::stop - BG -- "yes" --> BD["build-docker job<br/>checkout GitCommitId<br/>multi-arch amd64+arm64"] - BD --> DH[("Docker Hub push<br/>tag latest (main) / develop<br/>+ :SemVer2, multi-arch")]:::pub - BD --> GR["github-release job<br/>needs get-version + build-docker"] - GR --> MV{"branch == main AND<br/>SemVer2 has prerelease '-'?"}:::gate - MV -- "yes" --> MVX(["fail ::error::<br/>refuse to publish"]):::stop - MV -- "no" --> EX{"tag exists AND<br/>not workflow_dispatch?"}:::gate - EX -- "yes" --> NOP(["skip release-create<br/>(no-op republish)"]):::stop - EX -- "no" --> REL[("GitHub release (version anchor)<br/>tag = SemVer2 at GitCommitId<br/>LICENSE + README, generated notes<br/>prerelease = branch != main")]:::pub - end - RUN --> VAL - RUN --> GV - classDef trig fill:#dbeafe,stroke:#2563eb,color:#1e3a8a - classDef gate fill:#fef9c3,stroke:#ca8a04,color:#713f12 - classDef pub fill:#dcfce7,stroke:#16a34a,color:#14532d - classDef stop fill:#fee2e2,stroke:#dc2626,color:#7f1d1d -``` - -**Automation - Dependabot + merge-bot.** There is no codegen. Dependabot opens in-repo bot PRs; the merge-bot -(`pull_request_target`, App token) enables auto-merge on open and disables it on a maintainer push. A merged -bump does NOT publish on merge; it ships in the next weekly publisher run above (D8). - -```mermaid -flowchart TD - DEP(["Dependabot opens PR<br/>any ecosystem (github-actions here)"]):::trig --> MB - subgraph MBT ["merge-bot-pull-request.yml (pull_request_target, App token)"] - MB{"event / author"}:::gate - MB -- "opened/reopened<br/>dependabot[bot], in-repo branch" --> EN["enable auto-merge<br/>squash develop / merge main<br/>--delete-branch"] - MB -- "synchronize by maintainer<br/>on a dependabot branch" --> DIS["disable auto-merge<br/>(idempotent)"] - end - EN --> CK{"required checks pass?"}:::gate - CK -- "yes" --> MRG(["PR merges (App token)"]):::pub - CK -- "no" --> BLK(["merge blocked<br/>maintainer notified"]):::stop - MRG -. "ships in next weekly run<br/>(merges never publish)" .-> PUBR(["weekly publisher releases"]):::pub - classDef trig fill:#dbeafe,stroke:#2563eb,color:#1e3a8a - classDef gate fill:#fef9c3,stroke:#ca8a04,color:#713f12 - classDef pub fill:#dcfce7,stroke:#16a34a,color:#14532d - classDef stop fill:#fee2e2,stroke:#dc2626,color:#7f1d1d -``` - -## 4. Behavioral contract - expected outcomes - -Each is a **MUST**, stated as input -> output plus the failure it prevents. - -### D0 - Architecture - -- **D0.1 CI is one run, one branch.** Input: any push. Output: `test-pull-request` builds/validates exactly - `github.ref_name` and publishes nothing. *Prevents cross-branch ref mixing in CI.* -- **D0.2 The publisher builds one branch: the trigger ref.** Output: the `publish` job passes - `github.ref_name` as `ref` and `branch`, so it checks out, versions, and tags exactly the run's own branch - (the schedule's default branch, or a dispatch's branch). No matrix; the job is guarded to `main`/`develop`. - *Prevents cross-branch ref mixing - `github.ref` is the branch being published.* -- **D0.3 One version, threaded.** Output: NBGV runs once, every consumer reads it via `needs:` outputs; no - consumer recomputes it. *Allowed:* checking out a specific commit to build it, and recording the built - commit as the release `target_commitish`. *Prevents the image's version diverging from its tag.* - -### D1 - CI fast feedback - -- **D1.1 Every push validates and smoke-builds the image.** Output: on any push, the `validate` job (the - reusable `validate-task`) and `smoke-build` (the Docker target through `build-release-task` with - `smoke: true`) run, no paths filter. *Prevents a reusable-workflow or build break shipping untested.* -- **D1.2 Smoke builds the image.** Output: `smoke-build` builds the Docker image (amd64 only, seeded from the - branch-scoped cache) to prove it still builds; there is no unit-test job (no compiled code to test). -- **D1.3 Lint enforces the editor checks in CI.** Output: `validate-task`'s `lint` job runs `markdownlint-cli2`, - `cspell` on the user-facing docs (README, HISTORY), and `actionlint` (which shellchecks every `run:`). Same - checks the editor runs. There is no CSharpier / `dotnet format` step - nothing compiles. -- **D1.4 Smoke never publishes and never pushes.** Output: a smoke build builds the image but makes no GitHub - release, no Docker push, no Docker Hub overview update; every publish step is gated `!smoke` (or `push`). Smoke - still logs in to Docker Hub for higher pull/cache-read rate limits, so it does need the Docker Hub secrets (in - both the Actions and Dependabot stores); fork-safety rests on a fork being unable to push here, not on - withholding the login. -- **D1.5 One required aggregator gates merge.** Output: a single aggregator job must **succeed** (not merely - "not fail"), `needs:` `validate` and `smoke-build`, and blocks on any non-success. Its name is - ruleset-bound (D6.2) and must not be renamed. *Prevents a defect merging unverified.* - -### D2 - Validation at entry - -- **D2.1 Validate before publishing.** Output: a dedicated job/step asserts each cross-input invariant and - fails fast with `::error::` before a publish; downstream jobs `needs:` it. -- **D2.2 Main matches version classification.** Input: a real (non-smoke) publish run for `main`. Output: the - release fails loudly if `main` carries a prerelease suffix. It strips `+buildmetadata` before testing for - the prerelease `-`. Skipped on smoke. *Prevents a develop build published as the stable `latest`.* - -### D3 - Versioning and classification - -- **D3.1 NBGV runs once, threaded.** Output: NBGV runs once, classifying from the checked-out branch; no - consumer re-invokes it. The run builds the trigger ref, so `GITHUB_REF` already matches the branch being - versioned and NBGV classifies it correctly (no override needed). -- **D3.2 `main` = stable, others = prerelease.** Output: `main` -> `X.Y.<height>`, any other branch -> - `X.Y.<height>-g<sha>`. The release-version backstop and the GitHub-release `prerelease` expression name `main`; - `publicReleaseRefSpec` is `^refs/heads/main$`. -- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor, NBGV appends the - git height as the patch, never bumped on a cadence. *(Who raises the floor and when is a human-process rule - in `AGENTS.md`.)* - -### D4 - Release / publish - -- **D4.1 Publish only on schedule or dispatch - never on push.** Output: `publish-release` triggers are - `schedule` (weekly) and `workflow_dispatch` only. There is **no `push` trigger** and no `PUBLISH_ON_MERGE` - variable. A merge does not publish. The job is guarded to `github.ref_name` in (`main`, `develop`), so a - stray dispatch from a feature branch is a no-op. *Prevents per-merge release churn and a blind - publish-on-merge.* -- **D4.2 A publish builds the one trigger branch in full.** Output: the run builds the Docker image and - creates the GitHub release for `github.ref_name` - the schedule rebuilds `main` (stable / `latest`); a - dispatch publishes its own branch (`main` stable / `latest`, `develop` prerelease / `develop`). *Prevents a - half-published release and cross-branch ref mixing.* -- **D4.3 Tag the built commit.** Output: the release `target_commitish` is the run's `GitCommitId` (the tip of - `github.ref_name`), never a moving branch ref. *Prevents the tag landing on the wrong commit.* -- **D4.4 Release contents and flag.** Output: each release is a tag on the built commit (a version anchor) - plus the auto-generated notes and the in-tree `LICENSE` + `README.md`; `fail_on_unmatched_files` catches a - renamed/missing file. The GitHub-release `prerelease` boolean is `inputs.branch != 'main'`. No build - artifact is attached; the image pushes `latest`/`develop` + `:SemVer2` multi-arch tags to Docker Hub. -- **D4.5 No-op republish.** Input: a weekly re-run whose version is unchanged (no new commits). Output: the - release-create step is skipped when the tag already exists (refreshed only on `workflow_dispatch`), while - the Docker push still runs - re-pushing the same tags refreshes the base image. *Prevents duplicate releases - while still refreshing the image.* -- **D4.6 Publish is tested as built.** Output: the publish runs `validate-task` against the branch it publishes - (inside `build-release-task`, gated `!smoke`) before building, so a failing lint blocks the release. The run's - ref **is** the published branch, so the reusable-task definitions resolve from that branch - it is the - identical definition CI runs on the same tree. *Prevents publishing a tree that would fail the lint gate.* -- **D4.7 Docker publishing authenticates with Docker Hub credentials.** Output: the Docker target logs in via - `docker/login-action` with `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` and pushes with - `docker/build-push-action`. The Docker Hub repository overview is published separately, main-only, by - `publish-docker-readme-task.yml` from `Docker/README.md` using the same credentials. There is no NuGet/OIDC - publishing in this repo. *Prevents a missing-credential publish failure.* -- **D4.8 Branch-scoped Docker buildcache.** Output: the Docker build reads both branches' registry caches - (`buildcache-main`, `buildcache-develop`) and writes only its own branch's cache, only when pushing, so a - `main` and a `develop` publish never overwrite each other's cache. *Prevents one branch's publish destroying - the other's cache hit-rate.* - -### D5 - Resource cleanup - -- **D5.1 No transfer artifacts.** No job uploads or downloads a workflow artifact: the image ships to Docker - Hub and the release attaches `LICENSE` + `README.md` straight from the checkout. There is nothing to retain - or clean up. *(N/A unless a future target adds an artifact, which MUST then set `retention-days: 1` and - never blanket-delete the run's set.)* - -### D6 - Self-testing workflows - -- **D6.1 A change is testable on its own branch.** Output: a workflow or build change is exercised by CI on - the branch that introduces it, no dependency on reaching `main` first. -- **D6.2 Head-resolution, single producer, fork exception.** Output: CI runs on `push` to every branch so - reusable `./...` logic resolves from the head, and the aggregator's ruleset-bound `context:` is produced by - that push run as the sole producer of that name. Dependabot PRs are in-repo branches, validated the same - way. A fork cannot push, so it has no run and is validated by maintainer action - the one exception. - *Prevents a dual-producer context race and a false self-test claim for forks.* - -### D7 - Concurrency, permissions, safety - -- **D7.1 The publisher does not cancel mid-flight.** Output: ref-independent group, `cancel-in-progress: - false`. CI uses the `...-${{ github.ref }}` group with `cancel-in-progress: true`; the merge-bot keys on PR - number (D8.1). -- **D7.2 Skipped jobs still need valid permissions.** Output: every reusable job runs under valid least-privilege - `permissions:`; a callee's extra scope is granted by the caller. -- **D7.3 Boolean inputs both forms.** Declared in both trigger blocks, compared against `true` and `'true'`. - -### D8 - Bots and automation - -- **D8.1 Merge-bot.** Output: runs on `pull_request_target`, holds the App token, merges the PR by URL without - checking out its code. Enables auto-merge on `opened`/`reopened`; squash on `develop`, merge-commit on - `main` by the PR's base ref; disables auto-merge when a maintainer pushes to a bot branch. Concurrency keyed - on PR number. -- **D8.2 Dependabot auto-merges on green, every tier.** Output: every Dependabot PR - any ecosystem, - semver-major included - auto-merges once the required checks pass, with no version-tier exception (this repo - has only the `github-actions` ecosystem). A failing check blocks the merge. A merged bump does **not** itself - publish (dependencies are not shipped inputs); it ships in the next weekly publish. -- **D8.3 No codegen / upstream-version automation.** This repo has neither; the merge-bot carries only - `merge-dependabot` + `disable-auto-merge-on-maintainer-push`. - -### D9 - Style, static, and dropped workflows (see section 2) - -- **D9.1** Every action SHA-pinned with a version comment (sole exception: `dotnet/nbgv@master`); an installed-tool version (e.g. the actionlint - binary) is left unpinned to track latest. -- **D9.2** File/workflow/job/step names follow the suffix rules; a ruleset-bound `context:` name moves only in - lockstep with `repo-config/`. -- **D9.3** Bash `run:` blocks start `set -euo pipefail`; multi-line `if:` uses `>-`. -- **D9.4** Line endings follow `.editorconfig`. -- **D9.5 No decorative / dropped workflows.** No date-badge (`build-datebadge-*`), no tool-versions task, no - executable/`build-executable-task`, no `PUBLISH_ON_MERGE` variable, no `dorny/paths-filter`. Their presence - is a defect to remove. -- **D9.6** Style is enforced in CI by the `lint` job (D1.3), from the same config files the editor uses. - -### D10 - Repository configuration - -- **D10.1 Required configuration is present.** Output: the secrets, branch rulesets, and repository settings - section 6 lists are all in place. The detail and validation are in section 6; the audit is 5D. - -## 5. Test methodology - -### 5A. Static audit (no execution) - -Read the workflow files plus `version.json` and assert the fact behind each applicable guarantee with a -`file:line` citation: - -- **D0:** CI has no branch matrix; the publisher's single `publish` job passes `github.ref_name` as - `ref`/`branch` and is guarded to `main`/`develop`; NBGV invoked once, every other consumer reads it via - `needs:`; the run builds the trigger ref so `GITHUB_REF` matches the versioned branch. -- **D1:** CI runs on `push` with no paths filter; `validate` + `smoke-build` (the Docker target, `smoke: true`) - run; `lint` runs markdownlint, cspell on README/HISTORY, actionlint; there is no unit-test or CSharpier/ - `dotnet format` step; the aggregator `needs:` both and blocks on non-success. -- **D2:** the main release backstop checks the prerelease `-`, strips `+buildmetadata`, self-skips on smoke. -- **D3:** `main` appears in the backstop and the `prerelease` expression; `publicReleaseRefSpec` is - `^refs/heads/main$`. -- **D4:** `publish-release` triggers are `schedule` + `workflow_dispatch` only (no `push`, no - `PUBLISH_ON_MERGE`); the single `publish` job is guarded to `github.ref_name` in (`main`, `develop`) and - passes it as `ref`/`branch`; `target_commitish` is `GitCommitId`; the `prerelease` boolean - `== (inputs.branch != 'main')`; the release attaches `LICENSE` + `README.md` with - `fail_on_unmatched_files: true` and no build artifact; the Docker job logs in with `DOCKER_HUB_*` and pushes - `latest`/`develop` + `:SemVer2`; release-create gated `exists == false || workflow_dispatch`; Docker - buildcache is branch-scoped and write-gated on push; the Docker Hub overview push is gated to `main`. -- **D5:** no `upload-artifact` / `download-artifact` anywhere; no blanket artifact delete. -- **D6:** CI is `push` on every branch; the aggregator context has exactly one producer; no - `pull_request`-triggered fallback. -- **D7:** the publisher group is ref-independent with `cancel-in-progress: false`; the merge-bot keys on PR - number; CI uses the standard group; reusable jobs declare permissions. -- **D8/D9:** the merge-bot runs on `pull_request_target` with the App token, keyed on PR number; Dependabot - auto-merge has no semver-major exception; no codegen/upstream-version, date-badge, tool-versions, executable - task, `PUBLISH_ON_MERGE`, or `dorny/paths-filter`; actions SHA-pinned; - names/shells/conditionals per section 2. - -### 5B. End-to-end trace scenarios (deterministic from the YAML) - -| # | Input | Expected output | Exercises | -| --- | --- | --- | --- | -| S1 | push touching `Dockerfile` | `validate` + `smoke-build` run; the image builds, **no push, no release**; aggregator success | D0.1, D1 | -| S2 | push changing only docs | `validate` (lint checks markdown) + `smoke-build` run; nothing publishes | D1, D1.5 | -| S3 | push changing only `.github/workflows/**` | `smoke-build` exercises the changed reusable workflow head-resolved; `lint` runs actionlint; aggregator success | D1.1, D6.1 | -| S4 | weekly `schedule` | builds + publishes `main` only: stable release + refreshed `latest` (multi-arch); `target_commitish` = main's SHA; develop is not touched | D4.1, D4.2, D4.4 | -| S5 | `workflow_dispatch` from `develop` | builds + publishes `develop`: prerelease `X.Y.<height>-g<sha>` + `develop` image; `github.ref` is develop, so NBGV classifies it non-public | D4.1, D4.2, D3.2 | -| S6 | `workflow_dispatch` re-run, no new commits | release-create refreshed on dispatch (or skipped if the tag exists on schedule); Docker re-pushed (base refresh); no duplicate release | D4.5 | -| S7 | `workflow_dispatch` from a feature branch | the `publish` job's `github.ref_name in (main, develop)` guard skips it -> no publish | D4.1 | -| S8 | merged GitHub-Actions bump | not a shipped input and merges don't publish -> **no release**; ships in the next scheduled run | D4.1, D8.2 | -| S9 | PR with a markdown / spelling / workflow-YAML violation | the `lint` job fails -> aggregator blocks the merge | D1.3, D1.5 | -| S10 | `version.json` floor bump merged | merges don't publish -> no immediate release; the new floor ships in the next publish | D3.3, D4.1 | -| S11 | `develop` -> `main` promotion (merge commit) | the merge itself does not publish; `main`'s accumulated changes ship in the next scheduled run | D4.1, D8.1 | - -### 5C. Live probe (where warranted, never publishing) - -- Open a trivial-change PR and confirm S1 (the image smoke-builds, nothing pushed, aggregator green). -- After a `main` publish (schedule or dispatch) confirm a stable release (`isPrerelease == false`, tag plus - `LICENSE` + `README.md`, no build asset) and a multi-arch `latest` + `:SemVer2` - (`docker buildx imagetools inspect` shows amd64 + arm64) and the Docker Hub overview matching `Docker/README.md`; - after a `develop` dispatch confirm a prerelease `X.Y.<height>-g<sha>` + `develop` image. A re-run adds no duplicate - release. Absent publish rights, record indeterminate and rely on 5A/5B. - -### 5D. Configuration audit - -Run [`repo-config/configure.sh check`](./repo-config/). It confirms the listed secrets exist, the -`main`/`develop` rulesets enforce the required merge method + status check + signed commits + strict-off, and -the repository settings are in place, exiting non-zero on drift. Secret *values* cannot be read back, so it -asserts the names exist (failing if it cannot query them). The GitHub App installation is a best-effort -check (a precise check needs app-level auth, so it notes rather than fails). The Docker Hub token's validity and push scope are a -manual checklist item. - -### Assessment - -Operational when every applicable 5A item passes, every applicable 5B scenario matches (corroborated by 5C -where a live signal exists), and 5D configuration is in place. Procedure: **Audit** (5A + 5D) -> **Trace** -(5B) -> **Probe** (5C, without publishing) -> **Verdict** with the failing guarantee(s) and the triggering -input for each. - -## 6. Repository configuration - -The workflows depend on configuration outside the YAML. A misconfiguration surfaces only as a failed run, so -the configuration is part of "operational" (D10; audit 5D). - -**Secrets.** - -- `DOCKER_HUB_USERNAME` / `DOCKER_HUB_ACCESS_TOKEN` - Docker Hub credentials the Docker target logs in with to - push the image and the repository overview. Required in **both** the Actions and Dependabot secret stores: a - Dependabot-triggered push runs CI whose Docker smoke build logs in too, and that run gets the Dependabot - store. The access token needs push scope on `docker.io/ptr727/vscode-server-dotnetcore`. There is no - NuGet/OIDC publishing. -- `CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY` - the GitHub App credentials the merge-bot mints the App - token from. Required in **both** the Actions and Dependabot secret stores (a Dependabot-triggered run gets - the Dependabot store, not Actions secrets). The App must be installed on the repo with `contents: write` and - `pull_requests: write`. -- The built-in `GITHUB_TOKEN` needs no setup. **No `PUBLISH_ON_MERGE` variable is used.** - -**Branch rulesets.** - -- `main` - merge-commit merges only; requires the aggregator status check (`Check pull request workflow status - job`); requires signed commits; "require branches up to date before merging" is **off** (a forward-only - `develop` makes every post-release `main` tip unreachable from `develop`, so the strict check would fail - every release). -- `develop` - squash merges only (keeps history linear); requires the same status check; requires signed - commits; "up to date" is **off** (so same-batch bot PRs auto-merge in parallel). -- The required check's `context:` matches the aggregator job name verbatim (D6.2, D9.2). - -**Repository settings.** Auto-merge enabled; squash and merge-commit both allowed (each ruleset narrows its -branch to one); rebase off; auto-delete-on-merge **off** (so `main`/`develop` survive a promotion). Dependabot -version **and** security updates enabled. The GitHub App installed with the scopes above. - -**Validation.** This configuration is codified in [`repo-config/`](./repo-config/) and applied/audited by -`repo-config/configure.sh`; `check` is the 5D audit. Secret values cannot be read back, so the audit asserts -the names exist (failing if they cannot be queried); the App installation is a best-effort check. +# WORKFLOW.md + +The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**, the workflow style rules having their home in `GOVERNANCE.md`. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for the pipeline those workflows implement, which reaches past `.github/workflows/` to every file and repository setting a guarantee names, `version.json` and a branch ruleset's `context:` string among them. + +Its defining principle: **it describes required outcomes.** Two repos may implement the same guarantee with different YAML wherever that guarantee names no construct, and where one is named, D6.1's `release-asset-<branch>-<target>` and D9.2's ruleset-bound job `name:` among them, matching it **is** the outcome. Section 4 is what a workflow must satisfy, and the verdict below is how that is judged. The style conventions that section 2 points at are part of that contract wherever section 4 states one as a guarantee, and a violation of one that it does is a defect on the same terms as any other. + +Given this document and the `GOVERNANCE.md` sections it points at, an agent must be able to do three things to any project: + +1. **Audit** - statically check the structural fact each applicable guarantee implies, the style conventions among them (section 5A). +2. **Test** - trace the expected inputs/outputs (section 5B) and, where warranted, drive a live probe (section 5C). +3. **Assess** - render a verdict: **operational** (every *applicable* guarantee holds, every *applicable* scenario's predicted output equals the expected, and no 5C probe that was run contradicts either) or **not operational** (any *applicable* mismatch, which is a *defect*). + +> **Canonical scope.** An overlap between this document and `GOVERNANCE.md` resolves **by subject**, never by blanket precedence. This document wins on the applicability and N/A rules (section 1), the pipeline architecture (section 3), the contract (section 4), the test methodology (section 5), and the per-project-type walkthroughs (section 6). `GOVERNANCE.md` wins on the workflow style conventions, at its "Workflow YAML Conventions", which section 2 points at rather than restating; on the release policy, at its "Release Model"; on the branching model, at its "Branching Model"; and on what an operational repo is, at its "Operational Repositories". Section 3 summarizes those last three rather than owning them. Each file names where it defers. + +The guarantees are distilled from failures observed in practice. Section 4's preamble states how each item is written. + +## 1. Purpose and How to Use This Document + +- **Contract, not implementation.** Conform to the *outcomes* in section 4. Shape, job names, and file layout may differ between repos wherever no guarantee names them, and where one does, as D9.2 does for the ruleset-bound job `name:`, that name is itself the outcome. +- **Applicability.** A guarantee, or a 5B scenario, is **applicable** only if the repo contains the construct it governs: a given target, a transfer artifact, a registry push, a wrapper-version source. An item that governs an absent construct is **N/A**: record it as N/A and **exclude it from the verdict**. N/A is never a defect. Section 6 names which constructs each project type adds: start from the constructs every repo has, its pull request workflow among them, **union what every declared type adds**, and record an item N/A only where nothing in that union supplies its construct. Which triggers a construct carries is read from the workflow itself rather than from the type, per section 6. Owning the release task rather than calling the hub-hosted copy changes where a construct's evidence is cited, per 5A, never whether it is applicable. A near-empty pipeline (source-only) is mostly N/A and that is fine. +- **Operational is binary.** A workflow is operational only where section 5's Assessment records all three of its conjuncts met. A single applicable failure is a defect and makes the workflow **not operational**, whether it is an input/output mismatch or a static property of the committed source such as an unpinned action SHA, regardless of how clean the YAML looks. +- **Default branch.** Guarantees say "default branch" portably. It is implemented as the literal `main` in several places (the validate gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec`). These MUST all reference the repo's *actual* default branch. A divergence is a defect (D3.2). +- **Two layers when auditing.** The pipeline splits into an **orchestrator** layer (the PR entry workflow, the publisher, and the version and release jobs) and a **build-leaf** layer (the `build-<target>` tasks, whether separate files or jobs inside the release task). Inputs like `github`/`dockerhub`/`expect_release_assets` live on the orchestrator. A leaf receives `ref`/`branch`/`smoke` and whatever else its own target needs, a derived `push` among them where that leaf pushes. A package target declares no push input on either layer, because section 3's `Output Seam by Destination` puts its push in a `publish-<target>` job in the publisher, gated by `needs:` rather than by a flag. Assert an input a guarantee names in the layer that declares it. +- **The three verbs.** Audit (static), Test (trace + probe), Assess (verdict). Section 5 gives the exact procedure. + +## 2. Workflow Style Conventions + +`GOVERNANCE.md` "Workflow YAML Conventions" keeps the style rules, and this section points at it rather than restating it. Workflow YAML takes the same line-ending policy as every other file, which `GOVERNANCE.md` "Documentation Style Conventions" routes to under "Line Endings". Read the style rules before editing a workflow. Section 4 carries several of them as guarantees of its own, and where it does, a violation is a defect rather than a nit. They are not sufficient on their own: a perfectly styled workflow can still violate the guarantees they do not cover. + +## 3. Architecture + +### Branch Model + +Two workflow models, set per repo by the registry `workflowModel` field. `release` (default) is the feature-branch pipeline `WORKFLOW.md` specifies: + +```mermaid +flowchart LR + feature[feature branch] -->|squash| develop + develop -->|merge commit| main + main -.->|no back-merge| develop +``` + +`operational` repos (live-service config, `workflowModel: operational`) commit directly to `develop` and promote a known-good snapshot to `main` via an occasional PR: + +```mermaid +flowchart LR + edit[direct signed commit] -->|advisory CI| develop + pr[pull request] -->|lint CI, reported not required| develop + develop -->|merge commit, enforced lint CI| main +``` + +The direct commit is an **allowance, not a substitute for review**. The ruleset drops the pull-request *requirement*, which permits a direct push without withdrawing the pull request, so a change worth reviewing still takes one and both paths reach `develop` legally. Which changes those are is stated as a shape rather than a line count in `GOVERNANCE.md` "Operational Repositories", which owns the test and is the one place it is written, since nothing in a ruleset can apply it. What differs is when validation lands. On the direct-commit path the commit is already on the branch, so CI can only be advisory after the fact, and that is the accepted cost of the model. On the pull-request path the change has not landed, so validation is pre-merge and actionable, which is the moment it is worth the most, and the lint workflow's `pull_request` trigger therefore names `develop` alongside `main` (`WORKFLOW.md` section 6). That is what makes **D1.2** hold here, since its input is *any* PR and the operational model is no exception. The check is reported on a `develop` PR rather than required, because a required status check on `develop` binds the direct push too and would dissolve the allowance the model is built on. + +Their CI is lint/validation only (editorconfig/EOL plus domain linters such as Home Assistant or ESPHome config validation or a firmware build, but **no unit tests**), so the D-guarantees in `WORKFLOW.md` section 4 that assume a build/test pipeline are **N/A** exactly as for `source-only` (`WORKFLOW.md` section 6). What binds: the promotion gate, where the `develop -> main` PR must pass the required `Check pull request workflow status job`, and the source-only release on manual dispatch (`releaseTrigger: dispatch-only`; tag + source zip). Branch-model rulesets are specified in `GOVERNANCE.md` "Branching Model" rather than in `WORKFLOW.md`. + +### Two Layers: Orchestration vs Build + +- **Orchestration** is generic and forms the standardization baseline **at the job level**: the single-branch publisher, the `get-version`, `validate-release`, and `github-release` jobs, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **Build** is repo-owned in shape: the leaf a target runs. A repo owning its release task hosts its leaves itself. A repo calling the hub-hosted task shapes a leaf through a composite-action hook of its own, which the task runs in place of its hub default where that file exists. For the .NET, NuGet, and PyPI leaves it is `.github/actions/<job>/action.yml`, named for the build job (`dotnet-publish`, `build-nuget`, `build-pypi`). For the Docker leaf it is `.github/actions/docker-prepare/action.yml`, which a `docker_matrix` the caller passes bypasses along with the default. A `docker-build-base` hook, which has no default, builds a shared base layer where the caller sets `docker_build_base`. +- **What the repo curates** (by design, not a leak): the *list* of targets. A repo calling the hub-hosted release task reaches it by pin and carries no copy, so adding or dropping a target edits the caller's own surface. That is the `enable_<target>` value its publisher passes, the publisher's `on.push.paths` entries where it carries a `push` trigger, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3). It is also the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow, plus the separate `publish-<target>` job for a package target. The release task's `enable_*` inputs, its per-target build jobs, and their `github-release` and `build-docker` `needs:` entries belong to the task, so a target the hub task declares no input for is a change to that task first (`WORKFLOW.md` section 6, the `library` row). A repo owning its release task edits those in its own file, where "verbatim" applies to the `github-release` job and the version/publish-plan logic, except that job's own `needs:` list, and never to the task's job list. + +### The Seam Contract + +A target contributes a file to the GitHub release by uploading a workflow artifact named `release-asset-<branch>-<target>`. The release job collects **every** matching artifact by **pattern** (`pattern: release-asset-<branch>-*` + `merge-multiple: true`), never an `artifact-ids:` naming one job's output. Canonical for **every** repo, single-target included. Switching to an `artifact-ids:` handoff forks the release download. + +```mermaid +flowchart LR + dotnet[dotnet-publish] -->|release-asset-BRANCH-dotnet-publish| store[(run artifacts)] + nuget[build-nuget] -->|release-asset-BRANCH-nuget| store + store -->|pattern + merge-multiple| rel["github-release job (D6)"] + nuget -->|nuget-build-BRANCH| pub["publish-TARGET job in the repo's own publisher"] + pypi[build-pypi] -->|pypi-build-BRANCH| pub + pub -->|push| registries[(registries)] + docker[build-docker] -->|push| registries +``` + +The diagram writes `BRANCH` and `TARGET` where the prose writes `<branch>` and `<target>`, because a mermaid label is sanitized as HTML at render and an angle-bracket placeholder is dropped as an unknown tag. This reaches node labels as well as edge labels, which is why the Release Model diagram below writes `X.Y.Z-g-sha` rather than bracketing its own placeholder. + +### Reusable-Task Parameter Contract + +Every leaf and the release task take `ref`, `branch` (the **logical** branch that drives config/tags/prerelease), and where relevant `smoke`. Branch-derived config keys off `inputs.branch` (the logical branch the caller passes). Artifact names carry the branch. + +### Versioning + +NBGV versions the branch being published. Each run builds a single branch (the trigger ref), so `GITHUB_REF` already names it and NBGV classifies it directly, and no `IGNORE_GITHUB_REF` override is required. The default branch is the public-release ref, so it builds clean `X.Y.Z`. Every other branch builds a prerelease `X.Y.Z-g<sha>`. `version.json`'s `version` is the major.minor floor. NBGV appends the git height as the patch. **NBGV and `version.json` are retained even by a repo with no compiled code**, since they are the source of the release tag (`SemVer2`) and `target_commitish` (`GitCommitId`) and the prerelease classification. The .NET SDK is pulled in only as the versioning toolchain. A package build derives its registry version from the same NBGV outputs: the PyPI version is the `X.Y.Z` core of `SemVer2`, any prerelease or build segment dropped, with a PEP 440 `.dev0` appended on the `develop` branch. A wrapper repo may drive its build/image version from an external committed `name -> version` state file while NBGV still tags the release. + +### Validate-at-Entry + +When a workflow's inputs carry a cross-input or input-versus-derived-state invariant, assert it **once** in a dedicated entry job/step the downstream jobs `needs:`, failing fast with `::error::` before any build or publish. + +### Resource Lifecycle + +Workflow artifacts are an **intra-run handoff** only. Durable copies live on the release/registry. The rule: a transfer artifact handed **between jobs** is deleted by exact name/pattern **at its point of consumption**, the delete is **gated to the half of the consumption whose failure would leave it not yet redundant** (D5.2 names the two halves), and it is **best-effort**. **Every** `upload-artifact` sets `retention-days: 1` as the universal failure-path backstop, so no terminal blanket-delete job is needed. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. + +### Fast PR Feedback + +PRs validate fast and never publish: a paths-filter smoke-builds only changed targets. A validation job always runs. Smoke builds compile/lint/test but upload nothing and push nothing. One required aggregator gates the merge. See D1. + +```mermaid +flowchart TD + pr[pull request] --> ch[changes paths-filter] + ch -->|target changed| sb[smoke-build changed targets] + ch -->|workflow-only or docs| skip[smoke-build skipped] + val[validation job] --> agg["Check pull request workflow status job (D1)"] + sb --> agg + skip --> agg + agg -->|success| ok[merge allowed] +``` + +### Release Model + +Each publish builds a **single branch**, the trigger ref (`main` a release, `develop` a prerelease), so there is no branch matrix and `github.ref` always names the built branch. A **human merge never auto-publishes**: a `plan` job (`publish-plan-task.yml`) decides once, and a job downstream of it either gates on its outputs or inherits the skip through `needs:`. A run publishes on a **code-affecting bot push to `main`** (the App merges every Dependabot/codegen PR, so `github.actor` gates it, and the publisher's own `on.push.paths` list also drops a non-substantive change like an Actions bump), a **manual dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker, to refresh the base image). The `push` is main-only, so a develop bot merge publishes nothing (its prerelease comes via dispatch). A **source-only** repo publishes on **dispatch only**. Every release is a tag on the built commit plus a source archive, README, and LICENSE. Targets amend it with `release-asset-*` files, and a registry push contributes none, made by the Docker leaf for an image and by the separate `publish-<target>` job for a package. An unchanged version cuts no second release on a schedule or push trigger, while a dispatch refreshes it (D4.4). Docker re-pushes by design. + +```mermaid +flowchart TD + trig[main-only schedule / dispatch / paths-filtered push] --> one[build the one trigger branch] + one -->|main| vmain["version X.Y.Z stable (D3)"] + one -->|develop| vdev["version X.Y.Z-g-sha prerelease (D3)"] + vmain --> relm["github-release + registries: latest (D4)"] + vdev --> reld["github-release + registries: prerelease (D4)"] +``` + +### Output Seam by Destination + +Pick each output's path by **where the artifact goes**: + +- **File on the GitHub release** (zip, binary, packaged library): one leaf per output uploading `release-asset-<branch>-<name>`. The repo keeps `expect_release_assets: true` (its default). +- **Package-registry push** (NuGet, PyPI): the leaf builds and uploads a build artifact (`nuget-build-<branch>` / `pypi-build-<branch>`), and a separate `publish-<target>` job in the **publishing repository's own** publisher consumes it and pushes. Both registries publish through OIDC Trusted Publishing, never a stored API key, and two things put that push outside the leaf. Trusted publishing validates the OIDC token's `job_workflow_ref` claim, which names the workflow the job actually ran from, so a push made from a reusable workflow a *different* repository hosts is rejected at the token exchange, NuGet.org answering `HTTP 401` with `does not start with <owner>/<repo>/.github/workflows/`. That alone rules out a leaf another repository hosts. A leaf this repository hosts clears the claim, and the split still applies to it, because a called job declaring no `permissions:` runs under the calling job's whole grant, so a push anywhere inside the release task would put `id-token: write` on every job in it rather than at the one entry point D7.2 requires. The registered trusted-publishing policy therefore names the publisher, `publish-release.yml`. PyPI additionally gates its publish job behind an environment. NuGet.org binds its policy to the workflow file rather than to an environment and needs none. NuGet's leaf also uploads a `release-asset-*` carrying the package, and PyPI contributes none. +- **Image-registry push** (Docker): the leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only (arm64 emulation is reserved for the released image), and contributes no `release-asset-*`. +- **Filesystem on a host the project owns** (a static site, a config tree): the leaf builds the tree, ships it to the host, and contributes no `release-asset-*`. The transport is the repo's own. What the contract fixes is that the deploy is a **separate `workflow_dispatch`** from the release, so a redeploy of an unchanged commit mints no tag and a host rebuild, a rollback, or proving a branch on a non-production environment costs nothing; that its credentials come from a **per-environment GitHub Environment** rather than the repository secret store; and that the deploy ends by asserting **what the host serves** rather than the transport's exit status (D4.6). Retention at the destination is bounded by a declared count with one side recorded as owning the prune, which is the deploy where its credential can observe the destination and the host where that credential is deliberately write-only (D5.6). +- **No file target via the release task** (Docker-only, PyPI-only, source-only): the release is tag + source zip + README + LICENSE. The caller **MUST pass `expect_release_assets: false`** to the release task. A publisher with file targets retains the default `true`. This setting is caller-specific. The default `true` fails on `fail_on_unmatched_files` when no assets exist. A **source-only** repo also passes every `enable_*` input as false because it has no build leaf (see `WORKFLOW.md` section 6). + +`WORKFLOW.md` section 3 keeps the architecture, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/architecture.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + +## 4. Behavioral Contract: Expected Outcomes + +The required behaviors, organized by domain. Each is a **MUST**, and its `Output:` states what a conforming pipeline is required to hold. An `Output:` may be a behavior a run exhibits, or a property of the committed source such as a SHA-pinned action or a `retention-days:` setting, and the two kinds bind on the same terms. An item may also carry an `Input:`, where the guarantee turns on a particular trigger or state rather than on every run, a *Prevents:*, where the failure it rules out is not evident from the `Output:` itself, and an *Implication:* or a *Note:*, for a consequence and for a caveat. Applicability is `WORKFLOW.md` section 1's rule rather than a label's, so an item scoped to a repository shape says so in its own prose. A workflow that violates any *applicable* guarantee is **not operational**. + +### D1 - PR Fast-Feedback (Smoke) + +- **D1.1 Only changed targets build.** Input: a PR touching some targets. Output: the paths-filter marks exactly those targets and only their smoke builds run. Unchanged targets skip. A repo's own targets MUST each have a filter entry (so a touched target is never silently skipped), and that entry lists paths rather than negating them, so a change matching no entry marks nothing and every smoke build skips. *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* +- **D1.2 A validation job always runs.** Input: any PR. Output: a validation job runs unconditionally and the aggregator `needs:` it. That job is the caller's own job reaching the reusable validator, named `validate` in every shipped stub, and that name is what the aggregator's `needs:` carries. The validator's internal jobs (`lint`, `unit-test` and `validate` in the hub's `validate-task.yml`) are not addressable from a caller, so a `validate` in a caller's `needs:` list always names the caller's own job rather than the validator's internal one of the same name. The validator detects the tree rather than the repo's language, running the doc and repo gates everywhere, the `dotnet test` path only where a `*Tests*.csproj` project exists, and the `pytest` path only where a root `tests/` directory sits beside a root `pyproject.toml` and a root `uv.lock` or `requirements*.txt`, so a non-.NET repo calls the same one rather than replacing it. A repo whose tests take another shape can run them from its own `.github/actions/validate/action.yml` hook, which the validator runs where that file exists. That hook receives no secret, so a repo whose other-shape tests owe D1.6's coverage upload is one whose validation the validator cannot express. A repo whose validation it cannot express **replaces** the call (not deletes it) with its own validator and re-points the aggregator's `needs:` to the replacement. `smoke-build` `needs:` the `changes` job rather than the validation job, so no second `needs:` moves with it. *Prevents: a PR merging with no validation, or a dangling `needs:` that stops the whole workflow from loading.* +- **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* +- **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* +- **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, or `pytest --cov-report=xml` over a repo whose own `pyproject.toml` selects what to measure) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). The Python leg **fails its test step when no report was written**, since `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-<guid>.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. N/A for a repo carrying no tests for that type, and for a `lint-only` profile for it (per the hub's `registry/repos.json`). *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* + +### D2 - Input/State Validation at Entry + +- **D2.1 Validate before expensive work.** Output: a dedicated entry job/step asserts each cross-input/derived-state invariant and fails fast before builds. Downstream jobs `needs:` it. +- **D2.2 Release branch matches version classification.** Input: a real (non-smoke) release build. Output: the gate fails loudly if the default branch carries a prerelease suffix **or** a non-default branch carries none. It strips `+buildmetadata` before testing for the prerelease `-` (only a core/prerelease `-` counts), and on a smoke build the **check exits early while the job still reports success** (a detached PR head always versions as prerelease). Read that as the validation being skipped rather than the job, because a job-level `if:` would skip the job itself, and a dependent whose `if:` carries no status-check function, an absent `if:` included, gets the implicit `success()` and skips with it. `validate-release` has such dependents, so a job-level skip there would skip their smoke builds with it. *Prevents: a non-default leg published as stable, a build-metadata false-positive, and the gate blocking every default-base promotion PR.* +- **D2.3 Publish only from main or develop.** Input: a dispatch publish. Output: a dispatch from any ref other than `main` or `develop` fails fast. *Prevents: cutting a release from an unintended branch.* +- **D2.4 Mutually-exclusive / paired inputs are validated.** Input: a workflow with either/or or must-pair inputs (e.g. the docker-readme task's `repositories` XOR `manifest`+`manifest-jq`). Output: a half-filled or conflicting combination fails fast. *Prevents: a silent fall-through.* +- **D2.5 The release gate refuses a branch input git would not accept as a name.** Input: a release task taking a `branch` input, on any run. Output: the gate runs `git check-ref-format --branch` over that value and fails when git rejects it, ahead of D2.2's smoke exit, with an error echoing no part of the value. *Prevents: a `::` or `##[` in a caller's value forming a workflow command wherever a later step prints the branch, on a smoke run as much as on a publish.* + +### D3 - Versioning and Classification + +- **D3.1 One branch per run.** Input: a publish triggered on `main` or `develop`. Output: the run builds and versions that one branch, and `github.ref` names it, so NBGV classifies it directly (no `IGNORE_GITHUB_REF`). *Prevents: a cross-branch ref mismatch misclassifying the version.* +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`, and any other -> `X.Y.Z-g<sha>`. Every literal that keys default-branch behavior, `version.json`'s `publicReleaseRefSpec` among them, MUST name the repo's real default branch. The hub-hosted tasks and their default leaves write it as `main`, so a repo calling them has `main` as its default branch. +- **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor. NBGV appends the git height as the patch, and the maintainer moves the version by bumping the floor. NBGV and `version.json` are retained even by a no-compiler repo (they own the tag). +- **D3.4 Registry versions follow the classification, per registry.** Output: NuGet default = stable, others = prerelease (derived by NuGet.org from the SemVer2 `-g<sha>` suffix on `PackageVersion`, not a flag the workflow sets). PyPI builds from the `X.Y.Z` core of `SemVer2`, any prerelease or build segment dropped, and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule). The develop build stays `pip install --pre`-selectable, `.dev0` being a PEP 440 development release. *Note: at a shared `version.json` floor, `M.N.P.dev0` sorts below `M.N.P` at equal height, so a develop build does not always lead the stable release. A develop-only floor bump leads on the floor whatever the heights.* *Prevents: a non-default leg published as a release.* +- **D3.5 Wrapper repos may use an external version.** Output: a repo wrapping an upstream release drives its build/image version from a committed `name -> version` state file, while NBGV still tags the release. *Note: the tracker (the writer) ships without consumer wiring, so a wrapper must wire the leaf to read the state file (e.g. `jq` into the image tag) instead of `SemVer2`. If the leaf still tags off NBGV, the wrapper is not actually pinned to upstream.* + +### D4 - Release / Publish + +- **D4.1 Gated single-branch publish.** Output: PRs smoke-test and publish nothing. A **human merge never auto-publishes**. A `plan` job (`publish-plan-task.yml`) decides once. A job downstream of it either gates on the plan's outputs or inherits the skip through `needs:`. A gate on an output compares it against `'true'` rather than testing it bare, since a job output is always a string. A publish runs on a **code-affecting bot push to `main`** (gated to the codegen App / Dependabot `github.actor`, with an Actions-only bump matching no release path and publishing nothing), a **dispatch** of `main`/`develop`, or a **main-only weekly schedule** (Docker). A source-only repo publishes on dispatch only. Each run builds one branch. +- **D4.2 Tag the built commit.** Output: the release `target_commitish` is the built commit's SHA (NBGV's `GitCommitId`), never a branch name or a separately re-resolved ref. *Prevents: the tag landing on the default branch instead of the built tree.* +- **D4.3 Release contents.** Output: every release contains a tag on the built commit plus the auto source zip, README, and LICENSE. File targets attach `release-asset-*`. The `prerelease` value equals `branch != default`. A no-file-target caller sets `expect_release_assets: false` to reach the no-asset shape. This applies to Docker-only, PyPI-only, and source-only repos. A NuGet target is not among them, since its leaf uploads a `release-asset-*` carrying the package, so a NuGet-only caller keeps the default `true`. The setting relaxes `fail_on_unmatched_files` and skips the asset download. The release-create step fails when no assets exist and the setting retains its default `true`. A source-only caller also sets every `enable_*` input false. +- **D4.4 No-op republish.** Input: a re-run whose version is unchanged, on a schedule or push trigger. Output: no second release is cut, because the release-create step is skipped when `gh release view` finds a release for the tag, and the paired asset-delete is skipped with it. A **dispatch** re-run refreshes the release instead and runs that delete with it, which is why a dispatch-only publisher records this item's skip leg as unreachable rather than failed. Package-registry pushes are no-ops. The NuGet/PyPI publish steps are **not** statically gated on existence. They run and the **server** dedupes (`dotnet nuget push --skip-duplicate` turns a 409 into success, and PyPI does the same under `skip-existing: true`). **Docker always re-pushes** the image (base-image refresh), independently of the release-create skip, within the same run. *Prevents: duplicate releases and wasted pushes.* +- **D4.5 A build failure blocks every publish target, within the Docker limit below.** Input: a real publish where one enabled build fails. Output: nothing publishes. The limit: where a Docker target builds a shared base or more than one image, its task pushes the base and each image leg as it builds, so a leg failing after the base or a sibling leg pushed leaves those images in the registry with no release. `github-release` needs every build and carries the same `!failure() && !cancelled()` guard the terminal registry pusher (Docker) does, since the implicit `success()` would otherwise skip both on every run that disables a target rather than only on a failed one. A failed build therefore skips the release (no tag, no release), and Docker, which needs every other build, skips with it (no image push), while a **disabled** target, skipped rather than failed, still lets docker push. A package target's separate publish job needs its own gate for the same reason, since it sits outside the `github-release` and Docker `needs:` chains: it `needs:` the release-task call, so a failed build skips it with the rest. The push itself is what no gate can cover, because it runs after the whole release task and therefore after `github-release`, for the trusted-publishing reason `WORKFLOW.md` section 3's "Output Seam by Destination" package-registry bullet gives, so a rejected token exchange, a registry outage, or a trusted-publishing policy naming the wrong workflow file leaves a published release and tag for a version that never reached the registry. The recovery is a re-dispatch or a full re-run rather than a cleanup. **A full re-run is always available inside its window and is the only route once the branch tip has moved.** The `Re-run failed jobs` shortcut is not a third route here, D5.2's delete having already removed the artifact it would download. `GOVERNANCE.md` "Release Model", and the skill it routes to, carry the mechanics of each route, how to choose, and the window. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* +- **D4.6 Deploy verification names the release.** Input: a deploy to a filesystem on a host the project owns that completes without error. Output: a check against the running host asserts **which release is answering**, not merely that it answers. The artifact stamps its own version into the configuration it ships, and the check compares that against the version just installed, **waiting for convergence to a bounded timeout** rather than sampling once, because content goes live the instant a pointer moves while server rules wait on an asynchronous reload. The same check asserts **which environment** answered, since several environments serve a byte-identical artifact and a proxy rule aimed at the wrong one answers healthily under the right hostname. An unreachable host is reported distinctly from an HTTP status. *Prevents: a green deploy over a host still serving the previous release's configuration, a URL contract checked against the wrong environment, and a dead config watcher read as a routing fault.* + +### D5 - Resource Cleanup + +- **D5.1 Delete at the point of consumption.** Output: the job that downloads a **cross-job** transfer artifact deletes it (by exact name/pattern) right after consuming it. *Prevents: transfer artifacts accumulating against the storage quota.* +- **D5.2 Gate the delete by the kind of consumer it follows.** Output: where the consumer is a conditional step (the GitHub release create), the delete carries that same condition, narrowed by `inputs.expect_release_assets`. Where the consumer is a step that always attempts once its job runs (a package publish job's push), the delete is gated on the **download** having succeeded rather than on the push, as `if: ${{ !cancelled() && steps.<download-step-id>.outcome == 'success' }}`. A step whose `if:` carries no status-check function, an absent `if:` included, inherits `success()` instead, which skips it on exactly the failed push where the artifact is already downloaded and the release is already cut. So on a no-op re-run that is not a dispatch the `release-asset-*` delete is **skipped** with the release create it follows, while the `nuget-build-*` and `pypi-build-*` deletes still **run**. A dispatch re-run refreshes the release instead (D4.4), so its asset delete runs with it. Deleting the `nuget-build-*` or `pypi-build-*` artifact on the failed-push path costs the run its **Re-run failed jobs** route, since the re-run's download then finds nothing, so the recovery for a failed push is one of the two routes D4.5 names, and `GOVERNANCE.md` "Release Model", with the skill it routes to, sets out how far that cost actually reaches. *Prevents: deleting freshly built assets on a no-op re-run, and stranding a downloaded artifact when the push it fed fails.* +- **D5.3 Best-effort.** Output: cleanup is `continue-on-error`, tolerates a failed listing, and deletes **all** matching ids. *Prevents: a cleanup hiccup reddening a job whose publish succeeded.* +- **D5.4 Retention backstop.** Output: **every** `upload-artifact` sets `retention-days: 1`. +- **D5.5 Never blanket-delete.** Output: cleanup MUST NOT enumerate and delete the run's whole artifact set. *Prevents: destroying diagnostic/log artifacts and auto-emitted build-records.* +- **D5.6 A durable destination's retention is bounded and owned.** Input: a deploy that installs a release beside the retained ones on a host the project owns. Output: retention is bounded by a **declared count**, and the side owning the prune is **written down**. Where the deploy credential can observe the destination, the deploy asserts the count converged and fails when it does not. Where the credential is deliberately write-only, so it can neither delete nor read back, the prune belongs to the **host** and that ownership is recorded there: widening the credential to reach the destination would trade a real confinement boundary for a check, which is the wrong trade. The release the live pointer resolves to is never a prune candidate, whatever the sort order says. A prune that runs against a local scratch tree, or that is best-effort, or that no side is recorded as owning, satisfies none of this. Unlike D5.1 through D5.4, this destination is durable rather than a run-scoped artifact, so no retention backstop expires it. *Prevents: a destination growing without bound until the disk fills, which surfaces as a site outage rather than as a failed deploy; and the split-ownership version of the same, where each side assumes the other prunes.* + +### D6 - Seam / Architecture Conformance + +- **D6.1 Pattern handoff.** Output: the release job downloads by `pattern:`/`merge-multiple:`, not `artifact-ids:`. **File** targets upload `release-asset-<branch>-<target>`, and a target contributing no file to the release (Docker, PyPI) uploads no `release-asset-*` of its own, per D4.3, whatever other transfer artifact it uploads. The `pattern:` download is canonical for a single-target repo too, which does not special-case itself to `artifact-ids:`. +- **D6.2 Branch drives config.** Output: a called workflow's branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3 Branch-named artifacts.** Output: every artifact name carries the branch, so a branch's artifacts do not collide with another branch's. +- **D6.4 Target add/drop is consistent.** Output: adding or dropping a target updates **all** of: the `enable_<target>` value the publisher passes to the release task, the publisher's `on.push.paths` entries where it carries a `push` trigger, the `changes` paths-filter entry + output, the `smoke-build` enable-forward, and `expect_release_assets` where the change adds the first file target or drops the last (D4.3), plus, for a package target, the separate `publish-<target>` job. A repo owning its release task also updates the target's build job there, `build-<target>` or `dotnet-publish` for the .NET target, and its `github-release` and `build-docker` `needs:` entries. Everything in the `github-release` job **except its `needs:` list** stays verbatim, and so does the version and publish-plan logic. "Verbatim" never reaches the surfaces this item requires editing, that `needs:` list, the release task's job list, and the paths-filter among them. *Prevents: a partial subset that startup-fails on a missing leaf or never smoke-builds a target.* + +### D7 - Concurrency, Permissions, Safety + +- **D7.1 Publisher serializes.** Output: the publisher uses a **global, ref-independent** concurrency group with `cancel-in-progress: false`. *Prevents: a schedule and a dispatch double-pushing, or a cancelled publish leaving a partial release.* +- **D7.2 A called job's permissions block is validated before its `if:`.** Output: a reusable job declares `permissions:` only where **every** caller grants that scope at startup, and otherwise declares none and runs under whatever the calling job granted. A callee's extra scope (e.g. `actions: write` for cleanup, or `id-token: write` for OIDC) is granted by the caller and appears at exactly the one entry point that needs it. *Prevents: a `startup_failure` on every caller that does not grant a scope only one target needs, including a smoke build under a read-only pull request token.* +- **D7.3 A `github.event.inputs` boolean is compared as a string.** Output: a boolean read through `github.event.inputs.<name>` is compared against `'true'`, since that context delivers every input as a string whatever the input's declared type. Comparing it against the boolean `true` as well is dead rather than defensive: an operand-type mismatch casts each side to a number, a non-numeric string casts to `NaN`, and `NaN` compares equal to nothing, so `github.event.inputs.<name> == true` is false even on the run where the input arrived as `true`. The `inputs` context preserves the declared boolean on the `workflow_call` and `workflow_dispatch` paths alike, so an `inputs.<name>` read is used directly, and a both-forms comparison there is redundant rather than wrong, which is why the hub's Docker build task comparing its `build-base` input in both forms is not a finding. A workflow carrying both trigger blocks declares each boolean input in both, since one declaration does not propagate to the other, while a boolean that only ever arrives by `workflow_call` is declared in that block alone. `smoke` is such a boolean, every hub task declaring it being `workflow_call`-only, which is why D1.3 writes the workflow-layer gate `!inputs.smoke` against the real boolean and the composite-action gate `inputs.smoke != 'true'` against a string, a composite action's inputs being strings whatever their caller passed. A job or step **output** is always a string as well and takes the same `== 'true'` rather than a bare truthiness test, since the string `'false'` is truthy. *Prevents: a dispatch-path string read as truthy, and a comparison against the boolean `true`, which can never fire, standing in for the one that can.* +- **D7.4 Optional-dependency chaining.** Output: a cross-job condition chaining across an **optional** dependency allowlists `success`/`skipped` explicitly, paired with a status-check function such as `always()` or `!failure() && !cancelled()`. Without one the implicit `success()` applies and is false the moment any `needs:` job skipped, which is the case the allowlist exists to admit. *Prevents: a condition that reads as tolerant of a skipped dependency and is dead in exactly that case.* + +### D8 - Bots / Automation + +- **D8.1 Merge-bot.** Output: enables auto-merge on `opened`/`reopened` for **every** Dependabot tier including semver-major (the required checks are the gate, not the bump magnitude); dispatches `--squash`/`--merge` by the PR's base ref; disables on a maintainer-pushed `synchronize`; concurrency keyed on the **PR number**, not `github.ref`. *Prevents: two PRs colliding in auto-merge.* +- **D8.2 CodeGen and Dependabot.** Output: codegen runs as a matrix over both branches and is deterministic from an external source. `.github/dependabot.yml` targets both branches, and security PRs go to the default branch. +- **D8.3 Upstream-version tracker.** Output: a scheduled resolver prints a JSON `name -> version` object to a committed state file, opens a rolling per-branch bump PR naming only the moved keys, the merge-bot auto-merges it. The `main` pin push publishes via the release gate, while a `develop` pin does not auto-publish. It ships via a `develop` dispatch (prerelease) or the next promotion to `main`. The tracker's `bump-branch-prefix` + `branches` MUST match a merge-bot rule, one of the built-in `<prefix>-<base>` head/base pairs or a `rules` entry the caller passes, or auto-merge silently never fires. A tracker whose bump needs a human decision instead sets `auto-merge: false`, which prefixes the head so no merge-bot rule matches it, whatever `bump-branch-prefix` names. +- **D8.4 An identity allowlist used as a gate fails loud.** Where a gate compares `github.actor` (or a PR author) against hard-coded bot identities, the non-matching branch on an otherwise-legitimate trigger **emits a `::warning::`** rather than falling through silently. Output: a run that declines to act on an unrecognized identity is visibly annotated. *Prevents: the App being renamed, replaced, or reinstalled under a new slug, after which the comparison quietly evaluates false and the gate stops firing, a green and silent run that looks identical to a healthy one.* The masking matters most where a second path hides the loss: a weekly schedule keeps publishing, so the only symptom is release *timeliness*, easily missed for months. Where the failure is self-announcing instead (the merge-bot simply stops merging, so bot PRs visibly pile up) an annotation is optional. Resolving the identity at run time (mint an App token, read `GET /app`) removes the hard-coded string entirely and is the escalation if an allowlist proves fragile in practice. + +### D9 - Style / Static + +`GOVERNANCE.md` "Workflow YAML Conventions" names the tool D9.1 excepts and states the suffix rules D9.2 requires. + +- **D9.1** Every action or reusable workflow referenced from another repository is SHA-pinned with a version comment (sole exception: the documented lagging-tag tool). A local (`./`) or self-repository (`$/`) reference names no ref and takes no pin. +- **D9.2** File/workflow/job/step names follow the suffix rules. A ruleset-bound job's `name:` equals its ruleset `context:` (renamed together). +- **D9.3** Multi-line bash `run:` blocks start `set -Eeuo pipefail`. Multi-line `if:` uses `>-`. +- **D9.4** Docker layer cache targets a registry tag, not `type=gha`. `cache-to` writes only the built branch's `<repo>:buildcache-<branch>` and only on push, while `cache-from` reads both branches. A multi-image repo varies the cache **repository** rather than the tag, `<image>:buildcache-<branch>` per image, the tag alone being unable to distinguish two images. +- **D9.5** Line endings follow `.editorconfig`. + +`WORKFLOW.md` section 4 keeps the D-guarantees, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/d-guarantees.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + +## 5. Test Methodology + +An agent verifies a project in three escalating modes, then renders a verdict. **Skip N/A items** (`WORKFLOW.md` section 1): a guarantee or scenario for an absent construct is recorded N/A, not failed. + +### 5A. Static Audit (No Execution) + +Assert the structural fact each *applicable* D-guarantee implies, and record **pass**, **fail**, or **N/A** per item. This section says how an audit is run and recorded rather than what must hold: a guarantee names its own constructs, and the requirement is `WORKFLOW.md` section 4's item together with whatever that item defers to. + +Most of the evidence is in the workflow files and the composite actions they reach. Where a guarantee's evidence lies outside them, it is in practice the repo's branch ruleset, its Actions and Dependabot secret names, a workflow the repo only calls, a project or dependency file, or a committed file such as `version.json`, `.github/dependabot.yml`, `global.json`, `codecov.yml`, `.gitignore`, or `.editorconfig`. + +Cite what each verdict rests on. That is `file:line` for a file in the audited repo, its own name where a setting, a ruleset, or a secret name rather than a file is the evidence, and `<owner>/<repo>@<sha>` plus the `file:line` in that repo where the guarantee binds a workflow or composite action the audited repo only reaches, read at the SHA the caller pins. An **N/A** verdict names the absent construct instead, there being no line to cite. + +### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) + +For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the inputs and emit the predicted **run/skip + version + release + artifact-end-state** table, then compare to the expected. A scenario governing a construct the repo does not contain is N/A, per `WORKFLOW.md` section 1, and an absent trigger is such a construct. Each scenario's trigger belongs to one workflow, so read that workflow's own `on:` block rather than the repo's type: S1 to S4 the pull request workflow's, S5 to S10 the publisher's, S11 the upstream tracker's, and S12 and S13 the deploy workflow's. A publisher carrying only `workflow_dispatch` therefore records S5, S6 and S9 N/A, their push and schedule paths never firing there, and a repo with no publisher at all records S5 to S10 N/A together. Where a scenario's path runs through a workflow or composite action the repo only **calls**, trace that callee as the repo reaches it, read at the SHA the caller pins rather than at the callee's current default branch, which is the same evidence rule 5A states. Predicting from the callee's `main` predicts a table for YAML the audited repo never runs. A local (`./`) or self-repository (`$/`) call carries no pin of its own and runs at the workflow commit, so it is traced at whatever SHA the outermost pinning caller fixed. Minimum set: + +| # | Input | Expected output | Exercises | +| --- | --- | --- | --- | +| S1 | PR touching a build target | `changes` flags it; validation runs; that target's smoke build runs; no push, **no uploads**; validate-release **succeeds**, its check exiting early on smoke per D2.2; release **skipped**; aggregator **success**; version = prerelease; no release; no dangling artifacts | D1, D2.2, D3 | +| S2 | PR changing only docs | smoke-build **skipped**, validation runs, aggregator **success** | D1.1, D1.2, D1.5 | +| S3 | PR changing only `.github/workflows/**` | the filter marks no target -> smoke-build **skipped**, validation runs, aggregator **success** | D1.2, D1.4, D1.5 | +| S4 | PR base = default branch, carrying a build target | smoke versions as prerelease, validate-release **succeeds** with its check exited early per D2.2, so the default-branch arm does **not** fire, aggregator **success**, promotion not blocked | D1.5, D2.2, D3.2 | +| S5 | bot push to `main` not touching a release path (e.g. an Actions bump) | the paths filter excludes it, so nothing publishes | D4.1 | +| S6 | code-affecting **bot** push to `main` (a human push/promotion, or any develop push, does not) | the `plan` job gates it to the App/Dependabot actor, and `main` publishes a release | D3, D4 | +| S7 | publish run (schedule, a bot push to main, or a dispatch) | builds the **one** trigger branch: `main` -> `X.Y.Z`, `prerelease=false`, registry stable, readme run; `develop` -> `X.Y.Z-g<sha>`, `prerelease=true`, registry prerelease; `release-asset-*` consumed-then-deleted; each package build-artifact (`nuget-build-*`, `pypi-build-*`) deleted after its publish; **no dangling artifacts** | D3, D4, D5, D6, D7 | +| S8 | dispatch from a ref other than `main` or `develop` | **fails fast** | D2.3 | +| S9 | re-run publish on a schedule or push trigger, version unchanged (a dispatch re-run refreshes the release instead, per D4.4) | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **package build-artifacts still deleted** (their download succeeded); **Docker still re-pushes** the image; no duplicate release | D4.4, D5.2 | +| S10 | branch/version classification disagree | validate-release **fails loud**, build/publish skip | D2.2 | +| S11 | scheduled upstream-version bump (wrapper) | resolver detects a change -> commits the state file -> opens a per-branch bump PR -> the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3) -> the `main` pin publishes via the gate (a develop pin does not auto-publish, shipping instead via a develop dispatch or promotion) | D8.3, D3.5 | +| S12 | deploy dispatch naming an environment | the ref gate runs **first** (production from the default branch only, any ref to a non-production environment); validation runs; the callee re-asserts the environment name; a release installs under its own id; the pointer flips as a separate step; retention is bounded by whichever of the two D5.6 shapes the repo uses, so a deploy whose credential can observe the destination asserts the count converged and one confined write-only leaves it to the host; the live check asserts the environment and the release id, waiting out the reload, then the URL contract; **no tag and no release are created** | D2.1, D4.6, D5.6 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1 | + +### 5C. Live Probe (Where Warranted) + +Every probe here that opens a pull request, dispatches a workflow, or re-runs a real publish is the maintainer's to run, with the agent preparing the command and reading the result back afterwards. A harness that refuses such a write is the harness working as intended, and the refusal is neither re-shaped into a raw API call nor talked around (`GOVERNANCE.md` "Repository Boundaries and Write Safety"). + +- Open a trivial-change PR touching one target and confirm S1. *Caveat: the Docker leg logs in to the registry even on smoke and reads the buildcache, so it needs `DOCKER_HUB_*` secrets and cannot run on a fork PR (same-repo only).* +- Per registry: after a real publish, query NuGet.org for the expected version + prerelease classification (and the `.snupkg` on the symbol server), and confirm a re-run added no duplicate. For PyPI read the built `dist/*` filenames out of the build job's log, `.dev0` off `develop` vs a plain version on the default branch. +- Inspect the latest real publish's logs for `PublicRelease`/`SemVer2` per leg and confirm the artifact lifecycle (uploaded, consumed, deleted, with none left behind). +- **The deploy ref gate (S13) is verified only by tripping it.** Dispatch the production environment from a non-default ref and expect the run to fail at the gate. The evidence is four things, and each of them matters: the gate job's conclusion, its error text naming the expected and the received ref, every downstream job recorded as **skipped** rather than passed, and the production environment's deployment list carrying no deployment from the dispatched ref. Capture all four, because a gate that fails open and a gate nobody tripped produce the same empty run history, so "we have never seen it fail" is not evidence about the one control standing between a mis-dispatch and the live site. **The agent prepares the command and reads all four back afterwards. It does not fire it.** The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. + +### Assessment + +Record the workflow **operational** when every *applicable* 5A item passes, every *applicable* 5B scenario's predicted output equals the expected, and no 5C probe that was run contradicts either. N/A items are excluded, never counted as failures. Any *applicable* mismatch is a **defect** -> **not operational**. Procedure: + +1. **Audit** with 5A, recording each item's verdict and its evidence in the form 5A sets out. +2. **Trace** the applicable S-scenarios with 5B. Diff predicted vs expected. +3. **Probe** with 5C where a live signal exists that the static trace cannot produce, running the probes that only read and preparing the writing ones for the maintainer: live version classification, registry state, the artifact lifecycle of a real run, and the deploy ref gate. +4. **Verdict:** operational / not operational, with the failing guarantee(s) and the triggering input for each, the list of items recorded N/A, and the 5C probes prepared but not run. + +`WORKFLOW.md` section 5 keeps the test methodology, and the `workflow-ci-contract` Skill at `.agents/skills/workflow-ci-contract/references/test-methodology.md` in the hub, not a repo-relative link since that path is hub-local and not carried into every fleet repo, carries this section whole as a generated include. + +## 6. Per-Project-Type Test Walkthroughs + +Each type adds constructs to the pipeline, and the constructs are what decide a verdict. This section states what each type **adds**. Section 1's applicability rule turns that into the N/A set on its own: an item governing a construct the repo does not contain is N/A, and one governing a construct it contains is checked. No row here states an N/A list of its own, deliberately, so that reading one row can never take away a construct another row supplies. Walking the rows a repo's types select is the self-check that the contract holds for its shape. + +Three rules govern reading a row, each of them because reading one row alone has produced a wrong verdict. + +- **Types union, they do not choose.** A repo declaring more than one type contains the constructs of **every** type it declares, so an item is N/A only where no declared type supplies its construct. `source-only` beside another type is the case that catches a reader out: it adds no build target and takes none away. +- **The trigger scenarios come from the publisher's own `on:` block, not from the type.** S5, S6 and S9 turn on which triggers the publisher actually carries, and two repos of one type routinely differ there. Section 5B's preamble owns that rule and it binds here. +- **N/A names an absent construct, never an unexercised one.** The `pattern:` download (D6.1) and the `release-asset-*` cleanup step (D5.1 to D5.3 and D5.5) live in the release task, so a repo that owns that task and a repo that calls the hub-hosted copy both contain them and are both checked on them. Ownership decides only where the evidence is cited, in the repo's own file or at the SHA it pins, which is the form 5A gives. Section 5A requires an N/A verdict to name the construct that is absent, so an item that cannot be recorded that way is applicable. + +The table files each of S1 to S13 under exactly one row, which is what covers the set without a per-type list restating it. Filing is not the whole applicability test: a scenario is N/A when **any** construct it needs is absent, and that can be more than the row it sits under, S1 needing the pull request workflow as well as the build target. + +| Construct the repo contains | Scenarios it reaches | +| --- | --- | +| A pull request workflow with a validation job and the required aggregator | S2, S3 | +| A build target: a `changes` filter entry, a smoke build, and a leaf that builds it | S1, S4 | +| A publisher, meaning a workflow that cuts the release | S7, S8, S10, plus S5 and S6 wherever its own `on:` admits a push, and S9 wherever it admits a push or a schedule | +| An upstream-version tracker and its merge-bot | S11 | +| A deploy workflow targeting a filesystem on a host the project owns | S12, S13 | + +The rows below say which constructs a type brings with it. Read the repository for the rest: NBGV, `version.json` and the classification gate are reached on the smoke path too, so a repo with no publisher can still contain them, and a repo whose `releaseTrigger` is `none` has no publisher whatever its types say. + +What each type adds beyond that, and how its leaf behaves, is below. + +- **`dotnet-publish`.** The target runs a sequential `dotnet publish` runtime loop inside one composite-action job. Configuration is Release on the default branch and Debug otherwise. A non-smoke run builds the full runtime set, archives the combined output as a `.7z`, and uploads it as `release-asset-<branch>-dotnet-publish`. The archive is named from the project file stem unless `dotnet_publish_asset_name` overrides it. A smoke run builds a **strict, non-empty subset** of that runtime set and skips the archive and upload steps, so it uploads nothing. S1 smoke-builds that subset after a .NET project change. Where the repo has a publisher, S7 attaches the 7z from a non-smoke run. The non-default leg sets `prerelease=true`, and the default leg sets `prerelease=false`. GitHub marks the stable default release "Latest" automatically. +- **`nuget`.** The leaf uploads both `release-asset-<branch>-nuget` and `nuget-build-<branch>` on a non-smoke run and pushes nothing, and a separate `publish-nuget` job in the repo's own publisher consumes the second and runs `dotnet nuget push *.nupkg --skip-duplicate`, then deletes it under the download step's own success (D5.2). Section 3's `Output Seam by Destination` says why the push sits there rather than in the leaf. Configuration is Release on the default branch, Debug otherwise. Where symbols are enabled (`snupkg`), the push auto-carries the paired `.snupkg` to NuGet.org's symbol server and the release-asset `.7z` also contains it, a triple surface. NuGet.org derives `isPrerelease` from the SemVer2 `-g<sha>` suffix (the workflow sets no such flag). Test: S7 non-default leg publishes a prerelease package + asset, default a stable; S9 re-run is a server-side `--skip-duplicate` no-op. 5C: query NuGet.org for both versions and the symbol package. +- **`pypi`.** The leaf builds and uploads `pypi-build-<branch>`. A **separate** `publish-pypi` job (with `environment: pypi`, `id-token: write`, `actions: write`) does the OIDC Trusted-Publishing upload with `skip-existing: true`, then **consume-then-deletes** the build artifact under the download step's own success (D5.2), so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is the `X.Y.Z` core of `SemVer2` with `.dev0` appended on `develop` only, per D3.4. PyPI contributes no `release-asset-*`. A PyPI-only repo sets `expect_release_assets: false` at the caller. Test: S7 default leg publishes a release, non-default a `.dev0`; S9 is a `skip-existing` no-op; 5C inspects the `dist/*` filenames and the compute-version log. +- **`docker`.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache. A single-image repo caches to `<repo>:buildcache-<branch>`, and a multi-image repo varies the cache **repository** rather than the tag, `<image>:buildcache-<branch>` for each image, since the tag alone cannot distinguish two images (`cache-to` writes only the built branch and only on push, `cache-from` reads both branches). It contributes no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`. The readme job (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) runs **only** when the default branch publishes, whether called directly or reached through the hub-hosted `publish-docker-readme-task.yml`. Where the Docker Hub overview differs from the project README, the repo publishes a `Docker/README.md` through that task, the Hub description being size-limited. The docker-readme task validates its two mutually-exclusive input sources, `repositories` against `manifest`+`manifest-jq`, and defaults to the calling repository where neither is supplied, and a multi-image repo derives its publish matrix from the manifest. Docker **always re-pushes** the image, independently of a skipped release-create (S9). Test, under the default tag matrix, which a caller-supplied `matrix` or `docker-prepare` hook replaces: S7 default leg pushes `latest` + the version tag and updates the readme. Non-default pushes `develop` + the version tag (amd64 only). S9 still re-pushes. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. +- **`library` (a worked example, not a fleet type).** No such leaf ships, so this row walks D6.4's add-a-target procedure rather than an existing shape: a single new leaf that validates, zips, and uploads `release-asset-<branch>-library` (`retention-days: 1` per D5.4, upload gated on smoke being false per D1.3). Adding it means a new `enable_library` input, a `build-library` job and its `github-release` and `build-docker` `needs:` entries in the release task, a `library` paths-filter entry, `changes` output, and `smoke-build` enable-forward in the PR workflow (without that last one, D1.1 never smoke-builds the library), and in the publisher the `enable_library` value it passes and the library's paths in its `on.push.paths` where it carries a `push` trigger. **Only a repo that owns its release task can make the release-task half of that edit.** The hub-hosted release task declares a closed input set and a call passing an input it does not declare fails, so for a repo calling it adding a target is a change to the hub task first, and the enable-forward and the publisher's value wait on it. Keep `expect_release_assets: true` (it has a file target, unlike Docker). The caller's own validation job is replaced by a type-appropriate validator only where the reusable one cannot express this repo's validation, with the aggregator re-pointed to the replacement (D1.2). `smoke-build` keeps `needs: [changes]`, as D1.2 has it. `version.json` and the NBGV `get-version` **job** are retained (they own the tag). Test: S1 smoke runs validate+zip and uploads nothing; S7 attaches the zip, prerelease on the non-default leg; S9 on a push re-run skips release-create and the asset-delete (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run is D4.4's refresh case rather than S9's, re-uploading then re-deleting the asset. +- **`upstream-wrapper`.** The repo tracks an upstream project's releases rather than versioning its own code. A scheduled resolver writes a `name -> version` state file and opens a per-branch bump pull request, the merge-bot auto-merges it, or leaves it for the maintainer where the tracker sets `auto-merge: false` (D8.3), and the build leaf MUST read that state file for its build or image version instead of `SemVer2`, while NBGV still tags the release (D3.5), the tracker shipping without this consumer wiring. Test: S11 traces the bump from resolver to the publish that ships it, which for the `main` pin is the gated publish D8.3 describes. Nothing in this row depends on which build-target types the wrapper also declares. +- **`homeassistant`.** Adds a **file target**: distribution is a GitHub release that HACS installs from, so the release carries the integration zip. The contract's seam for that is D6.1's `release-asset-<branch>-<target>` upload collected by `pattern:`, which a repo owning its release task supplies itself, the hub-hosted task declaring a closed set of `enable_*` inputs that has no entry for this type. A repo reaching the same tag-plus-asset outcome through a differently-named artifact diverges from D4.3 and D6.1 alike, which is a defect against the seam rather than against the release it produces. Its Python side follows home-assistant/core conventions rather than this fleet's defaults, pip with `requirements*.txt`, a `custom_components/<domain>` layout, and standalone `.ruff.toml` and `pyrightconfig.json`, which is expected rather than drift, and `mypy --strict` runs in CI. Test: S1 smoke-builds the zip, and S7 attaches it where the repo has a publisher. +- **`eda`.** Adds a **file target**: distribution is a GitHub release data zip that a local EDA install pulls, supplied by the repo's own release task for the same reason the `homeassistant` row gives. It also adds design-data validation to the pull request gate, the analogue of the code linters, `kicad-cli` ERC/DRC or library linting in practice. Where the repo generates build-time artifacts (gerbers, drill files, a BOM), that generation is deterministic from the design inputs, which a data-only repo not yet building artifacts owes only once it starts. Test: S1 smoke-builds the zip, S2 and S3 cover the validation gate, and S7 attaches it where the repo has a publisher. +- **`codegen`.** Adds a generation workflow that runs as a matrix over both branches and is deterministic from an external source (D8.2), carrying no per-run timestamp or GUID. It contributes no build target and no publish scenario of its own, so it changes no row of the table above. +- **`csharp`, `python`, `cpp`, `docs`.** Add no pipeline construct. They decide what the validation job runs and what the repo's project configuration must hold, `csharp` the analyzer and central-MSBuild rules and D1.6's C# coverage leg, `python` the profile split and D1.6's Python one, `cpp` a shared `clang-format` feeding the lint gate, and `docs` a lint-only CI with no build or test. A repo declaring one of these and nothing else reaches only the scenarios its publisher and its pull request workflow already supply. +- **`source-only`.** Adds no build leaf of its own. In a repo declaring no build-target type beside it, the publish job reaches the reusable release task with `github: true`, every `enable_*` input false, and `expect_release_assets: false`, producing tag + source zip + README + LICENSE with no asset download, the paths-filter matches nothing so a retained `smoke-build` job is **structurally always skipped**, and the repo may drop that never-running job. A repo declaring a build-target type as well enables that target instead, this row taking nothing away from it. NBGV and `version.json` own the tag. Validation remains the caller's own job reaching the reusable validator. The aggregator `needs:` that validation job (D1.2), and a retained `smoke-build` job `needs:` the `changes` job rather than the validation job. The publish job depends on the same reusable validation task the PR workflow runs, which prevents a dispatch from releasing a ref that fails validation. The release task carries the D6.1 `pattern:` download and the `release-asset-*` cleanup step (D5.1 to D5.3 and D5.5), so a repo contains them whether it owns that task or calls the hub-hosted copy, and is checked on them either way. **Owning it changes only where the evidence is cited**, in the repo's own file or at the SHA it pins, per section 5A's citation rule. Test, where the repo has a publisher: S7 covers the release, S8 the dispatch guard, and S10 the classification gate. +- **`hugo` (a static site deployed to a host the project owns).** Two independent surfaces, and keeping them apart is the point. The **release** is the `source-only` shape above, unchanged. The **deploy** is its own `workflow_dispatch` carrying an `environment` choice input, so redeploying an unchanged commit mints no tag, which matters because redeploying is routine. It runs a ref gate **first**, before anything is installed or written (production from the default branch only, while any ref may reach a non-production environment, since proving a branch before it merges is what that environment is for), then the **same** reusable validation task the PR gate runs, so a dispatch cannot deploy a ref that fails validation, then calls the hub-hosted `deploy-site-task.yml`. That task declares the `environment:` inside itself rather than on the calling job, since GitHub rejects a job carrying both `uses:` and `environment:`. The caller still maps the crossing secrets explicitly under `secrets:`, `DEPLOY_SSH_PRIVATE_KEY` because the task declares it required, and the `SITE_AUTH_TOKEN_ID`/`SITE_AUTH_TOKEN` pair only where a token-gated live check needs it, `secrets: inherit` not being used on a cross-repository call. For a secret the environment defines, the value the task's job reads is resolved by the task's own `environment:` binding from the caller's environment store, not taken from that mapping. That task hard-asserts two interfaces. It requires the environment to carry `SITE_BASE_URL`, `DEPLOY_SSH_USER`, `DEPLOY_SSH_HOST` and `DEPLOY_SSH_KNOWN_HOSTS` as variables and `DEPLOY_SSH_PRIVATE_KEY` as a secret, each non-empty, failing fast naming every missing one, and it requires `SITE_AUTH_TOKEN_ID` and `SITE_AUTH_TOKEN` to be mapped together or not at all. And it requires a **deploy hook** at `.github/actions/deploy/action.yml`, one composite action the caller owns, run three times with its `mode` input set to `build`, `prune` and `verify`. The hook declares all four inputs the task passes across those runs, `mode`, `bundle-path`, `release-id` and `environment`, and its `build` run leaves `releases/<release-id>/` and `current` under `bundle-path`, both of which the task asserts. The task re-asserts the environment *name*, `production` or `staging`, in a job of its own, because the `environment:` binding resolves before any step runs and a `workflow_call` caller is not bound by the dispatch choice list a human sees. The ref gate gets no counterpart re-assertion, so a caller reaching the task directly is trusted for the ref and not for the environment, which is a deliberate asymmetry rather than an omission. Its environment-bound job then: checks out full history (a shallow clone silently changes page metadata), derives the release id **once** and exports it (deriving it twice yields ids seconds apart, and the live check then asserts a version nothing installed), runs the hook's `build` invocation, installs the deploy credential from the environment, uploads into a per-release directory hard-linked against the current release and carrying **no** delete flag (at an environment root a delete removes the rollback targets), flips the pointer as a separate atomic step so a failed transfer cannot half-publish, then runs the hook's `prune` and `verify` invocations, the second checking the running host (D4.6) against the site's own URL contract. Retention (D5.6) is bounded by a declared count with one side recorded as owning it: a deploy whose credential can observe the destination prunes and asserts the count in the `prune` hook, while a credential confined **write-only** can neither delete nor read back, so that repo's `prune` hook is a no-op, the prune is a host-side timer, and the repo's runbook records that ownership. Widening the credential to bring the prune in-pipeline would trade a real confinement boundary for a check, and is the wrong trade. What the guarantee rejects is neither side owning it. One thing the pipeline cannot assert and the server config must: a non-public environment serving a byte-identical copy must not be indexed, and that default belongs on the side that is harmless in production, since a non-public container missing the value is still behind its gate while a production container inheriting it deindexes the site silently. +- **Operational (a `workflowModel`, not a type).** A `workflowModel: operational` repo layers direct commits to `develop` onto the `source-only` release shape above. It has two workflows. The first is a **lint/validation** PR workflow that feeds the required `Check pull request workflow status job`. It uses the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator, with **no unit tests**. Examples include Home Assistant `hass --script check_config`, `esphome config`, or a firmware build. Its triggers differ from the `release` model. It runs on pushes to `develop`, pull requests to `[ main, develop ]`, and `workflow_dispatch`. Push validation is advisory. Pull request validation is enforced on `main` and reported but not required on `develop`. The second workflow is the standard `source-only` publisher. NBGV and `version.json` own the tag. The reusable release task creates tag + source zip + README + LICENSE. **The PR trigger names both branches, and naming `main` alone is a defect.** Omitting `develop` starts no validation when a PR opens against `develop`. The aggregator then never reports, and the PR appears clean with an empty check list. D1.2 forbids that output. Naming both causes a duplicate run after a PR merge. The change validates on the PR and again on the resulting push, regardless of merge method. The operational `develop` ruleset prescribes no merge method. The concurrency group uses the workflow name plus `${{ github.ref }}` (`GOVERNANCE.md` "Workflow YAML Conventions"). A pull request uses `refs/pull/<n>/merge`, while its push uses `refs/heads/develop`. The runs occupy different groups and neither cancels the other. Pay that cost. The lint-only gate costs only a few runner-minutes. Suppressing the push requires distinguishing a merge commit from a direct commit, which restores the ambiguity the trigger set removes. Having no build target, such a repo never reaches S1, whose input is a target change, on any pull request, promotion and `develop` pull requests included. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. + +<!-- Repo --> + +[codestyle]: ./CODESTYLE.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model diff --git a/cspell.json b/cspell.json index 4792b42..b6c8de6 100644 --- a/cspell.json +++ b/cspell.json @@ -1,17 +1,18 @@ -{ - "version": "0.2", - "language": "en-US", - "words": [ - "dockerhub", - "dotnetcore", - "linuxserver", - "LSIO", - "ptr" - ], - "ignorePaths": [ - "**/bin/**", - "**/obj/**", - ".artifacts/**", - ".git/**" - ] -} +{ + "version": "0.2", + "language": "en-US", + "words": [ + "dockerhub", + "dotnetcore", + "linuxserver", + "LSIO", + "Nerdbank", + "ptr" + ], + "ignorePaths": [ + "**/bin/**", + "**/obj/**", + ".artifacts/**", + ".git/**" + ] +} diff --git a/host-tools.json b/host-tools.json new file mode 100644 index 0000000..6abcc8b --- /dev/null +++ b/host-tools.json @@ -0,0 +1,4 @@ +{ + "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file, do not hold the same thing, and do not share a schema: an overlay allows an empty tools list and a partial entry, and the fleet declaration requires at least one entry and every field of each. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look. A repository copying this file leaves the $schema pointer behind, because the schemas are hub-only and no selector carries one, so a relative pointer resolves to a path that repository does not have and a schema-aware editor reports the file invalid for a reason nobody there can fix.", + "tools": [] +} diff --git a/repo-config/README.md b/repo-config/README.md deleted file mode 100644 index 0e295c0..0000000 --- a/repo-config/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# repo-config - -Repository configuration as code - the parts of "operational" that live in GitHub settings rather than -in workflow YAML: branch rulesets, repository settings, and the secrets the workflows read. This is the -concrete form of [`WORKFLOW.md`](../WORKFLOW.md) section 6 and guarantee **D10**, and the implementation -of its **5D configuration audit**. - -This directory is intentionally **not** under `.github/` - that path is GitHub's own (workflows, issue -templates); repository administration config-as-code is the maintainer's, so it lives here. - -## Files - -- [`configure.sh`](./configure.sh) - idempotent `gh api` script with two modes: - - `./repo-config/configure.sh check` - validate only, no writes; exits non-zero on drift (the 5D - audit). Read-only, but it reads the rulesets and secrets endpoints, so it still needs a `gh` token - with admin on the repo. - - `./repo-config/configure.sh apply` - create-or-update the rulesets and settings to match this - directory (needs admin; writes). -- [`ruleset-develop.json`](./ruleset-develop.json) - the `develop` branch ruleset (squash-only, linear - history, signed commits, the required status check, strict-status **off**). -- [`ruleset-main.json`](./ruleset-main.json) - the `main` branch ruleset (merge-commit-only, signed - commits, the same required check, strict **off**; no linear-history rule). -- [`settings.json`](./settings.json) - repository settings (auto-merge on; squash **and** merge-commit - allowed; rebase off; auto-delete-on-merge **off**). The repo-wide auto-delete **setting** is off so a - `develop -> main` promotion does not delete `develop` (GitHub's auto-delete would remove the merged head - branch). Per-merge deletion is explicit instead: the merge-bot deletes a merged bot branch with - `gh pr merge --delete-branch`, and a feature branch is deleted the same way (or via the merge UI's delete - button) - so `main`/`develop` survive while bot/feature branches are still cleaned up. - -## What it does not store - -Secret **values** are never readable through the API, so the script only asserts the required secret -**names** exist (`DOCKER_HUB_USERNAME` / `DOCKER_HUB_ACCESS_TOKEN` for the image, and the App credentials -`CODEGEN_APP_CLIENT_ID` / `CODEGEN_APP_PRIVATE_KEY` for the merge-bot), and *notes* (best-effort) whether a -GitHub App is installed - a precise check needs app-level auth, so the App-installation check does not fail -the audit. -The Docker Hub and App credentials must be set in **both** the Actions and Dependabot secret stores, since a -Dependabot-triggered run gets the Dependabot store. Set the values in the repository (or organization) secret store directly. There is no -NuGet publishing here; the GitHub release uses the built-in `GITHUB_TOKEN`. The Docker Hub access token's -validity and push scope are verified by hand, not by this script. - -## Applying, and the required-check rename lockstep - -The live ruleset's required status check is matched by **name** to the aggregator job in -[`test-pull-request.yml`](../.github/workflows/test-pull-request.yml) (`Check pull request workflow -status job`). GitHub binds the check by that exact string, so the ruleset JSON here, the live ruleset, and -the aggregator job name must move **in lockstep** ([`WORKFLOW.md`](../WORKFLOW.md) D6.2). If they drift, a -pull request runs CI but its required check never resolves and the PR cannot merge. - -So whenever the ruleset JSON or that job name changes, run `apply` against the live repo in the same -change that ships the workflow edit, then `check`: - -```sh -REPO=ptr727/VSCode-Server-DotNetCore ./repo-config/configure.sh apply # sync live rulesets + settings + security -REPO=ptr727/VSCode-Server-DotNetCore ./repo-config/configure.sh check # confirm no drift -``` - -First-time adoption is the same step: the live ruleset predates the renamed aggregator, so the first -`apply` is what lets a pull request against the new workflows go green. Both modes need a `gh` login -with admin on the repo (the rulesets and secrets endpoints require it). `apply` writes, `check` only -reads. - -## Why both a script and JSON - -The JSON files are the unambiguous source of truth for the configuration; the script applies and audits -them idempotently. An agent can also derive the same checks on the fly from `WORKFLOW.md` section 6, but -the committed script and JSON codify the exact intended state so the configuration is reproducible and -diffable rather than tribal knowledge. diff --git a/repo-config/configure.sh b/repo-config/configure.sh deleted file mode 100755 index 14e82e3..0000000 --- a/repo-config/configure.sh +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env bash -# Repository configuration as code - the secrets, branch rulesets, and settings the workflows assume -# (see WORKFLOW.md section 6, guarantee D10). Idempotent: `apply` configures a repo to match the JSON in -# this directory; `check` validates an existing repo and exits non-zero on drift (the 5D audit). Run from -# anywhere; the target repo is resolved from the current `gh` context unless $REPO is set (owner/name). -# -# ./repo-config/configure.sh check # validate only, no writes (the 5D audit) -# ./repo-config/configure.sh apply # create-or-update rulesets + settings (writes) -# -# Requires gh and jq. Both modes read the rulesets and secrets endpoints, which need admin on the repo, so -# gh must be authenticated with admin for `check` as well as `apply`. `check` only reads; `apply` writes. - -set -euo pipefail - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO="${REPO:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}" - -# Secrets by store (names only; values are never readable via the API). The Docker Hub credentials and the -# merge-bot App credentials must be set in BOTH stores: a Dependabot-triggered run gets the Dependabot secret -# store, not Actions secrets, and that run's push CI builds the Docker smoke, which logs in to Docker Hub. -# Publishing the GitHub release uses the built-in GITHUB_TOKEN (no secret needed). -REQUIRED_ACTIONS_SECRETS=(DOCKER_HUB_USERNAME DOCKER_HUB_ACCESS_TOKEN CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY) -REQUIRED_DEPENDABOT_SECRETS=(DOCKER_HUB_USERNAME DOCKER_HUB_ACCESS_TOKEN CODEGEN_APP_CLIENT_ID CODEGEN_APP_PRIVATE_KEY) -REQUIRED_CHECK="Check pull request workflow status job" - -note() { printf ' %s\n' "$*"; } -pass() { printf ' \033[32mok\033[0m %s\n' "$*"; } -fail() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAILED=1; } -FAILED=0 - -ruleset_id() { # name -> id (empty if absent); aborts with a visible reason on an API error - local out - # An absent ruleset is a successful call with no match (empty); only a real API error fails. Let gh print its - # own error on stderr (do not suppress it); add a generic context line and return non-zero so the run stops - # (the caller's $(...) cannot print the cause itself). - # per_page=100 returns every ruleset in one array (a repo has only a handful); the default page size is 30. - if ! out="$(gh api "repos/$REPO/rulesets?per_page=100")"; then - echo "ERROR: could not list rulesets for $REPO (see gh error above)" >&2 - return 1 - fi - # shellcheck disable=SC2016 # $n is a jq variable (--arg n), not a shell expansion - # Select the first match inside jq (not `| head -1`): under pipefail, head closing the pipe early can - # SIGPIPE jq and fail the function. - jq -r --arg n "$1" '[.[] | select(.name==$n) | .id] | first // empty' <<<"$out" -} - -apply_ruleset() { - local file="$1" name id - name="$(jq -r .name "$file")" - id="$(ruleset_id "$name")" - if [[ -n "$id" ]]; then - gh api -X PUT "repos/$REPO/rulesets/$id" --input "$file" >/dev/null - note "updated ruleset '$name' (#$id)" - else - gh api -X POST "repos/$REPO/rulesets" --input "$file" >/dev/null - note "created ruleset '$name'" - fi -} - -cmd_apply() { - echo "Applying repository configuration to $REPO" - apply_ruleset "$DIR/ruleset-develop.json" - apply_ruleset "$DIR/ruleset-main.json" - gh api -X PATCH "repos/$REPO" --input "$DIR/settings.json" >/dev/null - note "patched repository settings" - gh api -X PUT "repos/$REPO/vulnerability-alerts" >/dev/null - gh api -X PUT "repos/$REPO/automated-security-fixes" >/dev/null - note "enabled Dependabot alerts + security updates" - echo "Done. Run '$0 check' to validate." -} - -# --- validation (5D) ------------------------------------------------------------------------------- - -# assert MESSAGE TEST... - run the test command; pass on success, fail on non-zero (proper if/else, not -# the A && B || C footgun). The test command may read stdin (e.g. a `<<<` heredoc on the assert call). -# Do not redirect the assert call's stdout - that would also swallow the pass/fail line; commands that -# print (jq) use `jq_has`, which silences only itself. -assert() { - local msg="$1"; shift - if "$@"; then pass "$msg"; else fail "$msg"; fi -} - -# jq_has FILTER... - true iff the jq filter selects something; jq's own output is discarded, not the -# caller's. Reads JSON from stdin. -jq_has() { jq -e "$@" >/dev/null 2>&1; } - -# jq_lacks FILTER... - true iff the jq filter yields no truthy value (selects nothing, or only false/null). -# `jq -e` exits 1 (last output false/null) or 4 (no output at all) for the "lacks" cases, 0 for a truthy -# match, and 2/3/5 for a real error (malformed filter or input), which is propagated so the calling assert -# fails loudly. The `|| rc=$?` keeps jq in a list (exempt from set -e) so a non-zero exit captures rc instead -# of aborting. Only stdout is discarded - jq's stderr is kept so a real error shows its diagnostic. -jq_lacks() { local rc=0; jq -e "$@" >/dev/null || rc=$?; case "$rc" in 0) return 1 ;; 1|4) return 0 ;; *) return "$rc" ;; esac; } - -check_ruleset() { # name expected-merge-method expect-linear(true/false) - local name="$1" method="$2" linear="$3" id rs - id="$(ruleset_id "$name")" - if [[ -z "$id" ]]; then fail "ruleset '$name' missing"; return; fi - rs="$(gh api "repos/$REPO/rulesets/$id")" - assert "ruleset '$name' active" \ - test "$(jq -r '.enforcement' <<<"$rs")" = active - assert "'$name' merge method = $method" \ - test "$(jq -r '.rules[] | select(.type=="pull_request") | .parameters.allowed_merge_methods | join(",")' <<<"$rs")" = "$method" - assert "'$name' requires signed commits" \ - jq_has '.rules[] | select(.type=="required_signatures")' <<<"$rs" - assert "'$name' strict status policy off" \ - test "$(jq -r '.rules[] | select(.type=="required_status_checks") | .parameters.strict_required_status_checks_policy' <<<"$rs")" = false - # shellcheck disable=SC2016 # $c is a jq variable (--arg c), not a shell expansion - assert "'$name' requires '$REQUIRED_CHECK'" \ - jq_has --arg c "$REQUIRED_CHECK" '.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[] | select(.context==$c)' <<<"$rs" - if [[ "$linear" == "true" ]]; then - assert "'$name' requires linear history" \ - jq_has '.rules[] | select(.type=="required_linear_history")' <<<"$rs" - else - # main must NOT require linear history - it would block the develop -> main merge-commit promotion. - assert "'$name' does not require linear history" \ - jq_lacks '.rules[] | select(.type=="required_linear_history")' <<<"$rs" - fi -} - -# gh_ok ENDPOINT... - true iff the gh api call succeeds (2xx, including 204). Output and errors are -# discarded, so it is safe to pass to `assert`. -gh_ok() { gh api "$@" >/dev/null 2>&1; } - -check_settings() { - local s; s="$(gh api "repos/$REPO")" - # Drive every assertion from settings.json, so the check covers exactly the applied desired state and - # never drifts from the file (add a key there and it is audited here automatically). - local key want got - while IFS=$'\t' read -r key want; do - # shellcheck disable=SC2016 # $k is a jq variable (--arg k), not a shell expansion - got="$(jq -r --arg k "$key" '.[$k]' <<<"$s")" - assert "setting $key = $want" test "$got" = "$want" - done < <(jq -r 'to_entries[] | "\(.key)\t\(.value)"' "$DIR/settings.json") -} - -check_security() { - # apply enables both; audit that they are still on. vulnerability-alerts returns 204 when enabled and - # 404 when disabled; automated-security-fixes returns { "enabled": true/false }. - assert "Dependabot vulnerability alerts enabled" gh_ok "repos/$REPO/vulnerability-alerts" - assert "Dependabot automated security updates enabled" \ - jq_has '.enabled == true' < <(gh api "repos/$REPO/automated-security-fixes") -} - -check_secrets() { - # --paginate: the secrets endpoints page at 30, so without it a repo with many secrets could miss a - # required name and report a false failure. An API/auth error FAILs fast (the required secrets cannot be - # verified, so reporting "matches" would be wrong) - distinct from a genuinely missing secret, which also - # FAILs. gh prints its own error (stderr not suppressed) so the cause is actionable. - local actions deps - if ! actions="$(gh api --paginate "repos/$REPO/actions/secrets" --jq '.secrets[].name')"; then - fail "could not list Actions secrets (API error - cannot verify required secrets)"; return - fi - if ! deps="$(gh api --paginate "repos/$REPO/dependabot/secrets" --jq '.secrets[].name')"; then - fail "could not list Dependabot secrets (API error - cannot verify required secrets)"; return - fi - for s in "${REQUIRED_ACTIONS_SECRETS[@]}"; do - assert "actions secret $s present" grep -qx "$s" <<<"$actions" - done - for s in "${REQUIRED_DEPENDABOT_SECRETS[@]}"; do - assert "dependabot secret $s present" grep -qx "$s" <<<"$deps" - done -} - -check_app() { - # Best-effort: confirm a GitHub App installation backs the merge-bot automation. A precise check - # requires app-level auth; presence of the App secrets above is the practical proxy. - if gh api "repos/$REPO/installation" >/dev/null 2>&1; then - pass "a GitHub App is installed on the repo" - else - note "could not confirm App installation via this token (verify the merge-bot App is installed)" - fi -} - -cmd_check() { - echo "Validating repository configuration for $REPO" - check_ruleset develop squash true - check_ruleset main merge false - check_settings - check_security - check_secrets - check_app - # Not checkable via gh api beyond name presence: that DOCKER_HUB_ACCESS_TOKEN is valid and has push - # access to docker.io/ptr727/vscode-server-dotnetcore. Verify it by hand in the Docker Hub account. - note "verify manually: Docker Hub access token valid with push to docker.io/ptr727/vscode-server-dotnetcore" - if [[ "$FAILED" -ne 0 ]]; then echo "Configuration drift detected."; exit 1; fi - echo "Configuration matches." -} - -case "${1:-check}" in - apply) cmd_apply ;; - check) cmd_check ;; - *) echo "usage: $0 [apply|check]" >&2; exit 2 ;; -esac diff --git a/repo-config/ruleset-develop.json b/repo-config/ruleset-develop.json deleted file mode 100644 index daf7dd4..0000000 --- a/repo-config/ruleset-develop.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "develop", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["refs/heads/develop"], - "exclude": [] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_linear_history" }, - { "type": "required_signatures" }, - { - "type": "pull_request", - "parameters": { - "allowed_merge_methods": ["squash"], - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "required_review_thread_resolution": true - } - }, - { - "type": "required_status_checks", - "parameters": { - "do_not_enforce_on_create": false, - "strict_required_status_checks_policy": false, - "required_status_checks": [ - { "context": "Check pull request workflow status job", "integration_id": 15368 } - ] - } - }, - { - "type": "copilot_code_review", - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - } - } - ] -} diff --git a/repo-config/ruleset-main.json b/repo-config/ruleset-main.json deleted file mode 100644 index 0864a0e..0000000 --- a/repo-config/ruleset-main.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "main", - "target": "branch", - "enforcement": "active", - "conditions": { - "ref_name": { - "include": ["refs/heads/main"], - "exclude": [] - } - }, - "rules": [ - { "type": "deletion" }, - { "type": "non_fast_forward" }, - { "type": "required_signatures" }, - { - "type": "pull_request", - "parameters": { - "allowed_merge_methods": ["merge"], - "dismiss_stale_reviews_on_push": true, - "require_code_owner_review": false, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "required_review_thread_resolution": true - } - }, - { - "type": "required_status_checks", - "parameters": { - "do_not_enforce_on_create": false, - "strict_required_status_checks_policy": false, - "required_status_checks": [ - { "context": "Check pull request workflow status job", "integration_id": 15368 } - ] - } - }, - { - "type": "copilot_code_review", - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - } - } - ] -} diff --git a/repo-config/settings.json b/repo-config/settings.json deleted file mode 100644 index fc373ef..0000000 --- a/repo-config/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "allow_squash_merge": true, - "allow_merge_commit": true, - "allow_rebase_merge": false, - "allow_auto_merge": true, - "delete_branch_on_merge": false -} diff --git a/version.json b/version.json index 268e6f6..2c04b81 100644 --- a/version.json +++ b/version.json @@ -1,10 +1,10 @@ -{ - "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.1", - "publicReleaseRefSpec": [ - "^refs/heads/main$" - ], - "nugetPackageVersion": { - "semVer": 2 - } -} +{ + "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", + "version": "1.1", + "publicReleaseRefSpec": [ + "^refs/heads/main$" + ], + "nugetPackageVersion": { + "semVer": 2 + } +}