diff --git a/.editorconfig b/.editorconfig index 2da1302..c50c533 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,18 +1,21 @@ # 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 -# The default is LF rather than the fleet CRLF default. -# The `[*]` default follows the consuming application's native platform. -# Everything here is consumed by Linux. -# - Hugo builds the site in CI. -# - Caddy and OpenSSH read their config on an Ubuntu VPS. -# - The deploy scripts run there too. -# Taking the CRLF default would mean an LF override for nearly every file. -# That is the over-normalization the rule exists to prevent. -# Because LF is the default, the per-file pins the fleet baseline carries are unnecessary. -# Git's own enforcement for execution-sensitive files lives in `.gitattributes`. +# 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 @@ -26,19 +29,192 @@ trim_trailing_whitespace = true [*.sh] indent_style = tab -# Two trailing spaces are a hard line break in Markdown. -# Trimming them silently rewrites the content. +# Markdown files +# Two trailing spaces are a hard line break in Markdown, so trimming them silently rewrites content. [*.md] trim_trailing_whitespace = false # Xml files -[*.xml] +[*.{xml,csproj,props,targets}] indent_size = 2 -# Yaml files. `hugo.yaml` and the workflows are the bulk of this repo's configuration. +# 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 may drop from here down. +# This repo ships no .NET, so the block below is inert and costs nothing. + +# 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 + +# Repo-specific below: this repo's own additions layered on the fleet baseline above. + # JSON, including the parity-gate fixtures and `version.json` [*.json] indent_size = 2 diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json index 465ae06..ff06145 100644 --- a/.editorconfig-checker.json +++ b/.editorconfig-checker.json @@ -1,5 +1,10 @@ { "Exclude": [ + "(^|/)__pycache__/", + "(^|/)\\.mypy_cache/", + "(^|/)\\.pytest_cache/", + "(^|/)\\.ruff_cache/", + "(^|/)\\.venv/", "^static/media/", "^static/external/", "^themes/", diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b7ec8bd..e5326cc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,234 +1,68 @@ # Copilot Instructions -Repository conventions for GitHub Copilot (and any other AI agent reading this file). +Repository-wide instructions for GitHub Copilot. -The **canonical guide is [AGENTS.md](../AGENTS.md)** at the repo root. Read it first, then the [PR Review Etiquette](../GOVERNANCE.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), guidance for reviewing carried fleet content, plus the GitHub Copilot Review Runbook. +Read [AGENTS.md](../AGENTS.md) first. It routes every standing repository rule to its canonical +document. When performing code review, load and follow the `code-review` skill in +`.github/skills/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. -For code-style rules, see [`CODESTYLE.md`](../CODESTYLE.md) at the repo root, one guide with a General section plus a section per language the repo uses. - -Do not duplicate language-specific rules here. **Project-specific conventions and API/behavioral contracts also belong in [GOVERNANCE.md](../GOVERNANCE.md), not here.** This file is intentionally limited to the inline commit/PR-title summary, the guidance for reviewing carried fleet content, 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 `GOVERNANCE.md`, routed to from `AGENTS.md`, to be provider-independent. +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 -Summarized for VS Code's generators. The full rules, rationale, and examples are in [GOVERNANCE.md "Pull Request Title and Commit Message Conventions"](../GOVERNANCE.md#pull-request-title-and-commit-message-conventions). - -- Imperative subject, <= 72 characters, no trailing period, with an 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. +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 -Several of this repository's governance files are carried from a shared template and kept in sync across a fleet of sibling repositories, among them `AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, this file, and the `repo-config/` rulesets. Most of `AGENTS.md` is universal fleet law: every section that states a rule, as opposed to the two that describe this repository's own directory tree and devcontainer, is byte-locked and verified by an automated byte-for-byte match against the template canonical, not by line-by-line review. - -Two constraints follow when reviewing that content. - -- **A reference inside byte-locked text to a path or section this repository does not carry is intentional, not a broken link.** Universal rule text names shared infrastructure (a fleet registry, a reusable config snippet, the other workflow model's ruleset payload) that a given repository legitimately may not contain. Editing the text to "fix" such a reference would break the fleet audit that governs it, so the reference is correct as written. Do not report it as a dead link, a missing file, or a broken cross-reference. -- **A genuine substantive defect is still worth raising.** Byte-locked is not unreviewable. A self-contradiction, a factual error, or a real typo in the canonical prose is a valid finding, but note that the fix lands at the template and re-vendors to every repository, rather than proposing a local edit the audit would reject. +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 +that its fidelity rejects. ## GitHub Copilot Review Runbook -> This runbook implements the [GOVERNANCE.md "PR Review Etiquette"](../GOVERNANCE.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, fill the `` / `` / `` placeholders. - -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 [GOVERNANCE.md -> PR Review Etiquette](../GOVERNANCE.md#pr-review-etiquette). This section only describes how to make GitHub Copilot reliably execute it. +For every review: -### Triggering and Polling +1. Read the full pull request diff and count its changed files. +2. Follow `.github/skills/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 `code-review` skill. -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, so 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. +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 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, and 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. +A formal review with no findings is complete only when it covers the current head and states full +diff coverage. A refusal, partial or absent coverage statement, unrecognized output shape, +unresolved thread, or body-only finding blocks the review loop. Re-run the loop after every fix +push. Never infer review completion from `mergeStateStatus: CLEAN`. -**Read the low-confidence findings, which are not inline threads.** A review body can carry a collapsed `
` block of findings Copilot withheld from the inline threads, and those findings appear nowhere in `reviewThreads`, so a loop that polls threads alone never sees them and reports a clean pass. **Match the block on more than one phrasing.** Its heading has appeared both as `Suppressed comments (N)` and as "Comments suppressed due to low confidence", so a filter keyed on either one alone silently reports zero suppressed findings on a review that has them, the same false clean this rule exists to prevent, one level up in the detection. They have been right repeatedly, including a rule stated more broadly than its check enforced and a check that skipped fenced blocks in every rule but one. Read the body of every review, investigate each suppressed finding on the same footing as an inline one, and answer it in the PR conversation, since a suppressed finding has no thread to reply on or resolve. +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. Do not clear the request automatically because it may be active. If the maintainer directs a retry, remove Copilot in the pull request UI, add it again, and rerun `wait`. This recovery replaces only the review request and never changes the effort setting. -```sh -# `test` with an alternation, not `contains` on one phrasing: the heading wording has changed. -gh api repos///pulls//reviews --jq \ - '.[] | select(.body | test("Suppressed comments|low confidence")) | .body' +### Disproved Claims -# Scope it to the current head, so an answered finding from an earlier round does not re-open. -PR_HEAD=$(gh pr view --json headRefOid --jq '.headRefOid') -gh api repos///pulls//reviews --jq \ - "[.[] | select(.commit_id==\"$PR_HEAD\") | select(.body | test(\"Suppressed comments|low confidence\"))] | length" -``` +**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. -**Round 1 is normally auto-seeded, so 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." +**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 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`, with **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, so match the API, not a single spelling, when adapting them. +**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. -```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: "", name: "") { - 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') +**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. -# 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, and the auto-review-on-open normally supplies the first one (it may have **no inline comments**, which 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. - -**Cold start (round 1 not yet landed): read the id repo-wide, not from this PR.** The Copilot reviewer's bot node id is the reviewer bot *account's* node id and is **stable across every PR in the repo**. So a freshly opened PR that has neither a formal review nor an issue comment yet does **not** need UI seeding to bootstrap the id: read it from any prior Copilot review anywhere in the repo, then feed it into the `requestReviews` mutation to drive round 1. Query the **most recent** PRs (`first: 20` with an explicit newest-first order; plain `last: 20` returns the *oldest* PRs, which may predate Copilot on the repo), and **guard for an empty result**, since an empty `$BOT_ID` means none of the sampled PRs carry a Copilot review. Widen the window (raise the count or paginate) before concluding the repo has never had one and falling back to UI seeding; never feed an empty id into the mutation: - -```sh -BOT_ID=$(gh api graphql -f query=' -{ - repository(owner: "", name: "") { - pullRequests(first: 20, orderBy: { field: CREATED_AT, direction: DESC }) { - nodes { reviews(first: 20) { nodes { author { __typename login ... on Bot { id } } } } } - } - } -}' --jq '[.data.repository.pullRequests.nodes[].reviews.nodes[] - | select(.author.login == "copilot-pull-request-reviewer") - | .author.id] | first // empty') -if [ -z "$BOT_ID" ]; then - echo "no Copilot review in the 20 most recent PRs - widen the window, else fall back to UI seeding" >&2 - return 1 2>/dev/null || exit 1 # stop; do NOT call requestReviews with an empty id -fi -``` - -If Copilot posted **only an issue comment** on this PR and no formal review, you can instead read the id from that comment's author (`pullRequest.comments` -> author `... on Bot { id }`). Manual UI seeding is the last resort, needed only for a repo that has **never** had a Copilot review, so no prior id exists anywhere to read. 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, and 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. -- `requestReviews` with the reviewer's bot node id in **`userIds`** fails with `Could not resolve to User node`, because the Copilot reviewer is a **Bot**, so its node id goes in **`botIds`** (as in the mutation above), never `userIds`. -- `suggestedActors(capabilities: [CAN_BE_ASSIGNED])` lists `copilot-swe-agent` (the coding agent), not `copilot-pull-request-reviewer`, so do not source the reviewer's bot node id there. Read it from an existing review per step 1 above. -- There is no `removePullRequestFromReviewRequest` mutation, and removing the reviewer to force a fresh pass is unnecessary anyway, since `requestReviews` with `union: true` re-fires the review on the current head. - -### 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, so use the most recent Copilot comment for manual confirmation). Check both. - -**Count matches and compare numerically, so an empty result cannot read as success.** A poll that captures a `gh api --jq` result and exits on `[ "$found" != "0" ]` treats an **empty** string as a landed review, and an empty string is exactly what a mis-written filter returns. Pipe the matches through `wc -l` and test `-gt 0`, so a query that finds nothing and a query that ran wrong both read as "not yet". A `gh` call that fails to run reaches the test the same way, because it writes its message to stderr and prints nothing to stdout, so the `$(...)` around it still yields the empty string. A mistyped or unsupported flag is the usual cause, and `gh` reports one as `accepts 1 arg(s), received 4` rather than as anything resembling a review verdict. - -**Check head coverage before reading merge-state, never the reverse.** A push makes the required checks go green before Copilot re-reviews the new head, so `mergeStateStatus` can read `CLEAN` in the window before any formal review covers the head. A poll that exits on `CLEAN` merges into that gap. Gate on a formal review whose `commit.oid` equals the current head SHA first, then on zero unresolved threads, and only then read merge-state. - -```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///issues//comments --jq \ - '[.[] | select(.user.login=="copilot-pull-request-reviewer[bot]")] | last | {created_at, body: .body[:200]}' -``` - -Coverage is confirmed when (1) exits 0, and **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, and `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, meaning 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, so do not enter this retry path for it. - -**A slow review is pending, not missing, so poll with backoff and never escalate on a timeout alone.** Copilot can lag far beyond the usual one-to-three minutes when it has been re-requested many times in quick succession, because it throttles under load, and a re-review landing tens of minutes after the request is normal. A poll that times out is therefore evidence only that the review has not landed *yet*, not that Copilot is done or unresponsive. Report the status as "review still pending" and keep polling on a widening interval (for example 20s steps, then a few minutes) rather than stopping. Enter the escalation step below only when the `requestReviews` mutation itself no-ops or errors, or after a genuinely long wait with the request confirmed accepted, never merely because one fixed poll window elapsed. - -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"), falling 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 - -Every id below is captured from a live query into a variable and passed from there, never hand-typed, guessed, or pasted as a `PRRT_...` literal. A node id resolves globally, so a fabricated or stale id does not fail, it writes to a real thread on an unrelated repository. This runbook implements [GOVERNANCE.md "Repository Boundaries and Write Safety"](../GOVERNANCE.md#repository-boundaries-and-write-safety): write only to this repo, capture every id from a live query, and never suppress a mutation's output. - -List unresolved threads. Use `first: 100` with cursor-based pagination, and where `hasNextPage` is true, re-run with `after: ""` to retrieve the next page: - -```sh -gh api graphql -f query=' -{ - repository(owner: "", name: "") { - 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. Capture the target thread's id into `$TID` from the listing query above, filtering to the thread being answered by its `path`, and guard for an empty result so a mutation never runs on a guessed id. When a file carries more than one unresolved thread, `path` alone is ambiguous and `head -n 1` would pick the wrong one, so narrow by first-comment body (the query already fetches `comments(first: 1)` for this) by adding `and (.comments.nodes[0].body | contains(""))` to the `select`: - -```sh -TID=$(gh api graphql -f query=' -{ - repository(owner: "", name: "") { - pullRequest(number: ) { - reviewThreads(first: 100) { - nodes { id isResolved path comments(first: 1) { nodes { body } } } - } - } - } -}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] - | select(.isResolved == false and .path == "") - | .id' | head -n 1) -[ -n "$TID" ] || { echo "no matching unresolved thread on - do not guess an id" >&2; return 1 2>/dev/null || exit 1; } - -# Show the mutation's output. Never append an output-discard or force-success tail -# (>/dev/null, 2>/dev/null, &>/dev/null, || true, || :, || echo) to a write. -gh api graphql -f query=' -mutation($threadId: ID!, $body: String!) { - addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { - comment { id url } - } -}' -F threadId="$TID" -F body="Fixed in : ." - -# Confirm isResolved: true in this response before treating the thread as closed - a write that -# appears to fail may have taken on the server. -gh api graphql -f query=' -mutation($threadId: ID!) { - resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } -}' -F threadId="$TID" -``` - -Issue-level Copilot comments (those in `issues//comments`) have no resolution action, since GitHub provides no API or UI to resolve them. Reply if the finding warrants it, but no resolution step is needed or possible. - -### PR Edits and Merge-State Gotchas - -- **`gh pr edit --title/--body` is broken here.** It touches the deprecated Projects-classic `projectCards` GraphQL field and **exits non-zero without applying the change** (a stale PR description then survives review rounds). Edit the title/body via the API and verify it took: GraphQL `updatePullRequest(input: { pullRequestId, title, body })`, or REST `gh api -X PATCH repos///pulls/ -F body=@body.md` (the `@` reads the body from a file, so name it explicitly, not the literal `file`). -- **`main`/`develop` use rulesets, not classic branch protection.** The classic protection REST endpoint (`repos/.../branches//protection`) 404s, so read the ruleset instead. A `mergeStateStatus` of `BLOCKED` on a green PR is usually just **unresolved review threads** (the ruleset requires thread resolution); resolving them moves it to `CLEAN`. (`BLOCKED` is a `mergeStateStatus` value; don't confuse it with the separate `mergeable` field's `MERGEABLE`/`CONFLICTING`, which reports merge conflicts, not review gates.) -- **Push -> head-SHA read race.** A `headRefOid` read taken immediately after a push can return the **old** head, so re-read after the push registers, or a coverage poll evaluates the stale SHA. -- **Copilot is sometimes factually wrong** (e.g. it claimed `actionlint -color` "requires a value" when it is a boolean flag). Verify a finding before fixing, and decline with evidence when it is wrong, which is distinct from dismissing a still-present finding as stale. - -Reply-body conventions: - -- Accepted bug/style fix: include fixing commit SHA and a one-line summary. -- Declined style comment: cite the rule (GOVERNANCE.md or the CODESTYLE.md language section) and the existing-tree precedent. -- Declined architecture proposal: one-sentence rationale. -- Declined false positive on carried fleet content (a broken-link or dead-cross-reference flag inside byte-locked rule text): cite the "Reviewing Carried Fleet Content" section, since the reference is intentional and the text cannot be edited locally. - -After the final push, sweep-resolve stale older threads for removed code paths. +No entries yet. Add one here only after disproving a claim against this repository's own tree at a named revision. ## When in Doubt -Read [AGENTS.md](../AGENTS.md) to find the section that governs your change, and [GOVERNANCE.md](../GOVERNANCE.md) for the rule text itself. For code-style rules, [`CODESTYLE.md`](../CODESTYLE.md) (its General section plus the relevant language section) is authoritative. Don't restate any of these files' rules in commit bodies or PR descriptions, and keep those focused on the change itself. - -If you find a gap in the governance itself (this file, AGENTS.md, or GOVERNANCE.md is out of date, a rule is missing, something bit this repo and would bite the next), fix it in the governance docs as part of your change rather than only working around it locally. +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/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..1bef638 --- /dev/null +++ b/.github/skills/agent-conduct/SKILL.md @@ -0,0 +1,44 @@ +--- +name: agent-conduct +description: >- + Surfaces the ptr727/ProjectTemplate fleet's conduct rules at the three decision moments they are violated: about to claim work is done, verified, green, or fixed, about to proceed on an assumption the user could cheaply confirm, and a failure or review finding just surfaced a durable lesson. Use this whenever about to report success or completion of any task, 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, 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 sections are the always-on layer, and this skill fires at the moments rather than duplicating them, so do not load it 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, comment-and-doc-style for prose. The GOVERNANCE.md sections this skill summarizes keep the full rules. +--- + +# Agent Conduct + +## Why This Exists + +The fleet's conduct rules (verification before claiming done, asking instead of assuming, recording lessons) 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 in the carried `AGENTS.md` "Context and Delegation Discipline" section, which is the always-on layer. + +## Before Claiming Done + +Read `GOVERNANCE.md` "Verification Discipline" before reporting success on anything non-trivial. Its unifying property: every failure it lists is green. The checks that bind here: + +- **A green check is not evidence the work happened.** A skipped job and a passing job are indistinguishable in an aggregated required check, so confirm from the log that the job ran and produced what it promises. +- **Locate every check the change owes before running any**, from what the repository declares (`OPERATIONS.md` "Local Verification" beside the workflows), not from what the pipeline happens to run, since part of a contract is routinely unreachable from a runner and green is then the precise signal it was skipped. +- **Run the repo's whole lint gate before every push**, not the parts that look relevant, because the tool most likely to catch a change is often the one it seems least about. +- **A launched process is not a result.** Report the output the wait produced, and where it produced none, that absence is the report. Never name an external cause the record does not carry. +- **A local clone is not the branch it names.** Fetch immediately before reading, or read the live ref, and name the ref and commit in any finding a local read produced. +- **A "does not exist" claim names the branch it was checked against.** A worktree's default branch is not necessarily the one the content lives on: in-flight content on a `release`-model repo lands on `develop` before `main`, per `GOVERNANCE.md` "Branching Model," so check that branch before reporting anything absent repo-wide. +- **A test asserts the mechanism it names, and a gate has to be watched failing.** A case that passes for an incidental reason is worse than no case, because it is later cited as evidence. +- **Platform-specific code is verified only on the platform it runs on.** Reasoning about PowerShell, macOS, or WSL-specific behavior from a different host is not verification, however closely it matches an already-tested equivalent elsewhere. State an untested structural match as exactly that, never in the words used for a tested fact, and when no agent in the loop has access to the target platform, say so and defer or ship it labeled unverified. + +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`. + +## 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. +- **Raise blocked work as a direct interactive prompt** at the point the work stops, per `GOVERNANCE.md` "Communicating with the User": the blocked item is the message, the options offered are the actions themselves, and a handoff buried in a summary paragraph is a handoff that did not happen. Numbered lists are the fallback where no prompt mechanism exists. +- **References are clickable where they are read**: a pull request, issue, or commit on a Markdown surface is a Markdown link, and on a surface that renders neither, a bare `#123` with the link in the message before the prompt. +- **Capability is not permission.** A token's reach, a tool that happens to work, or a similar grant in a past session authorizes nothing, and the irreversible step (merge, publish, release, delete) stays the maintainer's. + +## When a Failure Surfaces a Lesson + +- **Durable knowledge lands in the committed docs, not in agent memory**, as part of the change that surfaced it, per `GOVERNANCE.md` "Durable Knowledge and Self-Improvement". Memory does not survive a new session or machine, so it holds only environment nuance and in-flight state. +- **Where the governing doc is carried from the hub, file the finding against `ptr727/ProjectTemplate`** rather than only patching it locally. A local fix leaves every sibling repo with the same trap. Search open and closed issues first, then update the matching issue or file a new one. +- **A review flags an instance, so fix the class**: sweep for the siblings before replying, because reviewers sample rather than enumerate. +- **A rule that keeps needing restating** is usually a stale or missing skills install, so run `python3 scripts/skills_install.py --report` from a hub checkout (the `fleet-conformance-check` skill) before concluding the rule does not exist. + +## Delegation, in One Paragraph + +The always-on rules live in `AGENTS.md` "Context and Delegation Discipline" and are not restated here. The two that intersect conduct: brief a subagent so it never needs a governance file, since anything it must honor has to be in its prompt, and never tier down the seat holding the judgment, because governance wording and the decision to decline a review finding are fleet-wide and durable when wrong. diff --git a/.github/skills/audit-a-repo/SKILL.md b/.github/skills/audit-a-repo/SKILL.md new file mode 100644 index 0000000..b13106a --- /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 fleet-conformance-check 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: a 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 with a `file:line` citation per applicable guarantee, then the 5B trace scenarios (`AUDIT.md` section 5). 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 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/carried-instruction-file-guard/SKILL.md b/.github/skills/carried-instruction-file-guard/SKILL.md new file mode 100644 index 0000000..47f8803 --- /dev/null +++ b/.github/skills/carried-instruction-file-guard/SKILL.md @@ -0,0 +1,31 @@ +--- +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, 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. +--- + +# 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/code-review/SKILL.md b/.github/skills/code-review/SKILL.md new file mode 100644 index 0000000..af77518 --- /dev/null +++ b/.github/skills/code-review/SKILL.md @@ -0,0 +1,71 @@ +--- +name: 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. +--- + +# 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 + +``` + +`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/comment-and-doc-style/SKILL.md b/.github/skills/comment-and-doc-style/SKILL.md new file mode 100644 index 0000000..aced18d --- /dev/null +++ b/.github/skills/comment-and-doc-style/SKILL.md @@ -0,0 +1,251 @@ +--- +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. +- **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` "Running the Linters Locally (Known-Working Invocations)" +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:** rules 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. +``` + +## 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..125bf0f --- /dev/null +++ b/.github/skills/comment-and-doc-style/references/carried-doc-references.md @@ -0,0 +1,61 @@ +# 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 + +Two 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. + +## 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..bdf0846 --- /dev/null +++ b/.github/skills/comment-and-doc-style/references/line-endings.md @@ -0,0 +1,116 @@ +# 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). + +## 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..88f3c38 --- /dev/null +++ b/.github/skills/copilot-instructions-keeper/SKILL.md @@ -0,0 +1,95 @@ +--- +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 for + drift against the hub or running a conformance sweep that touches this file, 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. +--- + +# 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 three named sections +(`Commit Messages and Pull Request Titles`, `Reviewing Carried Fleet Content`, `GitHub Copilot +Review Runbook`), 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 three 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..ee8d9a9 --- /dev/null +++ b/.github/skills/dotnet-codestyle/SKILL.md @@ -0,0 +1,210 @@ +--- +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. + Git hooks are optional, and a repo may wire a local runner (Husky.Net, with `dotnet husky run` + as a style step) for pre-commit enforcement, but CI is the gate that matters. + +**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 verbatim, 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. Local pre-commit hooks are optional, wire Husky.Net (or + another runner) if you want local enforcement. +- **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. See `references/testing.md` for the framework setup template. + +## 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..5eb4854 --- /dev/null +++ b/.github/skills/dotnet-codestyle/references/conventions.md @@ -0,0 +1,126 @@ +# .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) {} +``` 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..8f6e838 --- /dev/null +++ b/.github/skills/dotnet-codestyle/references/project-config.md @@ -0,0 +1,17 @@ +# .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> + ``` diff --git a/.github/skills/dotnet-codestyle/references/testing.md b/.github/skills/dotnet-codestyle/references/testing.md new file mode 100644 index 0000000..5a84a17 --- /dev/null +++ b/.github/skills/dotnet-codestyle/references/testing.md @@ -0,0 +1,25 @@ +# .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]`. diff --git a/.github/skills/fleet-conformance-check/SKILL.md b/.github/skills/fleet-conformance-check/SKILL.md new file mode 100644 index 0000000..2b6e840 --- /dev/null +++ b/.github/skills/fleet-conformance-check/SKILL.md @@ -0,0 +1,74 @@ +--- +name: fleet-conformance-check +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. +--- + +# Fleet Conformance Check + +## Why this exists + +A downstream repo today only finds out it has drifted when someone runs a hub-driven resync +against it by name. 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 stale or missing 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. +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`, when the stamp reports stale. + 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. Those are `resync-a-repo`'s job, driven from the hub with a named target, +never a downstream repo acting on itself. + +## Refresh cadence + +Re-run the installer when `--report` exits non-zero, and after any hub merge that touches +`.agents/skills/`. 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. Per AUDIT.md, a downstream repo does not write its + own audit report or resync itself against the hub, it names what it found and points at + `resync-a-repo`, run from a hub checkout, as the next step. +- **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 hub-driven resync 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/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/operational-vs-release-workflow/SKILL.md b/.github/skills/operational-vs-release-workflow/SKILL.md new file mode 100644 index 0000000..9a9e9f2 --- /dev/null +++ b/.github/skills/operational-vs-release-workflow/SKILL.md @@ -0,0 +1,156 @@ +--- +name: operational-vs-release-workflow +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 + develop -> main promotion traps (never delete develop, EOL-only conflicts), the two-phase + publish model (PRs smoke-test only, a human merge never auto-publishes), NBGV semantic + versioning, and the operational-repo delta (direct-to-develop commits, advisory CI, dispatch-only + release) that applies instead whenever the registry's workflowModel field for this repo reads + operational rather than release. Use this whenever choosing a target branch for a change, + promoting develop to main, resolving a develop -> main merge conflict, deciding whether a + release repo's config change needs a PR versus an operational repo's config change can commit + straight to develop, bumping version.json, adding or dropping a release target, or reasoning + about why a merge did or didn't trigger a publish. Triggers even when the request sounds like + ordinary git housekeeping ("just push this config fix", "merge develop into main", "cut a + release"), because the two workflow models genuinely differ (a direct-to-develop commit that is + correct in an operational repo is a rule violation in a release repo, and vice versa) and + applying the wrong one is not obviously wrong to a reader who only knows one of the two. +--- + +# Operational vs. Release Workflow + +## 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 (unchanged NBGV `SemVer2`) re-pushes nothing to any target keyed on the + version string, except Docker, which always re-pushes** to pick up upstream base-image + refreshes. Full guarantee and the `version.json` `pathFilters` boundary: + `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/operational-vs-release-workflow/references/branch-protection-and-promotion.md b/.github/skills/operational-vs-release-workflow/references/branch-protection-and-promotion.md new file mode 100644 index 0000000..597cd5f --- /dev/null +++ b/.github/skills/operational-vs-release-workflow/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 <owner>/<repo> 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. See the hub's `repo-config/README.md` +"Rulesets" for the configured state. + +## 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 repo's "Automatically delete head branches" toggle, kept off in the hub's + `repo-config/settings.json` 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/<owner>/<repo>/git/refs" -f ref=refs/heads/develop -f sha="$(gh pr view <n> --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 <file>`) **after + confirming each is content-identical modulo EOL, or that `develop` is a strict superset** + (`diff <(git show :2:f | tr -d '\r') <(git show :3:f | 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 <PR>` 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`. See the hub's +`repo-config/README.md` "Secrets" for which secrets each mechanism needs. diff --git a/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md b/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md new file mode 100644 index 0000000..ad7253f --- /dev/null +++ b/.github/skills/operational-vs-release-workflow/references/release-publish-mechanics.md @@ -0,0 +1,135 @@ +# 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), 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_<target>: 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 its path-filter entry in +`test-pull-request.yml`, and (for PyPI) the `publish-pypi` job in its own `publish-release.yml`, +since `id-token: write` belongs at that one entry point. CodeGen, versioning, badge, merge-bot, +and Dependabot are target-agnostic. + +## Orchestration vs. build: the override seam + +The pipeline splits into two layers. The **orchestration** layer is generic and is the +standardization baseline: `publish-release.yml` (single-branch publish plan), the `get-version` +task plus `github-release` job inside `build-release-task.yml`, `get-version-task.yml`, and the +aggregator shape of `test-pull-request.yml`. Within +`test-pull-request.yml`, only the `changes -> smoke-build -> check-workflow-status` aggregator +wiring and the ruleset-bound job name are verbatim orchestration, while the `unit-test` job and +the `dorny/paths-filter` entries are owned/per-target. The **build** layer is a hook: a composite +action at `.github/actions/build-<target>` the hub-hosted `build-release-task.yml` reaches. The +hub defaults require explicit project paths. A project needing more than a path override carries +its own hook. + +The contract that keeps the seam clean: **a target contributes files to the GitHub release by +uploading a workflow artifact named `release-asset-<branch>-<target>`.** The `github-release` job +collects every `release-asset-<branch>-*` artifact by pattern, so its `download-artifact` step +uses `pattern:`/`merge-multiple:`, **never an `artifact-ids:` that names a build job's output** +(the producing build jobs still appear in `needs` for sequencing). That makes the tag-the-commit +plus create-the-release plus attach-the-assets logic reusable **as-is** across repos. **This +name-pattern handoff is canonical for every repo, single-target included**: name your one asset +`release-asset-<branch>-<target>` and the verbatim `github-release` globs it. Do not switch a +single-target repo to an `artifact-id` output plus `download-artifact` `artifact-ids:`, which +looks tidier for 1:1 but forks the `github-release` download and breaks its verbatim carry. + +**What a repo still curates** (by design, not a leak): which `enable_<target>` inputs its caller +stub sets, per the per-target subsetting rule above. `build-release-task.yml` is hub-hosted +(`docs/reusable-workflows.md` "Stage 4: The Release Chain and the Docker Core"), so its job graph +and its `github-release` job are the hub's, not a per-repo file a caller edits. A repo adopting the +release chain carries only the caller stub in its own `publish-release.yml` and +`test-pull-request.yml`, naming the hub task by pin and setting the `enable_*`, `docker_image`, +and project-path inputs its targets need. + +## 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-<branch>-<name>`. This is where the + .NET `dotnet publish` or `dotnet build` and package push lives. 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): the target both builds **and** publishes to its + registry. NuGet pushes from inside the build-nuget hook (OIDC trusted publishing through + `NuGet/login`, no stored API key) *and* also uploads a `release-asset-*` (.7z) for the GitHub + release. PyPI is split: the build-pypi hook only builds and uploads the + `pypi-build-<branch>` artifact, and the separate `publish-pypi` job in the caller's own + `publish-release.yml` does the OIDC Trusted-Publishing upload (`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 multi-arch tags directly 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 weekly/dispatch 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), 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. + +## 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/pr-review-conduct/SKILL.md b/.github/skills/pr-review-conduct/SKILL.md new file mode 100644 index 0000000..ea79a75 --- /dev/null +++ b/.github/skills/pr-review-conduct/SKILL.md @@ -0,0 +1,166 @@ +--- +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 (including suppressed + low-confidence ones), 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 PR creation starts the review loop and mergeStateStatus: CLEAN + can go clean once checks pass and every known thread is resolved, while still saying nothing + about whether the review that resolved those threads 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. Provider-specific mechanics are implemented by + scripts/pr_review.py and bootstrapped by .github/copilot-instructions.md. This skill is the + contract those surfaces implement, not a replacement for them. +--- + +# 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. +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. +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. + +## 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. + +1. Push changes to the PR branch and open the pull request when it does not exist. +2. Run `scripts/pr_review.py status` 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 (mechanics in the Copilot runbook). The UI is a fallback only. +4. Run a bounded `scripts/pr_review.py wait` 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. +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 `Lite`, `Balanced`, or `Max` when the completed review exposes that metadata, and distinguishes an inherited `Default (<level>)` from an explicit choice. 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. After a timeout with `requested=yes`, rerun `wait` for another bounded interval by default because the request may still be active. If the maintainer directs a retry, remove Copilot in the pull request UI, add it again, and rerun `wait`. 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 the loop early.** A round count is not a stopping +condition, 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.** Reply with the fixing commit SHA. 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. An assertion ("this is fine") does not close a finding, + a decline needs evidence the reviewer itself could check. +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. +5. **Keeps recurring, so fix the class, not the instance.** 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. + +**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. + +## 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` +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. + +## 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. `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` resolves a thread by matching the finding's own +words instead of a line number a fix push can move. The repository's +`.github/copilot-instructions.md` bootstraps Copilot into the `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..02697a7 --- /dev/null +++ b/.github/skills/python-codestyle/SKILL.md @@ -0,0 +1,174 @@ +--- +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 | `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 the Python project directory: + +```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 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. These are +documented commands, and an optional VS Code tasks mirror (all `type: process`, no `&&` shell +chaining, so it runs the same on any task shell) is in the hub `vscode-tasks-python.json` snippet. +CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in, so +wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. + +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`. One test file per module (`test_<module>.py`), fixtures over setup/teardown, +fakes over mocks. Test the docstring's contract, not implementation details. See +`references/testing.md` for the full 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 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). +- 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..b7e62b0 --- /dev/null +++ b/.github/skills/python-codestyle/references/code-style.md @@ -0,0 +1,80 @@ +# 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 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..b7eedfb --- /dev/null +++ b/.github/skills/python-codestyle/references/profiles.md @@ -0,0 +1,74 @@ +# 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). +- **Disabled markdownlint rules**: repo-specific, `.markdownlint-cli2.jsonc` at the repo root is + the source of truth, not any example rule named here. +- **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..c19ff9d --- /dev/null +++ b/.github/skills/python-codestyle/references/testing.md @@ -0,0 +1,13 @@ +# Python Testing Conventions + +Use `pytest` with configuration in `[tool.pytest.ini_options]`. Default invocation: +`uv run pytest`. + +- 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..1d60f3a --- /dev/null +++ b/.github/skills/repo-worktree/SKILL.md @@ -0,0 +1,210 @@ +--- +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. + +## 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 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`. 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. + +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. Return that checkout to +its own working branch first when its tree is clean, and stop when it is not, because a dirty +tree there may be another task's uncommitted work. + +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, install the repository's declared + Python environment, then run `pre-commit install` through that environment. A uv project runs + `uv sync --frozen`, then `uv run pre-commit install`. +- **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..e291fb6 --- /dev/null +++ b/.github/skills/resync-a-repo/SKILL.md @@ -0,0 +1,84 @@ +--- +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 fleet-conformance-check 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. +--- + +# 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 `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 RESYNC.md section 1, since a +stale clone answers confidently instead of failing, and verify the host with +`python3 scripts/host_gate.py --repo <path-to-target-checkout>`. Then run the audit end to end, +RESYNC.md section 2, against the target's `main` branch, never `develop`. 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.** `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. **Before any verbatim re-vendor in this + step, run the `carried-instruction-file-guard` skill's distinctive-phrase probe against the + target file, every time, without exception.** 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. +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 everything 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|operational` from the hub at `main`, + then `apply` for what it reports, never from a carried copy. +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 +commit the report, done means measured, not applied. diff --git a/.github/skills/shell-codestyle/SKILL.md b/.github/skills/shell-codestyle/SKILL.md new file mode 100644 index 0000000..50b1f60 --- /dev/null +++ b/.github/skills/shell-codestyle/SKILL.md @@ -0,0 +1,55 @@ +--- +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, shellcheck cleanliness, and the + why-not-what comment rule. Use this whenever writing, reviewing, or editing a .sh file, 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 one. Everything else is Python, with a test under the scripts tree's `tests/` directory. + +## Rules + +- **`set -Eeuo pipefail`, before the first command the script runs.** A header comment sits above + it, as `repo-config/configure.sh` and the `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. `repo-config/configure.sh` is the worked + example, carrying five `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..720c59a --- /dev/null +++ b/.github/skills/skill-lifecycle/SKILL.md @@ -0,0 +1,49 @@ +--- +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 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 hand-authored source**, with optional `references/` and `scripts/` directories beside it. 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. +- **`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` reads that stamp against the checkout and exits non-zero when the machine is behind. 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 `fleet-conformance-check` 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 commit the source and both generated trees in one commit. 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 merge**: re-run `python3 scripts/skills_install.py` per machine, the cadence `docs/host-setup.md` "Fleet Skills Install" states. Until then every machine serves the previous skill set, which `--report` says. + +## Changing or Retiring a Skill + +- **Edit only the source tree.** 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. +- **Retiring is deleting the source directory and regenerating.** 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. + +## The Doc-Packaging Pattern + +Packaging keeps one topic in one authoritative place while the skill makes it surface automatically. It has two 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`, `agent-conduct` over its GOVERNANCE sections). The skill states per topic which doc section owns it. + +In both shapes the doc wins on any disagreement, and the skill is what needs fixing. A rule stated fully in both places is the drift this pattern exists to prevent, so an edit to a packaged rule lands in its owning place and the other side's summary is checked against it in the same change. diff --git a/.github/skills/standup-a-repo/SKILL.md b/.github/skills/standup-a-repo/SKILL.md new file mode 100644 index 0000000..54c277a --- /dev/null +++ b/.github/skills/standup-a-repo/SKILL.md @@ -0,0 +1,99 @@ +--- +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 fleet-conformance-check 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 `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`. 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 apply with + `repo-config/configure.sh apply owner/repo release|operational` from the hub at `main` and check with the same + command's `check` subcommand, 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/upstream-contribution-workflow/SKILL.md b/.github/skills/upstream-contribution-workflow/SKILL.md new file mode 100644 index 0000000..eec4fcf --- /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 (`operational-vs-release-workflow`) 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..e02fdfb --- /dev/null +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -0,0 +1,47 @@ +--- +name: workflow-ci-contract +description: >- + Governs the WORKFLOW.md CI/CD behavioral contract for every ptr727/ProjectTemplate fleet repo: the D1-D9 guarantees stated as the failure mode each prevents, the seam contract for release assets, the artifact lifecycle, NBGV versioning and classification, validate-at-entry, and the 5A/5B/5C test methodology with its per-type walkthroughs. Use this whenever writing or editing anything under .github/workflows/, adding or dropping a release target, auditing a repo's workflows, or reasoning about why a publish did or did not fire. This is the YAML half of the pipeline, and the operational-vs-release-workflow skill keeps the git half (branching, promotion, publish policy), so branch choice questions go there. 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 smoke build exercises, since workflow-only changes are deliberately not smoke-built. WORKFLOW.md keeps authority, and GOVERNANCE.md wins where the two overlap. +--- + +# Workflow CI Contract + +## Why This Exists + +`WORKFLOW.md` in the hub is the largest law doc, a behavioral contract stating required outcomes rather than a required implementation, and it had no skill surface, so agents edited workflow YAML without the contract in view. This skill is the summary plus the binding rules, with the guarantee catalog and the test methodology split into `references/`. `WORKFLOW.md` keeps authority for the contract and methodology, and `GOVERNANCE.md` ("Workflow YAML Conventions", "Release Model") wins where the two overlap. + +## How the Contract Is Read + +- **Outcomes, not bytes.** A workflow is correct when it satisfies the section 4 contract against the expected inputs and outputs, not when it matches a catalog snippet byte for byte. Two repos may implement one guarantee with different YAML. +- **Applicability.** A guarantee 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 a `workflow_call` task the hub hosts once, and a repo carries only a caller stub pinned to a hub release commit plus 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 phase each workflow migrates in. Until a workflow's phase ships, its copy is graded as below. +- **Two layers.** Orchestration (the PR entry workflow, publisher, version/release/badge jobs) is generic and standard at the job level. Build leaves (`build-<target>-task.yml`) are repo-owned. Inputs like `github`/`nuget`/`dockerhub`/`expect_release_assets` live on the orchestrator, a leaf only receives `ref`/`branch`/`smoke` and a derived `push`, so assert each input in the layer that declares it. What a repo curates is the list of targets, and adding or dropping one edits the whole surface together: the `enable_<target>` input, the `build-<target>` job and its `github-release` `needs:` entry, the `changes` paths-filter entry and output, and the `smoke-build` enable-forward (D6.4). + +## Style Rules That Break in One-Line Diffs + +- **Pin every action to a commit SHA** with a trailing `# vX.Y.Z` comment, first-party included. The one documented no-pin exception is `dotnet/nbgv@master`. Invent no others. +- **Names carry meaning**: `-task.yml` files and "task" names are reusable (`on: workflow_call`), entry points end in what they do and their names end in "action", every job `name:` ends in "job" and every step in "step". A ruleset-bound required check's job `name:` and the ruleset `context:` are one string renamed together, in the live ruleset and the hub's `repo-config/` payloads in lockstep, or required-check enforcement silently breaks. +- **Concurrency**: top-level workflows use `group: '${{ github.workflow }}-${{ github.ref }}'` with `cancel-in-progress: true`. The publisher is the documented exception: a global ref-independent group with `cancel-in-progress: false`, so publishes serialize and never cancel mid-push. +- **Shells**: every multi-line bash `run:` starts `set -Eeuo pipefail`. Multi-line `if:` uses `>-`, never `|`. +- **Boolean inputs** are declared in both trigger blocks and compared against both forms, `${{ inputs.foo == true || inputs.foo == 'true' }}`, since `workflow_dispatch` delivers strings. +- **Permissions validate before `if:`**, so even a skipped job needs valid `permissions:`, and a callee's extra scope (`actions: write`, `id-token: write`) is granted by the caller at the one entry point that needs it. +- **Chaining across optional jobs** allowlists `success`/`skipped` explicitly, because `!= 'failure'` lets `cancelled` through. +- **Docker layer cache** targets a registry tag (`buildcache-<branch>`), never `type=gha`. +- **Workflow YAML is LF.** Preserve endings on every edit. + +## The Core Behavioral Spine + +- **PRs validate fast and never publish**: a paths-filter smoke-builds only changed targets, a type-appropriate validation job always runs, and one required aggregator gates the merge, treating skipped smoke as pass and blocking on failure or cancelled. Smoke does a full compile/lint/test but pushes nothing and uploads nothing, every `upload-artifact` gated `!smoke`. +- **A human merge never auto-publishes**: a `plan` job decides once and every job gates on it. Publishes come from a code-affecting bot push to `main`, a manual dispatch of `main` or `develop`, or the main-only weekly Docker schedule. Each run builds the one trigger branch, `main` a clean `X.Y.Z`, anything else a prerelease `X.Y.Z-g<sha>`, with NBGV owning the patch from git height. The release tags the built commit's SHA (`GitCommitId`), never a branch name. +- **Validate at entry**: cross-input and input-versus-derived-state invariants are asserted once in a dedicated entry job the downstream jobs `needs:`, failing fast with `::error::` before expensive work. The release gate checks branch-versus-prerelease in both directions, strips `+buildmetadata`, and on smoke skips the check while the job still succeeds. +- **The seam contract**: a target contributes a release file by uploading `release-asset-<branch>-<target>`, and the release job collects by `pattern:` plus `merge-multiple:`, never `artifact-ids:`, canonical even for a single target. A repo with no file target passes `expect_release_assets: false` at the caller. +- **Artifacts are an intra-run handoff**: consume-then-delete at the point of consumption, gated to the consumer's condition, best-effort, `retention-days: 1` on every upload as the backstop, and never a blanket delete of the run's artifact set, which destroys the diagnostics you need when the run fails. +- **No-op republish**: an unchanged version re-pushes nothing, the release-create step skips when the tag exists, registries dedupe server-side (`--skip-duplicate`, `skip-existing: true`), and Docker alone always re-pushes by design. +- **A build failure blocks every publish target**: `github-release` needs every build, and the terminal registry pusher guards with `!failure() && !cancelled()`, so nothing partial ships. + +The full catalog, each guarantee with the failure mode it prevents, is in `references/d-guarantees.md`. Auditing, tracing, and probing a repo's workflows is `references/test-methodology.md`. + +## After Any Workflow Edit + +Workflow-only changes are not smoke-built, so run actionlint locally (the Docker invocation in `GOVERNANCE.md` "Running the Linters Locally", which bundles shellcheck for `run:` blocks) before pushing, and remember a workflow change is only fully exercised by CI, since `secrets: inherit`, `permissions:`, and `needs:` wiring resolve only in a real run. 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..b41c99a --- /dev/null +++ b/.github/skills/workflow-ci-contract/references/d-guarantees.md @@ -0,0 +1,70 @@ +# The D-Guarantees, Condensed + +Each guarantee is a MUST from `WORKFLOW.md` section 4, stated as input to output plus the failure mode it prevents. This is the condensed catalog for working from, and `WORKFLOW.md` keeps authority, so read the section there when a guarantee's exact wording decides a verdict. + +## D1: PR Fast-Feedback (Smoke) + +- **D1.1** Only changed targets build: each target has a paths-filter entry, unchanged targets skip. Prevents a changed target slipping through unbuilt. +- **D1.2** A validation job always runs on any PR, and a non-.NET repo replaces it (never deletes it), re-pointing every `needs:` on it, the aggregator and `smoke-build` both. Prevents a PR merging with no validation, or a dangling `needs:` failing the workflow to load. +- **D1.3** Smoke never publishes and never uploads: full compile/lint/test, no pushes, every `upload-artifact` gated `!smoke`. Prevents a PR publishing and orphaned artifacts. +- **D1.4** Workflow-file changes are not smoke-built (the filter excludes `.github/workflows/**`), actionlint still validates them. +- **D1.5** One required aggregator gates merge: `needs:` the changes and validation jobs, passes on skipped smoke, blocks on failure or cancelled, and its name is ruleset-bound (job `name:` equals ruleset `context:`, renamed together). +- **D1.6** Coverage reports to Codecov for C# and Python repos with tests, best-effort so an outage never reds the gate, with a `codecov.yml` setting statuses informational and `.gitignore` excluding coverage output. + +## D2: Validation at Entry + +- **D2.1** A dedicated entry job asserts each cross-input invariant before expensive work, downstream jobs `needs:` it. +- **D2.2** The release gate fails loud when the default branch carries a prerelease suffix or a non-default branch carries none, strips `+buildmetadata` first, and on smoke skips the check while the job still succeeds (a job-level `if:` would skip dependents with it). +- **D2.3** A dispatch publish from any ref other than `main` or `develop` fails fast. +- **D2.4** Mutually-exclusive or must-pair inputs are validated, a half-filled combination fails fast. + +## D3: Versioning and Classification + +- **D3.1** One branch per run: `github.ref` names the built branch, NBGV classifies it directly, no `IGNORE_GITHUB_REF`. +- **D3.2** Default branch yields `X.Y.Z`, every other branch `X.Y.Z-g<sha>`, and the default-branch literal in the gate, the `prerelease` expression, and `version.json`'s `publicReleaseRefSpec` all name the repo's real default branch. +- **D3.3** `version.json` sets the major.minor floor, NBGV appends git height as the patch, and both are retained even by a no-compiler repo, since they own the tag. +- **D3.4** Registry versions follow the classification per registry: NuGet.org derives prerelease from the SemVer2 suffix, PyPI builds from `AssemblyFileVersion` with `.dev0` appended on `develop` only, and the develop build stays `--pre`-selectable above the released version. +- **D3.5** A wrapper repo drives its image version from a committed `name -> version` state file, and the leaf must actually read it, since a leaf still tagging off NBGV means the wrapper is not pinned to upstream. + +## D4: Release and Publish + +- **D4.1** Gated single-branch publish: a human merge never auto-publishes, the `plan` job decides once, publishes come from a code-affecting bot push to `main`, a dispatch of `main`/`develop`, or the main-only weekly Docker schedule. +- **D4.2** `target_commitish` is the built commit's SHA (NBGV `GitCommitId`), never a branch name and never `github.sha`. +- **D4.3** Every release is a tag plus source zip, README, and LICENSE, file targets attach `release-asset-*`, and a no-file-target caller passes `expect_release_assets: false` or the release-create step fails on unmatched files. +- **D4.4** No-op republish: an unchanged version re-pushes nothing, the release-create skips when the tag exists (refreshed only on `workflow_dispatch`), registries dedupe server-side, and Docker always re-pushes by design. +- **D4.5** A failed build blocks every publish target: `github-release` needs every build, the terminal registry pusher guards `!failure() && !cancelled()`, so nothing partial ships. +- **D4.6** A deploy check asserts which release and which environment answer, waiting for convergence to a bounded timeout, with an unreachable host reported distinctly from an HTTP status. + +## D5: Resource Cleanup + +- **D5.1** A cross-job transfer artifact is deleted at its point of consumption. An in-run intermediate may rely on the retention backstop. +- **D5.2** The delete runs under the same condition as its consumer, so a no-op re-run skips the release-asset delete while the PyPI build-artifact delete still runs. +- **D5.3** Cleanup is best-effort (`continue-on-error`, tolerate a failed listing, delete all matching ids). +- **D5.4** Every `upload-artifact` sets `retention-days: 1`. +- **D5.5** Never blanket-delete the run's artifacts, which destroys diagnostics and auto-emitted build records. +- **D5.6** A durable deploy destination's retention is bounded by a declared count with one side recorded as owning the prune: the deploy where its credential can observe the destination, the host where the credential is deliberately write-only. + +## D6: Seam Conformance + +- **D6.1** The release job downloads by `pattern:`/`merge-multiple:`, never `artifact-ids:`, canonical for single-target repos too. +- **D6.2** Branch-derived config reads `inputs.branch`, never `github.ref_name`. +- **D6.3** Artifact names are branch-suffixed. +- **D6.4** A target add or drop updates the whole surface together: `enable_<target>` input, `build-<target>` job, `github-release` `needs:` entry, paths-filter entry and output, and the `smoke-build` enable-forward. + +## D7: Concurrency, Permissions, Safety + +- **D7.1** The publisher serializes: global ref-independent concurrency group, `cancel-in-progress: false`. +- **D7.2** Every reusable job declares valid `permissions:` (validated before `if:`), a callee's extra scope granted by the caller. +- **D7.3** Boolean inputs are declared in both trigger blocks and compared against both forms. +- **D7.4** Optional-dependency chaining allowlists `success`/`skipped` explicitly. + +## D8: Bots and Automation + +- **D8.1** The merge-bot enables auto-merge on `opened`/`reopened` for every Dependabot tier, dispatches squash or merge by base ref, disables on a maintainer-pushed `synchronize`, and keys concurrency on the PR number, not `github.ref`. +- **D8.2** Codegen runs a deterministic matrix over both branches, Dependabot targets both branches. +- **D8.3** The upstream tracker writes a committed `name -> version` state file via a rolling per-branch bump PR the merge-bot auto-merges, and its branch prefix must match the merge-bot's head-ref pairs or auto-merge silently never fires. +- **D8.4** An identity allowlist used as a gate emits a `::warning::` on the non-matching branch rather than falling through silently, since a renamed App slug otherwise turns the gate off invisibly. + +## D9: Style and Static + +SHA pins with version comments, the name-suffix rules, `set -Eeuo pipefail`, `if: >-`, registry-tag Docker cache with `cache-to` only the built branch on push and `cache-from` both branches, line endings per `.editorconfig`. 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..ecd0948 --- /dev/null +++ b/.github/skills/workflow-ci-contract/references/test-methodology.md @@ -0,0 +1,27 @@ +# Testing a Repo's Workflows + +The three escalating verification modes from `WORKFLOW.md` section 5, which keeps authority. N/A items (a check or scenario for an absent construct) are recorded and excluded, never failed. + +## 5A: Static Audit + +Read the workflow files plus `version.json` and assert the structural fact behind each applicable D-guarantee, each pass, fail, or N/A with a `file:line` citation, asserting each input in the layer that declares it. The core sweep covers: the paths-filter's target coverage and `.github/workflows/**` exclusion, smoke gating on every upload, the aggregator's `needs:` and skip/fail handling, the entry validation jobs and the two-directional release gate, the single-branch NBGV classification and the three default-branch literals agreeing, `target_commitish` from `GitCommitId`, the consume-then-delete artifact lifecycle with `retention-days: 1` everywhere and no blanket delete, the `pattern:` handoff and `inputs.branch` config, the publisher's serialized concurrency, and the SHA pins. `WORKFLOW.md` 5A lists the per-type addenda (console runtime matrix, NuGet `--skip-duplicate`, the PyPI OIDC environment split, Docker `expect_release_assets` and cache shape, the static-site deploy gates), so apply only the ones the repo's types imply. + +## 5B: Trace Scenarios + +For each applicable scenario, evaluate every job's `if:`/`needs:` against the inputs and compare the predicted run/skip, version, release, and artifact end state to the expected table in `WORKFLOW.md` 5B. The load-bearing ones: + +- **S1** a PR touching a target: that target smoke-builds, nothing uploads, the aggregator succeeds. +- **S5/S6** a bot push to `main`: publishes only when code-affecting, and a human push never does. +- **S7** a publish run builds the one trigger branch with the right classification and leaves no dangling artifacts. +- **S8** a dispatch from a ref other than `main`/`develop` fails fast. +- **S9** a no-op re-run: release-create skipped, registries dedupe, PyPI build artifact still deleted, Docker still re-pushes. +- **S10** branch and version classification disagree: the gate fails loud and everything downstream skips. +- **S12/S13** a deploy dispatch: ref gate first, environment re-asserted, pointer flip separate, live check names the release, and a production deploy from a non-default ref fails before anything is written. + +## 5C: Live Probe + +Only for what a static trace cannot settle: a trivial PR to confirm S1, a smoke push-probe of both branches' version classification, registry queries after a real publish, and the artifact lifecycle read from a real run's logs. The deploy ref gate is verified only by tripping it, and that dispatch is the maintainer's to run: the agent prepares the command and reads back the four evidence items (gate conclusion, its error text, every downstream job skipped, deployment count unchanged), and a harness refusal to fire it is the control working, never something to re-shape. + +## Verdict + +Operational iff every applicable 5A item passes and every applicable 5B scenario matches, with the failing guarantees and their triggering inputs named, and the N/A list recorded. Per-project-type walkthroughs mapping scenarios onto targets, including source-only, static-site, and operational shapes, are `WORKFLOW.md` section 6. diff --git a/.github/workflows/merge-bot-pull-request.yml b/.github/workflows/merge-bot-pull-request.yml index 381b974..9b951fc 100644 --- a/.github/workflows/merge-bot-pull-request.yml +++ b/.github/workflows/merge-bot-pull-request.yml @@ -1,96 +1,27 @@ name: Merge bot pull request action -# Auto-merges in-repo Dependabot pull requests: enable on opened or reopened, disable on a maintainer push. -# The merge method follows the base, since the two rulesets allow different forms. -# - develop takes squash. -# - main takes a merge commit. -# An App token is used rather than GITHUB_TOKEN, for two reasons. -# - It fires downstream workflows on merge, which GITHUB_TOKEN deliberately does not. -# - A Dependabot pull request's GITHUB_TOKEN is read-only regardless of who triggered the event. -# The trigger is pull_request_target rather than pull_request, because these jobs hold the App key. -# That resolves the workflow and action SHAs from the trusted base rather than from the pull request head. -# It is safe here because no job checks out pull request code, each one merging by URL alone. -# This repo carries no codegen workflow and no upstream-version tracker, so those jobs are not vendored. +# 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 ] + types: [opened, reopened, synchronize] -# Concurrency keys on the pull request number rather than github.ref. -# Under pull_request_target github.ref is the base branch, which would serialize every bot pull request. -# Setting cancel-in-progress to false means a follow-up synchronize cannot cancel an in-flight opened run. +# 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 - # Dependabot pull requests raised in this repo, never from a fork. - # Restricted to opened and reopened so the disable job below stays sticky. - 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: {} - # Every tier is auto-merged, semver-major included. - # The required checks are the gate, not the size of the bump. - # A major that breaks the build fails its checks and never merges. - - name: Merge pull request step - run: | - set -Eeuo 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 "$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 the bot's branch, which is a synchronize by a non-bot actor. - # Auto-merge is disabled so the maintainer's commits do not merge along with the bot's. - # Re-enabling it is then a deliberate manual act, and 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 - # An App token is required because a Dependabot pull request's GITHUB_TOKEN is read-only. - 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@37aa042042f51655ce368ec62dd1a655ac4b3716 # 2.0.428 + secrets: + CODEGEN_APP_CLIENT_ID: ${{ secrets.CODEGEN_APP_CLIENT_ID }} + CODEGEN_APP_PRIVATE_KEY: ${{ secrets.CODEGEN_APP_PRIVATE_KEY }} + # This repo has no tracker outside the built-in codegen/upstream-version pairs, and keeps the + # repository-wide branch auto-delete off with no bot-branch exception, so no `with:` block is needed. diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b6d474d..b461ba4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -9,59 +9,54 @@ concurrency: group: ${{ github.workflow }} cancel-in-progress: false +permissions: {} + jobs: + # Single source of the release-gate decision (publish or not, stable or not), reused by every job below. + # Also replaces this repo's own dispatch-ref assertion: the hub task errors identically on a ref other than main/develop. + plan: + name: Plan release job + uses: ptr727/ProjectTemplate/.github/workflows/publish-plan-task.yml@37aa042042f51655ce368ec62dd1a655ac4b3716 # 2.0.428 + with: + event_name: ${{ github.event_name }} + actor: ${{ github.actor }} + ref_name: ${{ github.ref_name }} + # The same reusable gate the PR runs - a dispatch cannot release a ref that fails validation. + # This repo's own validate-task.yml carries the Hugo build and URL-parity gate alongside the generic + # linters (see AGENTS.md/GOVERNANCE.md for why this repo has not yet adopted the hub-hosted validate-task.yml). validate: name: Validate sources job + needs: [plan] + if: ${{ needs.plan.outputs.publish == 'true' }} uses: ./.github/workflows/validate-task.yml + with: + # Pin the same dispatch-time commit publish below builds, so a push landing after dispatch + # cannot make validate and publish run against two different commits. + ref: ${{ github.sha }} permissions: contents: read # Publish the dispatched branch, where main is a release and develop a prerelease. - # NBGV computes the tag from the ref, then a GitHub release is created. - # The release is the tag plus the auto source archive, README, and LICENSE. - # This is a source-only repo, so there are no build targets. + # NBGV computes the tag; the release is the tag plus the auto source archive, README, and LICENSE. + # This is a source-only repo with no build targets, so every enable_* input is false. publish: name: Publish project release job - runs-on: ubuntu-latest - needs: [ validate ] + needs: [plan, validate] + if: ${{ needs.plan.outputs.publish == 'true' }} + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@37aa042042f51655ce368ec62dd1a655ac4b3716 # 2.0.428 permissions: contents: write - - steps: - - - name: Assert dispatch ref step - run: | - set -Eeuo pipefail - if [ "${{ github.ref_name }}" != "main" ] && [ "${{ github.ref_name }}" != "develop" ]; then - echo "::error::Dispatch publish-release from main (release) or develop (prerelease); got ${{ github.ref_name }}." - exit 1 - fi - + actions: write + with: # Full history for NBGV; pin the dispatch-time commit - a push landing after dispatch must not release unvalidated. - - name: Checkout code step - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - # NBGV versions the dispatched ref: main is the public-release ref (clean X.Y.Z), develop a prerelease. - # The action brings its own toolchain, so this repo needs no .NET SDK of its own. - - name: Compute version step - id: nbgv - uses: dotnet/nbgv@master - - # Create-or-refresh: every trigger here is a dispatch, so an existing tag is refreshed, never skipped. - # The target_commitish input pins the tag to the exact built commit, not the default branch. - # The release is the tag plus GitHub's auto source archive, README, and LICENSE, with no build assets. - - name: Create GitHub release step - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 - with: - generate_release_notes: true - tag_name: ${{ steps.nbgv.outputs.SemVer2 }} - target_commitish: ${{ steps.nbgv.outputs.GitCommitId }} - prerelease: ${{ github.ref_name != 'main' }} - files: | - LICENSE - README.md + ref: ${{ github.sha }} + branch: ${{ github.ref_name }} + smoke: false + github: true + enable_docker: false + enable_nuget: false + enable_pypi: false + enable_dotnet_publish: false + expect_release_assets: false diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 9487849..ebec5b6 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -4,6 +4,15 @@ name: Validate task on: workflow_call: + inputs: + # Empty (the default) checks out github.sha, which is already the dispatch-time commit for + # every job in this run. A caller that publishes/deploys off a separately-pinned ref (a + # cross-repo reusable-workflow call, which gets its own checkout context) passes that same + # ref here explicitly, so what validate checks stays provably the same commit as what ships. + ref: + required: false + type: string + default: '' jobs: @@ -21,6 +30,7 @@ jobs: - name: Checkout code step uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ inputs.ref || github.sha }} fetch-depth: 0 # Doc linters run as pinned action wrappers. @@ -71,7 +81,7 @@ jobs: - name: Validate config step run: | set -Eeuo pipefail - for f in repo-config/*.json spec/*.json version.json .editorconfig-checker.json; do + for f in spec/*.json version.json .editorconfig-checker.json; do jq empty "$f" done python3 -c 'import yaml,sys; yaml.safe_load(open("hugo.yaml"))' diff --git a/AGENTS.md b/AGENTS.md index 9f03b6d..c2f2a65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,15 @@ This repository is governed by a shared template, and the canonical rules, machi 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. @@ -40,6 +49,8 @@ An agent session is billed on the context it carries, not the work it does. Ever - **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 @@ -67,25 +78,30 @@ Every rule below is a level-two section of [`GOVERNANCE.md`](./GOVERNANCE.md). R | Working on | Section | | --- | --- | | Why the rules are shaped this way | `Foundational Principles` | -| Recording a durable lesson or updating governance | `Durable Knowledge and Self-Improvement` | -| Any push, API mutation, comment, label, or merge, or which checkout the work happens in | `Repository Boundaries and Write Safety` | +| Recording a durable lesson or updating governance | `Durable Knowledge and Self-Improvement`, surfaced at its decision moment 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 `operational-vs-release-workflow` Skill | | Releasing, version bumps, publishing | `Release Model`, packaged as the `operational-vs-release-workflow` Skill | | A live config repo rather than a code repo | `Operational Repositories`, packaged as the `operational-vs-release-workflow` 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, and resyncing one already stood up the same way is `resync-a-repo`, both hub-context only | +| 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` | | 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` | -| Requesting, answering, or closing a review | `PR Review Etiquette`, packaged as the `pr-review-conduct` Skill | -| Reporting progress or asking the user something | `Communicating with the User` | -| Editing a workflow YAML file | `Workflow YAML Conventions` | +| Proving work actually happened | `Verification Discipline`, surfaced at its decision moment by the `agent-conduct` Skill, and the section keeps the full rules | +| 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 | `code-review`, 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 moment by the `agent-conduct` Skill, 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, and this section and `WORKFLOW.md` keep the full rules | | 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` | -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 `fleet-conformance-check`, 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. +A row above with no Skill annotation is doc-only by decision, not by omission. A Skill surfaces rules at a trigger moment, and each unannotated section either binds always or carries no moment narrower than reading it: `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 must bind even when no Skill fires (the `gh-write-guard` hook and the host-wide instruction blocks the agent-safety installer maintains are their enforcement layer, and the one moment in the boundaries section narrow enough to surface, isolating into a worktree at task start, gets the `repo-worktree` Skill on top of that law rather than instead of it), 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 `fleet-conformance-check`, 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. 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. diff --git a/AUDIT.md b/AUDIT.md index e2ae548..bc5f462 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,21 +1,22 @@ # AUDIT.md -How an agent audits **this repository** against its own committed ground truth and reports drift. The audit is read-only. It never edits the repo, and it never touches another repository. +How an agent audits **this repository** against its ground truth and reports drift. The audit is read-only: it never edits this repo, and it reads the hub only, never writes to it or any other repository. -The ground truth is what this repo commits: the payloads in [`repo-config/`](./repo-config/), the secrets manifest in [`spec/secrets.json`](./spec/secrets.json), and the prose authorities ([`GOVERNANCE.md`](./GOVERNANCE.md), [`CODESTYLE.md`](./CODESTYLE.md), [`WORKFLOW.md`](./WORKFLOW.md), [`OPERATIONS.md`](./OPERATIONS.md)). A live setting that disagrees with a committed payload is drift, and the payload is right until a human decides otherwise. +The ground truth is the hub's committed `repo-config/` payloads, which this repo does not carry a copy of, the secrets manifest in [`spec/secrets.json`](./spec/secrets.json), and the prose authorities ([`GOVERNANCE.md`](./GOVERNANCE.md), [`CODESTYLE.md`](./CODESTYLE.md), [`WORKFLOW.md`](./WORKFLOW.md), [`OPERATIONS.md`](./OPERATIONS.md)). A live setting that disagrees with the hub's payload is drift, and the payload is right until a human decides otherwise. ## Scope This repo declares `types: ["source-only"]` and `workflowModel: release` with `lineEndings: "lf"`. -Two of those three are deliberate deviations from what the fleet spec would predict, recorded here rather than left to be rediscovered as drift: +Two of those three are deliberate deviations from what the fleet spec would predict, recorded here rather than left to be rediscovered as drift. A third gap is not a deviation from the spec but from this repo's own progress adopting it, recorded the same way: +- **`deploy-site.yml` still calls this repo's own local `deploy-site-task.yml`, not the hub-hosted one [WORKFLOW.md](./WORKFLOW.md) describes.** Splitting `make-release.sh`'s hard-link and assertion logic into the documented build/prune/verify hook shape is deferred: this repo's script layout (pruning lives inside `make-release.sh` rather than as its own script) doesn't match what the adoption guide assumes, and untangling that on the live SSH deploy path needs more care than a quick fix gives it. `WORKFLOW.md` describes the fleet's target shape, not yet this repo's actual one, for this one guarantee. - **`lineEndings: "lf"` on a `release` repo.** [`GOVERNANCE.md` "Line Endings"](./GOVERNANCE.md#line-endings) grants the native-platform default to operational repos only and holds `release` repos to the CRLF fleet default. Every consumer here is Linux: Hugo builds in CI, Caddy and OpenSSH read their config on Ubuntu, and the deploy scripts run there. Taking CRLF would mean an LF override for the shell scripts, the workflow YAML, the Caddyfile, the generated Caddy maps, and the content tree, which is the over-normalization that rule exists to prevent. The rule ties the ending to the workflow model when the thing that actually determines it is the consuming platform. - **`types: ["source-only"]` rather than `docs`.** `docs` detects a "governance-only repo" and asserts that CI runs linting only with no build. Both are false here, since this repo builds a site with Hugo and gates it on a URL contract. `source-only` detects "no `build-*-task.yml`", which is true, and its checks describe the release shape this repo actually has. Both selectors resolve to the same 24 baseline files, so the choice costs nothing and only one of them is honest. Three dimensions, each independently checkable: -1. **Settings and rulesets**, against the committed `repo-config/` payloads. +1. **Settings and rulesets**, against the hub's committed `repo-config/` payloads. 2. **Secrets**, by name only, against `spec/secrets.json`. 3. **The URL contract**, which is this repo's own reason to exist. @@ -30,7 +31,7 @@ Exits non-zero on any drift. It asserts rule presence, merge methods, and requir Two facts specific to this repo: -- The `develop` payload is [`repo-config/develop.json`](./repo-config/develop.json), the `release` variant, which gates `develop` behind a pull request and the required status check. The `operational/develop.json` variant permits direct signed pushes and is **absent** here. Carrying it would apply the wrong ruleset. +- The `develop` payload is the hub's `repo-config/develop.json`, the `release` variant, which gates `develop` behind a pull request and the required status check. The `operational/develop.json` variant permits direct signed pushes and does not apply here, since this repo's `workflowModel` is `release`. - The required check binds by name, `Check pull request workflow status job`, and turns green only after the pull request workflow has run once. ## 2. Secrets diff --git a/Blog.code-workspace b/Blog.code-workspace new file mode 100644 index 0000000..dca7324 --- /dev/null +++ b/Blog.code-workspace @@ -0,0 +1,27 @@ +// Standard workspace fragment carried by every fleet repo (catalog/snippets/vscode/base.jsonc, +// https://github.com/ptr727/ProjectTemplate/blob/main/catalog/snippets/vscode/README.md). +// This repo ships no dotnet/python/docker target, so it carries base.jsonc only. +{ + "folders": [{ "path": "." }], + "settings": { + "markdown.extension.toc.levels": "2..3", + "files.trimTrailingWhitespace": true, + "[markdown]": { "files.trimTrailingWhitespace": false }, + "[plaintext]": { "files.trimTrailingWhitespace": false }, + "files.encoding": "utf8", + "git.alwaysSignOff": true + }, + "extensions": { + "recommendations": [ + "anthropic.claude-code", + "arahata.linter-actionlint", + "davidanson.vscode-markdownlint", + "editorconfig.editorconfig", + "fanaticpythoner.better-todo-tree", + "github.vscode-github-actions", + "streetsidesoftware.code-spell-checker", + "timonwong.shellcheck", + "yzhang.markdown-all-in-one" + ] + } +} diff --git a/CODESTYLE.md b/CODESTYLE.md index f4e0759..3eff679 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1,8 +1,8 @@ # 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** (Shell, Python, .NET) 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. +This is the single code-style guide for the fleet. The **General** section applies to every language. Each **language section** (.NET, Shell, Hugo, Python) 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. +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 @@ -10,7 +10,7 @@ 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: `.NET` (not `.Net`), `CSharpier`, `ruff`, `pyright`, `uv`. Don't invent personal variants. +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 @@ -31,321 +31,15 @@ Each language defines a **clean-compile** verification: the combination of build ### 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] at the repo root is the single source of truth, and 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) are **intentional**, so do not "fix" them. `MD033` inline HTML stays **enabled**: HTML comments are permitted (markdownlint does not flag them), HTML elements are flagged, and anything with a native markdown equivalent uses the markdown. Fix violations at the source rather than disabling rules. -2. **Spelling**: All spelling must be clean via the CSpell VS Code integration, and words must be correctly spelled in **US English** (the repo-wide convention, per [GOVERNANCE.md][governance]). The shared `cspell.json` sets `"language": "en-US"` so British spellings are flagged, where a bare `"en"` accepts both US and British and silently passes the wrong spelling. Project-specific terms go in the shared `cspell.json` `words` list, the single source of truth the extension, CLI, and CI all read. The `.code-workspace` must **not** carry its own `cspell.words`/`cSpell.words` block, and when externalizing words into `cspell.json`, delete any word list left in the workspace (a leftover one duplicates the list and silently drifts). -3. **Spelling CI scope**: The enforced CI spell-check gate covers **`README.md` and `HISTORY.md` only**, because 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 README + HISTORY are the default; keep the CI workflow, the `Lint: Spelling` VS Code task, and the GOVERNANCE.md cspell one-liner 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, since cspell errors on a listed file that does not exist. Markdown *linting* (item 1) stays repo-wide `**/*.md`, which does not choke on technical terms. +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. 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.* -This is the style guide for any **.NET projects** in this repo. - -### Build Requirements - -#### Zero Warnings Policy - -**CRITICAL**: All builds must complete without warnings. The project enforces this through: - -1. **The `.NET Format` clean-compile task** (see [Clean-Compile Verification][clean-compile-verification]) - - The .NET clean-compile is the **`.NET Format`** VS Code task, which chains `CSharpier Format` -> `.NET Build` -> `dotnet format style --verify-no-changes`. These three task definitions are carried verbatim in [`.vscode/tasks.json`][vscode-tasks]. - - After any code change it must pass before commit. Run the `.NET Format` task. To run it natively instead, reproduce that task chain from [`.vscode/tasks.json`][vscode-tasks] exactly (`CSharpier Format`, then `.NET Build`, then the `dotnet format style --verify-no-changes --severity=info ...` verify) without dropping or loosening any argument (tasks.json is the canonical command spec). Bare `dotnet format` alone, skipping CSharpier or the build, is not sufficient. - -2. **Analyzer configuration** - - `<EnableNETAnalyzers>true</EnableNETAnalyzers>` with `<AnalysisLevel>latest-all</AnalysisLevel>` and `<AnalysisMode>All</AnalysisMode>` (full analyzer set enabled) - - `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`, so any diagnostic surfaced as a warning fails the build and must be fixed or deliberately suppressed, not left to accumulate (see [Analyzer Diagnostics and Suppressions][analyzer-diagnostics-and-suppressions]) - -3. **CI lint backstop** - - CI runs the clean-compile checks on every PR as the authoritative backstop - - Git hooks are optional, and a repo may wire a local runner (Husky.Net) for pre-commit enforcement, but CI is the gate that matters - -#### 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, and no version skew between projects. - -A repo whose projects still carry per-project analyzer settings or versioned `PackageReference` items is drifted, so move the shared property or version up to the root file rather than editing it in place. - -#### Build Tasks - -Available VS Code tasks (run them from VS Code's task runner, **Terminal -> Run Task**, or an agent's task-running tool). The three clean-compile tasks below are carried verbatim, 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 - -#### Code Formatting and Tooling - -1. **CSharpier**: Primary code formatter - - Invoked by the `CSharpier Format` task / `dotnet csharpier format --log-level=debug .` -2. **dotnet format**: Style verification - - Verify no changes: `dotnet format style --verify-no-changes --severity=info --verbosity=detailed` -3. **Other tools** - - `dotnet-outdated-tool`: Dependency update checks - - Nerdbank.GitVersioning: Version management - -CI is the authoritative lint backstop. Local pre-commit hooks are optional, so wire Husky.Net (or another runner) if you want local enforcement. - -#### Editor Baseline - -1. **Required VS Code extensions**: CSharpier, markdownlint, CSpell -2. **VS Code settings**: Use the workspace settings without overrides - -### Coding Standards and Conventions - -Note: Code snippets are illustrative examples only. Replace namespaces/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, in the classic `this`-parameter form or an `extension(<receiver>) { ... }` block on C# 14+ - - Implicit object creation when type is apparent - - Range and index operators - -4. **Expression-bodied members**: Use for applicable members - - 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 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 - } - } - ``` +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, and AOT-compatible project configuration. -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, but governed per repo by `.editorconfig` / `.gitattributes` per the [GOVERNANCE.md][governance] "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 - -1. **XML documentation** - - `<GenerateDocumentationFile>true</GenerateDocumentationFile>` - - Missing XML comments for public APIs are suppressed (`.editorconfig`) - - Must document all public surfaces. - - Single-line summaries, additional details in remarks, document input parameters, return values, exceptions, and add 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) {} - ``` - -#### Analyzer Suppressions (.NET) - -Follow the scope hierarchy in [Analyzer Diagnostics and Suppressions][analyzer-diagnostics-and-suppressions]. .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 (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; 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, and 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()`) and 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, and 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. **Read-only data**: Use immutable or frozen collections for read-only data sets -13. **Lazy initialization**: Use `Lazy<T>` for static, thread-safe instantiation (e.g., logger factory, HTTP factory) - -#### 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, so 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]` - -### 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> - ``` - -### Best Practices - -1. **Code reviews**: All changes go through pull requests +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. ## Shell @@ -357,7 +51,7 @@ The deploy and check scripts are Bash. They run on a Linux host and in CI, never ### Conventions -- **Every script opens with `set -euo pipefail`.** A deploy that continues past a failed step is worse than one that stops. +- **Every script opens with `set -Eeuo pipefail`.** A deploy that continues past a failed step is worse than one that stops, and `-E` lets an `ERR` trap inherit into functions, subshells, and command substitutions if one is ever added. - **Pin `umask` in any script that creates files a service reads.** An inherited umask is invisible until a file lands unreadable. - **Use `if` rather than `&&` for a conditional whose test may be false as the last command in a loop body or function.** Under `set -e` the false test becomes the block's exit status and terminates the script, which is precisely the case a tolerated-absence branch exists to handle. - **Quote every expansion.** An unquoted path splits on whitespace, and the failure surfaces only on the one file with a space in its name. @@ -381,164 +75,14 @@ The site is Hugo with a vendored theme. There is no plugin system and no build s *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.* -This is the style guide for any **Python project(s)** in this repo. - -**Adapt before propagating.** The rules below 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; 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` + a version source + `uv build` + a PyPI publish step), or a **source-only** repo with a static `version` and no publish step (see [Versioning][versioning-section]). -- **Disabled markdownlint rules** - repo-specific. `.markdownlint-cli2.jsonc` at the repo root is the source of truth, not any example rule named here. -- **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). A `[vscode-tasks]` reference must point wherever the repo actually keeps `tasks.json`. - -**Two profiles.** A repo's Python is one of two shapes, declared as the `build` or `lint-only` profile and validated against the `pyproject.toml` shape. The rest of this section (uv project, `uv.lock`, `uv run`, `src` layout, pytest coverage) describes 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 audit reads the shape there (`python.profile.detect`): - -- **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 [Line Endings][line-endings]). 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 line-ending default (CRLF in a CRLF-default repo, and a shebang-executed script is LF-pinned by path, per [Line Endings][line-endings]). There is no pytest suite and no coverage gate. 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` beside the script it exercises; measure them with `uvx coverage@latest run -m unittest discover -s <dir>` when a number is wanted, without adopting a threshold. A co-present `csharp` type still carries `codecov.yml` for its own tests. - -### 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 | `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; 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 the Python project directory: - -```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 Python clean-compile (see [Clean-Compile Verification][clean-compile-verification]) 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. These are documented commands; an optional VS Code tasks mirror (all `type: process`, no `&&` shell chaining, so it runs the same on any task shell) is in [`vscode-tasks-python.json`][vscode-tasks-python]. CI runs the same clean-compile commands as the authoritative backstop. Git hooks are opt-in; wire `pre-commit` for `ruff` and the type checker yourself if you want local enforcement. - -### 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 - -#### 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, so 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 (see [Analyzer Diagnostics and Suppressions][analyzer-diagnostics-and-suppressions]). - -#### 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`][history], 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 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, so fix freshly surfaced type errors rather than muting them (see [Analyzer Diagnostics and Suppressions][analyzer-diagnostics-and-suppressions]). - -#### 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. - -### Tests - -- `pytest` with the configuration in `[tool.pytest.ini_options]`. Default invocation: `uv run pytest`. -- 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. - -### 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 + `version.json`) own the tag. There is no publish step to guard, so `skip-existing` does not apply. - -### Linter Cleanliness - -Before pushing or opening a PR: +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, and versioning. -- 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 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. (Invoke them as separate steps, not `&&`-chained, so the runner shell is irrelevant.) -- Markdown in this directory follows the repo-wide [Markdown and Spelling][markdown-and-spelling] rules. +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. <!-- Repo --> -[analyzer-diagnostics-and-suppressions]: #analyzer-diagnostics-and-suppressions -[clean-compile-verification]: #clean-compile-verification [governance]: ./GOVERNANCE.md [governance-running-the-linters-locally]: ./GOVERNANCE.md#running-the-linters-locally-known-working-invocations [governance-verification-discipline]: ./GOVERNANCE.md#verification-discipline -[history]: ./HISTORY.md -[line-endings]: ./GOVERNANCE.md#line-endings -[markdown-and-spelling]: #markdown-and-spelling -[markdownlint-cli2]: ./.markdownlint-cli2.jsonc [readme]: ./README.md [root]: ./.editorconfig -[versioning-section]: #versioning -[vscode-tasks]: ./.vscode/tasks.json -[vscode-tasks-python]: ./.vscode/tasks.json - -<!-- External --> - -[docs-link]: https://docs.pytest.org/ -[latest-link]: https://hatch.pypa.io/latest/ -[mypy-link]: https://mypy-lang.org/ -[pep-0257-link]: https://peps.python.org/pep-0257/ -[pyright-link]: https://microsoft.github.io/pyright/ -[ruff-link]: https://docs.astral.sh/ruff/ -[uv-link]: https://docs.astral.sh/uv/ diff --git a/GOVERNANCE.md b/GOVERNANCE.md index fc2057d..dfaecb4 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -18,15 +18,19 @@ The specific rules in this file implement a few governing principles. Read these - **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 (`AGENTS.md`, `CODESTYLE.md`, `WORKFLOW.md`, or a committed backlog such as a `README.md` TODO section). 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 instead of only fixing it locally. Governance is not static: it improves by agents folding good patterns in and designing bad ones out. +This section keeps the full rules and is surfaced at its decision moment by 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. + ## 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. -- **Never fabricate, guess, or reuse an identifier passed to a write.** Every id a state-changing call consumes (a node id, a numeric id, a thread or comment id) is captured from a live query in the **same** session into a variable and passed from there. Do not hand-type an id, guess it, recall it from memory or an earlier 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. If a query returns no id, stop rather than invent one to proceed. +- **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. - **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 @@ -39,10 +43,7 @@ Agent-authored text illustrates with data the agent constructed, never with data ## Git and Commit Rules -The fleet's mechanical git rules: default to staging rather than committing, 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. +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 @@ -55,6 +56,7 @@ Two workflow models, set per repo by the registry `workflowModel` field. Most re `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. @@ -94,7 +96,7 @@ since that path is hub-local and not carried into every fleet repo. The summary 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 CRLF default. +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. ## Hub-Hosted Tooling @@ -108,7 +110,7 @@ The fleet's tooling lives in the hub once and a repository runs it from there ra **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.** A runner holds no hub checkout, so a workflow consumes the hub's composite action and pins it to a commit SHA, per the action-pinning rule under "Workflow YAML Conventions". 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. +**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. @@ -120,7 +122,7 @@ This is packaged as the `comment-and-doc-style` Skill at `.agents/skills/comment ## Documentation Style Conventions -The fleet's prose and formatting contract: what a carried file may reference, how Markdown links, headings, and tense are structured, the comment philosophy, the ASCII character-set tiers, the line-ending policy, and how a quantitative claim in a doc stays honest. Applies to docs and code/workflow comments alike. +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, 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. @@ -136,6 +138,10 @@ The full ASCII tier system (never legitimate, legitimate next to a number, alway 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. Their unifying property: **every failure below is green.** A skipped job and a passing job are indistinguishable in the aggregated required check, a pattern that matches less still exits zero, and a gate that stops gating still reports success. No linter, status check, or review layer catches any of them. @@ -152,13 +158,17 @@ The checks that separate work actually done from work that merely reports succes - **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 "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 "Branching Model" above, 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 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 fix the class.** When a reviewer cites one stale claim, one silent-narrowing pattern, or one mis-worded contract, sweep for its siblings before replying. Reviewers sample rather than enumerate. +This section keeps the full rules and is surfaced at its decision moment by 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. + ## PR Review Etiquette -The provider-agnostic review-loop contract every fleet repo follows: request a review on every push, confirm it covers the current head SHA and the full diff rather than only part of it, triage every finding including the low-confidence ones a review body collapses rather than threads, and reply and resolve. Never merge on a green or CLEAN merge state alone, since that field 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 finding, which opens no thread at all, unanswered. +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. 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. @@ -170,22 +180,26 @@ The provider-specific mechanics this contract needs to actually drive GitHub Cop - **Ask for input as a numbered list.** When you need the user to decide or answer, present the questions, and any options, as a numbered list so they can reply per number. A single inline question is fine, and two or more are always numbered. - **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. This supersedes the numbered-list rule above wherever an interactive prompt is available, and the numbered list is the fallback where none is. +This section keeps the full rules and is surfaced at its decision moment by 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. + ## Workflow YAML Conventions These conventions describe the target state. New and modified workflows must respect them. The rest of the repo is expected to be brought up to the same standard. Sweep PRs that apply a rule everywhere are welcome when a rule changes. +This section and [`WORKFLOW.md`](./WORKFLOW.md) keep the full rules, this section winning where the two overlap, and both are surfaced by 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. + - **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. 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**: reusable workflows (those with `on: workflow_call`) end in `-task.yml`. Entry-point workflows (`on: push` / `pull_request` / `schedule` / `workflow_dispatch`) do NOT use the `-task` suffix. They end with what they do: `-pull-request.yml`, `-release.yml`, etc. The suffix carries semantic meaning: a `-task.yml` file is meant to be `uses:`-d, never triggered directly. - **Workflow `name:`** (the top-level `name:` field): reusable workflow names end in **"task"** (e.g. `Build PyPI library task`), and entry-point workflow names end in **"action"** (e.g. `Publish project release action`, `Test pull request action`). The displayed action name in the GitHub Actions UI tells you at a glance whether you're looking at an orchestrator or a callee. -- **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 ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and `repo-config/{develop,main}.json` in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. -- **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. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because its three-job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) [`publish-release.yml`](./.github/workflows/publish-release.yml) uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. +- **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 ruleset-bound job's `name:` and its ruleset `context:` are the **same string**: rename them **together**, updating the live ruleset and the hub's `repo-config/` payloads in lockstep with the job `name:`, never one without the other, or required-status-check enforcement silently breaks. There is no un-suffixed exception. +- **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. **Documented exceptions** (both record the rationale inline in their header comment): (1) [`merge-bot-pull-request.yml`](./.github/workflows/merge-bot-pull-request.yml) uses `cancel-in-progress: false` because the merge-bot's job model (enable-auto-merge on opened, disable-auto-merge on maintainer-pushed synchronize, with method dispatched by base) requires each event to run to completion in arrival order, because cancellation would leave auto-merge in an inconsistent state. (2) `.github/workflows/publish-release.yml` uses both a **global, ref-independent group** (`group: ${{ github.workflow }}`, dropping the usual `-${{ github.ref }}`) and `cancel-in-progress: false`. It publishes shared ref-independent artifacts (both branches' Docker tags/caches and GitHub releases) on schedule/dispatch regardless of the triggering ref, so a ref-scoped group would let a scheduled run (ref `main`) and a manual dispatch (ref `develop`) run concurrently and double-push, and cancelling a publish mid-flight can leave a partially pushed tag set or a half-created release. The global group + queueing serializes every publish run to completion. - **Shells**: every bash surface, a multi-line `run:` block and every committed `.sh` 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`). 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. - **Conditionals**: multi-line `if:` uses folded scalar `if: >-` so YAML preserves whitespace correctly. Literal block (`if: |`) is wrong because it embeds newlines inside the boolean expression. - **Boolean inputs**: workflows triggered both via `workflow_call` and `workflow_dispatch` must declare each boolean input in *both* trigger blocks, since one definition does not propagate to the other. `workflow_call` delivers booleans as actual booleans, and `workflow_dispatch` delivers them as the *strings* `"true"`/`"false"`. Any `if:` consuming a boolean input must compare against both forms: `if: ${{ inputs.foo == true || inputs.foo == 'true' }}`. - **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** in a dedicated entry validation step/job that the downstream jobs `needs:`, before any expensive build or publish work, not as partial checks scattered deep in later jobs. One gate that fails fast with a clear `::error::` beats a late or one-directional check. Examples: `build-release-task.yml`'s `validate-release` job (branch-versus-prerelease, both directions) and `publish-docker-readme-task.yml`'s "Validate inputs step". - **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`. Either declare permissions at the call site, or omit the inner block and inherit. - **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')`. -- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they 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 (the `github-release` job deletes `release-asset-<branch>-*` after attaching them to the release, and `publish-release.yml`'s `publish-pypi` deletes `pypilibrary-build-<branch>` after publishing). 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. 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. +- **Artifact retention**: workflow artifacts are an intra-run handoff only, with durable copies living on the GitHub release rather than in workflow artifacts, so they 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 (the `github-release` job deletes `release-asset-<branch>-*` after attaching them to the release, and `publish-release.yml`'s `publish-pypi` deletes `pypi-build-<branch>` after publishing). 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. 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/from a registry tag (`type=registry`, e.g. `buildcache-<branch>` on Docker Hub), not the GitHub Actions cache (`type=gha`), to keep large image layers off the 10 GB Actions cache. A **multi-image** repo uses a **per-image** buildcache tag (`<repo>:buildcache-<branch>` for each image, plus the base image's own tag and inline cache). 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). @@ -195,61 +209,33 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): -- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** and **PSScriptAnalyzer** via Docker `:latest` (editorconfig-checker's action only installs the CLI, and PSScriptAnalyzer has no action, so the Docker one-liner is what actually runs each check). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. +- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** the same way for a repo that carries `.sh` files, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (neither one has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. - **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. -The Docker invocations below are the same ones the VS Code tasks use, for ad-hoc or headless (agent) runs. - -- **editorconfig-checker** (line endings + charset across the tree): - - ```sh - docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest - ``` - -- **actionlint** (GitHub Actions workflow YAML, run after any `.github/workflows/` edit, since workflow-only changes are not smoke-built): - - ```sh - docker run --rm --pull=always -v "$PWD":/repo --workdir /repo rhysd/actionlint:latest -color - ``` +The Docker invocations below run the same tools and configs as the VS Code tasks. Their headless form separates the image pull and minimizes repository exposure for an agent executor. - The `rhysd/actionlint` image bundles `shellcheck`, so it also validates `run:` shell blocks. The direct-binary/curl-installer path is often sandbox-blocked, so use Docker. +**Restricted executors keep tool state in a task-specific writable temporary directory.** Set each tool's own cache variable, such as `UV_CACHE_DIR` and `RUFF_CACHE_DIR`, instead of changing `HOME` or an agent configuration directory. A sandbox denial is not a lint result. Preserve the denial, then rerun the required command through the executor's scoped approval mechanism. Network approval covers any required fetch, including an image or package download. Host approval covers access to the Docker socket. Repository-exposure approval covers letting third-party image code read the checkout, even through a read-only mount. Persist approval only when the executor constrains the read-only mount, disabled networking, and resolved digest together. Never allow an unconstrained `docker run` prefix. PSScriptAnalyzer's separate module-install phase gets network approval without any repository mount. Report the approved rerun as the evidence. -- **markdownlint-cli2** (Markdown, mirroring the davidanson VS Code extension via the shared [`.markdownlint-cli2.jsonc`](./.markdownlint-cli2.jsonc), so the CLI and IDE agree): +Agent-specific authorization stays separate from the executor-neutral contract above: - ```sh - docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir davidanson/markdownlint-cli2:latest "**/*.md" - ``` +- **Codex:** execution rules match exact argument prefixes, so they cannot safely cover changing worktree paths and digests. Smart Approvals can therefore request repository-exposure approval per task. The no-prompt alternative combines `sandbox_mode = "danger-full-access"` with `approval_policy = "never"`. Use that pair only when an external sandbox contains the Codex process. It removes protection from every command rather than only lint. -- **cspell** (spelling in user-facing docs, with the word list and exclusions in [`cspell.json`](./cspell.json)): +Run the hub-hosted wrapper from the repository it checks: - ```sh - docker run --rm --pull=always -v "$PWD":/workdir --workdir /workdir ghcr.io/streetsidesoftware/cspell:latest --no-progress README.md HISTORY.md - ``` +```sh +python3 /path/to/ProjectTemplate/scripts/docker_lint.py --root "$PWD" +``` -- **PSScriptAnalyzer** (PowerShell, the peer of the shellcheck step, with the excluded rules and their reasons in [`PSScriptAnalyzerSettings.psd1`](./PSScriptAnalyzerSettings.psd1)): +The wrapper discovers tracked and unignored targets before it pulls applicable images. It reports a zero-target skip without pulling or mounting the repository. It pulls each applicable image in a distinct pull phase, then resolves the pulled repository digest. A digest prevents the tag from changing between the pull and execution. It does not make third-party code trusted. - ```sh - docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \ - pwsh -NoProfile -Command ' - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers - Import-Module PSScriptAnalyzer - $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ } - if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 } - $found = @() - foreach ($file in $files) { $found += Invoke-ScriptAnalyzer -Path $file -Settings ./PSScriptAnalyzerSettings.psd1 } - Write-Host "Checked $($files.Count) file(s)" - if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 } - Write-Host "no findings" - ' - ``` +After all pulls, the wrapper reports that repository mounts are about to begin. Each execution uses the resolved digest, disabled networking, and a read-only checkout mount. PSScriptAnalyzer installs its pinned module in a separate container without the checkout mount. File-argument linters receive each tracked path as a distinct argument, split across bounded batches before host command-line limits become relevant. - The module version is pinned beside the image, because the image alone does not fix it and a floating install makes a local run a different check from CI. 1.23.0 rather than the newest, since 1.24.0 needs a newer `System.Management.Automation` than the image carries and fails to import after installing cleanly. The file list comes from `git ls-files` for the same reason the shellcheck step uses it, and the count is printed because a run that read no files reports the same clean as one that read them all. +Every primary Docker command has a five-minute timeout by default. Use `--timeout` to select another positive bound. The wrapper emits a start and completion line for each primary command. Timeout cleanup has a separate maximum of 30 seconds and emits its result through the failed lint step. The wrapper reports the checked-file count for every linter, including tools that produce no success output. Timeout, container failure, zero-target execution, and successful quiet completion have distinct result lines. The wrapper names each lint container and removes it after a timeout. - **The list splits on whitespace rather than on a newline, and the regex is double-quoted.** A shell joins the file list with newlines and PowerShell joins it with spaces, so a newline-only split hands the analyzer one path holding every file, which it reports as one file it cannot find followed by a clean run over nothing. The double quotes are what let the whole invocation stay inside the single-quoted `-Command` a shell passes, since PowerShell escapes with a backtick and leaves the backslash alone. Run verbatim it reports `Checked 5 file(s)` from either shell. +Use repeated `--linter` options for a subset. The supported names are `editorconfig-checker`, `actionlint`, `markdownlint`, `cspell`, `shellcheck`, and `PSScriptAnalyzer`. editorconfig-checker reads the mounted tree. actionlint reads eligible workflows and includes shellcheck for `run:` blocks. markdownlint reads tracked and unignored Markdown files. CSpell reads `README.md` and `HISTORY.md` only. shellcheck and PSScriptAnalyzer run only when matching scripts are tracked or unignored. - In a configured editor the davidanson extension is enough. Use the Docker CLI when there's no IDE (agent/headless) or to confirm a clean run before pushing. +In a configured editor the `DavidAnson.vscode-markdownlint` extension is enough for Markdown. Use the wrapper for a headless run or before pushing. When pulling a public image fails on a Docker-Desktop/WSL credential-helper error (`docker-credential-desktop.exe: exec format error`), retry with an empty Docker config: `DOCKER_CONFIG=$(mktemp -d) docker run ...` after writing `{}` to `$DOCKER_CONFIG/config.json`. @@ -274,7 +260,7 @@ Contributors commit to this repo with signed commits, and a greenfield repo sign Every repo's GitHub repository details (the About panel) follow a fixed convention so the fleet stays consistent and self-describing. -- **Description** matches the README's **tagline**, its first non-empty line after the `#` H1 heading, as plain text, stripping Markdown links (`[text](url)` and `[text][ref]` become `text`) since a description is not rendered. 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. The README is the source of truth: set the description from it (`gh api -X PATCH repos/<owner>/<repo> -f description=...`), never the reverse. When the current description is *more specific* than the README (a chip revision or variant the README omits), surface the drift to the maintainer rather than silently discarding the detail, and the fix is to sharpen the README so the description follows it. Keep the line at most **100 characters**, Docker Hub's short-description cap and the tightest surface it feeds. For a repo that publishes a Docker image, the **Docker Hub short description** mirrors the same tagline, so one canonical sentence carries to the README, the About panel, and Docker Hub alike. 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 README is carried onward rather than corrected there. +- **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`. @@ -287,7 +273,7 @@ Every repo's GitHub repository details (the About panel) follow a fixed conventi - [`layouts/`](./layouts/), [`themes/`](./themes/), [`assets/`](./assets/), [`i18n/`](./i18n/): the theme and the template overrides that keep a strict build warning-free. - [`checks/`](./checks/): the URL contract and the gates that enforce it, covering both the built output and a running server. - [`deploy/`](./deploy/): the release script, the web-server config, and the redirect maps. -- [`repo-config/`](./repo-config/): branch rulesets and the repository settings, kept out of `.github/` (which is Actions-owned). +- Branch rulesets and repository settings are hub-hosted, not carried: this repo checks and applies them from a `ptr727/ProjectTemplate` checkout, per [GOVERNANCE.md "Hub-Hosted Tooling"](#hub-hosted-tooling). - [`spec/`](./spec/): the machine-readable ground truth this repo audits itself against. - [`host-tools.json`](./host-tools.json): the tools a host needs to work on this repo beyond the fleet's own declaration, layered over it tighten-only by the hub's host gate. It declares Hugo, since the URL contract is proven by building the site locally. - [`.github/workflows/`](./.github/workflows/): this repo's CI. diff --git a/WORKFLOW.md b/WORKFLOW.md index 6cd1e62..5941ac4 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,6 +1,6 @@ # WORKFLOW.md -The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for everything under [`.github/workflows/`][workflows]. +The guide for CI/CD **workflows** (GitHub Actions): a deliberate mixture of code style, architecture, a **behavioral contract** (expected inputs and outputs), and a **test methodology**. Code style lives in [`CODESTYLE.md`][codestyle]. This file is its sibling for everything under `.github/workflows/`. Its defining principle: **it describes required outcomes, not a required implementation.** Two repos may implement the same guarantee with different YAML. A workflow is correct when it **satisfies the contract** in section 4 and is **defect-free against the expected inputs and outputs**, not when it matches a reference implementation byte for byte. The conventions in section 2 keep workflows legible. The contract in section 4 is what they must *do*. @@ -58,14 +58,17 @@ flowchart LR ```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 ``` -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 below that assume a build/test pipeline are **N/A** exactly as for `source-only` (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"][governance-branching-model] and [repo-config/README.md][repo-config-readme], not here. +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"][governance-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` (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 below that assume a build/test pipeline are **N/A** exactly as for `source-only` (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"][governance-branching-model], not here. ### 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, the date-badge job, and the `changes -> smoke-build -> aggregator` shape of the PR workflow. These job *bodies* should not need per-repo edits. +- **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: the `build-<target>-task.yml` leaf tasks. - **What the repo curates** (by design, not a leak): the *list* of targets. This is **not** a byte-for-byte file carry. Adding or dropping a target edits the orchestrator's surface: the `enable_<target>` inputs and the `build-<target>` job + its `github-release` `needs:` entry in the release task, **and** the `changes` paths-filter entry + output + the `smoke-build` enable-forward in the PR workflow. "Verbatim" applies to the `github-release` job and the version/publish-plan logic, not to the release task's job list or the paths-filter. Subsetting is symmetric: the same surface you trim to drop a target you extend to add a new one (e.g. a `release-asset-<branch>-library` producer needs a new `enable_library` input, a `build-library` job, a `needs:` entry, and a `library` paths-filter). @@ -75,9 +78,9 @@ A target contributes a file to the GitHub release by uploading a workflow artifa ```mermaid flowchart LR - leafa[leaf: target A] -->|release-asset-branch-A| store[(run artifacts)] - leafb[leaf: target B] -->|release-asset-branch-B| store - store -->|pattern + merge-multiple| rel[github-release job] + 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)"] reg[registry leaf: nuget / pypi / docker] -->|push, no asset| registries[(registries)] ``` @@ -95,7 +98,7 @@ When a workflow's inputs carry a cross-input or input-versus-derived-state invar ### 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 same condition as the consumer**, 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, and an intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an aggregation step) may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. +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 same condition as the consumer**, 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. An intermediate consumed only within the same run may rely on the retention backstop alone. The run is **never** blanket-deleted (`.artifacts[].id`). See D5. ### Fast PR Feedback @@ -106,7 +109,7 @@ 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] + val[validation job] --> agg["Check pull request workflow status job (D1)"] sb --> agg skip --> agg agg -->|success| ok[merge allowed] @@ -119,10 +122,10 @@ Each publish builds a **single branch**, the trigger ref (`main` a release, `dev ```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] - one -->|develop| vdev[version X.Y.Z-g-sha prerelease] - vmain --> relm[github-release + registries: latest] - vdev --> reld[github-release + registries: prerelease] + 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 @@ -132,7 +135,8 @@ 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 publishes to its registry. NuGet pushes from the leaf *and* uploads a `release-asset-*`. PyPI is **split**: the leaf only builds + uploads its build artifact, a separate publish job does the OIDC upload (so `id-token: write` is granted at one entry point, behind an environment gate) and contributes **no** `release-asset-*`. - **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-*`. -- **No file target via the release task** (Docker-only, PyPI-only): the release is tag + source zip + README + LICENSE. The repo's **caller MUST pass `expect_release_assets: false`** to the release task (the input is never set by a publisher that ships file targets, which keeps the default `true`). This is the one case where the otherwise-verbatim publisher is edited. With the default `true` and no assets, the release-create step fails on `fail_on_unmatched_files`. A **source-only** repo has no release task at all. Its standalone `publish-release.yml` inlines `action-gh-release`, so `expect_release_assets` does not apply (see Section 6). +- **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 Section 6). ## 4. Behavioral Contract: Expected Outcomes @@ -142,41 +146,43 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **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). *Prevents: rebuilding everything, and a changed target slipping through unbuilt.* - **D1.2 A validation job always runs.** Input: any PR. Output: a type-appropriate validation job runs unconditionally and the aggregator `needs:` it. In a .NET repo this is the `unit-test` job (format/style/test). A non-.NET repo **replaces** it (not deletes) with its own validator (lint, schema-check) and re-points **every** `needs:` on it (both the aggregator and `smoke-build`, which `needs:` the validation job by name) to the replacement. *Prevents: a PR merging with no validation, or a dangling `needs:` that fails the whole workflow to load.* -- **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 `!smoke`). *Prevents: a PR publishing; orphaned artifacts churning the storage quota.* +- **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 `!smoke`). *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 excludes workflow files, so smoke-build skips. *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**, `needs:` the changes job and the validation job, 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's validation/test job. Output: tests run with coverage collection (`dotnet test --collect:"XPlat Code Coverage"` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** secret store and reaches the reusable validator via `secrets: inherit`. Required for **every** C# and Python repo that has tests (see `spec/secrets.json` `typeMechanisms`). The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/`, `*.cobertura.xml`; `.gitignore` is the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported; a stale, unused token; a coverage regression blocking an unrelated PR; a coverage artifact committed by a blanket add.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo's validation/test job. Output: tests run with coverage collection (`dotnet test --collect:"XPlat Code Coverage"` or `pytest --cov-report=xml`) and a `codecov/codecov-action` step uploads it, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). `CODECOV_TOKEN` lives in the repo's **actions** secret store and reaches the reusable validator via `secrets: inherit`. Required for **every** C# and Python repo that has tests. Where this guarantee does not apply, a repo's own `spec/secrets.json` may carry no `typeMechanisms` entry, and that absence is not drift. The repo also ships a **`codecov.yml`** that sets the project and patch statuses to **`informational: true`** so a coverage delta never gates a PR (a distinct knob from `fail_ci_if_error`, which only guards the upload step), and excludes intentionally-untested, non-shipped code (an example/demo or benchmark project) from the coverage denominator via `ignore`. A repo may override this to enforce a coverage threshold where its quality bar requires it. Coverage output is a build artifact, so `.gitignore` excludes it (e.g. `coverage/` and `*.cobertura.xml`, with `.gitignore` the full source of truth) so a blanket `git add -A` won't stage the untracked output. *Prevents: coverage silently going unreported; a stale, unused token; a coverage regression blocking an unrelated PR; a coverage artifact committed by a blanket add.* ### 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 skips with it unless that dependent opts out with `if: always()` and reads the result explicitly, the way the PR aggregator does. `github-release` carries `validate-release` in `needs:` and does **not** opt out, so a job-level skip there would couple the release to smoke through a second path on top of the `if:` it already carries. *Prevents: a non-default leg published as stable; a build-metadata false-positive; the gate blocking every default-base promotion PR.* +- **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 skips with it unless that dependent opts out with `if: always()` and reads the result explicitly, the way the PR aggregator does. `github-release` carries `validate-release` in `needs:` and does **not** opt out, so a job-level skip there would couple the release to smoke through a second path on top of the `if:` it already carries. *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.* ### 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`; any other -> `X.Y.Z-g<sha>`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. +- **D3.2 Default = public, others = prerelease.** Output: default branch -> `X.Y.Z`, and any other -> `X.Y.Z-g<sha>`. The default-branch literal in the gate, the `prerelease` expression, and `version.json` MUST all name the repo's real default branch. - **D3.3 Version floor + git height.** Output: `version.json` sets the major.minor floor. NBGV appends the git height as the patch, bumped only for a functional change by the maintainer. 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 `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule). The develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release; a renamed/extra branch silently getting a plain version.* +- **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 `AssemblyFileVersion` (`M.N.P.B`) and appends `.dev0` on the `develop` branch only (a two-branch literal, not a generic N-branch rule). The develop `.dev0` build must remain `pip install --pre`-selectable and sort above the default release (NBGV git height in the release segment keeps develop ahead). *Prevents: a non-default leg published as a release, and a renamed/extra branch silently getting a plain version.* - **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 first `plan` job (`publish-plan-task.yml`) decides once and every job gates on it: publish 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 is a tag on the built commit plus the auto source zip, README, and LICENSE; file-producing targets attach `release-asset-*`; `prerelease` equals `branch != default`. A no-file-target repo that uses the release task (Docker-only, PyPI-only) reaches the tag-only shape **only** with `expect_release_assets: false` set by the caller (which relaxes `fail_on_unmatched_files` and skips the asset download). With the default `true` and no assets the release-create step fails. A source-only repo reaches the same shape through its inlined `action-gh-release` instead, with no release task or `expect_release_assets`. -- **D4.4 No-op republish.** Input: a re-run whose version is unchanged. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it. 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; PyPI `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.** Input: a real publish where one enabled build fails. Output: nothing publishes. `github-release` needs every build, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build and guards its `if` with `!failure() && !cancelled()`, so a failed build skips docker too (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker build on smoke. *Prevents: a partial publish, e.g. a Docker image pushed while the executable build failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. +- **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 tag-only shape. This applies to Docker-only, PyPI-only, and source-only repos. 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. Output: nothing is re-pushed, because the release-create step is skipped when the tag exists (refreshed only on `workflow_dispatch`), and the paired asset-delete is skipped with it. 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.** Input: a real publish where one enabled build fails. Output: nothing publishes. `github-release` needs every build, so a failed build skips it (no tag, no release), and the terminal registry pusher (Docker) needs every other build and guards its `if` with `!failure() && !cancelled()`, so a failed build skips docker too (no image push) while a disabled or unchanged target (skipped, not failed) still lets docker push. *Prevents: a partial publish, e.g. a Docker image pushed while .NET publish failed and no release was cut.* A repo pushing two registry targets at once would need a build/publish split behind an all-builds gate, which none does today. +- **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. An intermediate consumed only within the same run (e.g. an executable's per-runtime outputs feeding an in-run aggregation) MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* +- **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. An intermediate consumed only within the same run MAY instead rely on the `retention-days: 1` backstop. *Prevents: transfer artifacts accumulating against the storage quota.* - **D5.2 Gate the delete to the consumer's condition.** Output: the delete runs under the **same** condition as its consuming step. Where the consumer is conditional (the GitHub release create), the delete is conditional too. Where the consumer always runs when its job runs (the PyPI publish step), the delete always runs, so on a no-op re-run the `release-asset-*` delete is **skipped** while the PyPI build-artifact delete still **runs** (its publish ran). *Prevents: deleting freshly built assets on a no-op re-run.* - **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 @@ -196,7 +202,7 @@ The required behaviors, organized by domain. Each is a **MUST**, stated as input - **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. Dependabot targets both branches, security PRs to default. -- **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 the merge-bot's hard-coded `<prefix>-<base>` head/base pairs, or auto-merge silently never fires. +- **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 (See Section 2) @@ -219,19 +225,20 @@ Read the workflow files plus `version.json` and assert the structural fact behin - **D1:** a `changes` paths-filter job exists, covers each of the repo's targets, and **excludes** `.github/workflows/**`; the PR entry workflow's smoke call sets `github/nuget/dockerhub: false` on the release task; the leaf receives `smoke: true` and a derived `push` (false on smoke); every build-task `upload-artifact` (and any aggregation job) is gated `!smoke`; the aggregator `needs:` the `changes` and validation jobs, blocks on `failure`/`cancelled`, passes on `skipped`; a validation job runs unconditionally. - **D2:** an entry validation job/step exists per complex-input workflow; the release gate checks both directions, strips `+buildmetadata`, and skips on smoke; the publisher rejects a dispatch from a ref other than `main` or `develop`. -- **D3:** each run builds one branch, so NBGV classifies `github.ref` directly (no `IGNORE_GITHUB_REF`); the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. +- **D3:** each run builds one branch, so NBGV classifies `github.ref` directly (no `IGNORE_GITHUB_REF`), and the default-branch literal in the gate (`== 'main'`), the `prerelease` expression (`!= 'main'`), and `version.json`'s `publicReleaseRefSpec` all name the repo's actual default branch. - **D4:** `target_commitish` is the NBGV commit id; `prerelease` equals `branch != default`; the release-create step is gated `exists == 'false' || github.event_name == 'workflow_dispatch'` (the step output is the string `'false'`, not a boolean); the asset-delete step is gated identically. A dispatch-only publisher (`releaseTrigger: dispatch-only`) may omit the gate and the exists-check entirely: every run is a dispatch, so the skip leg can never fire and create-or-refresh is unconditional. Record the gate N/A there, not missing. - **D5:** each cross-job transfer artifact has a delete step at its consumer, gated to the consumer's condition, `continue-on-error: true`, looping all ids; **every** upload sets `retention-days: 1`; **no** `.artifacts[].id` blanket delete exists anywhere. - **D6:** the release download uses `pattern:`/`merge-multiple:` (no `artifact-ids:`). Branch-derived config reads `inputs.branch` (a `github.ref_name` in such config is a finding). Artifact names are branch-suffixed. The target set is consistent across the release task and the paths-filter. - **D7:** the publisher concurrency group is ref-independent with `cancel-in-progress: false`. Reusable jobs declare permissions. Boolean `if:` uses both forms. -- **D8/D9:** merge-bot concurrency keys on PR number. The upstream tracker's branch prefix matches the merge-bot's head-ref pairs (wrapper repos). Actions are SHA-pinned. Names/shells/conditionals follow section 2. +- **D8/D9:** merge-bot concurrency keys on PR number. The upstream tracker's branch prefix matches a merge-bot rule (wrapper repos). Actions are SHA-pinned. Names/shells/conditionals follow section 2. **Per-type addenda (apply only the ones present):** -- **Console/executable:** the smoke runtime matrix is a strict non-empty subset of the full matrix. The per-runtime outputs (`publish-<branch>-<runtime>`) are aggregated by `pattern:` + `merge-multiple:` into one `release-asset-<branch>-<target>` and the aggregation job is gated `!smoke`. The per-runtime intermediates rely on the retention backstop (no explicit delete is required for an in-run intermediate). +- **.NET publish:** the smoke runtime set is a strict non-empty subset of the full runtime set. The selected set runs sequentially inside one composite-action job. A non-smoke run uploads one `release-asset-<branch>-dotnet-publish` artifact, while a smoke run skips the archive and upload steps. - **NuGet:** the publish step is gated `if: inputs.push` only (not on an existence check) and uses `--skip-duplicate`. `*.nupkg` push also carries the paired `.snupkg` to the symbol server where symbols are enabled. The `release-asset` zip carries the package(s). - **PyPI:** `publish-pypi` declares `environment: { name: pypi }`. `id-token: write` appears only on that job (absent from the build/PR path). `skip-existing: true` is set on the publish action. The build artifact is deleted after publish. The `pypi` environment has a deployment-branch rule. -- **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`. The leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only, since a plain Docker repo correctly tags off `SemVer2` and records this N/A). The readme/date-badge jobs are gated main-only. The docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`. The buildcache follows D9.4. +- **Docker:** a Docker-only repo's caller passes `expect_release_assets: false`. The leaf reads the external state file for the tag instead of `SemVer2` (wrapper repos only, since a plain Docker repo correctly tags off `SemVer2` and records this N/A). The readme job is gated main-only, both by the caller's branch input and inside the hub-hosted `publish-docker-readme-task.yml` itself. The docker-readme task validates `repositories` XOR `manifest`+`manifest-jq`. The buildcache follows D9.4. +- **Static site deployed to a host:** the generator is pinned by version **and** by a checksum verified before install, declared once across the workflows that install it. The deploy is a dispatch carrying an environment choice, with concurrency keyed on the **environment** and `cancel-in-progress: false`, and production gated to the default branch while any ref may reach a non-production environment. The reusable callee re-asserts the environment name in a job of its own. The upload targets a per-release directory and carries no delete flag at the environment root, and the pointer flip is a separate step. The terminal check asserts the golden-list length floors first, then the environment, then the release id, then the URL contract. Retention is bounded by a declared count and one side is recorded as owning the prune: the deploy asserts it where the credential can observe the destination, and the host owns it where the credential is confined write-only (D5.6). ### 5B. End-to-End Trace Scenarios (No Execution, Deterministic from the YAML) @@ -250,6 +257,8 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the | S9 | re-run publish, version unchanged | release-create **skipped**, `release-asset-*` delete **skipped**; NuGet/PyPI pushes no-op (server dedupe); **PyPI build-artifact still deleted** (its publish ran); **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 `<prefix>-<branch>` PR -> merge-bot auto-merges -> 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, D7.1 | +| S13 | deploy dispatch of a production environment from a non-default ref | **fails fast**, before anything is installed or written | D2.1, D2.3 | ### 5C. Live Probe (Where Warranted) @@ -257,6 +266,7 @@ For each *applicable* scenario, evaluate every job's `if:`/`needs:` against the - Drive a `smoke: true` push-probe of the build task for **both** the default and a non-default branch and assert the version classification (clean vs prerelease) and that the gate passes, **without publishing**. *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 inspect the `Compute PyPI version step` log and the built `dist/*` filenames for `.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, and the dispatch that trips it is the maintainer's to run.** 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 deployment count against the production environment unchanged. 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.** An agent harness may refuse to dispatch a production deploy, which 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"). The same split applies to any probe that acts on the deploy host directly, an outbound SSH exercising a forced command among them. ### Assessment @@ -271,20 +281,17 @@ The workflow is **operational** iff every *applicable* 5A item passes and every Each type maps the *applicable* S-scenarios onto its targets. The differences are which leaf tasks exist and what each produces, which 5A addenda apply, and which scenarios are N/A. Walking these is the self-check that the contract holds for each shape. -- **Console / executable application.** Target produces `release-asset-<branch>-executable` (a 7z archive, `Console.7z`) by building a per-runtime `dotnet publish` matrix, then an aggregation job downloads the per-runtime `publish-<branch>-<runtime>` intermediates (`pattern:` + `merge-multiple:`), zips them, and uploads the single asset. Smoke builds a strict subset of runtimes. The per-runtime upload **and** the aggregation job are both gated `!smoke`, so smoke uploads nothing. The per-runtime intermediates rely on `retention-days: 1` (no explicit delete). Test: S1 with a console change smoke-builds the subset and uploads nothing; S7 attaches the 7z, `prerelease=true` on the non-default leg and `prerelease=false` on the default leg (GitHub auto-marks the stable default release "Latest", and the workflow does not set it). -- **NuGet library.** The leaf both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset-<branch>-nugetlibrary`. 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 asset zip 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 library.** The leaf builds + uploads `pypilibrary-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, **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. 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 image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-<branch>`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); no `release-asset-*`, so a Docker-only repo's caller passes `expect_release_assets: false`; the readme (`peter-evans/dockerhub-description`, `DOCKER_HUB_ACCESS_TOKEN`) and date-badge jobs run **only** when the default branch publishes; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` 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). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates readme/badge. Non-default pushes the develop tag (amd64 only). S9 still re-pushes. S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. -- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset-<branch>-library` (`retention-days: 1`, upload gated `!smoke`, mirroring the nugetlibrary leaf's shape). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5). `version.json` + the NBGV `get-version` step 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 *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the nuget/pypi/docker/executable 5A addenda and their scenario clauses. -- **Source-only / no build.** There is no `build-release-task.yml` (its `appliesTo` excludes source-only) and no package/image leaf, so nothing is edited down. The release is a standalone dispatch-only `publish-release.yml` that inlines NBGV for the tag and `action-gh-release` for the release: tag + source zip + README + LICENSE, with no reusable release task and no asset download. With no target the paths-filter matches nothing, so `smoke-build` is **structurally always skipped**, and validation is carried solely by the (replaced, non-.NET) validation job that the aggregator and `smoke-build`'s own `needs:` must both point at (D1.2; or drop the never-running `smoke-build` job). NBGV and `version.json` are still retained (they own the tag). Its publish job gates on the repo's reusable validation task (`needs:` the same `workflow_call` job the PR workflow runs), so a dispatch cannot release a ref that fails validation. Applicable scenarios: S1 (validation only), S5/S6 (publish gating), S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification gate). N/A: S2-S4 (assume a smoke-built target), the artifact-lifecycle and registry clauses of S7/S9, the D5/D6 artifact items, and all per-type 5A addenda, all recorded N/A, not failed. -- **Operational (workflow model, not a build target).** A `workflowModel: operational` repo layers a direct-commit `develop` onto the **source-only** release shape (above). Two workflows: (1) a **lint/validation** PR workflow feeding the required `Check pull request workflow status job`, built from the generic linters (editorconfig/EOL, markdownlint, cspell, actionlint) plus a domain validator (Home Assistant `hass --script check_config`, `esphome config`, a firmware build), with **no unit tests**; its triggers differ from the `release` model: `push` to `develop` (advisory feedback on direct commits) plus `pull_request` to `main` (the enforced promotion gate) plus `workflow_dispatch`. (2) the standard **source-only publisher** on `workflow_dispatch` only (`releaseTrigger: dispatch-only`): NBGV + `version.json` own the tag, and a manual dispatch cuts a GitHub release (tag + source zip + README + LICENSE, via the standalone publisher's inlined `action-gh-release`). Applicable scenarios: S1 (validation) on the promotion PR, plus the source-only release set: S7 (tag-only release), S8 (dispatch guard), S9 (no-op republish), S10 (classification). N/A: the auto-publish paths (S5/S6 bot-push and schedule, neither of which an operational repo has) and every build/registry scenario. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. - -<!-- Workflow --> - -[workflows]: ./.github/workflows/ +- **.NET publish.** The target runs a sequential `dotnet publish` runtime loop inside one composite-action job. Configuration is Release on `main` and Debug otherwise. A non-smoke run builds the full runtime set, zips the combined output, and uploads `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 two-runtime subset and skips the zip and upload steps, so it uploads nothing. S1 smoke-builds that subset after a .NET project change. 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 both pushes (`dotnet nuget push *.nupkg --skip-duplicate`, gated `if: push` only) and uploads `release-asset-<branch>-nuget`. 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 asset zip 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, **unconditionally on consume**, so on S9 it is deleted even though the `release-asset-*` delete is skipped. The version is `AssemblyFileVersion` with `.dev0` appended on `develop` only, and must stay `--pre`-selectable and sorted above the default release. 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 image.** The leaf pushes the default branch multi-arch (amd64+arm64) and any other branch `amd64`-only, with a per-branch registry buildcache (`buildcache-<branch>`; a multi-image repo adds a per-image tag) (`cache-to` only the built branch and only on push, `cache-from` both branches); 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`; the docker-readme task validates `repositories` XOR `manifest`+`manifest-jq` 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). A **wrapper** repo tracks an upstream release: the upstream tracker writes a `name -> version` state file and the merge-bot auto-merges the bump PR (S11), and the leaf MUST read that file for the immutable tag instead of `SemVer2` (the tracker ships without this consumer wiring). Test: S7 default leg pushes `latest` + the version tag and updates the readme. Non-default pushes the develop tag (amd64 only). S9 still re-pushes. S11 ships the bumped upstream version next publish. 5C Docker probe needs `DOCKER_HUB_*` secrets and same-repo (not fork) runs. +- **Data / asset library.** A single new leaf: validate -> zip -> upload `release-asset-<branch>-library` (`retention-days: 1`, upload gated `!smoke`, mirroring the NuGet leaf's shape). Because no such leaf ships, you **add a target** (D6.4): a new `enable_library` input + `build-library` job + `github-release` `needs:` entry in the release task, and a `library` paths-filter entry + `changes` output + `smoke-build` enable-forward in the PR workflow (without it, D1.1 never smoke-builds the library). Keep `expect_release_assets: true` (it has a file target, unlike Docker). The .NET `unit-test` job is replaced by a type-appropriate validator with the aggregator **and** `smoke-build` both re-pointed to it (D1.2/D1.5). `version.json` + the NBGV `get-version` step 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 *scheduled* re-run release-create + asset-delete skip (the existing zip is untouched, no registry push), while a `workflow_dispatch` re-run **refreshes** the release and re-runs the asset-delete (the asset is re-uploaded then re-deleted). N/A: the NuGet, PyPI, Docker, and .NET publish 5A addenda and their scenario clauses. +- **Source-only / no build.** There is no package/image build leaf. A repo may own the reusable release task or call its hub-hosted copy. The dispatch-only `publish-release.yml` reaches the reusable plan, validation, and release tasks. Its publish job passes `github: true`, every `enable_*` input as false, and `expect_release_assets: false`. This produces tag + source zip + README + LICENSE with no asset download. With no target, the paths-filter matches nothing. A retained `smoke-build` job is therefore **structurally always skipped**. The repo may instead drop that never-running job. Validation remains the replaced, non-.NET validation job. The aggregator and any retained `smoke-build` job must depend on it (D1.2). NBGV and `version.json` own the tag. The publish job depends on the same reusable validation task that the PR workflow runs. This prevents a dispatch from releasing a ref that fails validation. Applicable scenarios are S1 (validation only), S7, S8, S9, and S10. S7 covers the tag-only release, S8 the dispatch guard, S9 no-op republish, and S10 the classification gate. S2-S6, D5/D6 artifact items, and all per-type 5A addenda are N/A. The artifact-lifecycle and registry clauses of S7/S9 are also N/A, not failed. +- **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: a dispatch-only `publish-release.yml` where NBGV and `version.json` own the tag, producing tag + source zip + README + LICENSE. 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`, binding the same `environment:` on the caller's own job so the one crossing secret, `DEPLOY_SSH_PRIVATE_KEY`, resolves from the GitHub Environment store and can be mapped explicitly rather than through `secrets: inherit`, which a cross-repository reusable workflow cannot use. Concurrency is keyed on the environment with `cancel-in-progress: false`, because a cancelled deploy leaves a release uploaded and unflipped. The task re-asserts the environment name 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. 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 a required deploy hook that builds the tree with whatever generator and precompression the site owns, 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 same hook again to prune old releases and to check the running host (D4.6). 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 here, while a credential confined **write-only** can neither delete nor read back, so there 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. Applicable scenarios: S1 (validation), the source-only release set S7/S8/S9/S10, and S12/S13 (the deploy dispatch). N/A: S2-S4, every registry scenario, and D5.1-D5.4 (the pipeline uploads no workflow artifact at all, so D5.6 is what applies in their place), all recorded N/A, not failed. +- **Operational (workflow model, not a build target).** 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** with `releaseTrigger: dispatch-only`. 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 }}` (Section 2). 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. S1 applies to every PR, including promotion and `develop` PRs. The source-only S7, S8, S9, and S10 scenarios also apply. Bot-push and schedule paths in S5/S6 are N/A, as are every build and registry scenario. See the branch-model note in Section 3 and [GOVERNANCE.md "Branching Model"][governance-branching-model]. <!-- Repo --> -[governance-branching-model]: ./GOVERNANCE.md#branching-model [codestyle]: ./CODESTYLE.md -[repo-config-readme]: ./repo-config/README.md +[governance-branching-model]: ./GOVERNANCE.md#branching-model +[governance-operational-repositories]: ./GOVERNANCE.md#operational-repositories diff --git a/cspell.json b/cspell.json index 3c40073..76df2f5 100644 --- a/cspell.json +++ b/cspell.json @@ -3,6 +3,7 @@ "language": "en-US", "ignorePaths": [ ".git/**", + "reports/*/**", "content/**", "static/**", "themes/**", diff --git a/repo-config/README.md b/repo-config/README.md deleted file mode 100644 index 69c72e1..0000000 --- a/repo-config/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# repo-config - -Repository and branch configuration held as committed files, kept out of `.github/` (which holds the GitHub-consumed configuration: workflows, Dependabot). - -- `main.json` plus one `develop` variant: the branch rulesets as the managed part of the writable API subset (`name`, `target`, `enforcement`, `conditions`, `rules`). The `develop` payload is `develop.json` (`release` repos) or `operational/develop.json` (`operational` repos). These are the canonical expected payloads that the self-audit (`AUDIT.md`) validates the live rulesets against, by asserting rule presence, merge methods and required checks rather than diffing bytes, so a ruleset GitHub has normalized does not read as drift. `bypass_actors` is writable and deliberately unmanaged, so no payload declares one and nothing asserts it: who may bypass a ruleset is a human decision taken in the UI, which the configure script preserves on `apply` and reports without asserting on `check`. -- This repo is `release`, so it carries `develop.json` and **not** `operational/develop.json`. The operational variant takes direct signed pushes with no PR gate, which is the wrong ruleset here. See "Rulesets" below. -- `configure.sh`: **hosted in the hub and run from a hub checkout, not carried here**, per the Hub-Hosted Tooling rule in `GOVERNANCE.md`. The payloads above are what this repo is audited against and stay with it. The script holds nothing per-repo and is one copy for the fleet, so a carried copy would only be current until the next fix to it. Name the target repository explicitly, since the command defaults to whichever repository the shell is sitting in. Two modes over the GitHub API. `repo-config/configure.sh apply ptr727/Blog release` creates-or-updates the settings, the Dependabot security features, and the rulesets idempotently. `repo-config/configure.sh check ptr727/Blog release` is the read-only inverse and exits non-zero on any drift, with the assertions driven by the committed payloads rather than a byte diff, so a GitHub-normalized stored ruleset does not false-positive. - -## Rulesets - -Two workflow models share `main.json` but differ on `develop` (registry `workflowModel`, default `release`): - -- **`release`** (`develop.json`): `develop` requires squash merges with linear history and a PR, the feature-branch pipeline. -- **`operational`** (`operational/develop.json`): `develop` takes **direct signed pushes**, carrying only `deletion`, `non_fast_forward`, and `required_signatures`; no PR, no status-check, no Copilot-on-push. CI runs on the push as advisory feedback. This is for live-service config repos that edit `develop` directly and promote a known-good snapshot to `main` via an occasional PR (see [GOVERNANCE.md "Branching Model"][governance-branching-model]). - -`main` (both models) requires merge-commit merges (no linear-history rule), signed commits, a passing `Check pull request workflow status job`, resolved review threads, and Copilot review, and blocks force-pushes and deletion, so a `develop -> main` promotion is always gated even when `develop` takes direct commits. Every ruleset intentionally leaves "Require branches to be up to date before merging" **off**, per [GOVERNANCE.md "Branching Model"][governance-branching-model]. - -The result is **exactly two rulesets named `develop` and `main`**, and the names are load-bearing (`GOVERNANCE.md` and the workflows reference them). Only the `develop` *content* varies by model. The required check binds by name and only turns green after the repo's PR workflow runs once. - -## Secrets - -Publish credentials required per mechanism are enumerated in `spec/secrets.json`. A repo needs only the mechanisms its own publish targets use, so a source-only repo needs none of the publish credentials below. NuGet and PyPI use keyless OIDC Trusted Publishing (no stored key; the publish job needs `id-token: write`, and PyPI additionally an `environment: pypi` gate). Docker Hub has no OIDC equivalent and uses a stored `DOCKER_HUB_USERNAME` + `DOCKER_HUB_ACCESS_TOKEN` in both the Actions and Dependabot secret stores. Codegen and merge-bot repos add a GitHub App (`CODEGEN_APP_CLIENT_ID` + `CODEGEN_APP_PRIVATE_KEY` in both stores; the app must be installed, not just created). App-token call sites use `client-id`, never the deprecated `app-id`. - -## Repo Settings - -The fleet-standard general settings live in [`settings.json`][settings-json] and are applied idempotently by `repo-config/configure.sh apply ptr727/Blog release` alongside the rulesets (`gh api PATCH /repos/{owner}/{repo}`). The two settings that depend on per-repo state, `has_discussions` (visibility) and `default_branch` (main-must-exist), are computed by the script, not stored in the file. That same `apply` also enables Dependabot vulnerability alerts and automated security updates, fleet policy applied via the API rather than a `settings.json` key. `repo-config/configure.sh check ptr727/Blog release` validates all of these and exits non-zero on drift. - -- **Default branch `main`** (the script sets it only when a `main` branch exists, never pointing the default at a missing branch). -- **Merge methods**: `Allow merge commits` and `Allow squash merging` on, **rebase off**, and each branch ruleset then picks its method (merge on `main`, squash on `develop`). -- **Auto-merge on** (the merge-bot needs it) and **`Always suggest updating pull request branches` on**. -- **`Automatically delete head branches` is OFF, deliberately.** With it on, a `develop -> main` promotion (whose PR head is `develop`) would delete `develop`. There is no per-branch exemption, so the repo-wide toggle stays off to protect `develop`. **The CLI has the same trap: never `gh pr merge --delete-branch` a promotion PR whose head is `develop`**, since the explicit flag deletes `develop` regardless of this setting (see [GOVERNANCE.md "Branching Model"][governance-branching-model]). -- **Wikis and Projects off. Discussions on public repos only** (off on private). **Sponsorships off**, since the button is driven by `.github/FUNDING.yml` rather than a REST toggle, and the fleet ships none. -- **Actions / General**: allow GitHub Actions to create and approve pull requests (for the bots). - -<!-- Repo --> - -[governance-branching-model]: ../GOVERNANCE.md#branching-model -[settings-json]: ./settings.json diff --git a/repo-config/develop.json b/repo-config/develop.json deleted file mode 100644 index 16c89f4..0000000 --- a/repo-config/develop.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "conditions": { - "ref_name": { - "exclude": [], - "include": [ - "refs/heads/develop" - ] - } - }, - "enforcement": "active", - "name": "develop", - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "required_linear_history" - }, - { - "type": "required_signatures" - }, - { - "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, - "required_reviewers": [] - }, - "type": "pull_request" - }, - { - "parameters": { - "do_not_enforce_on_create": false, - "required_status_checks": [ - { - "context": "Check pull request workflow status job", - "integration_id": 15368 - } - ], - "strict_required_status_checks_policy": false - }, - "type": "required_status_checks" - }, - { - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - }, - "type": "copilot_code_review" - } - ], - "target": "branch" -} diff --git a/repo-config/main.json b/repo-config/main.json deleted file mode 100644 index a99ed24..0000000 --- a/repo-config/main.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "conditions": { - "ref_name": { - "exclude": [], - "include": [ - "refs/heads/main" - ] - } - }, - "enforcement": "active", - "name": "main", - "rules": [ - { - "type": "deletion" - }, - { - "type": "non_fast_forward" - }, - { - "type": "required_signatures" - }, - { - "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, - "required_reviewers": [] - }, - "type": "pull_request" - }, - { - "parameters": { - "do_not_enforce_on_create": false, - "required_status_checks": [ - { - "context": "Check pull request workflow status job", - "integration_id": 15368 - } - ], - "strict_required_status_checks_policy": false - }, - "type": "required_status_checks" - }, - { - "parameters": { - "review_draft_pull_requests": true, - "review_on_push": true - }, - "type": "copilot_code_review" - } - ], - "target": "branch" -} diff --git a/repo-config/settings.json b/repo-config/settings.json deleted file mode 100644 index f0f3a2f..0000000 --- a/repo-config/settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "has_wiki": false, - "has_projects": false, - "allow_merge_commit": true, - "allow_squash_merge": true, - "allow_rebase_merge": false, - "allow_auto_merge": true, - "allow_update_branch": true, - "delete_branch_on_merge": false -} diff --git a/version.json b/version.json index f37bf94..a4f2c0b 100644 --- a/version.json +++ b/version.json @@ -3,5 +3,8 @@ "version": "1.0", "publicReleaseRefSpec": [ "^refs/heads/main$" - ] + ], + "nugetPackageVersion": { + "semVer": 2 + } }