From 107a481a01742fd6929bcc1cc390489090844ef4 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 14:58:10 -0700 Subject: [PATCH 01/18] Read an Uppercase Markdown Suffix as Markdown at Every Prose Gate Branch (#3096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `check_file` and `comment_wrap_findings` in `.github/actions/prose-gate/prose_lint.py` compared the suffix to `.md` case-sensitively at each Markdown-only branch, while `syntax_for` lowercases it. A file named `NOTES.MD` therefore had its comments extracted as Markdown and then skipped `dead-path`, `sentence-split`, `sentence-length`, `semicolon`, `dash`, and quote-stripping, and drew `comment-case` on an unpunctuated HTML comment `NOTES.md` is exempt from. A new `is_markdown(path)` reads the suffix in any case, and every Markdown suffix test in the file now calls it, the two broken functions and the three sites that already lowercased (`is_instruction_text`, `comment_added_findings`, `issue_ref_findings`), so no later site can drift back to a case-sensitive comparison. The now-redundant `elif path.suffix.lower() != ".md"` in the `sentence-length` branch becomes a plain `else`. ## Tests - `TestAnUppercaseMarkdownSuffixIsMarkdown` runs a constructed case per Markdown-only branch against both `notes.md` and `NOTES.MD`, including `` drawing no `comment-case` and a quoted duplicate inside an HTML comment that quote-stripping exempts. - `TestDeadPath.test_an_uppercase_markdown_suffix_is_judged_for_dead_paths` pins the `dead-path` branch. - With the fix reverted, 9 of the new subtests fail. The full `test_prose_lint` suite passes with it. Closes on promotion: #2804 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Markdown lint checks now apply consistently to files with uppercase or mixed-case `.md` extensions. --------- Co-authored-by: Claude Opus 5.5 --- .github/actions/prose-gate/prose_lint.py | 37 +++++++++++++----------- tests/test_prose_lint.py | 31 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 63677996..f86eae3a 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -852,13 +852,18 @@ def is_operations_runbook(path: Path, root: Path | None) -> bool: ) +def is_markdown(path: Path) -> bool: + """Whether the file is Markdown, its suffix read in any case as `syntax_for` reads it.""" + return path.suffix.lower() == ".md" + + def is_instruction_text(path: Path, root: Path | None) -> bool: """Whether this Markdown file is instruction text rather than the repository's own narrative. A skill is matched by its root rather than by its name, since the source tree and each generated distribution carry the same file under a root of its own. """ - if path.suffix.lower() != ".md": + if not is_markdown(path): return False key = repo_key(path, root) if root is not None else rel(path) if key in INSTRUCTION_DOCS or key.startswith(SKILL_ROOTS): @@ -2330,6 +2335,7 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ body and the comment line after it is not read as that line's continuation. """ comments, kept, configured = prose_comment_candidates(path, raw, lines) + markdown = is_markdown(path) out: list[tuple[int, str, str]] = [] prev_body = "" @@ -2344,7 +2350,7 @@ def comment_wrap_findings(path: Path, raw: str, lines: list[str]) -> list[tuple[ # A tool matches each one verbatim, so rewriting it breaks whatever reads it. # Group headers, the ToC-omit directive, and the agent-safety markers are the cases. # A comment that does punctuate a sentence is prose and is judged as prose. - if path.suffix == ".md" and not SENT_END.search(body): + if markdown and not SENT_END.search(body): prev_body = "" continue body = ENUM_PREFIX.sub("", body) @@ -2487,7 +2493,7 @@ def comment_added_findings(path: Path, raw: str) -> list[tuple[int, str, str]]: label is the remedy, since a directive whose written reason is punctuated as a sentence reads as one, and `TOOL_DIRECTIVE` cannot take it without encoding that tool's grammar. """ - if path.suffix.lower() == ".md" or syntax_for(path) is None: + if is_markdown(path) or syntax_for(path) is None: return [] spec = POWERSHELL_DOC if path.suffix.lower() in {".ps1", ".psm1"} else None bodies = comment_bodies(path, raw, spec) @@ -2649,7 +2655,7 @@ def issue_ref_findings( out: list[tuple[int, str, str]] = [] spans: dict[int, list[str]] = {} links: dict[int, list[str]] = {} - if path.suffix.lower() == ".md": + if is_markdown(path): if not is_instruction_text(path, root): return out fenced = fenced_lines(lines) @@ -3069,11 +3075,12 @@ def check_file( if raw != decoded and {"charset", "charset-unknown"} & rules: out.extend(f for f in charset_findings(1, "\ufeff") if f[1] in rules) lines = raw.split("\n") + markdown = is_markdown(path) # Only Markdown describes what the repo holds, so only Markdown is judged for dead paths. # The file's own directory anchors a relative link, the repository root anchors the rest. # No git means no deletion history to key on, so the rule stands down rather than guess. dead_root: Path | None = None - if "dead-path" in rules and path.suffix == ".md": + if "dead-path" in rules and markdown: found = str(root) if root else repo_root(path) if found: dead_root = Path(found) @@ -3094,13 +3101,13 @@ def check_file( # A source line holds identifiers and literals, and an attribute value may legally repeat. # Reading it rejects correct work, `class="gallery gallery-cols-1"` being the reported case. comments: dict[int, list[str]] = {} - if {"spelling", "dupword"} & rules and path.suffix != ".md": + if {"spelling", "dupword"} & rules and not markdown: for ln, text, _leading, _raw in extracted_comments(path, lines): comments.setdefault(ln, []).append(text) if "sentence-length" in rules: - if path.suffix == ".md": + if markdown: out.extend(sentence_length_findings(lines, allowed)) - elif path.suffix.lower() != ".md": + else: out.extend(comment_sentence_findings(path, raw, lines, allowed)) if path.suffix.lower() == ".py": out.extend(docstring_sentence_findings(raw, lines, allowed)) @@ -3127,11 +3134,11 @@ def check_file( out.extend(dead_path_findings(dead_root, path.resolve().parent, i, line)) txt = strip_inline_code(line) - prose = strip_quoted(txt) if path.suffix == ".md" else txt + prose = strip_quoted(txt) if markdown else txt # Both prose rules are Markdown-only until a comment can be told from code. # A shell script carries 78 statement separators that are not prose at all. - if path.suffix == ".md": + if markdown: unquoted = QUOTE_PREFIX.sub(lambda m: " " * len(m.group()), prose, count=1) if "semicolon" in rules: for span in list_spans(unquoted): @@ -3165,11 +3172,7 @@ def check_file( if "dupword" in rules: # Each comment on the line is judged on its own rather than joined with its neighbors. # Joining them would read the second's opening word as a repeat of the first's last. - texts = ( - [prose] - if path.suffix == ".md" - else [strip_inline_code(c) for c in comments.get(i, [])] - ) + texts = [prose] if markdown else [strip_inline_code(c) for c in comments.get(i, [])] for text in texts: for m in DUPWORD.finditer(text): if m.group(0).lower() in DUP_ALLOW: @@ -3177,12 +3180,12 @@ def check_file( out.append((i, "dupword", f"duplicated word '{m.group(1)}'")) if "spelling" in rules: - texts = [prose] if path.suffix == ".md" else comments.get(i, []) + texts = [prose] if markdown else comments.get(i, []) for m in BRITISH_RE.finditer(strip_inline_code(" ".join(texts))): found = m.group(0) out.append((i, "spelling", f"British spelling '{found}' -> '{us_form(found)}'")) - if "sentence-split" in rules and path.suffix == ".md": + if "sentence-split" in rules and markdown: stripped = txt.strip() is_prose = ( stripped diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index f9bbeb2c..71e97b67 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -3207,6 +3207,31 @@ def flag(self, text: str) -> list[str]: ] +class TestAnUppercaseMarkdownSuffixIsMarkdown(BaitCase): + """Every Markdown-only branch reads the suffix case-insensitively, as `syntax_for` does.""" + + def test_each_markdown_only_branch_reads_an_uppercase_suffix_alike(self) -> None: + over = " ".join(["word"] * (prose_lint.SENTENCE_WORD_CAP + 1)) + "." + british = next(iter(prose_lint.BRITISH)) + for rules, text, expected in ( + ({"comment-wrap", "comment-case"}, "\n", []), + ( + {"sentence-split"}, + "A sentence that keeps\ngoing onto the next line.\n", + ["sentence-split"], + ), + ({"sentence-length"}, over + "\n", ["sentence-length"]), + ({"semicolon"}, f"{SPLICE_BAIT}.\n", ["semicolon"]), + ({"dupword"}, f'\n', []), + ({"dash"}, "It runs - and it gates.\n", ["dash"]), + ({"dupword"}, f"One {DUP} here.\n", ["dupword"]), + ({"spelling"}, f"One {british} here.\n", ["spelling"]), + ): + for name in ("notes.md", "NOTES.MD"): + with self.subTest(rules=sorted(rules), text=text.strip(), name=name): + self.assertEqual(expected, self.kinds(text, rules, name=name)) + + class TestChangedLines(unittest.TestCase): """The `--diff` scope, which decides which findings a CI run is allowed to report. @@ -4772,6 +4797,12 @@ def test_a_relative_mention_anchors_on_the_files_own_directory(self) -> None: ) self.assertEqual([], self.kinds(root, "See [it](../scripts/kept.py).\n", rel="docs/D.md")) + def test_an_uppercase_markdown_suffix_is_judged_for_dead_paths(self) -> None: + root = self.repo() + self.assertIn( + "dead-path", self.kinds(root, "Run `scripts/gone.py` to apply.\n", rel="DOC.MD") + ) + def test_a_ref_or_directory_shaped_span_is_not_a_candidate(self) -> None: """`origin/develop` styles a ref and `scripts/` a layout, and neither asserts a file.""" root = self.repo() From 356c6aa9be6048969d57b5c30208777c1ef492b7 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 15:25:46 -0700 Subject: [PATCH 02/18] Track Composite Action Pins in Dependabot (#3098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace `directory: "/"` with `directories: ["/", "/.github/actions/*"]` on both `github-actions` entries (main and develop) in `.github/dependabot.yml`, so Dependabot scans each composite action under `.github/actions/` as well as `.github/workflows`. - Make the same change in `catalog/snippets/configs/dependabot.yml`, with a note that a repository carrying its own composite actions under `.github/actions` keeps the second glob. GitHub's Dependabot options reference states that `/` for GitHub Actions searches `/.github/workflows` and a root `action.yml`, and that `directories` supports the `*` wildcard. The lagging composite-action pins are left for Dependabot to raise once this lands. The snippet adds two comment lines, so this pull request carries the `comments` label. Closes on promotion: #2852 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .github/dependabot.yml | 8 ++++++-- catalog/snippets/configs/dependabot.yml | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 80b9d3aa..6f2fbe56 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,7 +9,9 @@ updates: - package-ecosystem: "github-actions" target-branch: "main" - directory: "/" + directories: + - "/" + - "/.github/actions/*" schedule: interval: "daily" groups: @@ -19,7 +21,9 @@ updates: - package-ecosystem: "github-actions" target-branch: "develop" - directory: "/" + directories: + - "/" + - "/.github/actions/*" schedule: interval: "daily" groups: diff --git a/catalog/snippets/configs/dependabot.yml b/catalog/snippets/configs/dependabot.yml index e2b8de31..e60ae9da 100644 --- a/catalog/snippets/configs/dependabot.yml +++ b/catalog/snippets/configs/dependabot.yml @@ -43,9 +43,13 @@ updates: - "husky" - "dotnet-outdated-tool" + # The "/" directory covers only .github/workflows and a root action.yml. + # A repo carrying its own composite actions under .github/actions, such as a validate hook, keeps the second glob so their pins stay current. - package-ecosystem: "github-actions" target-branch: "main" - directory: "/" + directories: + - "/" + - "/.github/actions/*" schedule: interval: "daily" groups: @@ -55,7 +59,9 @@ updates: - package-ecosystem: "github-actions" target-branch: "develop" - directory: "/" + directories: + - "/" + - "/.github/actions/*" schedule: interval: "daily" groups: From 579a4190f2c9a0dbb46e61c99084a4f01c570e5d Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 16:50:08 -0700 Subject: [PATCH 03/18] Prefer the Venv's Ruff and Pyright for a Declared Pip Directory (#3100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - In `.github/workflows/validate-task.yml`, a declared pip directory now runs the ruff its venv installs, as `.venv/bin/python -m ruff`, in both the "Lint Python step" and the "Check Python formatting step". It falls back to `uvx ruff@latest` only where the venv lacks ruff. - The pyright arm of the "Type check Python step" does the same: `"$venv_python" -m pyright --pythonpath "$venv_python"` where the venv installs pyright, else `uvx pyright@latest --pythonpath "$venv_python"`. This matches the existing mypy branch's `find_spec` test. - A uv directory, a lint-only directory, and an undeclared root keep their current calls. The "Sync Python dependencies step" already builds the venv for every declared pip directory before the lint steps run. - `tests/test_release_guards.py`: the stub venv interpreter now answers `find_spec` per module. A new test covers both ruff steps across declared pip with and without venv ruff, undeclared pip, lint-only, and uv. Two new type-check cases cover the pyright arm. - Docs: the python-codestyle skill (SKILL.md and `references/profiles.md`), `docs/reusable-workflows.md`, and the VS Code tasks snippet's comment now state the precedence. Three long sentences on the edited `docs/reusable-workflows.md` line were split. The workflow and test changes add or edit comment lines, so this pull request carries the `comments` label. ## Findings from the local passes The findings listed for the first two pushes are fixed in 2e8fb468, 4129684a, 769ab968, b31ea6c7, and 1debbcc8. The Dependabot snippet's missing `pip` ecosystem is pre-existing and filed as #3101. The pass over 1debbcc8 raised three more. Each has an outcome here: 1. `style`, declined. The pyright probe in the "Type check Python step" repeats the mypy probe with another module name, and the two could share one `sys.argv` probe. No existing helper does this job, and the two ruff steps are separate `run:` scripts that cannot share one. Merging the pair would rewrite the pre-existing mypy probe for no behavior change. 2. `style`, declined. The workflow comment "It falls back to uvx only where the venv holds none." states the fallback condition. #2850 asks for the precedence to be documented, and the venv-first, uvx-second order is that precedence. 3. Declined on evidence. The reviewer counted 26 whitespace tokens in the pre-commit header's first new sentence, or 24 with its code span as one word. `prose_lint.py --check sentence-length` over the branch diff reports no violation at 1debbcc8. Closes on promotion: #2850 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Chores** * Python linting and type checking now use tools installed in declared pip environments when available, with fallback tools used otherwise. UV-managed projects continue to use their locked tools. * Editor guidance now notes that Pyright versions may differ between the editor and command-line or CI checks. * **Documentation** * Updated Python setup guidance to clarify which tool versions are used for declared and undeclared directories. * **Tests** * Expanded coverage for tool selection across pip and UV project setups. --------- Co-authored-by: Claude Opus 5.5 --- .agents/skills/python-codestyle/SKILL.md | 19 ++-- .../python-codestyle/references/profiles.md | 12 +-- .../.source-digests/python-codestyle | 2 +- .../skills/python-codestyle/SKILL.md | 19 ++-- .../python-codestyle/references/profiles.md | 12 +-- .github/skills/python-codestyle/SKILL.md | 19 ++-- .../python-codestyle/references/profiles.md | 12 +-- .github/workflows/validate-task.yml | 15 ++- .../snippets/configs/vscode-tasks-python.json | 4 +- .../pre-commit/.pre-commit-config.yaml | 3 + docs/reusable-workflows.md | 2 +- tests/test_release_guards.py | 99 +++++++++++++++++-- 12 files changed, 172 insertions(+), 46 deletions(-) diff --git a/.agents/skills/python-codestyle/SKILL.md b/.agents/skills/python-codestyle/SKILL.md index c03ad315..16a69b96 100644 --- a/.agents/skills/python-codestyle/SKILL.md +++ b/.agents/skills/python-codestyle/SKILL.md @@ -64,8 +64,10 @@ default baseline on first-party code (a repo may instead run mypy with its stric 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 where CI runs pyright, the -editor and the CLI/CI (`uv run pyright` in the uv form, `uvx pyright@latest` in the pip form, -pointed at the environment in a declared directory) run the same engine and never disagree. +editor and the CLI/CI run the same engine. A pyright the requirements pin can lag the one Pylance +bundles, though. The CLI/CI form is `uv run pyright` in the uv form. In the pip form a declared +directory runs the environment's own pyright, or `uvx pyright@latest` pointed at the environment +where it installs none. 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 @@ -124,6 +126,7 @@ carries a `[project]` table it then installs the directory itself as editable, w uv venv --clear # creates .venv, replacing an existing one uv pip install -r requirements.txt -r requirements-dev.txt # one -r per requirements*.txt uv pip install -e . # only where pyproject.toml has a [project] table +# in a declared directory whose .venv installs ruff, .venv/bin/python -m ruff replaces uvx ruff@latest uvx ruff@latest format # auto-format uvx ruff@latest check --fix # auto-fix lint uvx ruff@latest check # verify lint clean @@ -134,10 +137,14 @@ uvx ruff@latest format --check # verify format clean Its type checker runs against that environment. mypy runs as `.venv/bin/python -m mypy` where the environment installs it, and otherwise as `uvx mypy@latest --python-executable .venv/bin/python`, adding `--python-version` with the environment's version unless the mypy config pins one. pyright -runs as `uvx pyright@latest --pythonpath .venv/bin/python`. Where the config sits in the project -directory, CI runs the checker there with no path, as in the uv form. A declared subdirectory with -no config of its own uses the repository root's instead. CI then runs the checker from the root -with the directory as its path and the interpreter as `/.venv/bin/python`. Run it the same +runs as `.venv/bin/python -m pyright --pythonpath .venv/bin/python` where the environment installs +it, and otherwise as `uvx pyright@latest --pythonpath .venv/bin/python`. ruff follows the same +precedence, as `.venv/bin/python -m ruff` where the environment installs it, in place of the +`uvx ruff@latest` above. In a directory the validator's `python-directories` input declares, a tool +the requirements pin is then the version CI runs. Where the config +sits in the project directory, CI runs the checker there with no path, as in the uv form. A +declared subdirectory with no config of its own uses the repository root's instead. CI then runs +the checker from the root with the directory as its path and the interpreter as `/.venv/bin/python`. Run it the same way, as in `/.venv/bin/python -m mypy `. On Windows each interpreter path ends in `.venv\Scripts\python.exe` instead. Those ruff commands and that type checker are the pip form's clean-compile, run with pytest before committing. diff --git a/.agents/skills/python-codestyle/references/profiles.md b/.agents/skills/python-codestyle/references/profiles.md index 03dc74e3..fe3482ac 100644 --- a/.agents/skills/python-codestyle/references/profiles.md +++ b/.agents/skills/python-codestyle/references/profiles.md @@ -41,12 +41,12 @@ configuration alone marks the lint-only one: `[project.optional-dependencies]` extra. The pip form is a `pyproject.toml` beside a `requirements*.txt`, installed with pip, whether or not it carries a `[project]` table. A committed `uv.lock` makes a directory the uv form even where a `requirements*.txt` sits beside it. - In the pip form CI builds the environment as `SKILL.md` "Local development loop" shows, runs - pytest from it as `.venv/bin/python -m pytest`, and runs ruff through `uvx`. In a directory - declared in the hub validator's `python-directories` input it runs the type checker through - `uvx` pointed at that environment, or runs mypy from the environment where it is installed - there, while the undeclared default root runs a bare `uvx @latest` with nothing - installed. + In the pip form CI builds the environment as `SKILL.md` "Local development loop" shows, and runs + pytest from it as `.venv/bin/python -m pytest`. A directory declared in the hub validator's + `python-directories` input runs ruff and the type checker from that environment where it + installs them. It otherwise runs them through `uvx`, with the checker pointed at that + environment. The undeclared default root runs `uvx ruff@latest` and a bare + `uvx @latest`, with nothing installed. - **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 diff --git a/.claude-plugin/fleet-skills/.source-digests/python-codestyle b/.claude-plugin/fleet-skills/.source-digests/python-codestyle index b6863085..0148f2b3 100644 --- a/.claude-plugin/fleet-skills/.source-digests/python-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/python-codestyle @@ -1 +1 @@ -7bbaad0be74556f3 +78b687fa4e03cda7 diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md index c03ad315..16a69b96 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/SKILL.md @@ -64,8 +64,10 @@ default baseline on first-party code (a repo may instead run mypy with its stric 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 where CI runs pyright, the -editor and the CLI/CI (`uv run pyright` in the uv form, `uvx pyright@latest` in the pip form, -pointed at the environment in a declared directory) run the same engine and never disagree. +editor and the CLI/CI run the same engine. A pyright the requirements pin can lag the one Pylance +bundles, though. The CLI/CI form is `uv run pyright` in the uv form. In the pip form a declared +directory runs the environment's own pyright, or `uvx pyright@latest` pointed at the environment +where it installs none. 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 @@ -124,6 +126,7 @@ carries a `[project]` table it then installs the directory itself as editable, w uv venv --clear # creates .venv, replacing an existing one uv pip install -r requirements.txt -r requirements-dev.txt # one -r per requirements*.txt uv pip install -e . # only where pyproject.toml has a [project] table +# in a declared directory whose .venv installs ruff, .venv/bin/python -m ruff replaces uvx ruff@latest uvx ruff@latest format # auto-format uvx ruff@latest check --fix # auto-fix lint uvx ruff@latest check # verify lint clean @@ -134,10 +137,14 @@ uvx ruff@latest format --check # verify format clean Its type checker runs against that environment. mypy runs as `.venv/bin/python -m mypy` where the environment installs it, and otherwise as `uvx mypy@latest --python-executable .venv/bin/python`, adding `--python-version` with the environment's version unless the mypy config pins one. pyright -runs as `uvx pyright@latest --pythonpath .venv/bin/python`. Where the config sits in the project -directory, CI runs the checker there with no path, as in the uv form. A declared subdirectory with -no config of its own uses the repository root's instead. CI then runs the checker from the root -with the directory as its path and the interpreter as `/.venv/bin/python`. Run it the same +runs as `.venv/bin/python -m pyright --pythonpath .venv/bin/python` where the environment installs +it, and otherwise as `uvx pyright@latest --pythonpath .venv/bin/python`. ruff follows the same +precedence, as `.venv/bin/python -m ruff` where the environment installs it, in place of the +`uvx ruff@latest` above. In a directory the validator's `python-directories` input declares, a tool +the requirements pin is then the version CI runs. Where the config +sits in the project directory, CI runs the checker there with no path, as in the uv form. A +declared subdirectory with no config of its own uses the repository root's instead. CI then runs +the checker from the root with the directory as its path and the interpreter as `/.venv/bin/python`. Run it the same way, as in `/.venv/bin/python -m mypy `. On Windows each interpreter path ends in `.venv\Scripts\python.exe` instead. Those ruff commands and that type checker are the pip form's clean-compile, run with pytest before committing. diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/references/profiles.md b/.claude-plugin/fleet-skills/skills/python-codestyle/references/profiles.md index 03dc74e3..fe3482ac 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/references/profiles.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/references/profiles.md @@ -41,12 +41,12 @@ configuration alone marks the lint-only one: `[project.optional-dependencies]` extra. The pip form is a `pyproject.toml` beside a `requirements*.txt`, installed with pip, whether or not it carries a `[project]` table. A committed `uv.lock` makes a directory the uv form even where a `requirements*.txt` sits beside it. - In the pip form CI builds the environment as `SKILL.md` "Local development loop" shows, runs - pytest from it as `.venv/bin/python -m pytest`, and runs ruff through `uvx`. In a directory - declared in the hub validator's `python-directories` input it runs the type checker through - `uvx` pointed at that environment, or runs mypy from the environment where it is installed - there, while the undeclared default root runs a bare `uvx @latest` with nothing - installed. + In the pip form CI builds the environment as `SKILL.md` "Local development loop" shows, and runs + pytest from it as `.venv/bin/python -m pytest`. A directory declared in the hub validator's + `python-directories` input runs ruff and the type checker from that environment where it + installs them. It otherwise runs them through `uvx`, with the checker pointed at that + environment. The undeclared default root runs `uvx ruff@latest` and a bare + `uvx @latest`, with nothing installed. - **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 diff --git a/.github/skills/python-codestyle/SKILL.md b/.github/skills/python-codestyle/SKILL.md index c03ad315..16a69b96 100644 --- a/.github/skills/python-codestyle/SKILL.md +++ b/.github/skills/python-codestyle/SKILL.md @@ -64,8 +64,10 @@ default baseline on first-party code (a repo may instead run mypy with its stric 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 where CI runs pyright, the -editor and the CLI/CI (`uv run pyright` in the uv form, `uvx pyright@latest` in the pip form, -pointed at the environment in a declared directory) run the same engine and never disagree. +editor and the CLI/CI run the same engine. A pyright the requirements pin can lag the one Pylance +bundles, though. The CLI/CI form is `uv run pyright` in the uv form. In the pip form a declared +directory runs the environment's own pyright, or `uvx pyright@latest` pointed at the environment +where it installs none. 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 @@ -124,6 +126,7 @@ carries a `[project]` table it then installs the directory itself as editable, w uv venv --clear # creates .venv, replacing an existing one uv pip install -r requirements.txt -r requirements-dev.txt # one -r per requirements*.txt uv pip install -e . # only where pyproject.toml has a [project] table +# in a declared directory whose .venv installs ruff, .venv/bin/python -m ruff replaces uvx ruff@latest uvx ruff@latest format # auto-format uvx ruff@latest check --fix # auto-fix lint uvx ruff@latest check # verify lint clean @@ -134,10 +137,14 @@ uvx ruff@latest format --check # verify format clean Its type checker runs against that environment. mypy runs as `.venv/bin/python -m mypy` where the environment installs it, and otherwise as `uvx mypy@latest --python-executable .venv/bin/python`, adding `--python-version` with the environment's version unless the mypy config pins one. pyright -runs as `uvx pyright@latest --pythonpath .venv/bin/python`. Where the config sits in the project -directory, CI runs the checker there with no path, as in the uv form. A declared subdirectory with -no config of its own uses the repository root's instead. CI then runs the checker from the root -with the directory as its path and the interpreter as `/.venv/bin/python`. Run it the same +runs as `.venv/bin/python -m pyright --pythonpath .venv/bin/python` where the environment installs +it, and otherwise as `uvx pyright@latest --pythonpath .venv/bin/python`. ruff follows the same +precedence, as `.venv/bin/python -m ruff` where the environment installs it, in place of the +`uvx ruff@latest` above. In a directory the validator's `python-directories` input declares, a tool +the requirements pin is then the version CI runs. Where the config +sits in the project directory, CI runs the checker there with no path, as in the uv form. A +declared subdirectory with no config of its own uses the repository root's instead. CI then runs +the checker from the root with the directory as its path and the interpreter as `/.venv/bin/python`. Run it the same way, as in `/.venv/bin/python -m mypy `. On Windows each interpreter path ends in `.venv\Scripts\python.exe` instead. Those ruff commands and that type checker are the pip form's clean-compile, run with pytest before committing. diff --git a/.github/skills/python-codestyle/references/profiles.md b/.github/skills/python-codestyle/references/profiles.md index 03dc74e3..fe3482ac 100644 --- a/.github/skills/python-codestyle/references/profiles.md +++ b/.github/skills/python-codestyle/references/profiles.md @@ -41,12 +41,12 @@ configuration alone marks the lint-only one: `[project.optional-dependencies]` extra. The pip form is a `pyproject.toml` beside a `requirements*.txt`, installed with pip, whether or not it carries a `[project]` table. A committed `uv.lock` makes a directory the uv form even where a `requirements*.txt` sits beside it. - In the pip form CI builds the environment as `SKILL.md` "Local development loop" shows, runs - pytest from it as `.venv/bin/python -m pytest`, and runs ruff through `uvx`. In a directory - declared in the hub validator's `python-directories` input it runs the type checker through - `uvx` pointed at that environment, or runs mypy from the environment where it is installed - there, while the undeclared default root runs a bare `uvx @latest` with nothing - installed. + In the pip form CI builds the environment as `SKILL.md` "Local development loop" shows, and runs + pytest from it as `.venv/bin/python -m pytest`. A directory declared in the hub validator's + `python-directories` input runs ruff and the type checker from that environment where it + installs them. It otherwise runs them through `uvx`, with the checker pointed at that + environment. The undeclared default root runs `uvx ruff@latest` and a bare + `uvx @latest`, with nothing installed. - **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 diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 2e2c27c2..42f53a1a 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -337,10 +337,13 @@ jobs: fi done <<< "$PYTHON_PROJECTS" + # A declared pip directory runs the ruff its venv installs, so a version its requirements pin is the one CI runs. + # It falls back to uvx only where the venv holds none. - name: Lint Python step if: steps.python.outputs.any == 'true' env: PYTHON_PROJECTS: ${{ steps.python.outputs.projects }} + DECLARED: ${{ steps.python.outputs.declared }} run: | set -Eeuo pipefail while IFS=$'\t' read -r kind dir; do @@ -348,6 +351,8 @@ jobs: echo "ruff check in $dir" if [ "$kind" = uv ]; then (cd "$dir" && uv run ruff check .) < /dev/null + elif [ "$kind" = pip ] && [ "$DECLARED" = "true" ] && "$dir/.venv/bin/python" -c 'import importlib.util, sys; sys.exit(importlib.util.find_spec("ruff") is None)' < /dev/null; then + (cd "$dir" && .venv/bin/python -m ruff check .) < /dev/null else (cd "$dir" && uvx ruff@latest check .) < /dev/null fi @@ -357,6 +362,7 @@ jobs: if: steps.python.outputs.any == 'true' env: PYTHON_PROJECTS: ${{ steps.python.outputs.projects }} + DECLARED: ${{ steps.python.outputs.declared }} run: | set -Eeuo pipefail while IFS=$'\t' read -r kind dir; do @@ -364,6 +370,8 @@ jobs: echo "ruff format --check in $dir" if [ "$kind" = uv ]; then (cd "$dir" && uv run ruff format --check .) < /dev/null + elif [ "$kind" = pip ] && [ "$DECLARED" = "true" ] && "$dir/.venv/bin/python" -c 'import importlib.util, sys; sys.exit(importlib.util.find_spec("ruff") is None)' < /dev/null; then + (cd "$dir" && .venv/bin/python -m ruff format --check .) < /dev/null else (cd "$dir" && uvx ruff@latest format --check .) < /dev/null fi @@ -446,7 +454,12 @@ jobs: (cd "$config_dir" && uvx mypy@latest --python-executable "$venv_python" "${version_args[@]}" "${targets[@]}") < /dev/null fi elif [ "$kind" = pip ] && [ "$DECLARED" = "true" ]; then - (cd "$config_dir" && uvx pyright@latest --pythonpath "$venv_python" "${targets[@]}") < /dev/null + if "$venv_python" -c 'import importlib.util, sys; sys.exit(importlib.util.find_spec("pyright") is None)'; then + # The venv's own pyright is the version its requirements pin, as with ruff, and the flag still names the interpreter whose packages it reads. + (cd "$config_dir" && "$venv_python" -m pyright --pythonpath "$venv_python" "${targets[@]}") < /dev/null + else + (cd "$config_dir" && uvx pyright@latest --pythonpath "$venv_python" "${targets[@]}") < /dev/null + fi else (cd "$config_dir" && uvx "$checker@latest" "${targets[@]}") < /dev/null fi diff --git a/catalog/snippets/configs/vscode-tasks-python.json b/catalog/snippets/configs/vscode-tasks-python.json index eb7e26c1..b9f0f376 100644 --- a/catalog/snippets/configs/vscode-tasks-python.json +++ b/catalog/snippets/configs/vscode-tasks-python.json @@ -22,7 +22,9 @@ // The aggregator then runs one `uv pip install` taking every `requirements*.txt` as a `-r` argument. // It ends with `uv pip install -e .` where the directory's pyproject.toml has a [project] table. // The directory also adapts or drops "uv Upgrade", which runs `uv sync` too. - // The directory runs ruff as `uvx ruff@latest`, pytest as `.venv/bin/python -m pytest` (`.venv\Scripts\python.exe` on Windows), and the type checker against `.venv`, per the python-codestyle Skill "Local development loop". + // A pip-form directory declared in the validator's `python-directories` input runs ruff as `.venv/bin/python -m ruff` (`.venv\Scripts\python.exe` on Windows) where its environment installs ruff, matching CI. + // Any other pip-form directory runs ruff as `uvx ruff@latest`. + // Every pip-form directory runs pytest as `.venv/bin/python -m pytest` (`.venv\Scripts\python.exe` on Windows), and the type checker against `.venv`, per the python-codestyle Skill "Local development loop". // A lint-only Scripts-profile subtree, with no [project], no [build-system], no `uv.lock`, and no `requirements*.txt`, runs every tool via `uvx ` rather than `uv run `. // The type-checker swap composes with it: `uvx mypy@latest` for a mypy repo, `uvx pyright@latest` otherwise. // It omits "uv Sync" and "uv Upgrade", and "Python Test" runs the unittest command the repository's OPERATIONS.md gives rather than pytest, per the python-codestyle Skill "Two profiles". diff --git a/catalog/snippets/pre-commit/.pre-commit-config.yaml b/catalog/snippets/pre-commit/.pre-commit-config.yaml index 9a5335b7..727d1d6a 100644 --- a/catalog/snippets/pre-commit/.pre-commit-config.yaml +++ b/catalog/snippets/pre-commit/.pre-commit-config.yaml @@ -7,6 +7,9 @@ # The type checker and ruff hooks run via uvx, native tooling, no Docker. # That needs no project dependency, matching CI's own invocation for the lint-only profile. # A repo on the build profile with a uv.lock may swap an entry for `uv run ` to pin its own project version instead. +# A pip-form repo whose root the validator's `python-directories` input declares may swap an entry for `.venv/bin/python -m ` where its requirements install that tool, matching CI. +# Run that way, pyright also takes `--pythonpath .venv/bin/python`. +# On Windows each of those interpreter paths is `.venv\Scripts\python.exe` instead. # The prose/EOL hooks fetch their scripts fresh from ptr727/ProjectTemplate's `main` branch via hub-fetch-run.py, rather than vendoring or pinning a copy. # See that file's docstring for why. # A fetch failure fails the commit, and it never silently skips the gate. diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index f5b4ba2b..4135cc9a 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -444,7 +444,7 @@ A repo whose Python lives below the root names each project directory in `python Tools ``` -Both Python jobs run in each named directory. The `lint` job runs ruff and the type check there, and the `unit-test` job runs that directory's `tests/` under coverage and uploads each report. A directory with its own `uv.lock` or `requirements*.txt` runs `pytest`. One with neither, whose `pyproject.toml` holds no `[project]` or `[build-system]` table, is the `lint-only` profile and runs `unittest` under `coverage`. A named directory that is neither, or has no `tests/`, or configures neither mypy nor pyright, fails its job, since every Python directory owes a suite and a type check. A named subdirectory with no type-checker config of its own uses the root's, running the checker from the root. A uv workspace member carries no lock of its own, since its lock belongs to the workspace root, so a workspace is named by its root rather than by its members. A caller naming nothing keeps the root, where a root `pyproject.toml` is tracked, and naming a subdirectory drops the root unless `.` is named beside it. A root named that way sets `testpaths` in its own pytest configuration, since pytest otherwise collects the subdirectory's suite from the root too. The declaration rather than discovery decides what is gated, so the `lint` job only warns about a tracked `.py` file no named directory covers. The fleet audit compares the input against the registry's `pythonDirectories` and reports that file as a finding. +Both Python jobs run in each named directory. The `lint` job runs ruff and the type check there, and the `unit-test` job runs that directory's `tests/` under coverage and uploads each report. A directory with its own `uv.lock` or `requirements*.txt` runs `pytest`. One with neither, whose `pyproject.toml` holds no `[project]` or `[build-system]` table, is the `lint-only` profile and runs `unittest` under `coverage`. A named directory that is neither, or has no `tests/`, or configures neither mypy nor pyright, fails its job. Every Python directory owes a suite and a type check. In a named directory with `requirements*.txt` and no `uv.lock`, the `lint` job runs the ruff, mypy, or pyright its environment installs. A version its requirements pin is then the one CI runs. It runs `uvx @latest` only for a tool the environment lacks. A named subdirectory with no type-checker config of its own uses the root's, running the checker from the root. A uv workspace member carries no lock of its own, since its lock belongs to the workspace root. A workspace is therefore named by its root rather than by its members. A caller naming nothing keeps the root, where a root `pyproject.toml` is tracked. Naming a subdirectory drops the root unless `.` is named beside it. A root named that way sets `testpaths` in its own pytest configuration, since pytest otherwise collects the subdirectory's suite from the root too. The declaration rather than discovery decides what is gated, so the `lint` job only warns about a tracked `.py` file no named directory covers. The fleet audit compares the input against the registry's `pythonDirectories` and reports that file as a finding. A repo whose package claims more than one interpreter names them all in `python-versions`, a JSON array the `unit-test` job reads through `fromJSON` as its matrix, one leg per entry: diff --git a/tests/test_release_guards.py b/tests/test_release_guards.py index f344e382..a4584eea 100755 --- a/tests/test_release_guards.py +++ b/tests/test_release_guards.py @@ -1098,13 +1098,20 @@ def run_python_tests_step( stubs.mkdir() # Each stub writes the report its real tool would, so the step's own report check is what decides. # The venv interpreter answers a version query with a version no host carries, so a test can tell it was asked. - # It holds mypy only where VENV_MYPY is set, and logs a mypy run the way the uv stubs log theirs. + # It holds mypy, ruff, or pyright only where VENV_MYPY, VENV_RUFF, or VENV_PYRIGHT is set. + # It logs a run of any of them the way the uv stubs log theirs. python_stub = ( "#!/usr/bin/env bash\n" 'case "$1 $2" in\n' - '"-c "*find_spec*) [ -n "${VENV_MYPY:-}" ] ;;\n' + '"-c "*find_spec*)\n' + ' case "$2" in\n' + ' *\'"mypy"\'*) [ -n "${VENV_MYPY:-}" ] ;;\n' + ' *\'"ruff"\'*) [ -n "${VENV_RUFF:-}" ] ;;\n' + ' *\'"pyright"\'*) [ -n "${VENV_PYRIGHT:-}" ] ;;\n' + " *) exit 1 ;;\n" + " esac ;;\n" '"-c "*) echo 3.99 ;;\n' - '"-m mypy") echo "python $*" >> "$STUB_LOG" ;;\n' + '"-m mypy" | "-m ruff" | "-m pyright") echo "python $*" >> "$STUB_LOG" ;;\n' "*) echo x > coverage.xml ;;\n" "esac\n" ) @@ -1117,8 +1124,7 @@ def run_python_tests_step( '*" pytest "*|*" xml "*) echo x > coverage.xml ;;\n' "esac\n" ) - venv_python = work / ".venv/bin/python" - if venv_python.exists(): + for venv_python in work.glob("**/.venv/bin/python"): venv_python.write_text(python_stub, encoding="utf-8") venv_python.chmod(0o755) for name in ("uv", "uvx"): @@ -1237,6 +1243,8 @@ def test_type_check_picks_its_checker_and_environment(self) -> None: root_config = {"pyproject.toml": "[tool.mypy]\n", "sub/pyproject.toml": "[tool.ruff]\n"} in_sub = {"PYTHON_PROJECTS": "lint-only\tsub\n"} venv_mypy = {"VENV_MYPY": "1"} + pyright_section = {**mypy_section, "pyproject.toml": "[tool.pyright]\n"} + venv_pyright = {"VENV_PYRIGHT": "1"} cases = { "declared pip mypy reads the venv": ( "pip", @@ -1286,6 +1294,22 @@ def test_type_check_picks_its_checker_and_environment(self) -> None: 0, r"^python -m mypy\n", ), + "declared pip pyright reads the venv": ( + "pip", + pyright_section, + True, + None, + 0, + r"^uvx pyright@latest --pythonpath /\S+/\.venv/bin/python\n", + ), + "declared pip pyright installed in the venv runs there": ( + "pip", + pyright_section, + True, + venv_pyright, + 0, + r"^python -m pyright --pythonpath /\S+/\.venv/bin/python\n", + ), "undeclared pip mypy keeps the old call": ( "pip", mypy_section, @@ -1356,9 +1380,72 @@ def test_type_check_picks_its_checker_and_environment(self) -> None: self.assertRegex(written, re.compile(call, re.MULTILINE)) if env is in_sub: self.assertIn("mypy in sub, configured in .", stdout) - if env is venv_mypy: + if env is venv_mypy or env is venv_pyright: self.assertNotIn("uvx", written) + @unittest.skipUnless( + shutil.which("bash") and os.name == "posix", + "runs stand-in tools only a POSIX host can execute", + ) + def test_ruff_prefers_a_declared_pip_directory_venv(self) -> None: + """A declared pip directory's ruff is the one its requirements pin, and uvx floats only where the venv lacks one.""" + pip_tree = {"pyproject.toml": "", "requirements.txt": "", ".venv/bin/python": ""} + venv_ruff = {"VENV_RUFF": "1"} + for step, args in ( + ("Lint Python step", "check ."), + ("Check Python formatting step", "format --check ."), + ): + cases = { + "declared pip ruff installed in the venv runs there": ( + "pip", + pip_tree, + True, + venv_ruff, + f"python -m ruff {args}\n", + ), + "declared pip with no venv ruff floats": ( + "pip", + pip_tree, + True, + None, + f"uvx ruff@latest {args}\n", + ), + "undeclared pip keeps the old call": ( + "pip", + pip_tree, + False, + venv_ruff, + f"uvx ruff@latest {args}\n", + ), + "lint-only floats": ( + "lint-only", + pip_tree, + True, + venv_ruff, + f"uvx ruff@latest {args}\n", + ), + "a declared pip subdirectory reads its own venv": ( + "pip", + {f"sub/{path}": text for path, text in pip_tree.items()}, + True, + {**venv_ruff, "PYTHON_PROJECTS": "pip\tsub\n"}, + f"python -m ruff {args}\n", + ), + "uv runs its locked ruff": ( + "uv", + {"pyproject.toml": ""}, + True, + venv_ruff, + f"uv run ruff {args}\n", + ), + } + for label, (kind, tree, declared, env, call) in cases.items(): + with self.subTest(step=step, case=label): + code, _, written = self.run_python_tests_step( + kind, tree, declared, step_name=step, extra_env=env + ) + self.assertEqual((0, call), (code, written)) + @unittest.skipUnless( shutil.which("bash") and os.name == "posix", "runs stand-in tools only a POSIX host can execute", From 8931e9e2825088b5978d3d53201019ebc577f2e8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 17:30:24 -0700 Subject: [PATCH 04/18] Add a Pip Ecosystem to the Carried Dependabot Snippet (#3103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `catalog/snippets/configs/dependabot.yml`: add a dual-targeted `pip` pair, one entry for `main` and one for `develop`, in the same shape as the `uv` pair. It is commented as kept only by a repo with a pip-form Python directory, a `requirements*.txt` with no `uv.lock`, and carries the same `ruff`/`pyright`/`mypy` exclude-patterns as the `uv` entries. Its placeholder directory is `/PythonProject`, kept apart from the lint-only Scripts profile's vocabulary. - `spec/project-types.json`: widen the `python.scripts.uvx` assert's build-profile clause so it names the pip form beside the uv form. The pip form takes a tool's version from the `requirements*.txt` that Dependabot's pip ecosystem bumps, where a directory declared in `python-directories` installs that tool into its `.venv`. The audit side, `spec/audit.py` cross-checking a `pip` ecosystem, stays with #2286. This change adds comment lines to a config snippet, which the issue asks for, so it carries the `comments` label. ## Local review findings - `introduced`: `.github/dependabot.yml:6` describes the snippet as "covering nuget and uv", which no longer lists everything it covers. The sibling list at `spec/project-types.json` `setup.dependabot.ecosystems` ("nuget/uv/npm") has the same gap. The edit budget for the opening push was spent. **Fixed in 59f8b998**, which names pip in both lists. Closes on promotion: #3101 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/dependabot.yml | 2 +- catalog/snippets/configs/dependabot.yml | 30 +++++++++++++++++++++++++ spec/project-types.json | 4 ++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6f2fbe56..99f9f05f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,7 @@ # The github-actions ecosystem is the only one this repo ships. # Both main and develop are targeted so each stays current independently of the develop to main release cadence. # The merge-bot auto-merges each base with its per-base method. -# See catalog/snippets/configs/dependabot.yml for the multi-ecosystem reference, covering nuget and uv, that a code-shipping repo uses. +# See catalog/snippets/configs/dependabot.yml for the multi-ecosystem reference, covering nuget, uv, and pip, that a code-shipping repo uses. version: 2 updates: diff --git a/catalog/snippets/configs/dependabot.yml b/catalog/snippets/configs/dependabot.yml index e60ae9da..85b55f86 100644 --- a/catalog/snippets/configs/dependabot.yml +++ b/catalog/snippets/configs/dependabot.yml @@ -98,3 +98,33 @@ updates: - "ruff" - "pyright" - "mypy" + + # The pip pair is kept only by a repo with a pip-form Python directory, a requirements*.txt with no uv.lock. + # Its directory points at that Python directory, so adjust it per repo layout. + - package-ecosystem: "pip" + target-branch: "main" + directory: "/PythonProject" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" + exclude-patterns: + - "ruff" + - "pyright" + - "mypy" + + - package-ecosystem: "pip" + target-branch: "develop" + directory: "/PythonProject" + schedule: + interval: "daily" + groups: + pypi-deps: + patterns: + - "*" + exclude-patterns: + - "ruff" + - "pyright" + - "mypy" diff --git a/spec/project-types.json b/spec/project-types.json index 2dd82d3d..2f0d2b2a 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -45,7 +45,7 @@ { "id": "python.coverage.codecov", "verdict": "letter", "assert": "Every Python directory with tests runs them under coverage and uploads the report to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false). A directory with a uv.lock or a requirements*.txt runs pytest --cov-report=xml, and because --cov-report=xml alone measures nothing and writes no file, it declares pytest-cov among its test dependencies, a dev dependency group in a uv project and a requirements*.txt entry on pip, and selects the coverage source in its own pyproject.toml, an addopts --cov= entry in practice. A lint-only directory runs coverage run -m unittest discover -s tests, then coverage xml. The validator's Python leg runs in each directory the caller declares in python-directories, or at the root where it declares none and a root pyproject.toml is tracked. It deletes the directory's coverage.xml before the run and fails the test step when the run did not write it, and it fails a declared directory that has no tests/. CODECOV_TOKEN is stored in both the repo actions and dependabot secrets stores, the second so the upload does not skip on a Dependabot PR, and the caller maps it to the reusable validator by name. N/A for a repo carrying no tests for this type, which the validator permits only at an undeclared root. In a mixed repo the codecov.yml file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md" }, { "id": "python.directories.declared", "verdict": "letter", "assert": "A repository carrying a tracked .py file other than the catalog's carried hub-fetch-run.py declares the python type, or suppresses that discovery advisory with a driftNote naming (python.directories.declared) and its reason, per spec/type-model.md. Every such file sits in a directory the registry's pythonDirectories names, or anywhere under the root where it names none and a root pyproject.toml is tracked. Every caller of the hub's validate-task.yml passes exactly those directories in its python-directories input, as a literal (|) block or a single value, so the audit can see a missing declaration the validator itself only warns about. A root tests/ suite with no root uv.lock or requirements*.txt is never run by the undeclared root default, so a lint-only root declares '.' and a root declaring [project] adds a manifest. spec/audit.py checks each of these mechanically.", "intentRef": "WORKFLOW.md" }, { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "Build profile: the committed uv.lock resolves to LF through the repository-wide .editorconfig and .gitattributes defaults. A CRLF-native operational repo adds a narrow uv.lock LF override only if it adopts the uv build profile. N/A for a non-uv Python repo (a Home Assistant integration on pip/requirements) and for the lint-only profile (no uv.lock by definition).", "intentRef": "GOVERNANCE.md#line-endings", "minProfile": "build" }, - { "id": "python.scripts.uvx", "verdict": "letter", "assert": "Lint-only profile only: the tools run via uvx (no project install, no lockfile). A uvx @ pin in a run: step is not Dependabot-trackable, so CI runs uvx ruff@latest / uvx mypy@latest - the fleet rule pins only what Dependabot auto-updates and otherwise runs latest, never a manual pin that goes stale. VS Code tasks, README, and CI all run the unpinned latest. N/A for the build profile (which pins tool versions via uv.lock + uv sync --frozen instead).", "intentRef": "CODESTYLE.md" } + { "id": "python.scripts.uvx", "verdict": "letter", "assert": "Lint-only profile only: the tools run via uvx (no project install, no lockfile). A uvx @ pin in a run: step is not Dependabot-trackable, so CI runs uvx ruff@latest / uvx mypy@latest - the fleet rule pins only what Dependabot auto-updates and otherwise runs latest, never a manual pin that goes stale. VS Code tasks, README, and CI all run the unpinned latest. N/A for the build profile, whose uv form pins tool versions via uv.lock + uv sync --frozen instead, and whose pip form takes a tool's version from the requirements*.txt that Dependabot's pip ecosystem bumps where a directory declared in python-directories installs that tool into its .venv.", "intentRef": "CODESTYLE.md" } ] }, "cpp": { @@ -161,7 +161,7 @@ { "id": "setup.secrets.present", "verdict": "letter", "assert": "Every requiredSecret for the repo's publish mechanisms is configured (per spec/secrets.json).", "intentRef": "repo-config/README.md" }, { "id": "setup.secrets.noforbidden", "verdict": "letter", "assert": "No forbidden secret is present (e.g. a static NUGET_API_KEY on an OIDC repo).", "intentRef": "spec/secrets.json" }, { "id": "setup.driftnotes.current", "verdict": "intent", "assert": "A registry driftNote records a current deviation from the baseline; once resolved the note is deleted, not left describing finished work. spec/audit.py flags a note asserting outstanding work (pending / not yet / missing / behind / ...) on a repo that otherwise audits clean. Findings derived from an audit run carry its stamp and are re-verified at pickup, never trusted as current state.", "intentRef": "AUDIT.md" }, - { "id": "setup.dependabot.ecosystems", "verdict": "intent", "assert": "For each ecosystem the repo's tree implies, .github/dependabot.yml declares it (dual-target main+develop per the fleet norm): github-actions when .github/workflows/ is present (its workflows reference actions, else their versions go stale and a stood-up merge-bot has no PRs to auto-merge), devcontainers when a .devcontainer is present. A missing implied ecosystem is a drift finding. Language ecosystems (nuget/uv/npm) are directory-scoped, audited by inspection.", "intentRef": "GOVERNANCE.md#branching-model" } + { "id": "setup.dependabot.ecosystems", "verdict": "intent", "assert": "For each ecosystem the repo's tree implies, .github/dependabot.yml declares it (dual-target main+develop per the fleet norm): github-actions when .github/workflows/ is present (its workflows reference actions, else their versions go stale and a stood-up merge-bot has no PRs to auto-merge), devcontainers when a .devcontainer is present. A missing implied ecosystem is a drift finding. Language ecosystems (nuget/uv/pip/npm) are directory-scoped, audited by inspection.", "intentRef": "GOVERNANCE.md#branching-model" } ] }, "runtime-secrets": { From df8775d89e5424ece48d08aec0379a31d5f281d1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 18:36:40 -0700 Subject: [PATCH 05/18] Accept .codecov.yml as an Alternative to codecov.yml (#3105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - A `spec/files.json` baseline entry may declare `alternatives`, other paths the tool reading the file accepts it at. The `codecov.yml` entry names `.codecov.yml`, which Codecov also reads at the repository root. - `spec/audit.py` reads the declared path first and each alternative only while nothing is found (`read_baseline_file`). A repo holding only `.codecov.yml` passes the file-presence check, one holding neither gets one LETTER naming both paths, and the intent staleness advisory is read at the path the file was found at (`baseline_file_findings`). - `spec/validate.py` (`alternatives_errors`) refuses a list off the schema's shape, alternatives on anything but a whole-file presence or intent entry, alternatives on a path a second baseline entry also names, and an alternative that is itself a declared path. - `spec/fidelity_honesty.py` reads a downstream copy at the same paths through the shared helpers, and `contents_text` is now the one contents decoder in the files this touches. - WORKFLOW.md D1.6, `spec/fidelity-model.md`, `spec/type-model.md`, and the Python coverage check in `spec/project-types.json` name `.codecov.yml`. The skill copies of WORKFLOW.md section 4 are regenerated. ## Verification - `spec/audit.py --selftest`: new cases cover the declared path only, the alternative only (present, staleness read at the alternative), both (declared wins), and neither (one LETTER naming both). Reverting `found` to `path` in `baseline_file_findings` fails the alternative case. - `tests/test_spec_validate.py` `BaselineAlternativesCase` covers the validator, including the live codecov entry. - `spec/validate.py`, the full unittest suite, ruff, mypy, `build_dist.py --check`, `prose_lint.py --diff`, markdownlint, cspell, and editorconfig-checker pass locally. ## Local review findings Each finding a local pass left open after its push's edit budget, with its outcome: - `introduced`: the `check_units` docstring said `downstream_path` is always the entry's `path`. Fixed in 2b7f211e. - `introduced`: `fidelity_honesty.fetch` and `baseline_file_findings` each decoded the contents response. Fixed in 2b7f211e with the shared `contents_text`. The untouched `spec/workflow_reuse.py` copy is #3106. - `introduced`: the `alternatives_errors` docstring gave an inaccurate rationale, a broken wrap, and two long sentences. Fixed in 59e4057e by cutting it to the rules it enforces. - `introduced`: the `audit_repo` and `fidelity_pass` call sites that pass alternatives are not tested, only the helpers they call. Deferred to #3107, since it needs a fixture driving `audit_repo`'s baseline loop. - Declined: the schema-shape clauses in `alternatives_errors` read as over-hardening to one pass. `spec/validate.py` states CI runs no schema validation, so it enforces `files.schema.json`'s shape itself, which the PR review asked for. Closes on promotion: #2996 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../references/d-guarantees.md | 2 +- .../.source-digests/workflow-ci-contract | 2 +- .../references/d-guarantees.md | 2 +- .../references/d-guarantees.md | 2 +- WORKFLOW.md | 2 +- spec/audit.py | 200 +++++++++++++++--- spec/fidelity-model.md | 2 + spec/fidelity_honesty.py | 20 +- spec/files.json | 2 +- spec/files.schema.json | 1 + spec/project-types.json | 2 +- spec/type-model.md | 4 +- spec/validate.py | 35 +++ tests/test_spec_validate.py | 64 ++++++ 14 files changed, 287 insertions(+), 53 deletions(-) diff --git a/.agents/skills/workflow-ci-contract/references/d-guarantees.md b/.agents/skills/workflow-ci-contract/references/d-guarantees.md index 4c469742..31fabbcc 100644 --- a/.agents/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.agents/skills/workflow-ci-contract/references/d-guarantees.md @@ -15,7 +15,7 @@ The required behaviors, organized by domain. Each is a **MUST**, and its `Output - **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* - **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* - **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* -- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, `pytest --cov-report=xml` in a Python directory with its own `uv.lock` or `requirements*.txt`, whose own `pyproject.toml` selects what to measure, or `coverage run -m unittest discover -s tests` then `coverage xml` in a `lint-only` one) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). Each leg **fails its test step when the run wrote no report**, since the best-effort upload reads a missing report exactly as it reads a healthy run, and on the Python leg `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. A Python directory declared in the validator's `python-directories` input owes a `tests/` suite whatever its profile, so the validator fails one that has none. N/A for a repo carrying no tests for that type. *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, `pytest --cov-report=xml` in a Python directory with its own `uv.lock` or `requirements*.txt`, whose own `pyproject.toml` selects what to measure, or `coverage run -m unittest discover -s tests` then `coverage xml` in a `lint-only` one) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). Each leg **fails its test step when the run wrote no report**, since the best-effort upload reads a missing report exactly as it reads a healthy run, and on the Python leg `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Codecov also reads a **`.codecov.yml`** at the repository root, so that name serves too. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. A Python directory declared in the validator's `python-directories` input owes a `tests/` suite whatever its profile, so the validator fails one that has none. N/A for a repo carrying no tests for that type. *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry diff --git a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract index 15375d57..109e01ea 100644 --- a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract +++ b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract @@ -1 +1 @@ -76921cd09044d019 +82e59e34aeb7fac2 diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md index 4c469742..31fabbcc 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/references/d-guarantees.md @@ -15,7 +15,7 @@ The required behaviors, organized by domain. Each is a **MUST**, and its `Output - **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* - **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* - **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* -- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, `pytest --cov-report=xml` in a Python directory with its own `uv.lock` or `requirements*.txt`, whose own `pyproject.toml` selects what to measure, or `coverage run -m unittest discover -s tests` then `coverage xml` in a `lint-only` one) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). Each leg **fails its test step when the run wrote no report**, since the best-effort upload reads a missing report exactly as it reads a healthy run, and on the Python leg `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. A Python directory declared in the validator's `python-directories` input owes a `tests/` suite whatever its profile, so the validator fails one that has none. N/A for a repo carrying no tests for that type. *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, `pytest --cov-report=xml` in a Python directory with its own `uv.lock` or `requirements*.txt`, whose own `pyproject.toml` selects what to measure, or `coverage run -m unittest discover -s tests` then `coverage xml` in a `lint-only` one) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). Each leg **fails its test step when the run wrote no report**, since the best-effort upload reads a missing report exactly as it reads a healthy run, and on the Python leg `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Codecov also reads a **`.codecov.yml`** at the repository root, so that name serves too. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. A Python directory declared in the validator's `python-directories` input owes a `tests/` suite whatever its profile, so the validator fails one that has none. N/A for a repo carrying no tests for that type. *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry diff --git a/.github/skills/workflow-ci-contract/references/d-guarantees.md b/.github/skills/workflow-ci-contract/references/d-guarantees.md index 4c469742..31fabbcc 100644 --- a/.github/skills/workflow-ci-contract/references/d-guarantees.md +++ b/.github/skills/workflow-ci-contract/references/d-guarantees.md @@ -15,7 +15,7 @@ The required behaviors, organized by domain. Each is a **MUST**, and its `Output - **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* - **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* - **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* -- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, `pytest --cov-report=xml` in a Python directory with its own `uv.lock` or `requirements*.txt`, whose own `pyproject.toml` selects what to measure, or `coverage run -m unittest discover -s tests` then `coverage xml` in a `lint-only` one) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). Each leg **fails its test step when the run wrote no report**, since the best-effort upload reads a missing report exactly as it reads a healthy run, and on the Python leg `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. A Python directory declared in the validator's `python-directories` input owes a `tests/` suite whatever its profile, so the validator fails one that has none. N/A for a repo carrying no tests for that type. *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, `pytest --cov-report=xml` in a Python directory with its own `uv.lock` or `requirements*.txt`, whose own `pyproject.toml` selects what to measure, or `coverage run -m unittest discover -s tests` then `coverage xml` in a `lint-only` one) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). Each leg **fails its test step when the run wrote no report**, since the best-effort upload reads a missing report exactly as it reads a healthy run, and on the Python leg `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Codecov also reads a **`.codecov.yml`** at the repository root, so that name serves too. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. A Python directory declared in the validator's `python-directories` input owes a `tests/` suite whatever its profile, so the validator fails one that has none. N/A for a repo carrying no tests for that type. *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry diff --git a/WORKFLOW.md b/WORKFLOW.md index be7035d1..b9d0e806 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -143,7 +143,7 @@ The required behaviors, organized by domain. Each is a **MUST**, and its `Output - **D1.3 Smoke never publishes and never uploads.** Input: `smoke: true`. Output: full compile/lint/test, but no registry/image push, no release, and **no** artifact uploads (every `upload-artifact`, including any aggregation job, is gated on smoke being false, written `!inputs.smoke` at the workflow layer and `inputs.smoke != 'true'` in a composite action, whose inputs are strings). *Prevents: a PR publishing, and orphaned artifacts churning the storage quota.* - **D1.4 Workflow-file changes are not smoke-built.** Input: a PR changing only `.github/workflows/**`. Output: the paths-filter marks no target, so smoke-build skips. An inclusion list satisfying D1.1 reaches this by leaving workflow paths out of every target's entry. *Implication: a workflow-only change is not smoke-built, but actionlint still validates it in CI.* - **D1.5 One required aggregator gates merge.** Input: any PR. Output: a single aggregator job must **succeed**, run under `if: always()` so a failed or skipped dependency cannot skip the gate itself, `needs:` the validation job, and the `changes` and `smoke-build` jobs too wherever the repo has a smoke build, treat a **skipped** smoke build as pass, and **block** on `failure`/`cancelled`. Its name is ruleset-bound: the job `name:` and the ruleset `context:` are the same string and MUST be renamed together, never independently. *Prevents: a paths-filter error letting a target-changing PR merge unbuilt.* -- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, `pytest --cov-report=xml` in a Python directory with its own `uv.lock` or `requirements*.txt`, whose own `pyproject.toml` selects what to measure, or `coverage run -m unittest discover -s tests` then `coverage xml` in a `lint-only` one) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). Each leg **fails its test step when the run wrote no report**, since the best-effort upload reads a missing report exactly as it reads a healthy run, and on the Python leg `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. A Python directory declared in the validator's `python-directories` input owes a `tests/` suite whatever its profile, so the validator fails one that has none. N/A for a repo carrying no tests for that type. *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* +- **D1.6 Coverage is reported to Codecov (C# and Python).** Input: a C# or Python repo that has tests for that type. Output: the validation job runs those tests under coverage collection (`dotnet test --coverage --coverage-output-format cobertura --results-directory ./coverage`, leaving `--coverage-output` unset so each test project writes its own report rather than overwriting a shared one, `pytest --cov-report=xml` in a Python directory with its own `uv.lock` or `requirements*.txt`, whose own `pyproject.toml` selects what to measure, or `coverage run -m unittest discover -s tests` then `coverage xml` in a `lint-only` one) and a `codecov/codecov-action` step uploads the report, **best-effort** (`continue-on-error` and/or `fail_ci_if_error: false`, so a Codecov outage or an absent token never reds the gate). Each leg **fails its test step when the run wrote no report**, since the best-effort upload reads a missing report exactly as it reads a healthy run, and on the Python leg `--cov-report=xml` alone selects nothing to measure. The C# leg renames each report to `coverage-.cobertura.xml` before the upload step reads the directory, `codecov-cli`'s own finder not matching the default name, and a repo owning its validator rather than calling the hub's owes that rename itself. `CODECOV_TOKEN` lives in the repo's **actions** and **dependabot** secret stores, the second because a run triggered by a Dependabot pull request reads the Dependabot store and the upload would otherwise skip silently on every bot pull request. A caller reaching the reusable validator across repositories names the secret it passes (`secrets:` with `CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}`), on its pull request path and its publisher path alike, because `secrets: inherit` is documented for a caller in the same organization or enterprise, which a personal account is not. A call by local path stays inside one repository, where the caller's own store is the one the callee reads, so `secrets: inherit` is available there instead of naming each secret. The repo ships a **`codecov.yml`** setting the project and patch statuses to **`informational: true`** so a coverage delta never gates a pull request (a repo whose quality bar requires a threshold may turn that off), and excluding intentionally-untested, non-shipped code (an example or benchmark project) from the denominator via `ignore`. Codecov also reads a **`.codecov.yml`** at the repository root, so that name serves too. Coverage output is a build artifact, so `.gitignore` excludes it. The C# invocation runs under **Microsoft.Testing.Platform**, and the runner declaration, package references, and version floor an MTP-based test project needs are `CODESTYLE.md`'s .NET side. The Python invocation needs **`pytest-cov`** and a coverage selector, which are `CODESTYLE.md`'s Python side. A Python directory declared in the validator's `python-directories` input owes a `tests/` suite whatever its profile, so the validator fails one that has none. N/A for a repo carrying no tests for that type. *Prevents: coverage silently going unreported, and a coverage regression blocking an unrelated pull request.* ### D2 - Input/State Validation at Entry diff --git a/spec/audit.py b/spec/audit.py index 10817a5d..05af3859 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2729,6 +2729,79 @@ def intent_canonical_rel(item, path): return path +def declared_alternatives(baseline): + """Map each baseline path to the `alternatives` its entry declares, the other paths it may sit at. + + spec/validate.py holds a path declaring alternatives to a single entry, so nothing here merges. + """ + return { + item["path"]: list(item["alternatives"]) for item in baseline if item.get("alternatives") + } + + +def contents_text(content): + """The decoded text of a contents API response, or None where it holds no inline file content. + + A directory returns a list, and a too-large file returns encoding "none". An empty file decodes + to an empty string, so the guard is on the encoding rather than on truthiness. + """ + if not isinstance(content, dict) or content.get("encoding") != "base64": + return None + return base64.b64decode(content["content"]).decode("utf-8", "replace") + + +def read_baseline_file(slug, ground, path, alternatives=()): + """The path a baseline file was found at and its contents response, or `(path, None)`. + + The declared path is read first and an alternative only while nothing is found. A repo holding + both is therefore read at the declared path. + """ + for candidate in (path, *alternatives): + content = gh(f"repos/{slug}/contents/{candidate}?ref={ground}", ok404=True) + if content is not None: + return candidate, content + return path, None + + +def baseline_file_findings(slug, ground, path, alternatives, item, is_hub): + """Read one baseline file, returning `(content, text, findings)` for what reading it alone owes. + + Where no path is present, an interface unit's absence is DRIFT, since a workflow's naming varies + more than a carried config's. Any other unit's absence is a file-presence LETTER. `text` is None + where the contents API returned no inline content, as for a too-large file, while an empty file + decodes to an empty string. An intent unit outside the hub gets its staleness advisory, read at + the path the file was found at. The hub's own copies are the canonicals, so it has none. + """ + found, content = read_baseline_file(slug, ground, path, alternatives) + fid = item.get("fidelity") if item else "presence" + if content is None: + if fid == "interface": + return ( + None, + None, + [("DRIFT", f"interface: {path} absent on {ground}, cannot verify its contract")], + ) + named = " or ".join((path, *alternatives)) + return ( + None, + None, + [ + ( + "LETTER", + f"file: {named} absent on {ground} (verify intent per AUDIT.md section 7)", + ) + ], + ) + text = contents_text(content) + if item is None or fid != "intent" or is_hub: + return content, text, [] + return ( + content, + text, + check_intent_staleness(slug, ground, found, intent_canonical_rel(item, path), text), + ) + + def check_intent_staleness(slug, ground, path, canonical_rel, down_text): """The intent-staleness advisory: a last-modified comparison, since intent has no content check. @@ -3071,11 +3144,12 @@ def audit_repo(entry, spec, branch=None): # It only runs where dependabot.yml exists, since its absence is already a file-presence LETTER below. # Language ecosystems such as nuget, uv and npm are directory-scoped and not yet cross-checked here. db = gh(f"repos/{slug}/contents/.github/dependabot.yml?ref={ground}", ok404=True) - if db and db.get("content"): + db_text = contents_text(db) + if db_text: declared = set( re.findall( r'^[ \t]*-?[ \t]*package-ecosystem:[ \t]*["\']?([\w-]+)', - base64.b64decode(db["content"]).decode("utf-8", "replace"), + db_text, re.MULTILINE, ) ) @@ -3106,6 +3180,7 @@ def audit_repo(entry, spec, branch=None): verbatim_secs = {} # path -> set of section names checked byte-for-byte against the hub canonical doc_texts = {} # README.md / HISTORY.md content, retained for the mirror check check_item = {} # path -> entry, for a fidelity interface/verbatim entry (last applicable wins per path) + alternatives = declared_alternatives(spec["files"]["baseline"]) path_order = [] for item in spec["files"]["baseline"]: if not applies(item.get("appliesTo", "*"), sel): @@ -3122,31 +3197,19 @@ def audit_repo(entry, spec, branch=None): if item.get("fidelity") in ("interface", "verbatim", "intent"): check_item[path] = item for path in path_order: - content = gh(f"repos/{slug}/contents/{path}?ref={ground}", ok404=True) item = check_item.get(path) fid = item.get("fidelity") if item else "presence" + content, text, file_findings = baseline_file_findings( + slug, + ground, + path, + alternatives.get(path, []), + item, + entry.get("name") == hub_repo_name(), + ) + findings.extend(file_findings) if content is None: - # An interface unit's presence is DRIFT rather than LETTER, since a workflow's naming is more variable than a carried config, so absence is a hint to verify. - # Any other unit's absence is a file-presence LETTER. - if fid == "interface": - findings.append( - ("DRIFT", f"interface: {path} absent on {ground}, cannot verify its contract") - ) - else: - findings.append( - ( - "LETTER", - f"file: {path} absent on {ground} (verify intent per AUDIT.md section 7)", - ) - ) continue - # Guard on encoding rather than truthiness, since an empty file returns encoding "base64" with an empty content that decodes to an empty string. - # A too-large or non-inline payload returns encoding "none", where text stays None and is flagged. - text = ( - base64.b64decode(content["content"]).decode("utf-8", "replace") - if content.get("encoding") == "base64" - else None - ) if path in ("README.md", "HISTORY.md") and text is not None: doc_texts[path] = text # retained for the README/HISTORY mirror check below # Interface conformance (name + wiring) plus any verbatim job regions the contract pins. @@ -3184,12 +3247,6 @@ def audit_repo(entry, spec, branch=None): ) else: findings.extend(check_verbatim(path, text, item.get("reference") or path)) - # Intent staleness: the one advisory an intent unit gets, a last-modified comparison. - # The hub's own copies are the canonicals, so the hub itself has nothing to trail. - elif item is not None and fid == "intent" and entry.get("name") != hub_repo_name(): - findings.extend( - check_intent_staleness(slug, ground, path, intent_canonical_rel(item, path), text) - ) # Heading-based presence is only meaningful for Markdown. # A section named on a non-md file (a tasks.json task group) is not checked, only the file's presence, and RESYNC.md aligns it by hand. needed = wanted_sections[path] @@ -3452,12 +3509,7 @@ def audit_repo(entry, spec, branch=None): if not ws_name.endswith(".code-workspace"): continue ws = gh(f"repos/{slug}/contents/{ws_name}?ref={ground}", ok404=True) - # The isinstance guard is needed because the contents API returns a list for a directory, where .get would raise. - ws_text = ( - base64.b64decode(ws["content"]).decode("utf-8", "replace") - if isinstance(ws, dict) and ws.get("encoding") == "base64" - else None - ) + ws_text = contents_text(ws) if ws_text is None: findings.append( ( @@ -6173,6 +6225,84 @@ def history_read_only(entry, spec, branch=None): " ok repo_tree: a truncated tree and a missing tree sha both return None, and a whole one drops non-blobs" ) + real_gh = globals()["gh"] + + def holding(*paths): + def fake(path, ok404=False): + if "/commits?path=" in path: + name = path.split("/commits?path=", 1)[1].split("&", 1)[0] + stamp = {"commit": {"committer": {"date": "2000-01-01T00:00:00+00:00"}}} + return [stamp] if name in paths else [] + name = path.split("/contents/", 1)[1].split("?", 1)[0] + return ( + {"encoding": "base64", "content": "eA==", "name": name} if name in paths else None + ) + + return fake + + alt_cases = [ + (("codecov.yml",), ("codecov.yml", "codecov.yml"), "only the declared path"), + ((".codecov.yml",), (".codecov.yml", ".codecov.yml"), "only the alternative"), + (("codecov.yml", ".codecov.yml"), ("codecov.yml", "codecov.yml"), "both, declared wins"), + ((), ("codecov.yml", None), "neither"), + ] + try: + for held, (want_path, want_name), label in alt_cases: + globals()["gh"] = holding(*held) + got_path, got = read_baseline_file("o/r", "main", "codecov.yml", [".codecov.yml"]) + got_name = got["name"] if got is not None else None + if (got_path, got_name) != (want_path, want_name): + ok = False + print(f" FAIL read_baseline_file, {label} -> {got_path}, {got_name}") + else: + print( + f" ok read_baseline_file, {label}: read at {got_path}, found={got is not None}" + ) + real_hub_change = globals()["hub_last_change"] + real_canon_text = globals()["canonical_current_text"] + globals()["hub_last_change"] = lambda rel: ("2020-01-01T00:00:00+00:00", "abc1234") + globals()["canonical_current_text"] = lambda rel: "canonical" + unit = {"path": "codecov.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md"} + try: + globals()["gh"] = holding(".codecov.yml") + alt_content, _, alt_findings = baseline_file_findings( + "o/r", "main", "codecov.yml", [".codecov.yml"], unit, False + ) + globals()["gh"] = holding() + _, _, none_findings = baseline_file_findings( + "o/r", "main", "codecov.yml", [".codecov.yml"], unit, False + ) + finally: + globals()["hub_last_change"] = real_hub_change + globals()["canonical_current_text"] = real_canon_text + stale_at_alt = [t for lvl, t in alt_findings if lvl == "DRIFT" and ".codecov.yml" in t] + if alt_content is None or len(alt_findings) != 1 or len(stale_at_alt) != 1: + ok = False + print(f" FAIL baseline_file_findings, only the alternative -> {alt_findings}") + else: + print( + " ok baseline_file_findings, only the alternative: present, its staleness read at the alternative" + ) + want_none = [ + ( + "LETTER", + "file: codecov.yml or .codecov.yml absent on main (verify intent per AUDIT.md section 7)", + ) + ] + if none_findings != want_none: + ok = False + print(f" FAIL baseline_file_findings, neither -> {none_findings}") + else: + print(" ok baseline_file_findings, neither: one LETTER naming both paths") + finally: + globals()["gh"] = real_gh + live_alts = declared_alternatives(load("spec/files.json")["baseline"]) + if live_alts.get("codecov.yml") != [".codecov.yml"]: + ok = False + print(f" FAIL declared_alternatives on the live manifest -> {live_alts}") + else: + print(" ok declared_alternatives: the live manifest names .codecov.yml for codecov.yml") + # Applicability turns on tests, so a package-only build repo is N/A rather than owing a token for a report nothing produces. type_mechs = {"csharp": "codecov", "python": "codecov"} coverage_cases = [ diff --git a/spec/fidelity-model.md b/spec/fidelity-model.md index c91f75f9..920331b3 100644 --- a/spec/fidelity-model.md +++ b/spec/fidelity-model.md @@ -17,6 +17,8 @@ Each [`spec/files.json`][files] entry declares one `fidelity`, defaulting to `pr A declaration in `trees[]` uses **verbatim-tree**. Each included source file exists at the corresponding target path with identical bytes. A pruned target contains no extra path. The tree root exists even when the included source inventory is empty. Tree target roots never overlap because two declarations cannot own the same path safely, regardless of whether either declaration prunes extras. +A whole-file `presence` or `intent` entry may also declare `alternatives`, other paths its tool accepts the file at. The `codecov.yml` entry names `.codecov.yml` this way. The audit reads the declared path first and an alternative only while nothing is found. The file is absent only where no path holds it. `spec/validate.py` refuses `alternatives` on any other entry, on a path a second entry also names, and where an alternative is itself a declared path. + Fidelity is a declared field defaulting to `presence`, never inferred from `whole`/`placeholders`. `.editorconfig` and `.markdownlint-cli2.jsonc` are both whole with no placeholders yet sit at opposite fidelity, because the discriminator is governance, not field shape. ## Why Each Unit Sits Where It Does diff --git a/spec/fidelity_honesty.py b/spec/fidelity_honesty.py index 9d2d22fd..7c73fb95 100755 --- a/spec/fidelity_honesty.py +++ b/spec/fidelity_honesty.py @@ -22,7 +22,6 @@ """ import argparse -import base64 import sys import audit # sibling, import-safe (its main is guarded) @@ -33,19 +32,18 @@ SECTION_SEP = " > " # joins a file path and a verbatim section name into one unit label (path > section), ASCII -def fetch(slug, path, ref): - """Decoded downstream file content, or None if absent / not inline.""" - content = audit.gh(f"repos/{slug}/contents/{path}?ref={ref}", ok404=True) - if content is None or content.get("encoding") != "base64": - return None - return base64.b64decode(content["content"]).decode("utf-8", "replace") +def fetch(slug, path, ref, alternatives=()): + """Decoded downstream file content at path or its first alternative present, or None if absent / not inline.""" + _, content = audit.read_baseline_file(slug, ref, path, alternatives) + return audit.contents_text(content) def check_units(spec): """Yield the fleet's check units - whole intent/verbatim files, plus each verbatim section of any file. Each is (label, fidelity, entry_appliesTo, section_appliesTo, downstream_path, canonical_path, extract). - downstream_path is where the repo's copy lives (always the entry's `path`); canonical_path is the hub's + downstream_path is the entry's `path`. The repo's copy lives there unless it sits at one of the entry's + `alternatives`, which fetch reads in order where `path` is absent. canonical_path is the hub's canonical (the `reference` snippet for a reference-backed unit, else the same path). extract is None for a whole file, or a section extractor for a verbatim section (the region is compared, not the whole file). """ @@ -90,6 +88,7 @@ def fidelity_pass(spec): spreads = [] # (unit dict, spread dict) for the full table promote = [] # intent units that are uniform fleet-wide mislabel = [] # verbatim units that diverge non-stale + alternatives = audit.declared_alternatives(spec["files"]["baseline"]) for label, fid, ent_ap, sec_ap, down_path, canon_path, extract in check_units(spec): unit = {"path": label, "fidelity": fid} @@ -116,7 +115,10 @@ def fidelity_pass(spec): if not (audit.applies(ent_ap, sel) and audit.applies(sec_ap, sel)): continue text = fetch( - audit.repo_slug(r), down_path, audit.ground_branch_of(r, defaults=defaults) + audit.repo_slug(r), + down_path, + audit.ground_branch_of(r, defaults=defaults), + alternatives.get(down_path, []), ) if ( text is None diff --git a/spec/files.json b/spec/files.json index a2905b24..b5a6d7b8 100644 --- a/spec/files.json +++ b/spec/files.json @@ -31,7 +31,7 @@ { "path": ".github/workflows/deploy-site.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["assert-ref", "validate", "deploy"], "requireTokensInJob": { "deploy": ["deploy-site-task.yml", "with:\n environment:", "contents: read", "DEPLOY_SSH_PRIVATE_KEY"] }, "forbidTokensInJob": { "deploy": ["\n environment:"] } }, "intentRef": "docs/reusable-workflows.md#adopting-the-type-specific-tasks", "appliesTo": ["hugo"] }, { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks.json", "appliesTo": ["csharp"] }, { "path": ".vscode/tasks.json", "sections": ["clean-compile task group"], "reference": "catalog/snippets/configs/vscode-tasks-python.json", "appliesTo": ["python"] }, - { "path": "codecov.yml", "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, + { "path": "codecov.yml", "alternatives": [".codecov.yml"], "fidelity": "intent", "reference": "catalog/snippets/configs/codecov.yml", "intentRef": "WORKFLOW.md", "appliesTo": ["csharp", "python"] }, { "path": ".dockerignore", "appliesTo": ["docker"] }, { "path": "Docker/README.md", "reference": "catalog/snippets/configs/docker-hub-readme.md", "appliesTo": ["docker"] } ] diff --git a/spec/files.schema.json b/spec/files.schema.json index 11602265..d363e6a8 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -32,6 +32,7 @@ "additionalProperties": false, "properties": { "path": { "type": "string" }, + "alternatives": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "uniqueItems": true }, "whole": { "type": "boolean" }, "sections": { "type": "array", diff --git a/spec/project-types.json b/spec/project-types.json index 2f0d2b2a..3ce40d13 100644 --- a/spec/project-types.json +++ b/spec/project-types.json @@ -42,7 +42,7 @@ { "id": "python.pyright.config", "verdict": "intent", "assert": "Build profile: first-party code (src or the integration package) is type-checked in CI by pyright strict, by mypy, or by both. Each CI checker runs strict (pyright strict mode, mypy's strict flags), the strong typing baseline. Third-party strictness is relaxed only where a dependency has no usable types. N/A for the lint-only profile, whose type checker is mypy over stdlib-only code (python.mypy.allowed).", "intentRef": "CODESTYLE.md", "minProfile": "build" }, { "id": "python.config.placement", "verdict": "letter", "assert": "ruff and the type-checker config live in pyproject.toml (canonical); standalone .ruff.toml / pyrightconfig.json / mypy.ini / .mypy.ini, or a setup.cfg [mypy] section, is a drift finding. A Home Assistant integration is the exception - it follows home-assistant/core standalone-config conventions and is scored by ha.python.conventions instead.", "intentRef": "CODESTYLE.md" }, { "id": "python.mypy.allowed", "verdict": "intent", "assert": "mypy is permitted as an additional type checker or as the only one, not banned. It is required for a Home Assistant integration (platinum strict-typing), is the lint-only profile's type checker, and may be a build directory's only CI checker. When used it runs in CI and the editor.", "intentRef": "CODESTYLE.md" }, - { "id": "python.coverage.codecov", "verdict": "letter", "assert": "Every Python directory with tests runs them under coverage and uploads the report to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false). A directory with a uv.lock or a requirements*.txt runs pytest --cov-report=xml, and because --cov-report=xml alone measures nothing and writes no file, it declares pytest-cov among its test dependencies, a dev dependency group in a uv project and a requirements*.txt entry on pip, and selects the coverage source in its own pyproject.toml, an addopts --cov= entry in practice. A lint-only directory runs coverage run -m unittest discover -s tests, then coverage xml. The validator's Python leg runs in each directory the caller declares in python-directories, or at the root where it declares none and a root pyproject.toml is tracked. It deletes the directory's coverage.xml before the run and fails the test step when the run did not write it, and it fails a declared directory that has no tests/. CODECOV_TOKEN is stored in both the repo actions and dependabot secrets stores, the second so the upload does not skip on a Dependabot PR, and the caller maps it to the reusable validator by name. N/A for a repo carrying no tests for this type, which the validator permits only at an undeclared root. In a mixed repo the codecov.yml file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md" }, + { "id": "python.coverage.codecov", "verdict": "letter", "assert": "Every Python directory with tests runs them under coverage and uploads the report to Codecov via codecov/codecov-action, best-effort (continue-on-error and fail_ci_if_error: false). A directory with a uv.lock or a requirements*.txt runs pytest --cov-report=xml, and because --cov-report=xml alone measures nothing and writes no file, it declares pytest-cov among its test dependencies, a dev dependency group in a uv project and a requirements*.txt entry on pip, and selects the coverage source in its own pyproject.toml, an addopts --cov= entry in practice. A lint-only directory runs coverage run -m unittest discover -s tests, then coverage xml. The validator's Python leg runs in each directory the caller declares in python-directories, or at the root where it declares none and a root pyproject.toml is tracked. It deletes the directory's coverage.xml before the run and fails the test step when the run did not write it, and it fails a declared directory that has no tests/. CODECOV_TOKEN is stored in both the repo actions and dependabot secrets stores, the second so the upload does not skip on a Dependabot PR, and the caller maps it to the reusable validator by name. N/A for a repo carrying no tests for this type, which the validator permits only at an undeclared root. In a mixed repo the codecov.yml (or .codecov.yml) file-presence is still required by any co-present type that has tests, e.g. csharp.", "intentRef": "WORKFLOW.md" }, { "id": "python.directories.declared", "verdict": "letter", "assert": "A repository carrying a tracked .py file other than the catalog's carried hub-fetch-run.py declares the python type, or suppresses that discovery advisory with a driftNote naming (python.directories.declared) and its reason, per spec/type-model.md. Every such file sits in a directory the registry's pythonDirectories names, or anywhere under the root where it names none and a root pyproject.toml is tracked. Every caller of the hub's validate-task.yml passes exactly those directories in its python-directories input, as a literal (|) block or a single value, so the audit can see a missing declaration the validator itself only warns about. A root tests/ suite with no root uv.lock or requirements*.txt is never run by the undeclared root default, so a lint-only root declares '.' and a root declaring [project] adds a manifest. spec/audit.py checks each of these mechanically.", "intentRef": "WORKFLOW.md" }, { "id": "python.uvlock.pinned", "verdict": "letter", "assert": "Build profile: the committed uv.lock resolves to LF through the repository-wide .editorconfig and .gitattributes defaults. A CRLF-native operational repo adds a narrow uv.lock LF override only if it adopts the uv build profile. N/A for a non-uv Python repo (a Home Assistant integration on pip/requirements) and for the lint-only profile (no uv.lock by definition).", "intentRef": "GOVERNANCE.md#line-endings", "minProfile": "build" }, { "id": "python.scripts.uvx", "verdict": "letter", "assert": "Lint-only profile only: the tools run via uvx (no project install, no lockfile). A uvx @ pin in a run: step is not Dependabot-trackable, so CI runs uvx ruff@latest / uvx mypy@latest - the fleet rule pins only what Dependabot auto-updates and otherwise runs latest, never a manual pin that goes stale. VS Code tasks, README, and CI all run the unpinned latest. N/A for the build profile, whose uv form pins tool versions via uv.lock + uv sync --frozen instead, and whose pip form takes a tool's version from the requirements*.txt that Dependabot's pip ecosystem bumps where a directory declared in python-directories installs that tool into its .venv.", "intentRef": "CODESTYLE.md" } diff --git a/spec/type-model.md b/spec/type-model.md index fbe2fb87..d2adf7cc 100644 --- a/spec/type-model.md +++ b/spec/type-model.md @@ -34,13 +34,13 @@ A language type is present at one of two **depths**, declared as its `profile`: - **build** - the language is compiled, tested, and/or packaged in this repo. Its full check set applies (style, type-check, tests, coverage, packaging). - **lint-only** - the language is present and style-checked here, but not built or packaged in this repo. Its lint/style/type-check checks apply, and build and packaging checks are N/A. Test and coverage checks are N/A too, except for Python, whose lint-only directories still owe a suite run under coverage. -Each check may declare the **minimum profile** it needs via a `minProfile` field. A check without one applies at every profile, and a check with `minProfile: build` applies only at `build`. So lint/style/type-check checks omit it, while build and package checks set `build`, and so does a coverage check for a language other than Python. The coverage requirement (the CODECOV_TOKEN secret and the codecov.yml file) is N/A for a lint-only language other than Python, and otherwise follows tests: the audit reads the repo's tree for tests and holds the requirement N/A where it finds none. +Each check may declare the **minimum profile** it needs via a `minProfile` field. A check without one applies at every profile, and a check with `minProfile: build` applies only at `build`. So lint/style/type-check checks omit it, while build and package checks set `build`, and so does a coverage check for a language other than Python. The coverage requirement (the CODECOV_TOKEN secret and the codecov.yml file, or its .codecov.yml alternative) is N/A for a lint-only language other than Python, and otherwise follows tests: the audit reads the repo's tree for tests and holds the requirement N/A where it finds none. The profile is **declared and validated**, not merely detected. `python` already reads its shape structurally from `pyproject.toml` (a uv PROJECT with tests and a lockfile, versus stdlib SCRIPTS tooling). That structural read becomes the profile **validator**. A declared `python` profile that contradicts the pyproject shape is a false declaration. One concept (the declared profile), checked by detection, rather than two ways to classify. ### Consequence for cross-cutting checks -A cross-cutting check that presumes a built, tested language must respect the profile. In particular the coverage checks (the `CODECOV_TOKEN` secret and the `codecov.yml` file presence) are **tests-aware**: they are N/A for a lint-only language other than Python, and for any language the repo carries no tests for. A language with no tests here must never manufacture a coverage finding, whether it is lint-only or a package-only build language whose tests live elsewhere or are not yet written. +A cross-cutting check that presumes a built, tested language must respect the profile. In particular the coverage checks (the `CODECOV_TOKEN` secret and the `codecov.yml` or `.codecov.yml` file presence) are **tests-aware**: they are N/A for a lint-only language other than Python, and for any language the repo carries no tests for. A language with no tests here must never manufacture a coverage finding, whether it is lint-only or a package-only build language whose tests live elsewhere or are not yet written. ## Languages diff --git a/spec/validate.py b/spec/validate.py index 53a6caef..d31a59e5 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -222,6 +222,40 @@ def escapes_repo_root(value): ) +def alternatives_errors(baseline): + """The errors in each baseline entry's `alternatives`, the other paths its file may sit at instead. + + Only a whole-file presence or intent entry may declare them, as the one entry naming its path. + No alternative may be a declared path. CI runs no schema validation, so the list's shape is + checked here as files.schema.json states it. + """ + entries = [i for i in baseline if isinstance(i, dict) and isinstance(i.get("path"), str)] + declared = {i["path"] for i in entries} + errors = [] + for item in entries: + if "alternatives" not in item: + continue + alts = item["alternatives"] + path = item["path"] + if not (is_str_list(alts) and alts and all(alts) and len(set(alts)) == len(alts)): + errors.append( + f"files.json: {path} alternatives must be a non-empty array of distinct, non-empty strings" + ) + elif declared.intersection(alts): + errors.append( + f"files.json: {path} alternative(s) {', '.join(sorted(declared.intersection(alts)))} already declared as a baseline path" + ) + if item.get("fidelity", "presence") not in ("presence", "intent") or "sections" in item: + errors.append( + f"files.json: {path} declares alternatives, which only a whole-file presence or intent unit may carry" + ) + if sum(1 for other in entries if other["path"] == path) > 1: + errors.append( + f"files.json: {path} declares alternatives, so no other baseline entry may name it" + ) + return errors + + def reduces_to_repo_root(value): """Whether `value` reduces to the repository root under POSIX path rules. @@ -1296,6 +1330,7 @@ def check_selector(where, applies_to): errors.append( f"files.json: Copilot instructions reference skill path {path} outside every carried tree include" ) + errors.extend(alternatives_errors(baseline)) for item in baseline: if not isinstance(item, dict): errors.append(f"files.json: baseline entry {item!r} is not an object") diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index d6883c87..96627095 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -151,6 +151,70 @@ def test_checks_an_explicit_whole_intent_file_that_also_lists_sections(self) -> self.assertEqual(1, len(validate.carried_link_errors(self.root, baseline))) +class BaselineAlternativesCase(unittest.TestCase): + """A baseline entry's `alternatives`, the other paths its file may sit at instead.""" + + def test_an_intent_file_with_an_alternative_is_clean(self) -> None: + baseline = [{"path": "tool.yml", "fidelity": "intent", "alternatives": [".tool.yml"]}] + + self.assertEqual(validate.alternatives_errors(baseline), []) + + def test_an_entry_without_alternatives_is_clean(self) -> None: + baseline = [{"path": "tool.yml"}, {"path": "tool.yml", "fidelity": "intent"}] + + self.assertEqual(validate.alternatives_errors(baseline), []) + + def test_a_list_off_the_schema_shape_is_rejected(self) -> None: + for alts in (None, [], [1], ".tool.yml", [""], [".tool.yml", ".tool.yml"]): + with self.subTest(alts=alts): + baseline = [{"path": "tool.yml", "alternatives": alts}] + self.assertEqual( + validate.alternatives_errors(baseline), + [ + "files.json: tool.yml alternatives must be a non-empty array of distinct, non-empty strings" + ], + ) + + def test_an_alternative_that_is_a_declared_path_is_rejected(self) -> None: + for baseline in ( + [{"path": "tool.yml", "alternatives": ["tool.yml"]}], + [{"path": "tool.yml", "alternatives": [".tool.yml"]}, {"path": ".tool.yml"}], + ): + with self.subTest(baseline=baseline): + errors = validate.alternatives_errors(baseline) + self.assertEqual(len(errors), 1) + self.assertIn("already declared as a baseline path", errors[0]) + + def test_a_unit_checked_beyond_presence_may_not_carry_one(self) -> None: + for extra in ({"fidelity": "verbatim"}, {"fidelity": "interface"}, {"sections": ["A"]}): + with self.subTest(extra=extra): + baseline = [{"path": "tool.md", "alternatives": [".tool.md"], **extra}] + self.assertEqual( + validate.alternatives_errors(baseline), + [ + "files.json: tool.md declares alternatives, which only a whole-file presence or intent unit may carry" + ], + ) + + def test_a_second_entry_naming_the_same_path_is_rejected(self) -> None: + baseline = [ + {"path": "tool.md", "alternatives": [".tool.md"]}, + {"path": "tool.md", "sections": [{"name": "A", "fidelity": "verbatim"}]}, + ] + + self.assertEqual( + validate.alternatives_errors(baseline), + ["files.json: tool.md declares alternatives, so no other baseline entry may name it"], + ) + + def test_the_live_codecov_entry_accepts_the_dotfile_name(self) -> None: + baseline = validate.load("spec/files.json")["baseline"] + codecov = next(item for item in baseline if item["path"] == "codecov.yml") + + self.assertEqual(codecov.get("alternatives"), [".codecov.yml"]) + self.assertEqual(validate.alternatives_errors(baseline), []) + + class VersionLiteralCase(unittest.TestCase): """The hub's instruction documents name no three-part version or commit SHA, verbatim sections included.""" From 2a37637f6aad043bb06917e83eb0dacb33e5ba2a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 18:57:15 -0700 Subject: [PATCH 06/18] Move workflow_reuse fetch onto audit.contents_text (#3109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `spec/workflow_reuse.py` `fetch` onto `audit.contents_text`, as `spec/fidelity_honesty.py` `fetch` already is, removing its own base64 decode and the unused `base64` import. A directory listing now returns None where it previously raised. Closes on promotion: #3106 Local strict review pass: 0 findings. Spec selftest, spec validate, and the CLI usage and spec validate suites pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 5.5 --- spec/workflow_reuse.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/spec/workflow_reuse.py b/spec/workflow_reuse.py index b30df69c..1b70f643 100755 --- a/spec/workflow_reuse.py +++ b/spec/workflow_reuse.py @@ -25,7 +25,6 @@ """ import argparse -import base64 import difflib import re import sys @@ -68,9 +67,7 @@ def fetch_dir(slug, path, ref): def fetch(slug, path, ref): """Decoded downstream file content, or None if absent / not inline.""" content = audit.gh(f"repos/{slug}/contents/{path}?ref={ref}", ok404=True) - if content is None or content.get("encoding") != "base64": - return None - return base64.b64decode(content["content"]).decode("utf-8", "replace") + return audit.contents_text(content) def norm_lines(text): From 97255b7b3179ec83fcb57dd15fad4414e8de758f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 19:30:52 -0700 Subject: [PATCH 07/18] Test the Alternatives Wiring at the audit_repo and fidelity_pass Call Sites (#3111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `tests/test_audit.py` runs `spec/audit.py` `audit_repo` against a faked `gh`, with a constructed Python repository holding `.codecov.yml` and no `codecov.yml`. It asserts the alternative is read and that no file-presence finding names `codecov.yml` as absent. A control case holding neither asserts the one LETTER naming both paths, which shows the baseline loop ran. - `tests/test_fidelity_honesty.py` runs `spec/fidelity_honesty.py` `fidelity_pass` against a faked `gh`, with the same repository shape holding the canonical text at `.codecov.yml`. It asserts the copy lands in `match` and not in `unavailable`. - The fake raises on any read it does not expect, so a guessed answer cannot steer either run past the code under test. The hub's tracked paths and the canonical's git history are patched to empty, since both walk git and fetch the hub's origin, which a shallow CI checkout refuses. ## Verification - Reverting `alternatives.get(path, [])` to `[]` at the `audit_repo` call site fails both `test_audit` cases. Reverting `alternatives.get(down_path, [])` to `[]` at the `fidelity_pass` call site fails the `test_fidelity_honesty` case. With the argument restored, all three pass. - The three tests pass with `audit._hub_main_rev` and `audit._require_full_history` replaced by functions that raise, so no fetch or history read is reached. - `python3 spec/audit.py --selftest`, `python3 spec/validate.py`, and `python -m unittest discover -s tests` pass, and ruff format, ruff check, mypy, and the prose gate are clean. - Local strict review: two passes. The first raised three findings (two hermeticity defects and one sentence length), all fixed in db9db6bf. The second raised 0. Closes on promotion: #3107 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Tests** * Expanded coverage for repository audits when configuration files use an alternative filename. * Verified audits read the alternative file without reporting it missing, preserve the canonical path during fidelity comparisons, and report absence when neither filename is present. --------- Co-authored-by: Claude Opus 5.5 --- tests/test_audit.py | 119 +++++++++++++++++++++++++++++++++ tests/test_fidelity_honesty.py | 76 +++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100755 tests/test_audit.py create mode 100755 tests/test_fidelity_honesty.py diff --git a/tests/test_audit.py b/tests/test_audit.py new file mode 100755 index 00000000..083ecae3 --- /dev/null +++ b/tests/test_audit.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Drive audit_repo against a faked gh, for what only a whole run of it shows.""" + +from __future__ import annotations + +import base64 +import pathlib +import sys +import unittest +from typing import Any +from unittest import mock + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "spec")) +import audit + +SLUG = "owner/Fixture" +ENTRY = { + "name": "Fixture", + "url": f"https://github.com/{SLUG}", + "status": "cataloged", + "types": ["python"], + "hasDevelop": False, +} +CARRIED = {"pyproject.toml", "tests/test_a.py"} +HEAD = {"commit": {"sha": "c0ffee", "commit": {"tree": {"sha": "feed"}}}} +CODECOV = { + "path": "codecov.yml", + "alternatives": [".codecov.yml"], + "appliesTo": ["python"], +} + + +class FakeGh: + """The REST reads audit_repo makes, answered for a repository carrying exactly `tree`. + + An unanticipated read fails the test rather than answering. A guessed answer could steer + audit_repo past the loop under test while still reading as a pass. + """ + + def __init__(self, tree: set[str]) -> None: + self.tree = tree + self.paths: list[str] = [] + + def __call__(self, path: str, ok404: bool = False) -> Any: + self.paths.append(path) + base = path.split("?", 1)[0] + if base == f"repos/{SLUG}": + return {"private": False, "default_branch": "main"} + if base.startswith(f"repos/{SLUG}/branches/"): + return HEAD + if base == f"repos/{SLUG}/rulesets": + return [] + if base.endswith("/secrets"): + return {"secrets": []} + if base.startswith(f"repos/{SLUG}/git/trees/"): + blobs = [{"path": p, "type": "blob", "sha": "0"} for p in sorted(self.tree)] + return {"tree": blobs, "truncated": False} + if base.startswith(f"repos/{SLUG}/contents/"): + name = base.split("/contents/", 1)[1] + if name in self.tree: + return {"encoding": "base64", "content": base64.b64encode(b"x\n").decode()} + if ok404: + return None + raise AssertionError(f"unexpected gh read: {path}") + + +class AuditRepoAlternativesCase(unittest.TestCase): + """audit_repo's baseline loop passes each path's declared alternatives into the shared reader. + + The reader's own handling of alternatives is covered by the engine's --selftest, so what this + adds is the call site. Were it to stop passing them, a repository holding only an alternative + would read as missing the declared path while that self-test still passed. + """ + + def run_audit(self, tree: set[str]) -> tuple[list[tuple[str, str]], FakeGh]: + """Audit a Python repository carrying `tree`, where a test module makes codecov.yml owed. + + The hub's tracked paths come from a fetch of its origin. Answering them as empty keeps the + run offline and leaves the checkout the suite runs in untouched. + """ + spec = { + "registry": {"repos": [ENTRY], "defaults": {}}, + "settings": audit.load("repo-config/settings.json"), + "secrets": audit.load("spec/secrets.json"), + "files": {"baseline": [CODECOV]}, + } + fake = FakeGh(tree) + with ( + mock.patch.object(audit, "gh", fake), + mock.patch.object(audit, "hub_tracked", lambda rev=None: frozenset()), + ): + findings, _ = audit.audit_repo(ENTRY, spec) + return findings, fake + + def test_a_repository_holding_only_the_alternative_carries_the_file(self) -> None: + findings, fake = self.run_audit(CARRIED | {".codecov.yml"}) + + self.assertIn(f"repos/{SLUG}/contents/.codecov.yml?ref=main", fake.paths) + absent = [text for _, text in findings if text.startswith("file: codecov.yml")] + self.assertEqual(absent, []) + + def test_a_repository_holding_neither_is_reported_under_both_names(self) -> None: + """The control, showing the loop under test ran and reports an absence it sees.""" + findings, _ = self.run_audit(CARRIED) + + self.assertIn( + ( + "LETTER", + ( + "file: codecov.yml or .codecov.yml absent on main " + "(verify intent per AUDIT.md section 7)" + ), + ), + findings, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fidelity_honesty.py b/tests/test_fidelity_honesty.py new file mode 100755 index 00000000..51fa150c --- /dev/null +++ b/tests/test_fidelity_honesty.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Drive fidelity_pass against a faked gh, for how it reads each repository's copy.""" + +from __future__ import annotations + +import base64 +import pathlib +import sys +import unittest +from typing import Any +from unittest import mock + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "spec")) +import audit +import fidelity_honesty + +SLUG = "owner/Fixture" +ENTRY = { + "name": "Fixture", + "url": f"https://github.com/{SLUG}", + "status": "cataloged", + "types": ["python"], +} +CANONICAL = "catalog/snippets/configs/codecov.yml" +CODECOV = { + "path": "codecov.yml", + "alternatives": [".codecov.yml"], + "fidelity": "intent", + "reference": CANONICAL, + "appliesTo": ["python"], +} + + +class FidelityPassAlternativesCase(unittest.TestCase): + """fidelity_pass passes each unit's declared alternatives into the shared reader. + + The reader's own handling of alternatives is covered by spec/audit.py's --selftest, so what + this adds is the call site. Were it to stop passing them, a repository holding only an + alternative would land in the unavailable bucket while that self-test still passed. + + The canonical's history is a git walk that refuses a shallow clone and fetches the hub's + origin, so it is answered as empty. The copy then matches on the current canonical alone. + """ + + def test_a_copy_held_only_at_the_alternative_is_read_and_compared(self) -> None: + canonical = (audit.ROOT / CANONICAL).read_bytes() + reads: list[str] = [] + + def fake_gh(path: str, ok404: bool = False) -> Any: + reads.append(path) + if path == f"repos/{SLUG}/contents/.codecov.yml?ref=main": + return {"encoding": "base64", "content": base64.b64encode(canonical).decode()} + if path == f"repos/{SLUG}/contents/codecov.yml?ref=main" and ok404: + return None + raise AssertionError(f"unexpected gh read: {path}") + + spec = { + "registry": {"repos": [ENTRY], "defaults": {}}, + "files": {"baseline": [CODECOV]}, + } + with ( + mock.patch.object(audit, "gh", fake_gh), + mock.patch.object(audit, "git_file_history", lambda rel_path: []), + ): + spreads, _, _ = fidelity_honesty.fidelity_pass(spec) + + self.assertIn(f"repos/{SLUG}/contents/.codecov.yml?ref=main", reads) + [(unit, spread)] = spreads + self.assertEqual(unit["path"], "codecov.yml") + self.assertIsNotNone(spread) + self.assertEqual(spread["match"], ["Fixture"]) + self.assertEqual(spread["unavailable"], []) + + +if __name__ == "__main__": + unittest.main() From c18116568bb956614ade2163a6a9374b7a34cb4e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 20:18:53 -0700 Subject: [PATCH 08/18] Add a Catalog Snippet for the Release-With-Smoke Caller (#3113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds `catalog/snippets/workflows/test-pull-request-smoke.yml`, the release-with-smoke `test-pull-request.yml` caller, pinned to `6c88850128629bb449fd73c7fbf82968c6cd1557 # 2.0.587`, the release the `publish-release.yml` snippet pins. At that tag `build-release-task.yml` declares every input the smoke job passes and defaults each `enable_*` input true, as the stub's comment says. - `docs/reusable-workflows.md` "Adopting the Gates" links the snippet, and its stub block is now that snippet byte for byte, so the `@ # ` placeholder pins are gone from it. The Stage 2 snippet item is ticked. The wrapped `base_ref`/`ref_name` comment is split to one sentence per line in both copies. - `catalog/snippets/workflows/README.md` lists the snippet, and no longer names "Adopting the Gates" as holding stub shapes without one. - `tests/test_release_guards.py` adds a test holding the doc block and the snippet byte-identical, sharing a `yaml_blocks` helper with the existing `publish-release.yml` check. Placeholders in the other shapes (the `validate` input fragments, the pure-function and type-specific stubs) are unchanged, since the issue asks only for this shape's snippet. The new snippet's comments are moved verbatim from the doc stub, so this pull request carries the `comments` label. Closes on promotion: #2854 ## Local review findings listed at opening The local strict review's two-round edit budget was spent on the opening push, so these two went to this pull request's review loop. Both are now closed: - `introduced`, design: `yaml_blocks` used a regex rather than `fenced_blocks`. **Fixed in 587f53ae**, which builds it on `fenced_blocks` and names `docs/reusable-workflows.md` in that scanner's docstring. - `introduced`, design: the new test's `assertNotIn("", stub)` overlapped the repo gate's `sha-pin` check. **Fixed in 587f53ae**, which drops it. ## Test plan - `python3 -m unittest discover -s tests -p test_release_guards.py`: 27 tests OK. Changing the snippet's pin comment or `smoke: true` fails the new test. - `scripts/prose_lint.py --diff origin/develop` with comments allowed, `scripts/repo_gate.py` (sha-pin resolved 8 pins), `spec/validate.py`, `spec/workflow_reuse.py --selftest`, `scripts/build_dist.py --check`, ruff, mypy: clean. - `scripts/docker_lint.py` markdownlint, cspell, editorconfig-checker, actionlint: clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- catalog/snippets/workflows/README.md | 3 +- .../workflows/test-pull-request-smoke.yml | 93 +++++++++++++++++++ docs/reusable-workflows.md | 13 +-- tests/test_release_guards.py | 29 +++++- 4 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 catalog/snippets/workflows/test-pull-request-smoke.yml diff --git a/catalog/snippets/workflows/README.md b/catalog/snippets/workflows/README.md index f515e716..45446426 100644 --- a/catalog/snippets/workflows/README.md +++ b/catalog/snippets/workflows/README.md @@ -2,12 +2,13 @@ The reusable build and publish workflow tasks serve code-shipping repositories. They are **inert reference here** because this repository is source-only. The hub's `.github/workflows/` directory contains both its own entry-point workflows and the reusable tasks downstream repositories reach by pin. Each row below names the canonical caller snippet for one or more `WORKFLOW.md` guarantees. The table intentionally lists snippets rather than every hub-hosted task. The audit checks that downstream Actions satisfy those guarantees, not that they match these bytes. Release-chain hooks are composite actions because each hook is repository-owned content. They are `dotnet-publish`, `build-nuget`, `build-pypi`, `docker-prepare`, and `docker-build-base`. -A caller stub for a hub-hosted task carries no snippet of its own until the task ships in a release: its `uses:` line would pin a commit no release carries yet, which the pin gate rejects. `test-pull-request.yml` and `run-periodic-codegen-pull-request.yml` gained theirs once `2.0.352` released `validate-task.yml` and `run-codegen-pull-request-task.yml`. `publish-release.yml` gained its own once a release carried the renamed .NET publish interface and the package push had moved to the caller's own job. [`docs/reusable-workflows.md`][reusable-workflows] "Adopting the Gates" and "Adopting the Type-Specific Tasks" carry the remaining stub shapes as reference until each has a snippet of its own. `get-version-task.yml` and `publish-plan-task.yml` below carry no snippet at all, for a different reason: each is called as a job inside a larger stub rather than reached by its own top-level caller. +A caller stub for a hub-hosted task carries no snippet of its own until the task ships in a release: its `uses:` line would pin a commit no release carries yet, which the pin gate rejects. `test-pull-request.yml` and `run-periodic-codegen-pull-request.yml` gained theirs once `2.0.352` released `validate-task.yml` and `run-codegen-pull-request-task.yml`. `publish-release.yml` gained its own once a release carried the renamed .NET publish interface and the package push had moved to the caller's own job. `test-pull-request-smoke.yml` gained its own later, pinned at landing to the same `2.0.587` release as `publish-release.yml`. [`docs/reusable-workflows.md`][reusable-workflows] "Adopting the Type-Specific Tasks" carries the remaining stub shapes as reference until each has a snippet of its own. `get-version-task.yml` and `publish-plan-task.yml` below carry no snippet at all, for a different reason: each is called as a job inside a larger stub rather than reached by its own top-level caller. | File | Role | WORKFLOW.md guarantees | | --- | --- | --- | | `merge-bot-pull-request.yml` | Caller stub for the hub-hosted merge-bot task, pinned to a hub release, the shape every repo carries | D8.1, D8.3, D8.4 | | `test-pull-request.yml` | Caller stub for the hub-hosted `validate-task.yml`, the no-build operational trigger shape, pinned to a hub release | D1.2, D1.5 | +| `test-pull-request-smoke.yml` | Caller stub for the hub-hosted `validate-task.yml` and a `build-release-task.yml` smoke build, the release-with-smoke trigger shape, pinned to a hub release and carried downstream as `test-pull-request.yml` | D1.1, D1.2, D1.3, D1.4, D1.5, D7.4 | | `run-periodic-codegen-pull-request.yml` | Caller stub for the hub-hosted `run-codegen-pull-request-task.yml`, pinned to a hub release, the same per-repo shape a codegen repo carries today | D8.2 | | `publish-release.yml` | Caller stub for the hub-hosted release chain, the NuGet-library shape, pinned to a hub release, with the package push in the caller's own job so the OIDC `job_workflow_ref` claim names the publishing repository | D4.1, D5.2, D7.1, D7.2 | | `.github/workflows/get-version-task.yml` | Hub-hosted and reached by pin from a leaf or publisher rather than carried, with no caller-stub snippet since it is called as a job inside a larger stub, per [`docs/reusable-workflows.md`][reusable-workflows] | D3 | diff --git a/catalog/snippets/workflows/test-pull-request-smoke.yml b/catalog/snippets/workflows/test-pull-request-smoke.yml new file mode 100644 index 00000000..f4c8c0d0 --- /dev/null +++ b/catalog/snippets/workflows/test-pull-request-smoke.yml @@ -0,0 +1,93 @@ +name: Test pull request action + +on: + pull_request: + branches: [main, develop] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + + # One entry per target this repo builds, listing that target's own paths, so a touched target never falls through unfiltered (D1.1). + # A negation such as '!.github/workflows/**' marks a docs-only pull request as a target change, which scenario S2 requires to skip. + # A repo carrying its own build hook lists .github/actions/** too, since that hook builds the target. + changes: + name: Detect changed targets job + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + release: ${{ steps.filter.outputs.release }} + steps: + - name: Checkout code step + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Filter changed paths step + id: filter + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + release: + - 'Widget/**' + - 'Directory.Build.props' + - 'Directory.Packages.props' + - 'global.json' + - 'version.json' + - '.github/actions/**' + + # A C# or Python repo adds secrets: with CODECOV_TOKEN mapped by name here, per D1.6. + validate: + name: Validate sources job + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@6c88850128629bb449fd73c7fbf82968c6cd1557 # 2.0.587 + permissions: + contents: read + + # Never publishes and never uploads (D1.3): smoke: true disables every publish path in build-release-task. + smoke-build: + name: Smoke build job + needs: [changes] + if: needs.changes.outputs.release == 'true' + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@6c88850128629bb449fd73c7fbf82968c6cd1557 # 2.0.587 + permissions: + contents: read + # A Docker repo maps DOCKER_HUB_USERNAME and DOCKER_HUB_ACCESS_TOKEN by name here, since the task logs in to Docker Hub on every build for the higher rate limit. + with: + smoke: true + github: false + dockerhub: false + # Every enable_* input defaults true on the task, so this one-target caller turns off the three it does not build and leaves its own on that default, the same set its publish-release.yml passes. + enable_docker: false + enable_pypi: false + enable_dotnet_publish: false + nuget_project: ./Widget/Widget.csproj + # On a pull_request event github.ref_name is the PR ref (for example 123/merge), never the target branch. + # The logical branch therefore reads base_ref first and falls back to ref_name only on a non-PR trigger. + branch: ${{ github.base_ref || github.ref_name }} + + # Treats a skipped smoke-build (an unchanged target) as pass, and blocks on failure or cancelled (D1.5, D7.4). + check-workflow-status: + name: Check pull request workflow status job + runs-on: ubuntu-latest + needs: [changes, validate, smoke-build] + if: always() + steps: + - name: Check workflow results step + run: | + set -Eeuo pipefail + for result in "changes:${{ needs.changes.result }}" "validate:${{ needs.validate.result }}"; do + name="${result%%:*}" + value="${result#*:}" + if [[ "$value" != "success" ]]; then + echo "::error::Job '$name' did not succeed ($value)." + exit 1 + fi + done + if [[ "${{ needs.smoke-build.result }}" != "success" && "${{ needs.smoke-build.result }}" != "skipped" ]]; then + echo "::error::Job 'smoke-build' did not succeed (${{ needs.smoke-build.result }})." + exit 1 + fi diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 4135cc9a..32a0a283 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -156,7 +156,7 @@ Hub: `validate-task.yml` hosts a `lint` job (the fleet doc-lint block, language - [x] Hub pull request on `develop` with the task, the hub's own hook and default, the manifest contracts, and the catalog snippets left for the release that follows, [#760][pr-760]. - [x] Promoted to `main` in #774 (`0b07a59d`) and released as `2.0.352`, the first tag carrying `validate-task.yml`. -- [ ] Catalog snippets for both stub shapes in [Adopting the Gates][adopting-the-gates]. The no-build shape has one, `catalog/snippets/workflows/test-pull-request.yml`, re-pinned to `2.0.376` in #826. The release-with-smoke shape reaches the hub's `build-release-task.yml` by pin, so a snippet of its own is now writable, and this item stays open until one lands. +- [x] Catalog snippets for both stub shapes in [Adopting the Gates][adopting-the-gates]. The no-build shape has one, `catalog/snippets/workflows/test-pull-request.yml`, re-pinned to `2.0.376` in #826. The release-with-smoke shape has `catalog/snippets/workflows/test-pull-request-smoke.yml`, pinned to `2.0.587` when it landed. - [x] Hook override path observed on a hub pull request run, [proof run][override-path-run] (runs `./.github/actions/validate`, no hub checkout). Default path observed on PhotoCleaner's adoption pull request, [pilot smoke run][pilot-smoke-run], where the hub's `validate-default` ran because that repo carries no `validate` hook. The follow-up self-reference pilot also runs the bundled prose and repository gates through `$/.github/actions/` without checking out the hub. - [x] PhotoCleaner (pilot, release trigger shape with smoke, the same repo that piloted stage 1): ptr727/PhotoCleaner#55 on `develop` (`c80cb29`), promoted in ptr727/PhotoCleaner#56 (`fa91db0`), both on 2026-08-16. `test-pull-request.yml` calls the hub validate task and no repo hook was needed. - [x] HomeAutomation-Config (second pilot, operational trigger shape at adoption, and release model since its 2026-09-24 reclassification): ptr727/HomeAutomation-Config#58 on `develop` (`d920805`), merged 2026-08-19 and carried on `main`, its ground-truth branch, where `test-pull-request.yml` calls `validate-task.yml` by pin, and the same audit reports no `interface` finding on that file. @@ -295,7 +295,7 @@ Two copies today filter Dependabot by ecosystem and semver tier before merging. ## Adopting the Gates -A downstream repo replaces its own `validate-task.yml` job bodies and its `test-pull-request.yml`'s inline lint job with one of the two stub shapes, and deletes the copy of `validate-task.yml` per the `retire` disposition in `spec/divergences.json`. The [no-build caller snippet][no-build-caller-snippet] is the complete no-build shape and carries an adoptable released pin. The catalog snapshot stays at that bootstrap pin as later releases ship. After adoption, the first downstream Dependabot run advances the pin in `.github/workflows/test-pull-request.yml`. The release-with-smoke shape below keeps the placeholder `@ # ` until it has a catalog snippet of its own. A downstream `publish-release.yml` reaches the same `validate-task.yml` by a released pin for its `validate` job. The hub workflow uses its local task path instead. +A downstream repo replaces its own `validate-task.yml` job bodies and its `test-pull-request.yml`'s inline lint job with one of the two stub shapes, and deletes the copy of `validate-task.yml` per the `retire` disposition in `spec/divergences.json`. The [no-build caller snippet][no-build-caller-snippet] is the complete no-build shape and carries an adoptable released pin. The catalog snapshot stays at that bootstrap pin as later releases ship. After adoption, the first downstream Dependabot run advances the pin in `.github/workflows/test-pull-request.yml`. The release-with-smoke shape below is the [release-with-smoke caller snippet][smoke-caller-snippet] byte for byte, pinned the same way. A downstream repo carries it as `.github/workflows/test-pull-request.yml`, since the catalog file takes another name only because the no-build snippet holds that one. A downstream `publish-release.yml` reaches the same `validate-task.yml` by a released pin for its `validate` job. The hub workflow uses its local task path instead. **No-build repos** carry the operational trigger shape [WORKFLOW.md "Branch Model"][workflow] states and [#585][issue-585] settles: a direct push to `develop` runs CI advisory (no required check binds the direct-commit allowance), and a `pull_request` to `main` or `develop` runs it pre-merge and actionable. A release-model repo with no build target takes the same stub with a `pull_request: branches: [main, develop]` trigger instead, since it has no direct-commit allowance to keep advisory. @@ -347,7 +347,7 @@ jobs: # A C# or Python repo adds secrets: with CODECOV_TOKEN mapped by name here, per D1.6. validate: name: Validate sources job - uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@ # + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@6c88850128629bb449fd73c7fbf82968c6cd1557 # 2.0.587 permissions: contents: read @@ -356,7 +356,7 @@ jobs: name: Smoke build job needs: [changes] if: needs.changes.outputs.release == 'true' - uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@ # + uses: ptr727/ProjectTemplate/.github/workflows/build-release-task.yml@6c88850128629bb449fd73c7fbf82968c6cd1557 # 2.0.587 permissions: contents: read # A Docker repo maps DOCKER_HUB_USERNAME and DOCKER_HUB_ACCESS_TOKEN by name here, since the task logs in to Docker Hub on every build for the higher rate limit. @@ -369,8 +369,8 @@ jobs: enable_pypi: false enable_dotnet_publish: false nuget_project: ./Widget/Widget.csproj - # On a pull_request event github.ref_name is the PR ref (for example 123/merge), never the target branch, - # so the logical branch reads base_ref first and only falls back to ref_name on a non-PR trigger. + # On a pull_request event github.ref_name is the PR ref (for example 123/merge), never the target branch. + # The logical branch therefore reads base_ref first and falls back to ref_name only on a non-PR trigger. branch: ${{ github.base_ref || github.ref_name }} # Treats a skipped smoke-build (an unchanged target) as pass, and blocks on failure or cancelled (D1.5, D7.4). @@ -810,6 +810,7 @@ Four things the hub cannot prove fall to downstream adopters. The first is cross [run-purpleair-417]: https://github.com/ptr727/homeassistant-purpleair/actions/runs/37581098442 [run-startup-failure]: https://github.com/ptr727/ProjectTemplate/actions/runs/31972504539 [secrets]: ../spec/secrets.json +[smoke-caller-snippet]: ../catalog/snippets/workflows/test-pull-request-smoke.yml [todo]: ../TODO.md [workflow]: ../WORKFLOW.md [workflow-d8]: ../WORKFLOW.md#d8---bots--automation diff --git a/tests/test_release_guards.py b/tests/test_release_guards.py index a4584eea..6a8492da 100755 --- a/tests/test_release_guards.py +++ b/tests/test_release_guards.py @@ -57,8 +57,8 @@ def fenced_blocks(markdown: str) -> list[FencedBlock]: Legal Markdown is among what this refuses, a fence under a second list level and one carrying a tilde in its info string among it, and the refused cases below are the list. - No document here uses any of it today, and only `AUDIT.md` is under test, so that stays - true by convention rather than by check. + No document here uses any of it today. Only `AUDIT.md` and `docs/reusable-workflows.md` are + under test, so that stays true by convention rather than by check. One container case is left rather than refused. An HTML block holding an odd number of fence-shaped lines flips the open and closed parity, since a renderer reads the block as raw @@ -204,6 +204,11 @@ def shell_blocks(markdown: str) -> list[str]: return [b.body for b in fenced_blocks(markdown) if b.label.lower() in SHELL_LABELS] +def yaml_blocks(markdown: str) -> list[str]: + """The body of every fenced YAML block in `markdown`, ending in the newline a file holding it ends in.""" + return [b.body + "\n" for b in fenced_blocks(markdown) if b.label.lower() == "yaml"] + + def fenced_shell_block(markdown: str, marker: str) -> str: """The one fenced shell block in `markdown` containing `marker`. @@ -396,8 +401,7 @@ def suffix_for(event: str, ref: str, actor: str) -> str: self.assertEqual("", suffix_for("push", main, "ptr727-codegen[bot]")) self.assertEqual("-7", suffix_for("push", main, "someone")) self.assertEqual("-7", suffix_for("push", "refs/heads/develop", "ptr727-codegen[bot]")) - docs = (REPO / "docs/reusable-workflows.md").read_text(encoding="utf-8") - blocks = re.findall(r"(?ms)^```yaml\n(.*?)^```\n", docs) + blocks = yaml_blocks((REPO / "docs/reusable-workflows.md").read_text(encoding="utf-8")) stub_name = stub.splitlines()[0] self.assertIn(stub, [block for block in blocks if block.startswith(stub_name)]) @@ -701,6 +705,15 @@ def test_hub_release_task_never_pushes_to_a_package_registry(self) -> None: with self.subTest(marker=marker): self.assertIn(marker, stub_text) + def test_smoke_caller_stub_is_its_snippet_byte_for_byte(self) -> None: + """The documented release-with-smoke stub is the catalog snippet, so the two cannot drift.""" + stub = (REPO / "catalog/snippets/workflows/test-pull-request-smoke.yml").read_text( + encoding="utf-8" + ) + blocks = yaml_blocks((REPO / "docs/reusable-workflows.md").read_text(encoding="utf-8")) + self.assertIn(stub, blocks) + self.assertIn("smoke: true", stub) + def test_release_asset_hook_upload_stays_with_the_task(self) -> None: workflow = (REPO / ".github/workflows/build-release-task.yml").read_text(encoding="utf-8") jobs = re.split(r"(?m)^ (?=[a-z][a-z-]*:\n)", workflow.split("\njobs:\n", 1)[1]) @@ -820,7 +833,13 @@ def test_no_bare_whitespace_predicate_in_the_block_scanner(self) -> None: offenders: list[tuple[str, str]] = [] exempt = 0 - for function in (fenced_blocks, shell_blocks, mislabeled_posix_blocks, fenced_shell_block): + for function in ( + fenced_blocks, + shell_blocks, + yaml_blocks, + mislabeled_posix_blocks, + fenced_shell_block, + ): found, marked = bare_whitespace_predicates(inspect.getsource(function)) offenders.extend((function.__name__, line) for line in found) exempt += marked From 5d369fd15aa705da8ef38ed1d67ac891c778016f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 21:24:23 -0700 Subject: [PATCH 09/18] Stop the Doc Gates' Printed Names Forging Workflow Commands (#3116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The Actions runner reads a stdout or stderr line as a workflow command in two ways, and a tracked name or a file's own text could reach either one through the output of both doc gates. - **The current form** opens on `::` once the runner trims leading whitespace, so the indent in front of a `repo_gate.py` finding gives no protection. - **The legacy form** is `##[` anywhere in the line. The runner's `ActionCommand.TryParse` finds it with `IndexOf` and still runs it when a registered command name follows up to `]`. The issue did not name this form, and it is fixed here as well. ## Changes - `repo_gate.py`'s `printable()` now spells the colon opening such a line as `\x3a`, and the bracket of each `##[` as `\x5b`. That matches how it already spells each control character. Every finding and note passes through it, so no call site changed. - `prose_lint.py` gains the same `printable()`, control escaping included, and applies it to every line carrying a path or file text. That covers the finding line, `--list-files`, the scope note, the summary's per-file lines, the filesystem-walk warning, and the error lines that quote paths or the diff base. The finding line opened on the raw tracked name with no indent, so a name opening on `::` forged a command there directly. A name holding a newline also printed raw, which let it start a line of its own. - The prose gate's composite action echoed each line of the tracked `.github/prose-gate-excludes` raw, before `prose_lint.py` ran. A fork controls that file, so an exclusion holding `##[`, or a carriage return followed by `::`, forged a command there. The echo now spells the bracket as `\x5b` and a carriage return as `\x0d`. - `tests/runner_commands.py` models which lines the runner would pass to a command parser. It follows the runner's two parsers rather than the gates' own escaping. Both gates' tests use it, through `printable()` and through a real run over tracked names built to forge each form. The run covers the finding lines, `--summary`, `--list-files`, and a name the scope note prints on stderr. A parity test keeps the two gates' `printable()` copies equal, since each gate is fetched downstream as one file and cannot import the other. ## Design choice The issue left a choice: neutralize the printed lines, or wrap the findings in a `::stop-commands::` block with a random token. This change neutralizes the lines, for three reasons: - Neutralizing covers the legacy `##[` form along with the `::` form, in a single helper each gate already routes its output through. - The output stays deterministic, so a test can check it without a runner, and a local run shows no token lines. - A `stop-commands` block depends on its resume line being printed. A gate killed mid-run would leave command processing off for the rest of the step, and that includes the composite action's own `::error::` lines. ## Verification - The new tests fail with the two neutralizing lines removed from both gates (14 failures). Removing a single call site also fails a test: the finding line's, the `--summary` line's, or the `--list-files` line's `printable()`, or either substitution in the action's echo. - `python3 -m unittest discover -s tests` passes with 2867 tests and 7 skipped. ruff format, ruff check, and mypy are clean, as are `prose_lint.py --diff origin/develop`, `repo_gate.py`, and `spec/validate.py`. - actionlint, run through `scripts/docker_lint.py`, is clean. - The local strict review ran three rounds. Round 1 found the action's echo and the untested output modes. Round 2 found the carriage-return route. Round 3 raised no findings. ## Follow-up - #3115 moves the release-guard tests' inline command check onto `tests/runner_commands.py`. That check misses a command line led by whitespace. Closes on promotion: #1874 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Text in validation messages and file paths that resembles workflow commands is now displayed safely rather than being interpreted as a command by the automation runner. * Control characters and invalid text are escaped in output to prevent unexpected line breaks or command-like behavior. Validation results and outcomes remain unchanged. --- .github/actions/prose-gate/action.yml | 4 +- .github/actions/prose-gate/prose_lint.py | 88 ++++++++++++++++-------- .github/actions/repo-gate/repo_gate.py | 8 +++ tests/runner_commands.py | 35 ++++++++++ tests/test_prose_lint.py | 73 ++++++++++++++++++++ tests/test_repo_gate.py | 41 +++++++++++ 6 files changed, 220 insertions(+), 29 deletions(-) create mode 100644 tests/runner_commands.py diff --git a/.github/actions/prose-gate/action.yml b/.github/actions/prose-gate/action.yml index 7062d07f..0e1799a6 100644 --- a/.github/actions/prose-gate/action.yml +++ b/.github/actions/prose-gate/action.yml @@ -84,7 +84,9 @@ runs: excluded_paths+=("$excluded") exclude_args+=(--exclude "$excluded") done < "$exclude_file" - echo "Excluding ${#excluded_paths[@]} path(s) from $exclude_file: ${excluded_paths[*]}" + inert_excludes="${excluded_paths[*]}" + inert_excludes="${inert_excludes//$'\r'/'\x0d'}" + echo "Excluding ${#excluded_paths[@]} path(s) from $exclude_file: ${inert_excludes//'##['/'##\x5b'}" fi # The context is preferred and the default environment variable is the fallback, so a reference form that populates only one of them still names the pin. # Naming a repository with no ref, or a ref with no repository, would be worse than naming nothing, since the value exists to identify one commit. diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index f86eae3a..3003cd67 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -899,6 +899,26 @@ def quoted(paths, cap: int | None = None) -> str: return ", ".join(repr(str(p)) for p in shown) + more +CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") +COMMAND_LEAD = re.compile(r"\A(\s*):(?=:)") +LEGACY_COMMAND = "##[" + + +def printable(line: str) -> str: + """`line` spelled so it prints as one line of this gate's output and nothing else. + + A tracked name and a file's own text both reach a finding, and either may hold a newline, an + escape sequence, or a workflow command. The Actions runner executes a line opening on `::` + once leading whitespace is trimmed, and a line holding `##[` anywhere. So each C0 or C1 + control and DEL is escaped, as are the colon opening such a line and the bracket of each + `##[`. A lone surrogate from a name that is not UTF-8 is escaped too, for a strict stream. + """ + escaped = CONTROL.sub(lambda m: f"\\x{ord(m.group()):02x}", line) + escaped = COMMAND_LEAD.sub(lambda m: f"{m.group(1)}\\x3a", escaped) + escaped = escaped.replace(LEGACY_COMMAND, "##\\x5b") + return escaped.encode("utf-8", "backslashreplace").decode("utf-8") + + def repo_root(path: Path) -> str: """The repository top level containing `path`, or '' when git cannot say.""" start = path if path.is_dir() else path.parent @@ -1001,7 +1021,9 @@ def discover( # It also printed that git could not describe a tree git describes fine. if candidates is None and not repo_root(base): print( - f"warning: git cannot describe {base}, falling back to a filesystem walk", + printable( + f"warning: git cannot describe {base}, falling back to a filesystem walk" + ), file=sys.stderr, ) # A walk reports what is on disk, so it carries the untracked files already. @@ -3288,18 +3310,22 @@ def main(argv: list[str] | None = None) -> int: if unusable: # Quoted, since a path holding a space or a comma is unreadable in a bare comma-joined list. print( - f"error: requested path(s) are not a file or a directory: {quoted(unusable)}. " - "Refusing rather than falling back to the current directory, which would scan one " - "tree and choose the rule set from another.", + printable( + f"error: requested path(s) are not a file or a directory: {quoted(unusable)}. " + "Refusing rather than falling back to the current directory, which would scan " + "one tree and choose the rule set from another." + ), file=sys.stderr, ) return 2 git_roots = {found for found in (repo_root(Path(p)) for p in scan_paths) if found} if len(git_roots) > 1: print( - f"error: the requested paths span more than one repository ({quoted(git_roots)}). " - "Each declares its own workflow model, so no single rule set is correct for all of " - "them. Run the gate once per repository.", + printable( + f"error: the requested paths span more than one repository ({quoted(git_roots)}). " + "Each declares its own workflow model, so no single rule set is correct for all " + "of them. Run the gate once per repository." + ), file=sys.stderr, ) return 2 @@ -3355,7 +3381,7 @@ def main(argv: list[str] | None = None) -> int: if a.list_files: for f in files: - print(keys[f]) + print(printable(keys[f])) return 0 discovered = len(files) @@ -3368,10 +3394,12 @@ def main(argv: list[str] | None = None) -> int: # A CI adoption hits this first, where an unresolvable base walls off the first run. # Scoping to nothing instead would report a false clean, so neither default is honest. print( - f"error: cannot diff against {a.diff!r}, so the run cannot be scoped to changed " - "lines. Refusing to scan the whole tree instead, since that reports the existing " - "backlog as though this change introduced it. Check the ref exists and that the " - "checkout carries its history.", + printable( + f"error: cannot diff against {a.diff!r}, so the run cannot be scoped to changed " + "lines. Refusing to scan the whole tree instead, since that reports the existing " + "backlog as though this change introduced it. Check the ref exists and that the " + "checkout carries its history." + ), file=sys.stderr, ) return 2 @@ -3390,12 +3418,14 @@ def main(argv: list[str] | None = None) -> int: if unread: shown = quoted(unread, 5) print( - f"error: the diff against {a.diff!r} names {len(unread)} readable file(s) " - f"this run was asked about, and the scan matched none of them: {shown}. " - "Refusing to report a clean run, since a gate that read nothing is " - "indistinguishable from a gate with nothing to read. Check that the run " - "starts at the repository top level and that the requested paths cover " - "the change.", + printable( + f"error: the diff against {a.diff!r} names {len(unread)} readable " + f"file(s) this run was asked about, and the scan matched none of them: " + f"{shown}. Refusing to report a clean run, since a gate that read nothing " + "is indistinguishable from a gate with nothing to read. Check that the " + "run starts at the repository top level and that the requested paths " + "cover the change." + ), file=sys.stderr, ) return 2 @@ -3415,18 +3445,20 @@ def main(argv: list[str] | None = None) -> int: bykind[kind] = bykind.get(kind, 0) + 1 byfile[keys[f]] = byfile.get(keys[f], 0) + 1 if not a.summary: - print(f"{keys[f]}:{ln}: {kind}: {msg}") + print(printable(f"{keys[f]}:{ln}: {kind}: {msg}")) inscope = sum(len(scope[keys[f]]) for f in files) if scope is not None else None print( - scope_note( - len(files), - discovered, - inscope, - a.diff, - gate_provenance(a.provenance), - [keys[f] for f in refused], - [keys[f] for f in undecoded], + printable( + scope_note( + len(files), + discovered, + inscope, + a.diff, + gate_provenance(a.provenance), + [keys[f] for f in refused], + [keys[f] for f in undecoded], + ) ), file=sys.stderr, ) @@ -3436,7 +3468,7 @@ def main(argv: list[str] | None = None) -> int: print(f" {k:16} {v}", file=sys.stderr) if a.summary: for k, v in sorted(byfile.items(), key=lambda kv: -kv[1])[:15]: - print(f" {v:5} {k}", file=sys.stderr) + print(printable(f" {v:5} {k}"), file=sys.stderr) return 1 if total else 0 diff --git a/.github/actions/repo-gate/repo_gate.py b/.github/actions/repo-gate/repo_gate.py index 1bc0cd9e..832bd76b 100755 --- a/.github/actions/repo-gate/repo_gate.py +++ b/.github/actions/repo-gate/repo_gate.py @@ -56,6 +56,8 @@ ABSENT = {"404", "422"} GH_TIMEOUT = 20 CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") +COMMAND_LEAD = re.compile(r"\A(\s*):(?=:)") +LEGACY_COMMAND = "##[" # How a check says it did less than its name. # A gate that quietly degrades to a weaker reading prints the same clean line as one that ran. @@ -783,8 +785,14 @@ def printable(line: str) -> str: or drives the reader's terminal, so each C0 or C1 control and DEL is escaped here, where it is shown. A byte that is not UTF-8 arrives as a lone surrogate, which a strict stream refuses to encode, so it is spelled as an escape too, leaving the result safe on any stream. + + The Actions runner executes a line opening on `::` once leading whitespace is trimmed. It also + executes a line holding `##[` anywhere. A name shaped like either forges a workflow command. + The colon opening such a line and the bracket of each `##[` are escaped for that reason. """ escaped = CONTROL.sub(lambda m: f"\\x{ord(m.group()):02x}", line) + escaped = COMMAND_LEAD.sub(lambda m: f"{m.group(1)}\\x3a", escaped) + escaped = escaped.replace(LEGACY_COMMAND, "##\\x5b") return escaped.encode("utf-8", "backslashreplace").decode("utf-8") diff --git a/tests/runner_commands.py b/tests/runner_commands.py new file mode 100644 index 00000000..43dfa3b7 --- /dev/null +++ b/tests/runner_commands.py @@ -0,0 +1,35 @@ +"""Which output lines the GitHub Actions runner would read as a workflow command. + +Modeled on the runner's own two parsers rather than on the escaping a gate applies. A test +asserting through it measures the runner's reading of the output, not the gate's intent. The +current form opens on `::` once leading whitespace is trimmed. The legacy form is `##[` anywhere +in the line. + +`FORGING_NAMES` holds constructed names, one per way a line reaches either parser. The +ideographic space is whitespace the runner's trim removes, as is the plain indent a gate prints. +`FORGING_LINES` adds edge shapes, and `ORDINARY_LINES` holds lines a gate must print unchanged. +""" + +from __future__ import annotations + +FORGING_NAMES = ( + "::error title=forged::all checks passed", + "\u3000::warning::forged", + "run-##[error]forged", +) +FORGING_LINES = (*FORGING_NAMES, " ::add-mask::x", "::", "a::b ##[ ##[") +ORDINARY_LINES = ("docs/a.md:3: dupword: x", "a::b: mid-line colons", "# [heading]") + + +def opens_a_command(line: str) -> bool: + """Whether the runner would hand `line` to a command parser at all.""" + return line.lstrip().startswith("::") or "##[" in line + + +def forged_lines(output: str) -> list[str]: + """Every line of `output` the runner would read as a command. + + `str.splitlines` splits on more boundaries than the runner's line reader does. That only ever + adds candidate lines, so a clean answer here is a clean answer there. + """ + return [line for line in output.splitlines() if opens_a_command(line)] diff --git a/tests/test_prose_lint.py b/tests/test_prose_lint.py index 71e97b67..846cd453 100755 --- a/tests/test_prose_lint.py +++ b/tests/test_prose_lint.py @@ -30,6 +30,7 @@ from unittest import mock from host_capability import bash_or_skip +from runner_commands import FORGING_LINES, FORGING_NAMES, ORDINARY_LINES, forged_lines from workflow_steps import step_script, yaml_step, yaml_value try: @@ -4432,6 +4433,16 @@ def test_a_declared_vendored_path_does_not_block_the_gate(self) -> None: self.assertEqual(0, result.returncode, result.stdout + result.stderr) self.assertIn("Excluding 1 path(s)", result.stdout) + def test_an_exclusion_shaped_like_a_command_is_echoed_inert(self) -> None: + (self.root / ".github" / "prose-gate-excludes").write_bytes( + b"vendor/x##[error]forged\nvendor/a\r::error::forged\n" + ) + result = self.run_action() + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual([], forged_lines(result.stdout + result.stderr)) + self.assertIn("vendor/x##\\x5berror]forged", result.stdout) + self.assertIn("vendor/a\\x0d::error::forged", result.stdout) + def test_an_empty_contract_is_no_exclusions(self) -> None: (self.root / ".github" / "prose-gate-excludes").write_bytes(b"") result = self.run_action() @@ -5397,6 +5408,68 @@ def test_a_filename_that_is_not_utf8_is_scanned_and_reported(self) -> None: self.assertNotIn("UnicodeEncodeError", done.stderr) +class TestNamesCannotForgeWorkflowCommands(unittest.TestCase): + """A tracked name or file text shaped like a workflow command prints as inert text. + + The finding line opens on the tracked name with no indent at all. The runner reads both of + the gate's streams, so each is checked, on every output mode that prints a name. + """ + + NAMES = (*FORGING_NAMES, "split.md\n::error::forged") + UNDECODED = "run-##[error]undecoded.md" + + def test_printable_leaves_no_line_a_command_parser_reads(self) -> None: + for line in FORGING_LINES: + with self.subTest(line=line): + self.assertEqual([], forged_lines(prose_lint.printable(line))) + + def test_printable_leaves_an_ordinary_line_as_it_was(self) -> None: + for line in ORDINARY_LINES: + with self.subTest(line=line): + self.assertEqual(line, prose_lint.printable(line)) + + def tree(self) -> Path: + root = Path(self.enterContext(tempfile.TemporaryDirectory())) + subprocess.run(["git", "init", "-q", str(root)], check=True) + try: + for name in self.NAMES: + (root / f"{name}.md").write_text("It has a dupword dupword in it.\n", "utf-8") + (root / self.UNDECODED).write_bytes(b"caf\xe9\n") + except OSError: + self.skipTest("this filesystem refuses a colon, a bracket, or a newline in a name") + subprocess.run(["git", "-C", str(root), "add", "-A"], check=True) + return root + + def run_gate(self, root: Path, *args: str) -> subprocess.CompletedProcess[str]: + done = subprocess.run( + [sys.executable, str(PROSE_LINT_SCRIPT), str(root), "--check", "dupword", *args], + capture_output=True, + encoding="utf-8", + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + check=False, + cwd=root, + ) + self.assertEqual([], forged_lines(done.stdout)) + self.assertEqual([], forged_lines(done.stderr)) + return done + + def test_a_forging_name_raises_its_finding_without_forging_a_command(self) -> None: + done = self.run_gate(self.tree()) + self.assertEqual(1, done.returncode, done.stderr) + self.assertEqual(len(self.NAMES), done.stdout.count(": dupword: "), done.stdout) + self.assertIn("\\x3a:error title=forged::all checks passed.md:1: dupword", done.stdout) + self.assertIn("split.md\\x0a::error::forged.md:1: dupword", done.stdout) + self.assertIn("run-##\\x5berror]undecoded.md", done.stderr) + + def test_the_summary_and_the_file_listing_print_a_forging_name_inert(self) -> None: + root = self.tree() + summary = self.run_gate(root, "--summary") + self.assertIn("run-##\\x5berror]forged.md", summary.stderr) + listing = self.run_gate(root, "--list-files") + self.assertEqual(0, listing.returncode, listing.stderr) + self.assertIn("\\x3a:error title=forged::all checks passed.md", listing.stdout) + + class TestDiffScopeReachesAQuotedName(unittest.TestCase): """A name git quotes in a diff header must still land in scope. diff --git a/tests/test_repo_gate.py b/tests/test_repo_gate.py index c3547f86..06f23873 100755 --- a/tests/test_repo_gate.py +++ b/tests/test_repo_gate.py @@ -27,6 +27,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / ".github/actions/repo-gate")) import repo_gate +from runner_commands import FORGING_LINES, FORGING_NAMES, ORDINARY_LINES, forged_lines REPO = Path(__file__).resolve().parent.parent GOVERNANCE = REPO / "GOVERNANCE.md" @@ -586,6 +587,46 @@ def test_a_control_character_in_a_name_prints_escaped_rather_than_raw(self) -> N self.assertFalse(any(l.startswith("[ok ] forged") for l in out.getvalue().splitlines())) +class TestNamesCannotForgeWorkflowCommands(GitTreeCase): + """A tracked name shaped like a workflow command prints as text the runner does not execute.""" + + def test_printable_leaves_no_line_a_command_parser_reads(self) -> None: + for line in FORGING_LINES: + with self.subTest(line=line): + self.assertEqual([], forged_lines(f" {repo_gate.printable(line)}")) + + def test_printable_leaves_an_ordinary_line_as_it_was(self) -> None: + for line in ORDINARY_LINES: + with self.subTest(line=line): + self.assertEqual(line, repo_gate.printable(line)) + + def test_both_gates_escape_a_line_identically(self) -> None: + """Each gate is fetched as one file, so each holds its own copy, and this keeps them equal.""" + prose_gate = REPO / ".github/actions/prose-gate" + with mock.patch.object(sys, "path", [str(prose_gate), *sys.path]): + prose_lint = importlib.import_module("prose_lint") + for line in (*FORGING_LINES, *ORDINARY_LINES, "tab\there", "\x85::x", "bad\udcffbyte"): + with self.subTest(line=line): + self.assertEqual(repo_gate.printable(line), prose_lint.printable(line)) + + def test_a_forging_name_raises_its_finding_without_forging_a_command(self) -> None: + gitattributes = TestEolCoverage.GITATTRIBUTES.replace("eol=lf", "eol=crlf", 1) + tree = {name: "#!/bin/sh\nexit 0\n" for name in FORGING_NAMES} + try: + self.coverage(gitattributes, tree) + except OSError: + self.skipTest("this filesystem refuses a colon, a bracket, or a wide space in a name") + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = repo_gate.main(["--root", str(self.tmp), "--check", "eol-coverage"]) + self.assertEqual(1, rc) + self.assertEqual([], forged_lines(out.getvalue())) + self.assertIn( + "\\x3a:error title=forged::all checks passed: tracked shebang", out.getvalue() + ) + self.assertIn("run-##\\x5berror]forged: tracked shebang", out.getvalue()) + + class TestGovernanceCoupling(unittest.TestCase): def test_the_exception_set_matches_what_the_doc_documents(self) -> None: """The doc calls it the one documented exception, so the code must not carry a second.""" From 0a0660dbe21fa324d5d365dccb0159d721f309b0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 21:43:36 -0700 Subject: [PATCH 10/18] Move the Release-Guard Tests' Workflow-Command Checks Onto runner_commands (#3118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `tests/test_audit.py` runs `spec/audit.py` `audit_repo` against a faked `gh`, with a constructed Python repository holding `.codecov.yml` and no `codecov.yml`. It asserts the alternative is read and that no file-presence finding names `codecov.yml` as absent. A control case holding neither asserts the one LETTER naming both paths, which shows the baseline loop ran. - `tests/test_fidelity_honesty.py` runs `spec/fidelity_honesty.py` `fidelity_pass` against a faked `gh`, with the same repository shape holding the canonical text at `.codecov.yml`. It asserts the copy lands in `match` and not in `unavailable`. - The fake raises on any read it does not expect, so a guessed answer cannot steer either run past the code under test. The hub's tracked paths and the canonical's git history are patched to empty, since both walk git and fetch the hub's origin, which a shallow CI checkout refuses. ## Verification - Reverting `alternatives.get(path, [])` to `[]` at the `audit_repo` call site fails both `test_audit` cases. Reverting `alternatives.get(down_path, [])` to `[]` at the `fidelity_pass` call site fails the `test_fidelity_honesty` case. With the argument restored, all three pass. - The three tests pass with `audit._hub_main_rev` and `audit._require_full_history` replaced by functions that raise, so no fetch or history read is reached. - `python3 spec/audit.py --selftest`, `python3 spec/validate.py`, and `python -m unittest discover -s tests` pass, and ruff format, ruff check, mypy, and the prose gate are clean. - Local strict review: two passes. The first raised three findings (two hermeticity defects and one sentence length), all fixed in db9db6bf. The second raised 0. Closes on promotion: #3107 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- tests/test_release_guards.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/test_release_guards.py b/tests/test_release_guards.py index 6a8492da..fc66f118 100755 --- a/tests/test_release_guards.py +++ b/tests/test_release_guards.py @@ -16,6 +16,7 @@ from typing import NamedTuple from host_capability import bash_or_skip +from runner_commands import forged_lines from workflow_steps import step_script_text REPO = Path(__file__).resolve().parents[1] @@ -314,7 +315,11 @@ def compute(branch: str, semver2: str) -> int: # The rejected value is printed for diagnosis, and neither command syntax may survive in it. # The runner reads a :: command only at a line start, but a ##[ command anywhere in a line. - for injected in ("1.2\n::error::injected", "1.2\n##[error]injected"): + for injected in ( + "1.2\n::error::injected", + "1.2\n##[error]injected", + "1.2\n\u3000::warning::injected", + ): with self.subTest(injected=injected): output.write_text("", encoding="utf-8") env = {**os.environ, "BRANCH": "main", "SEMVER2": injected} @@ -329,9 +334,10 @@ def compute(branch: str, semver2: str) -> int: ) self.assertEqual(1, verdict.returncode) self.assertIn("injected", verdict.stdout) - lines = verdict.stdout.splitlines() - self.assertEqual(1, len([line for line in lines if line.startswith("::")])) - self.assertNotIn("##[", verdict.stdout) + self.assertEqual( + ["::error::semver2 carries no M.N.P core to build a PEP 440 version from."], + forged_lines(verdict.stdout), + ) # The version step compares the branch but never prints it. output.write_text("", encoding="utf-8") @@ -572,9 +578,12 @@ def gate(branch: str, semver2: str, smoke: str = "false") -> tuple[int, str]: with self.subTest(smoke=smoke): code, output = gate(injected, "1.2.34-g1a2b3c4d5e", smoke=smoke) self.assertEqual(1, code) - commands = [line for line in output.splitlines() if line.startswith("::")] - self.assertEqual(1, len(commands)) - self.assertNotIn("##[", output) + self.assertEqual( + [ + "::error::The branch input is not a valid git branch name, so the build is refused." + ], + forged_lines(output), + ) self.assertNotIn("injected", output) # A valid branch reaches the version check, whose refusal names the version it rejected. From 0b9ec4f4f679e7282e9c1125aebfee6f0a321290 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 22:29:16 -0700 Subject: [PATCH 11/18] State the Subprocess Encoding Rule Where Carriers Read Python Style (#3120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary States the subprocess encoding rule where a fleet carrier reads Python style: every text-mode `subprocess` call names its `encoding`, and what a byte outside that encoding is worth is a per-call `errors` decision, the strict default deciding it by raising. `CODESTYLE.md`'s Python section is a pointer to the `python-codestyle` Skill, so the rule goes under "Patterns to avoid" in `.agents/skills/python-codestyle/references/code-style.md`, which the pointer's scope sketch already names. The pointer itself needs no change. The rule carries its reason: below Python 3.15, outside UTF-8 Mode, an unnamed encoding falls to the locale encoding, usually UTF-8 on Linux and the ANSI code page on Windows, so a child writing UTF-8 is misread or rejected on any non-UTF-8 host, and no static check in the ruff, pyright, or mypy toolchain looks for the omission. The call names the encoding its child uses on that pipe, in both directions. It states the rule rather than this repository's own test, since a carrier gets the rule and not the mechanism. The generated distributions are regenerated with `scripts/build_dist.py`. ## Verification - `python3 scripts/build_dist.py --check`: current. - `python3 scripts/prose_lint.py --diff `, with and without `--check sentence-length`: clean. - `python3 scripts/docker_lint.py` for markdownlint, cspell, and editorconfig-checker: 0 issues. - `python3 spec/validate.py`: OK. - `local-strict-review`: round 1 raised 3 findings (a sentence over the length limit, the reason unbounded by UTF-8 Mode and Python 3.15, and an "only guard" claim that did not follow), all fixed in 9dbef04d. Round 2 raised none. CodeRabbit's locale finding was fixed in 1974fa90, and the passes after it raised two more (a misread tied to the host rather than the child, and a rule covering only the read direction), fixed in e0af782a and 11691d41. The final pass, on 11691d41, raised none. Closes on promotion: #1578 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../skills/python-codestyle/references/code-style.md | 10 ++++++++++ .../fleet-skills/.source-digests/python-codestyle | 2 +- .../skills/python-codestyle/references/code-style.md | 10 ++++++++++ .../skills/python-codestyle/references/code-style.md | 10 ++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.agents/skills/python-codestyle/references/code-style.md b/.agents/skills/python-codestyle/references/code-style.md index fb28bb42..cca918e1 100644 --- a/.agents/skills/python-codestyle/references/code-style.md +++ b/.agents/skills/python-codestyle/references/code-style.md @@ -87,6 +87,16 @@ - **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. +- **Don't leave a text-mode `subprocess` call to the locale's encoding.** Every call that reads + or writes a pipe as text (`text=True`, `universal_newlines=True`, or an `errors` argument) names + the encoding its child uses on that pipe. Below Python 3.15, a call that names none encodes and + decodes with the locale encoding unless UTF-8 Mode is on. That encoding follows the host's + locale, usually UTF-8 on Linux and the ANSI code page on Windows. The omission therefore looks + correct on a UTF-8 host. On any other, a Windows host being the common case, it misreads or + rejects non-ASCII output from a child writing UTF-8. No static check in the ruff, pyright, or + mypy toolchain looks for the omission in a subprocess call. Naming the encoding settles how the + bytes are translated, not what a byte or character outside that encoding is worth. Each call + decides that with its own `errors` argument, and the strict default decides it by raising. diff --git a/.claude-plugin/fleet-skills/.source-digests/python-codestyle b/.claude-plugin/fleet-skills/.source-digests/python-codestyle index 0148f2b3..75e73847 100644 --- a/.claude-plugin/fleet-skills/.source-digests/python-codestyle +++ b/.claude-plugin/fleet-skills/.source-digests/python-codestyle @@ -1 +1 @@ -78b687fa4e03cda7 +a54a634cf8810359 diff --git a/.claude-plugin/fleet-skills/skills/python-codestyle/references/code-style.md b/.claude-plugin/fleet-skills/skills/python-codestyle/references/code-style.md index fb28bb42..cca918e1 100644 --- a/.claude-plugin/fleet-skills/skills/python-codestyle/references/code-style.md +++ b/.claude-plugin/fleet-skills/skills/python-codestyle/references/code-style.md @@ -87,6 +87,16 @@ - **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. +- **Don't leave a text-mode `subprocess` call to the locale's encoding.** Every call that reads + or writes a pipe as text (`text=True`, `universal_newlines=True`, or an `errors` argument) names + the encoding its child uses on that pipe. Below Python 3.15, a call that names none encodes and + decodes with the locale encoding unless UTF-8 Mode is on. That encoding follows the host's + locale, usually UTF-8 on Linux and the ANSI code page on Windows. The omission therefore looks + correct on a UTF-8 host. On any other, a Windows host being the common case, it misreads or + rejects non-ASCII output from a child writing UTF-8. No static check in the ruff, pyright, or + mypy toolchain looks for the omission in a subprocess call. Naming the encoding settles how the + bytes are translated, not what a byte or character outside that encoding is worth. Each call + decides that with its own `errors` argument, and the strict default decides it by raising. diff --git a/.github/skills/python-codestyle/references/code-style.md b/.github/skills/python-codestyle/references/code-style.md index fb28bb42..cca918e1 100644 --- a/.github/skills/python-codestyle/references/code-style.md +++ b/.github/skills/python-codestyle/references/code-style.md @@ -87,6 +87,16 @@ - **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. +- **Don't leave a text-mode `subprocess` call to the locale's encoding.** Every call that reads + or writes a pipe as text (`text=True`, `universal_newlines=True`, or an `errors` argument) names + the encoding its child uses on that pipe. Below Python 3.15, a call that names none encodes and + decodes with the locale encoding unless UTF-8 Mode is on. That encoding follows the host's + locale, usually UTF-8 on Linux and the ANSI code page on Windows. The omission therefore looks + correct on a UTF-8 host. On any other, a Windows host being the common case, it misreads or + rejects non-ASCII output from a child writing UTF-8. No static check in the ruff, pyright, or + mypy toolchain looks for the omission in a subprocess call. Naming the encoding settles how the + bytes are translated, not what a byte or character outside that encoding is worth. Each call + decides that with its own `errors` argument, and the strict default decides it by raising. From c5ecaa6792b44ed60510a761bceac956e33b614e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 23:01:04 -0700 Subject: [PATCH 12/18] Name the Carried Skill and the Skipped Review Correctly (#3122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the two live findings of #1416. - **Item 1.** `docs/pr-reviewer-reference.md` named the hub-only `.agents/skills/pr-review-conduct/SKILL.md` path as the home of a rule "carried into repositories that hold no copy of this file". The carried tree is the generated `.github/skills`, so the sentence now names the `pr-review-conduct` skill without a path, the form `AGENTS.md` uses for hub-local skill paths. Its sentence and the paragraph's first sentence are split, since the sentence-length check flagged both on the edited line. - **Item 2.** The `pr-review-conduct` frontmatter description said "when a reviewer looks missing or skipped". The review is what gets skipped, so it now reads "when a review looks missing or skipped". The section body no longer carries the wrong noun, so only the description changes. - **Item 3 is already fixed on develop.** The skip-notice bullet already says to comment "the reviewer's documented review command", which ties the trigger to the reviewer that posted the notice, so it is left unchanged. The generated `.github/skills` and `.claude-plugin` trees are regenerated with `scripts/build_dist.py`. Closes on promotion: #1416 ## Verification - `python3 scripts/build_dist.py --check`: current. - `prose_lint.py --diff `, with and without `--check sentence-length`: clean. - `docker_lint.py` for markdownlint and editorconfig-checker: 0 issues. - `repo_gate.py`: 0 issues. `spec/validate.py`: OK. `tests/test_build_dist.py`: 89 passed. - `local-strict-review`: round 1 raised no review findings, and its sentence-length run flagged two sentences on the edited line, split in cfa05b32. Round 2 raised none. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .agents/skills/pr-review-conduct/SKILL.md | 2 +- .claude-plugin/fleet-skills/.source-digests/pr-review-conduct | 2 +- .claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md | 2 +- .github/skills/pr-review-conduct/SKILL.md | 2 +- docs/pr-reviewer-reference.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/skills/pr-review-conduct/SKILL.md b/.agents/skills/pr-review-conduct/SKILL.md index 45e8d285..4ec78725 100644 --- a/.agents/skills/pr-review-conduct/SKILL.md +++ b/.agents/skills/pr-review-conduct/SKILL.md @@ -12,7 +12,7 @@ description: >- nothing about whether the review covered the current head SHA, read the full diff, or left a suppressed low-confidence finding, which opens no thread at all, unanswered. Also triggers when a review loop looks stuck (no review landing, findings that keep reappearing) or when deciding a - finding is real, false, deferred, or a deliberate decline, or when a reviewer looks missing or + finding is real, false, deferred, or a deliberate decline, or when a review looks missing or skipped. This skill is the contract that `scripts/pr_review.py`, `drive-pr`, and `merge-and-release` implement: running the loop hands-off is `drive-pr` and merging main is `merge-and-release`, each winning for its own action while this skill still binds the gate. diff --git a/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct b/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct index b339c37f..16756fca 100644 --- a/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct +++ b/.claude-plugin/fleet-skills/.source-digests/pr-review-conduct @@ -1 +1 @@ -5aaa06a65aadb606 +97815b2d2157b2a4 diff --git a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md index 45e8d285..4ec78725 100644 --- a/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/pr-review-conduct/SKILL.md @@ -12,7 +12,7 @@ description: >- nothing about whether the review covered the current head SHA, read the full diff, or left a suppressed low-confidence finding, which opens no thread at all, unanswered. Also triggers when a review loop looks stuck (no review landing, findings that keep reappearing) or when deciding a - finding is real, false, deferred, or a deliberate decline, or when a reviewer looks missing or + finding is real, false, deferred, or a deliberate decline, or when a review looks missing or skipped. This skill is the contract that `scripts/pr_review.py`, `drive-pr`, and `merge-and-release` implement: running the loop hands-off is `drive-pr` and merging main is `merge-and-release`, each winning for its own action while this skill still binds the gate. diff --git a/.github/skills/pr-review-conduct/SKILL.md b/.github/skills/pr-review-conduct/SKILL.md index 45e8d285..4ec78725 100644 --- a/.github/skills/pr-review-conduct/SKILL.md +++ b/.github/skills/pr-review-conduct/SKILL.md @@ -12,7 +12,7 @@ description: >- nothing about whether the review covered the current head SHA, read the full diff, or left a suppressed low-confidence finding, which opens no thread at all, unanswered. Also triggers when a review loop looks stuck (no review landing, findings that keep reappearing) or when deciding a - finding is real, false, deferred, or a deliberate decline, or when a reviewer looks missing or + finding is real, false, deferred, or a deliberate decline, or when a review looks missing or skipped. This skill is the contract that `scripts/pr_review.py`, `drive-pr`, and `merge-and-release` implement: running the loop hands-off is `drive-pr` and merging main is `merge-and-release`, each winning for its own action while this skill still binds the gate. diff --git a/docs/pr-reviewer-reference.md b/docs/pr-reviewer-reference.md index f07e2d19..ed21c490 100644 --- a/docs/pr-reviewer-reference.md +++ b/docs/pr-reviewer-reference.md @@ -16,7 +16,7 @@ Much of what follows is external product state that the fleet observes rather th As of October 2026 both candidates run on their open-source tiers alone. Neither CodeRabbit nor Qodo reviews a private repository, so a private repository has Copilot as its only pull request reviewer, and Copilot's own review budget, a self-configured premium request cap, runs out under concurrent pull requests. CodeRabbit's open-source tier auto-reviews only a repository with at least ten stars, so on `ProjectTemplate`, which holds fewer, it reviews only on an explicit trigger. Qodo's open-source program auto-reviews only a public repository past a 200-star threshold, so in this fleet it auto-reviews `PlexCleaner` alone. The Claude GitHub App is installed on the account and is not configured as a reviewer, since the `local-strict-review` Skill already runs a review pass before every push toward a pull request. -The consequence a review loop actually needs: **a private repository has Copilot alone**, and no trigger produces a candidate review there, while **a public repository below CodeRabbit's star gate still has a CodeRabbit review for the asking**, and **a repository below Qodo's star gate gets no automatic Qodo review**, so its absence there is not waited on. `.agents/skills/pr-review-conduct/SKILL.md` "Which Reviewers a Repository Actually Has" states the rule an agent applies, in a form that needs none of the facts above, since it is carried into repositories that hold no copy of this file. +The consequence a review loop actually needs: **a private repository has Copilot alone**, and no trigger produces a candidate review there. **A public repository below CodeRabbit's star gate still has a CodeRabbit review for the asking**. **A repository below Qodo's star gate gets no automatic Qodo review**, so its absence there is not waited on. `pr-review-conduct` "Which Reviewers a Repository Actually Has" states the rule an agent applies. That rule needs none of the facts above, since the skill is carried into repositories that hold no copy of this file. ## Interaction and Operations From ddaeefb41141d3453883d044ca7eba8cbd997155 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Thu, 15 Oct 2026 23:33:00 -0700 Subject: [PATCH 13/18] Log the Docker Task's Build-Base Leg In to Docker Hub (#3124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `build-docker-task.yml`'s `docker-build-base` job now runs the same "Require Docker Hub credentials" and "Login to Docker Hub" steps the `build-docker` job carries, ahead of the caller's hook. A composite action cannot read `secrets`, so before this a hook asked to push its base image had no credentials to push with. - The hook now also receives `branch` and `platforms`, the platform list the core builds the product images for, so a base build matches them rather than inferring them from `push`. The selection moves from the `build-docker` job's `env` to workflow-level `env`, so both jobs read one value. The handoff named `branch` and `smoke`, the issue offered either those or the resolved platforms, and the local review pass found `smoke` would make each hook re-derive a selection the file header says is decided once here. A composite action also reads that boolean as a string, so a hook copying the core's expression would never select multi-arch. - `docs/reusable-workflows.md` "The Docker Family" states the hook contract: the login before the hook, the four inputs it receives, and that the hook sets up its own QEMU and Buildx for a multi-arch list. - A caller hook declaring only `push` and `ref` gets a runner "Unexpected input(s)" warning until it declares the two new inputs, and the run does not fail. A live Docker Hub push cannot be exercised from a local session, so verification is actionlint, markdownlint, editorconfig-checker, the prose and repo gates, `spec/validate.py`, and the unittest suite. Closes on promotion: #1892 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Chores** * Standardized platform selection across Docker build jobs: eligible main builds target both AMD64 and ARM64, while other builds target AMD64. * Docker Hub credentials are validated before login, and base-image builds receive the selected branch and platform information. * **Documentation** * Clarified Docker Hub login requirements and the information and build setup available to multi-image build hooks. --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/build-docker-task.yml | 25 ++++++++++++++++++++++--- docs/reusable-workflows.md | 2 +- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-docker-task.yml b/.github/workflows/build-docker-task.yml index e10536ca..0ff11dcb 100644 --- a/.github/workflows/build-docker-task.yml +++ b/.github/workflows/build-docker-task.yml @@ -79,6 +79,9 @@ on: DOCKER_HUB_ACCESS_TOKEN: required: true +env: + PLATFORMS: ${{ (!inputs.smoke && inputs.branch == 'main') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} + jobs: # Resolves the image matrix once: the caller's matrix input directly, else the caller's own docker-prepare hook, else the hub default built from image. @@ -165,12 +168,31 @@ jobs: echo "::error::This hook has no hub default; a repo enabling build-base must carry it." exit 1 + - name: Require Docker Hub credentials step + env: + DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }} + DOCKER_HUB_ACCESS_TOKEN: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + run: | + set -Eeuo pipefail + if [[ -z "$DOCKER_HUB_USERNAME" || -z "$DOCKER_HUB_ACCESS_TOKEN" ]]; then + echo "::error::DOCKER_HUB_USERNAME and DOCKER_HUB_ACCESS_TOKEN must both be non-empty." + exit 1 + fi + + - name: Login to Docker Hub step + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + - name: Run caller docker-build-base hook step if: ${{ hashFiles('.github/actions/docker-build-base/action.yml') != '' }} uses: ./.github/actions/docker-build-base with: push: ${{ inputs.push }} ref: ${{ inputs.ref }} + branch: ${{ inputs.branch }} + platforms: ${{ env.PLATFORMS }} build-docker: name: Build Docker image job @@ -183,9 +205,6 @@ jobs: strategy: matrix: image: ${{ fromJSON(needs.docker-prepare.outputs.matrix) }} - env: - # Multi-arch (amd64+arm64) only on a full main publish, and smoke and develop build amd64 only. - PLATFORMS: ${{ (!inputs.smoke && inputs.branch == 'main') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} steps: diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 32a0a283..f23ac914 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -97,7 +97,7 @@ The five live `build-docker-task.yml` copies share an identical core. It is QEMU - **Vanilla single-target** repos differ only in the image name and the build-arg list. That is data, carried in the stub's `with:`. - **Upstream-pinned** repos read a committed upstream version file before the build and add a `:` tag and version build-args. That is a `docker-prepare` hook. -- **Multi-image** repos read a matrix file, optionally build base images first, then build each image with its own tags, args and cache repository. That is a `docker-prepare` hook emitting the matrix, plus a `docker-build-base` hook the task calls when `build-base` is set. +- **Multi-image** repos read a matrix file, optionally build base images first, then build each image with its own tags, args and cache repository. That is a `docker-prepare` hook emitting the matrix, plus a `docker-build-base` hook the task calls when `build-base` is set. The task logs in to Docker Hub before that hook runs, since a composite action cannot read secrets. It passes the hook `push`, `ref`, `branch`, and `platforms`, the platform list the core builds the product images for. The hook sets up its own QEMU and Buildx for a multi-arch list. The hub task takes `push`, `ref`, `branch`, `smoke`, the NBGV version outputs, `image`, an optional `matrix` (a JSON list of `{name, tags, build-args, context, dockerfile, cache-repo}`, defaulting to the single entry `image` implies), and `build-base`. The core job body stays hub-owned, so the cache policy, the multi-arch platform selection (`linux/amd64,linux/arm64` on a non-smoke `main` build), and the login-on-smoke are decided once. The in-job description push earlier per-repo copies carried is not part of the hub core (see the readme paragraph below). From 40a131c91fe1cdc11b1a89deb51764e57475b3b2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 16 Oct 2026 00:11:02 -0700 Subject: [PATCH 14/18] Reconcile VSCode-Server-DotNetCore in the Hub After Its Resync (#3126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciles the hub's records for VSCode-Server-DotNetCore after its resync, each change checked against the live `main` of that repository. - Deletes the stale `driftNotes` entry in `registry/repos.json`, since `main` calls the hub's `publish-docker-readme-task.yml` and keeps no local copy. - Ticks the Stage 3 `get-version-task.yml`, Stage 4 "vanilla Docker only", and Stage 5 `publish-docker-readme-task.yml` items in `docs/reusable-workflows.md`, and drops a `delete-branch: true` qualifier from the merge-bot item that `main` does not set. - Regenerates `reports/workflow-reuse.md`. - Rewrites `reports/vscode-server-dotnetcore/audit.md` from a `spec/audit.py` run at hub `ddaeefb4` against `main`, 0 defect, letter, or error findings. Local strict review: two rounds, all findings fixed. Closes on promotion: #1899 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- docs/reusable-workflows.md | 10 +- registry/repos.json | 3 +- reports/vscode-server-dotnetcore/audit.md | 101 ++++------------ reports/workflow-reuse.md | 139 +++++++++------------- 4 files changed, 86 insertions(+), 167 deletions(-) diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index f23ac914..b2b37669 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -136,7 +136,7 @@ Adoptable since `2.0.338`. Each repo replaces the whole of its `.github/workflow - [x] LanguageTags (`delete-branch: true`): adopted on `develop` in ptr727/LanguageTags#336 at `e44eb09`, merged 2026-09-02 and carried on `main`, its ground-truth branch, where `merge-bot-pull-request.yml` calls `merge-bot-task.yml` by pin. - [x] aiopurpleair (`delete-branch: true`): adopted on `develop` in ptr727/aiopurpleair#103 at `3b69dc3`, merged 2026-09-09 and carried on `main`, its ground-truth branch, where `merge-bot-pull-request.yml` calls `merge-bot-task.yml` by pin. - [x] MediaTools (`delete-branch: true`): adopted on `develop` in ptr727/MediaTools#32 at `7ed693e`, merged 2026-09-26 and carried on `main`, its ground-truth branch, where `merge-bot-pull-request.yml` calls `merge-bot-task.yml` by pin. -- [x] VSCode-Server-DotNetCore (`delete-branch: true`): adopted on `develop` in ptr727/VSCode-Server-DotNetCore#129 at `ca46db7`, merged 2026-09-26 and carried on `main`, its ground-truth branch, where `merge-bot-pull-request.yml` calls `merge-bot-task.yml` by pin. +- [x] VSCode-Server-DotNetCore: adopted on `develop` in ptr727/VSCode-Server-DotNetCore#129 at `ca46db7`, merged 2026-09-26 and carried on `main`, its ground-truth branch, where `merge-bot-pull-request.yml` calls `merge-bot-task.yml` by pin. - [x] Blog: adopted on `develop` in ptr727/Blog#97 at `71bcb64`, merged 2026-08-23 and carried on `main`, its ground-truth branch, where `merge-bot-pull-request.yml` calls `merge-bot-task.yml` by pin. - [x] ESPHome-Config: adopted on `develop` in ptr727/ESPHome-Config#100 at `a21dfd7`, merged 2026-08-19 and carried on `main`, its ground-truth branch, where `merge-bot-pull-request.yml` calls `merge-bot-task.yml` by pin. - [x] HomeAssistant-Config: adopted on `develop` in ptr727/HomeAssistant-Config#33 at `f980637`, merged 2026-08-20 and carried on `main`, its ground-truth branch, where `merge-bot-pull-request.yml` calls `merge-bot-task.yml` by pin. @@ -191,12 +191,12 @@ Hub: `get-version-task.yml` and `publish-plan-task.yml` hosted, and the downstre - [x] Hub pull request on `develop`, #759. - [x] Promoted to `main` in #774 (`0b07a59d`) and released as `2.0.352`, the first tag carrying `get-version-task.yml` and `publish-plan-task.yml`. - [ ] Catalog snippet for a caller stub, after the release: a caller of either task is a job inside a repo's own `publish-release.yml` or a future `build-release-task.yml` rather than a standalone top-level workflow, so today's honest answer is no snippet, only the `with:`/`uses:` lines in [Adopting the Pure Functions](#adopting-the-pure-functions). Revisit if an adopting repo's shape argues otherwise. -- [ ] Adoption, one checkbox per carrier added when the hub pull request merges: today `get-version-task.yml` has 8 carriers and `publish-plan-task.yml` 3, with ESPHome-NonRoot and NxWitness carrying both. +- [ ] Adoption, one checkbox per carrier added when the hub pull request merges. - [ ] ESPHome-NonRoot (`get-version-task.yml` and `publish-plan-task.yml`) - [ ] NxWitness (`get-version-task.yml` and `publish-plan-task.yml`) - [ ] PhotoCleaner (`get-version-task.yml`) - [ ] PlexCleaner (`get-version-task.yml`, and gains the `plan` job D4.1 requires by adopting `publish-plan-task.yml` rather than by a copy) - - [ ] VSCode-Server-DotNetCore (`get-version-task.yml`) + - [x] VSCode-Server-DotNetCore (`get-version-task.yml`): carries no copy, with versioning through the hub's `build-release-task.yml`, which calls `get-version-task.yml`, adopted on `develop` in ptr727/VSCode-Server-DotNetCore#129 and carried on `main`, its ground-truth branch, at `e571822`, where the `main` audit run 2026-10-16T06:40:34Z reports no `interface` finding. - [x] KiCadLibrary (`get-version-task.yml`): its copy deleted, with versioning through the hub's `build-release-task.yml`, which calls `get-version-task.yml`, in ptr727/KiCadLibrary#60, promoted to `main` in ptr727/KiCadLibrary#65, where the `main` audit run 2026-10-04T01:15:54Z reports no `interface` finding. - [ ] aiopurpleair (`get-version-task.yml`) - [x] homeassistant-purpleair (`get-version-task.yml`): adopted on `develop` in ptr727/homeassistant-purpleair#372 at `2725f91`, which deleted its copy and pointed its local `build-release-task.yml` at the hub's. It is promoted to `main` in ptr727/homeassistant-purpleair#373 at `32a33d1`. Since the Stage 4 adoption below, the call runs through the hub's `build-release-task.yml`, and `main` carries no `get-version-task.yml`. @@ -213,7 +213,7 @@ Hub: `build-release-task.yml` provides the `dotnet-publish`, `build-nuget`, and - [x] Promoted to `main` in #774 (`0b07a59d`) and released as `2.0.352`, the first tag carrying `build-release-task.yml` and `build-docker-task.yml`. The first release attempt, on `82fecef`, ended in `startup_failure` in [run-startup-failure][run-startup-failure] because `build-nuget` and `github-release` declared job-level permissions. #772 fixed it before #774 promoted. - [x] PhotoCleaner (pilot, vanilla Docker plus .NET publish): ptr727/PhotoCleaner#55 on `develop` (`c80cb29`), promoted in ptr727/PhotoCleaner#56 (`fa91db0`). The migration deleted five carried task files. It mapped every value to a task input (`dotnet_publish_project`, `docker_image`) and needed no repo hook. The [pilot publish run][pilot-publish-run] released `1.1.11` with the .NET publish asset and Docker image. That run exposed an archive naming regression between the hub default and the repo leaf. The fix derives the name from the project file, with `dotnet_publish_asset_name` as an override. The next PhotoCleaner release proves the fix. - [ ] PlexCleaner (second, the same shape) -- [ ] VSCode-Server-DotNetCore (vanilla Docker only) +- [x] VSCode-Server-DotNetCore (vanilla Docker only): `publish-release.yml` on `main`, its ground-truth branch, calls `build-release-task.yml` by pin with `enable_docker: true` and the other legs off, and `main` at `e571822` carries only the `merge-bot-pull-request.yml`, `publish-release.yml`, and `test-pull-request.yml` stubs, where the `main` audit run 2026-10-16T06:40:34Z reports no `interface` finding. - [ ] ESPHome-NonRoot (`docker-prepare` hook for the upstream pin) - [ ] NxWitness (matrix hook and `build-base`) - [x] KiCadLibrary (`build-release-asset` hook for its library zip, the hook's first caller): adopted in ptr727/KiCadLibrary#60, promoted to `main` in ptr727/KiCadLibrary#65, where the `main` audit run 2026-10-04T01:15:54Z reports no `interface` finding, and first published as 2.1.48. @@ -234,7 +234,7 @@ Hub: `publish-docker-readme-task.yml` with a `docker-readme-transform` hook, `ch - [x] Hub pull request on `develop` in #761. - [x] Promoted to `main` in #774 (`0b07a59d`) and released as `2.0.352`, the first tag carrying `publish-docker-readme-task.yml`, `check-upstream-version-task.yml`, `deploy-site-task.yml`, and `run-codegen-pull-request-task.yml`. - [ ] Adoption, one checkbox per carrier added when the hub pull request merges. - - [ ] VSCode-Server-DotNetCore (`publish-docker-readme-task.yml`) + - [x] VSCode-Server-DotNetCore (`publish-docker-readme-task.yml`): `publish-release.yml` on `main`, its ground-truth branch, calls `publish-docker-readme-task.yml` by pin and keeps no local copy, at `e571822`, where the `main` audit run 2026-10-16T06:40:34Z reports no `interface` finding. - [ ] NxWitness (`publish-docker-readme-task.yml`, moving the readme job out of `publish-release.yml`, and `run-codegen-pull-request-task.yml` with its scheduler) - [ ] ESPHome-NonRoot (`check-upstream-version-task.yml`, both trackers, the second with `auto-merge: false`) - [ ] Blog (`deploy-site-task.yml`, keeping `deploy-site.yml` as its own caller) diff --git a/registry/repos.json b/registry/repos.json index 9f03d70a..aba290ab 100644 --- a/registry/repos.json +++ b/registry/repos.json @@ -137,8 +137,7 @@ "publish": [{ "target": "docker", "mechanism": "static-secret" }], "requiredSecrets": ["DOCKER_HUB_USERNAME", "DOCKER_HUB_ACCESS_TOKEN"], "consumerModel": "pull", - "releaseTrigger": "two-phase", - "driftNotes": ["Separate publish-docker-readme-task for the Hub overview."] + "releaseTrigger": "two-phase" }, { "name": "NxWitness", diff --git a/reports/vscode-server-dotnetcore/audit.md b/reports/vscode-server-dotnetcore/audit.md index 68349ac7..05a02327 100644 --- a/reports/vscode-server-dotnetcore/audit.md +++ b/reports/vscode-server-dotnetcore/audit.md @@ -1,97 +1,46 @@ # Audit: VSCode-Server-DotNetCore -- **Audited branch:** main (`80067f91167f2b56f223946397b69e41b70be834`) +- **Audited branch:** main (`e5718224c45eb598356d50a47be9df9566d79e7b`) - **Types:** docker (from registry) - **Verdict:** operational -- **Date:** 2026-07-04 +- **Date:** 2026-10-16 + +Basis: `python3 spec/audit.py VSCode-Server-DotNetCore` run 2026-10-16T06:40:34Z at hub `ddaeefb4`, reporting 0 defect, letter, or error findings, plus a read of the `main` tree. This report replaces the 2026-07-04 report, which described the repository before its resync to hub 2.0.685 (ptr727/VSCode-Server-DotNetCore#127 through #131) and the second resync to hub 2.0.972 (ptr727/VSCode-Server-DotNetCore#153). ## Develop Drift -`develop` vs `main`: ahead 1, behind 19 (`gh api repos/ptr727/VSCode-Server-DotNetCore/compare/main...develop` -> `status: diverged, ahead_by: 1, behind_by: 19`). **Diverged - a drift finding.** `develop` carries 1 commit `main` lacks and is 19 behind. Under the forward-only model (no `main -> develop` back-merge) the `main`-only CI/CD rework and Dependabot bumps merged straight to `main` have not reached `develop`. The publisher has no push trigger (schedule is `main`-only, `publish-release.yml:12-16`) and `develop` publishes only via manual dispatch, so the divergence has no release impact; still worth reconciling. +`develop` vs `main`: ahead 2, behind 78 (`gh api repos/ptr727/VSCode-Server-DotNetCore/compare/main...develop` -> `status: diverged`). Classified by content, the two trees are identical (a recursive diff of the `main` and `develop` tarballs reports no difference). The two `develop` commits are the Dependabot bumps ptr727/VSCode-Server-DotNetCore#157 and #159, whose same-titled twins ptr727/VSCode-Server-DotNetCore#158 and #160 reached `main`. Benign, inherent to merge-commit promotions. Not a drift finding. ## Dimensions | Dimension | Letter | Intent | Verdict | Evidence (file:line) | | --- | --- | --- | --- | --- | -| csharp | - | - | N/A | no `*.csproj`/`*.slnx`/`*.cs` in tree (`git ls-files`); the image installs the .NET SDK but ships no in-repo source. `.editorconfig:55-222` retains the shared `[*.cs]`+ReSharper block as inert governance | -| nuget | - | - | N/A | no `dotnet nuget push` workflow | -| pypi | - | - | N/A | no Python project / `gh-action-pypi-publish` | -| python | - | - | N/A | no `pyproject.toml`/`setup.py` | -| console | - | - | N/A | no executable build task | -| docker | pass | pass | pass | multi-arch `linux/amd64,linux/arm64` (`build-docker-task.yml:54`); registry layer cache `buildcache-` (never `type=gha`) `:81-84` (`cache-from` reads both branches, `cache-to` writes only-branch + only-on-push); trimmed `Docker/README.md` published via `peter-evans/dockerhub-description`, main-only (`publish-docker-readme-task.yml:27-34`); image always re-pushes on `inputs.push` (`dockerhub && !smoke`) independent of the release-create skip (`build-release-task.yml:64,118`); plain docker repo tags off `:SemVer2` (`build-docker-task.yml:77`), correct - not a wrapper. Static `DOCKER_HUB_*` secret (expected for docker, not OIDC) | -| branch-model | drift | pass | drift | both branches protected (live rulesets `enforcement: active`); normalized diff of committed `repo-config/ruleset-{develop,main}.json` vs live matches on rules/conditions **except** the committed files omit `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []` inside the `pull_request` params. Required-check `context` `Check pull request workflow status job` (`ruleset-main.json:32`) matches the repo's aggregator `name:` (`test-pull-request.yml:48`) - fleet canonical, not drift. Off-baseline filenames - see Drift | -| repo-setup | pass | pass | pass | actions **and** dependabot stores both hold `DOCKER_HUB_USERNAME`, `DOCKER_HUB_ACCESS_TOKEN` (docker-hub) and `CODEGEN_APP_CLIENT_ID`, `CODEGEN_APP_PRIVATE_KEY` (merge-bot App, `merge-bot-pull-request.yml:42-43`); no forbidden `NUGET_API_KEY`/`CODEGEN_APP_ID`; no stale `DOCKER_HUB_PASSWORD` (cleaner than the ESPHome/PlexCleaner stale-secret cases) | -| linter-parity | pass | pass | pass | one `.markdownlint-cli2.jsonc` + `cspell.json` drive editor + CLI + CI (`validate-task.yml:29-45`); CI runs markdownlint `:29-32`, cspell `:35-41`, actionlint `:44-45`; no C#/Python configs needed (docker-only) | -| recurring-violations | drift | pass | drift | comments concise; docs ASCII-clean (em-dash/smart-quote grep -> none); `cspell.json:3` `"language": "en-US"`. **Letter miss (eol):** `.editorconfig` lacks the global `[*] end_of_line = crlf` default (`:17-22`) **and** `build-docker-task.yml` is committed with **mixed** endings (4 LF lines against its own `[*.{yml,yaml}] crlf` rule) plus a trailing space after a Dockerfile line-continuation. See Drift #1/#2/#3 | -| readme-structure | drift | pass | drift | heavily reshaped from `spec/readme-structure.md`: `## License` is the **first** section (`README.md:6`, canonical last), no `## Build and Distribution` parent, no `## Table of Contents`, no `Releases`/`Use Cases`/`Questions or Issues` sections, headings are flat `## Build Status`/`## Container Images`/`## Release History`/`## Usage` (`:10,16,31,38`), all links are **inline** (not reference-style). Distribution/status/tags/usage content present but non-canonical. `Docker/README.md` is the intentionally-trimmed Hub overview, not measured against the project-README spec | -| workflow (WORKFLOW.md 5A/5B) | drift | pass | drift | all applicable D-guarantees hold by **outcome**; divergences are structural (see 5A/5B): single-branch-per-run publisher, no `changes` paths-filter, NBGV without `IGNORE_GITHUB_REF`, dispatch guard skips instead of failing fast, one-directional embedded release gate, bespoke release task attaching repo files directly without `expect_release_assets` | - -## WORKFLOW.md 5A Static Audit (applicable D-guarantees) - -- **D1.1 / D1.4 (paths-filter):** DRIFT - no `changes`/`dorny/paths-filter` job; `validate` and `smoke-build` run on **every** push (`test-pull-request.yml:25-42`, deliberate per header `:1-10`). Over-builds; with a single always-built target the "changed target slips unbuilt" failure cannot occur. -- **D1.2 (validation always runs):** PASS - `validate` (the lint gate, this docker-only repo's replacement for `unit-test`) runs unconditionally `if: !github.event.deleted` (`test-pull-request.yml:25-28`); the aggregator `needs:` it `:50`. -- **D1.3 (smoke uploads/pushes nothing):** PASS - the smoke caller passes `github: false`, `dockerhub: false`, `smoke: true` (`test-pull-request.yml:38-42`); the Docker push is gated `inputs.dockerhub && !inputs.smoke` (`build-release-task.yml:64`), github-release `inputs.github && !inputs.smoke` `:69`. No `upload-artifact` exists (Docker pushes to the registry; the release attaches repo files directly). -- **D1.5 (one aggregator):** PASS - `check-workflow-status`, `needs: [validate, smoke-build]`, `always() && !github.event.deleted`, fails on any non-`success` (`test-pull-request.yml:47-63`). Name is the ruleset-bound context `:48`. -- **D2.1 / D2.2 (validate-at-entry):** DRIFT (letter) / PASS (intent) - the release gate is the `Verify public release version step` **inside** github-release (`build-release-task.yml:83-93`), not an upfront entry job; it checks **one direction only** (main-not-prerelease), is `inputs.branch == 'main'`-guarded, and only fires inside the `!smoke` github-release. Safe because `version.json:4-6` makes `main` the sole public ref and the publisher builds one branch per run. -- **D2.3 (dispatch guard):** DRIFT - the publish job `if: github.ref_name == 'main' || github.ref_name == 'develop'` (`publish-release.yml:33`) **silently skips** a dispatch from any other ref rather than failing fast with `::error::`. -- **D2.4 (paired/XOR inputs):** N/A - the docker-readme task takes only `branch` and pushes a single `repository:` (`publish-docker-readme-task.yml:5-10,33`); no `repositories` XOR `manifest` combination to validate. -- **D3.1 (version from checked-out branch):** DRIFT (letter) / PASS (intent) - NBGV runs once in `get-version` on `inputs.ref` (`get-version-task.yml:47-57`); **no** `IGNORE_GITHUB_REF=true`. Correct because the publisher builds one branch per run with `github.ref` aligned; a future both-branch matrix would misclassify. -- **D3.2 (default = public, others = prerelease):** PASS - `version.json:4-6` `publicReleaseRefSpec ^refs/heads/main$`; gate literal `inputs.branch == 'main'` `build-release-task.yml:84`; `prerelease: inputs.branch != 'main'` `:124`; image moving tag `main => latest`, else `develop` `build-docker-task.yml:76`. -- **D3.3 (floor + git height):** PASS - `version.json:3` `version "1.1"`; NBGV appends the git height. Retained though the repo has no compiled code (owns the release tag/`GitCommitId`). -- **D3.4 (registry version per classification):** PASS - every published image carries the `:SemVer2` tag (`build-docker-task.yml:77`) plus the moving `latest`/`develop` `:76`. -- **D3.5 (wrapper external version):** N/A - **plain docker repo**, not an upstream wrapper: no `upstream-version.json`, no tracker; the leaf correctly tags off `SemVer2`. The base image `lscr.io/linuxserver/code-server:latest` (`Dockerfile:26`) is `:latest`, refreshed by the weekly rebuild, not a committed pin. -- **D4.1 (two-phase / both branches):** DRIFT - two-phase holds (merges never publish; the publisher runs only on `schedule` + `workflow_dispatch`, no push trigger, `publish-release.yml:12-16`), but it builds **only** the trigger branch `:39-40`, not both via a matrix. The weekly schedule rebuilds `main` only; `develop` only via manual dispatch. Outcomes correct; structural divergence. -- **D4.2 (tag the built commit):** PASS - `target_commitish: needs.get-version.outputs.GitCommitId` `build-release-task.yml:123`; github-release checks out the same commit `:79`. -- **D4.3 (release contents):** DRIFT (letter) / PASS (intent) - tag + `generate_release_notes` + `LICENSE` + `README.md` with `fail_on_unmatched_files: true` (`build-release-task.yml:121-128`); `prerelease = branch != main` `:124`. No `expect_release_assets` input - the bespoke release task attaches the two repo files directly (both exist, so the gate passes) rather than the template's `expect_release_assets: false` + `release-asset-*` mechanism. Equivalent tag+files outcome; forks the mechanism (differs from ESPHome, which omitted `fail_on_unmatched_files`). -- **D4.4 (no-op republish):** PASS - release-create gated `exists == false || workflow_dispatch` `build-release-task.yml:118`; the existence check `:95-112` skips a duplicate on a weekly re-run; Docker still re-pushes within the same run. -- **D5 (cleanup):** N/A - no `upload-artifact` / cross-job transfer artifact anywhere (Docker pushes to the registry; the release attaches `LICENSE`/`README.md`, not produced assets). -- **D6.1 / D6.2 / D6.3 (seam):** PASS (mostly N/A) - no `release-asset-*` artifacts, so no `pattern:`/`artifact-ids:` handoff to police; branch-derived config/tags key off `inputs.branch` (`build-docker-task.yml:76-77`, `build-release-task.yml:84,124`), never `github.ref_name`. -- **D7.1 / D7.2 (concurrency / permissions):** PASS - publisher group `${{ github.workflow }}` ref-independent + `cancel-in-progress: false` (`publish-release.yml:20-22`); the publish caller grants `contents: write` at the one entry point `:36-37`, docker-readme `contents: read`; the merge-bot jobs declare least-privilege `permissions:`; Docker uses the static secret (no OIDC). -- **D7.3 / D7.4 (booleans / optional-dep chaining):** PASS - `build-docker` uses `!cancelled() && get-version == 'success' && (validate == 'success' || 'skipped')` `build-release-task.yml:54`; booleans are `workflow_call`-only here. -- **D8.1 (merge-bot):** PASS - auto-merge on `opened`/`reopened` (`merge-bot-pull-request.yml:28-31`), method by base ref (`develop => --squash`, `main => --merge`) `:48-56`; disable on maintainer `synchronize` `:66-70`; concurrency keyed on the PR number `:17-19`; App token so the merge fires downstream workflows `:38-43`. -- **D8.2 (Dependabot):** PASS - `github-actions` on both `main` and `develop`, daily, grouped (`dependabot.yml:6-27`). -- **D8.3 (upstream tracker):** N/A - not a wrapper. -- **D9.1 (SHA-pinning):** PASS - all actions SHA-pinned with version comments; `dotnet/nbgv@master` is the sanctioned lagging-tag exception (`get-version-task.yml:53-57`). -- **D9.4 (docker cache):** PASS - registry `buildcache-`, `cache-to` writes only the built branch and only on push, `cache-from` reads both (`build-docker-task.yml:81-84`). -- **D9.5 (line endings):** DRIFT - see Drift #1 (`build-docker-task.yml` committed with mixed endings against `.editorconfig`). -- **NuGet / PyPI / console 5A addenda:** N/A. - -## WORKFLOW.md 5B Trace (applicable scenarios) - -- **S1** (PR touches the Docker target): `validate` (lint) + `smoke-build` (amd64-only, no push) run; no push/upload; github-release skipped (smoke); aggregator success; version prerelease. PASS. -- **S2 / S3** (docs-only / workflow-only PR): `smoke-build` **runs** (no paths-filter) instead of skipping; aggregator success - safe but over-builds. DRIFT. -- **S4** (PR base = main): smoke prerelease; the main-only verify step is inside the `!smoke` github-release so it does not fire; promotion not blocked. PASS. -- **S5 / S6** (push, opt-in un/set): N/A - the publisher has **no** push trigger (`publish-release.yml:12-16`), so no push ever publishes (stronger than the opt-in-variable model). -- **S7** (scheduled/dispatched publish): builds **only** the trigger branch. Schedule -> `main` -> stable + `latest` + `:SemVer2`, Docker Hub overview refreshed; dispatch from `develop` -> prerelease + `develop` tag, readme skipped. DRIFT (single-branch); outcomes correct. -- **S8** (dispatch from a non-publishable ref): publish job **skipped** (`publish-release.yml:33`; template fails fast). DRIFT. -- **S9** (re-run, version unchanged): release-create skipped (tag exists, non-dispatch); Docker still re-pushes; no duplicate release. PASS. -- **S10** (branch/version disagree): the main-only verify step fails loud if `main` carries a prerelease suffix (`build-release-task.yml:90-93`). PASS. -- **S11** (wrapper bump): N/A - not a wrapper. +| csharp | - | - | N/A | no `*.csproj`, `*.slnx`, or `*.cs` in the tree; the image installs the .NET SDKs and ships no in-repo source | +| nuget | - | - | N/A | `enable_nuget: false` (`publish-release.yml`, publish job `with:`) | +| pypi | - | - | N/A | no Python project; `enable_pypi: false` | +| python | - | - | N/A | no `pyproject.toml` or `setup.py` | +| dotnet-publish | - | - | N/A | `enable_dotnet_publish: false` in both callers | +| hugo | - | - | N/A | no site | +| docker | pass | pass | operational | the image is built by the hub's `build-release-task.yml` with `enable_docker: true` and `docker_image: ptr727/vscode-server-dotnetcore` (`publish-release.yml:41-59`); the Hub overview is published by the hub's `publish-docker-readme-task.yml` (`publish-release.yml:66`), main-only per the job comment, and no local copy of either task remains (`.github/workflows/` holds three files). Static `DOCKER_HUB_*` secrets, expected for docker | +| branch-model | pass | pass | operational | rulesets `develop` and `main` both `enforcement: active` (`gh api repos/ptr727/VSCode-Server-DotNetCore/rulesets`); the required context `Check pull request workflow status job` matches the aggregator `name:` (`test-pull-request.yml`); the stale `repo-config/ruleset-*.json` copies are gone | +| carried-scope | pass | pass | operational | the `spec/audit.py` run reports no `scope` finding | +| repo-setup | pass | pass | operational | the actions and dependabot secret stores both hold `DOCKER_HUB_USERNAME`, `DOCKER_HUB_ACCESS_TOKEN`, `CODEGEN_APP_CLIENT_ID`, and `CODEGEN_APP_PRIVATE_KEY`; no forbidden `NUGET_API_KEY` or `CODEGEN_APP_ID` | +| runtime-secrets | pass | pass | operational | every secret the callers map (`publish-release.yml:46-47,70-71`, `test-pull-request.yml` smoke job) exists in the actions store | +| verbatim-tree | drift | pass | drift | the carried instruction files and skills tree match a past hub revision, not the current canonical (`spec/audit.py`: `verbatim` and `tree` findings for `GOVERNANCE.md`, `.markdownlint-cli2.jsonc`, and 16 `.github/skills/` files), because the hub advanced after the 2.0.972 resync. Re-vendor from hub `main` | +| linter-parity | pass | pass | operational | one `.markdownlint-cli2.jsonc` and `cspell.json` drive editor and CI, and the hub's `validate-task.yml` runs in both `test-pull-request.yml` and `publish-release.yml:33` | +| recurring-violations | pass | pass | operational | `.editorconfig:21` sets the global `end_of_line = lf` default; no committed rulesets or mixed-ending workflow remain; `Docker/Dockerfile` is the path the hub Docker core expects | +| readme-structure | pass | pass | operational | `README.md` carries `## Build and Distribution` (`:7`), `## Table of Contents` (`:35`), `## Installation`, `## Usage`, `## Questions or Issues`, `## 3rd Party Tools`, and `## License` last (`:90`) | +| workflow (WORKFLOW.md 5A/5B) | pass | pass | operational | the three workflows are thin callers pinned to hub 2.0.1004 (`098016c8`): `test-pull-request.yml` calls `validate-task.yml` and `build-release-task.yml` with `smoke: true`, `github: false`, `dockerhub: false`, and an always-run aggregator; `publish-release.yml` calls `publish-plan-task.yml`, `validate-task.yml`, `build-release-task.yml`, and `publish-docker-readme-task.yml`, with the ref-keyed concurrency group (`:10-11`) and a weekly schedule (`:8`); `merge-bot-pull-request.yml` calls the hub merge-bot task. The five retired task workflows, the bespoke release task among them, are gone | ## Defects (most severe first) -None. No applicable check fails both letter and intent. +None. The `spec/audit.py` run reports 0 defect, letter, or error findings. ## Drift Findings -1. **`build-docker-task.yml` is committed with mixed line endings, violating the repo's own `.editorconfig` CRLF rule.** 82 of 86 lines are CRLF but 4 are LF-only - lines `52`, `57`, `64`, `70`, which are exactly the `uses: @ # vX` lines Dependabot rewrites - while `.editorconfig:35-37` mandates `[*.{yml,yaml}] end_of_line = crlf` and every other workflow YAML is pure CRLF (`git ls-files --eol` -> `i/mixed` for this file, `i/crlf` for the rest). Nothing caught the split because `.gitattributes:6` uses `* -text` (no renormalization) and `.editorconfig` carries no global `[*] end_of_line` default, so an automated tool rewriting individual lines with LF leaves the file mixed. A **concrete** `recurring.eol` violation, and a sharper form than ESPHome's whole-file-LF case: here the automated `uses:` edits landed LF inside an otherwise-CRLF file. No runtime impact (Actions parses either), but a genuine hygiene miss and diff-churn risk. -2. **`.editorconfig` lacks the canonical global line-ending default.** The `[*]` block (`.editorconfig:17-22`) sets charset/indent/trim but **no** `end_of_line`; CRLF is re-declared per file-type (`:26,31,36,41,53`) with LF pins for `*.sh` and Dockerfiles (`:45,49`) - the older per-extension form the strengthened `recurring.eol` flags. Template canonical is `[*] end_of_line = crlf` + LF pins. **(Recurs with Utilities / PlexCleaner / LanguageTags / ESPHome-NonRoot - five repos now, and here the missing default enabled finding #1.)** -3. **Dockerfile line-continuation carries a trailing space.** `Dockerfile:76` ends with a backslash line-continuation followed by a trailing space (`cat -A` shows `dotnet-install.sh \ $`), which also violates `.editorconfig:22` `trim_trailing_whitespace = true` (no Dockerfile override relaxes it). BuildKit tolerates it (the image publishes), but it is a latent line-continuation hazard and a trailing-whitespace lint miss with no CI guard. -4. **Publisher builds one branch per run, not a both-branch matrix.** `publish-release.yml:12-16,39-40` publishes only the trigger branch; the weekly schedule rebuilds `main` only, `develop` only on manual dispatch. WORKFLOW.md D4.1's model schedules both via a matrix. Outcomes correct; `develop` gets no scheduled refresh. **(Recurs with PlexCleaner / ESPHome-NonRoot.)** -5. **No `changes` paths-filter; every push smoke-builds.** `test-pull-request.yml:25-42` runs `validate` + `smoke-build` on every push including docs-only and workflow-only. **(Recurs across the fleet.)** -6. **Committed rulesets omit fields the live rulesets carry.** `repo-config/ruleset-{develop,main}.json` omit top-level `bypass_actors` (live carries `RepositoryRole 5 always`) and `required_reviewers: []` inside the `pull_request` params. Enforcement intact; context matches. **(Recurs with LanguageTags / ESPHome-NonRoot.)** -7. **repo-config filenames off-baseline.** Committed as `repo-config/ruleset-{develop,main}.json`; `spec/files.json:15-16` expects `repo-config/develop.json` / `main.json`. **(Recurs with Utilities / PlexCleaner / LanguageTags / ESPHome-NonRoot.)** -8. **NBGV without `IGNORE_GITHUB_REF`; release gate one-directional and embedded.** `get-version-task.yml:47-57` runs NBGV with no `IGNORE_GITHUB_REF=true`; `build-release-task.yml:83-93` verifies only that `main` is not prerelease, inside github-release. Correct only because one branch is built per run. **(Recurs with PlexCleaner / LanguageTags / ESPHome-NonRoot.)** -9. **Dispatch guard skips instead of failing fast.** `publish-release.yml:33` silently no-ops a dispatch from a non-`main`/`develop` ref; WORKFLOW.md D2.3 wants a fail-fast `::error::`. **(Recurs across the fleet.)** -10. **Docker-only release attaches repo files without the `expect_release_assets` mechanism.** The bespoke `build-release-task.yml:117-128` lists `LICENSE`/`README.md` directly with `fail_on_unmatched_files: true` rather than the template's `expect_release_assets: false` + `release-asset-*` pattern download. Equivalent tag+files outcome; forks the mechanism (differs from ESPHome-NonRoot, which reached tag-only by omitting `fail_on_unmatched_files`). -11. **README reshaped from the canonical structure.** `## License` is the first section (`README.md:6`); no `## Build and Distribution` parent, no `## Table of Contents`, no `Releases`/`Use Cases`/`Questions or Issues`; headings are flat `##` siblings; all links inline, not reference-style. Content complete; shape non-canonical - more minimal than the other docker repos. -12. **`HISTORY.md` overstates the publish model.** `HISTORY.md:8` says the weekly run "publishes both `main` ... and `develop`", but the schedule is `main`-only (`publish-release.yml:12-16`) and `develop` publishes only via manual dispatch. Minor doc/behavior mismatch. -13. **`develop` diverged from `main`** (ahead 1, behind 19). See Develop Drift. +- Carried content trails the hub (evidence: the `spec/audit.py` run named under Basis, whose `verbatim`, `tree`, and `intent` lines name each file, the `intent` lines also naming the later hub commit; the carried `GOVERNANCE.md` sections sit at `GOVERNANCE.md:27` Repository Boundaries and Write Safety, `:174` Verification Discipline, `:248` Workflow YAML Conventions, and `:279` Editor and Tasks): `GOVERNANCE.md` (four sections), `CODESTYLE.md`, `WORKFLOW.md`, `.github/copilot-instructions.md`, `.editorconfig`, `.editorconfig-checker.json`, `.gitattributes`, `.markdownlint-cli2.jsonc`, `cspell.json`, `version.json`, and 16 files under `.github/skills/` match a past hub revision or predate a later hub change. The resync landed at hub 2.0.972 and the hub has moved since. The next resync closes it, and it is that repository's work. +- The earlier report's structural findings (single-branch publisher, no paths-filter, mixed line endings, off-baseline `repo-config/` names, reshaped README, bespoke release task) are resolved by the resync and no longer apply. ## Proposed Registry / Spec Updates -- Registry `VSCode-Server-DotNetCore` types `["docker"]`, publish `docker` via `static-secret`, status `cataloged` are accurate; **no** upstream-wrapper type applies (own `Dockerfile`, no `upstream-version.json`). Keep `version.json` + NBGV. Consider advancing `status` once the structural workflow drifts and EOL hygiene are addressed. -- **Spec signal (strongest EOL case yet).** The missing global `[*] end_of_line = crlf` default now recurs across five repos, and here it produced a **mixed-ending** file (four automated `uses:` lines committed LF inside an otherwise-CRLF workflow) because `.gitattributes: * -text` does not renormalize. Strengthens the case for a machine check that asserts (a) a global `[*] end_of_line` default exists and (b) tracked files' committed endings match `.editorconfig` - a **per-line** check would catch this mixed case a whole-file check would miss. -- **Spec gap - merge-bot App secrets.** As with PlexCleaner / ESPHome-NonRoot, `spec/secrets.json` models `CODEGEN_APP_CLIENT_ID`/`CODEGEN_APP_PRIVATE_KEY` only under `codegen-app`, but here they are required by the **merge-bot** on a repo with no codegen. Model a `merge-bot`/`app-signing` mechanism requiring the App secrets. -- **Spec gap - docker-only tag+files release.** This repo reaches a no-`release-asset` release by hand-listing `LICENSE`/`README.md` with `fail_on_unmatched_files: true`, a third distinct shape (vs the template's `expect_release_assets: false` and ESPHome's omit-`fail_on_unmatched`). If docker-only repos keep hand-rolling the release task, the spec should bless one canonical no-asset form. -- **Recurring cross-fleet drifts** (off-baseline `repo-config/ruleset-*.json` filenames, committed rulesets omitting `bypass_actors`/`required_reviewers`, no `changes` paths-filter, skip-not-fail dispatch guard, single-branch publisher, NBGV without `IGNORE_GITHUB_REF`) appear here too. +- The `driftNotes` entry "Separate publish-docker-readme-task for the Hub overview." is stale, since the repository calls the hub's task and keeps no local copy. Deleted in the same change as this report. diff --git a/reports/workflow-reuse.md b/reports/workflow-reuse.md index 6759cd42..7235bf5d 100644 --- a/reports/workflow-reuse.md +++ b/reports/workflow-reuse.md @@ -1,12 +1,12 @@ # Fleet workflow reuse report -Generated by `python3 spec/workflow_reuse.py --report` at hub `5ce0374` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. +Generated by `python3 spec/workflow_reuse.py --report` at hub `ddaeefb4` - do not hand-edit. Each row reads a repo's ground-truth branch at generation time and compares it against the hub canonical of the same name after line-ending, action-pin, and job-needs normalization, per [`spec/fidelity-model.md`][fidelity-model] "Normalization". Git dates this file. The target model and the migration phases are in [`docs/reusable-workflows.md`][reusable-workflows]. ## Fleet Total -- **101 workflow files, 9,592 lines** across 20 downstream repos, 92 of them named for a hub canonical. No workflow at all in EspDinIoT. -- **3,513 lines (37%) are byte-identical to a hub canonical** after normalization, which is the confirmed duplication. The rest is mostly a per-repo edit of the same canonical rather than independent code. -- **Files reaching a hub reusable workflow or composite action through a pinned `uses:`: 19.** That is the state every carried copy converges to, so this number rises and the two above fall as the migration lands. +- **69 workflow files, 5,382 lines** across 20 downstream repos, 64 of them named for a hub canonical. No workflow at all in EspDinIoT. +- **2,133 lines (40%) are byte-identical to a hub canonical** after normalization, which is the confirmed duplication. The rest is mostly a per-repo edit of the same canonical rather than independent code. +- **Files reaching a hub reusable workflow or composite action through a pinned `uses:`: 51.** That is the state every carried copy converges to, so this number rises and the two above fall as the migration lands. ## Per Workflow @@ -14,137 +14,108 @@ Downstream copies of each hub canonical. A variant is a cluster of copies each a | File | Copies | Lines | Identical to hub | Variants | Callers | | --- | --- | --- | --- | --- | --- | -| `build-release-task.yml` | 9 | 1,709 | 827 | 6 | 0 | -| `test-pull-request.yml` | 20 | 1,615 | 501 | 14 | 6 | -| `merge-bot-pull-request.yml` | 17 | 1,401 | 276 | 7 | 6 | -| `publish-release.yml` | 17 | 1,335 | 506 | 15 | 6 | -| `validate-task.yml` | 9 | 891 | 323 | 7 | 0 | -| `build-docker-task.yml` | 4 | 512 | 266 | 4 | 0 | -| `get-version-task.yml` | 7 | 439 | 342 | 4 | 0 | -| `publish-plan-task.yml` | 3 | 252 | 198 | 1 | 0 | -| `run-codegen-pull-request-task.yml` | 2 | 161 | 127 | 1 | 0 | -| `check-upstream-version-task.yml` | 1 | 133 | 93 | 1 | 0 | -| `run-periodic-codegen-pull-request.yml` | 2 | 48 | 29 | 2 | 0 | -| `publish-docker-readme-task.yml` | 1 | 34 | 25 | 1 | 0 | +| `test-pull-request.yml` | 20 | 1,720 | 594 | 15 | 16 | +| `publish-release.yml` | 17 | 1,566 | 687 | 14 | 16 | +| `merge-bot-pull-request.yml` | 17 | 565 | 348 | 3 | 16 | +| `validate-task.yml` | 3 | 352 | 102 | 3 | 0 | +| `build-release-task.yml` | 1 | 140 | 89 | 1 | 0 | +| `check-upstream-version-task.yml` | 1 | 132 | 94 | 1 | 0 | +| `build-docker-task.yml` | 1 | 124 | 61 | 1 | 0 | +| `publish-plan-task.yml` | 1 | 84 | 66 | 1 | 0 | +| `get-version-task.yml` | 1 | 56 | 49 | 1 | 0 | +| `run-periodic-codegen-pull-request.yml` | 2 | 52 | 43 | 2 | 2 | ### Variant Members Each variant names the repos whose copies cluster together, so a hub task's inputs and hooks can be designed against the shapes that exist rather than against the canonical alone. -- `build-release-task.yml` - - 2: ESPHome-NonRoot, VSCode-Server-DotNetCore - - 1: KiCadLibrary - - 3: LanguageTags, MediaTools, Utilities - - 1: PlexCleaner - - 1: aiopurpleair - - 1: homeassistant-purpleair - `test-pull-request.yml` - 1: AudioCleaner - 1: Blog - 2: DevKitCIoT, HolidayLights - 1: ESPHome-Config - 1: ESPHome-NonRoot - - 2: Financial-Modeling, HomeAssistant-Config + - 3: Financial-Modeling, HomeAssistant-Config, Vantage-Config - 1: HomeAutomation-Config - 1: KiCadLibrary - - 4: LanguageTags, MediaTools, Utilities, aiopurpleair + - 3: LanguageTags, MediaTools, Utilities - 1: NxWitness - 1: PhotoCleaner - - 2: PlexCleaner, VSCode-Server-DotNetCore - - 1: Vantage-Config - - 1: homeassistant-purpleair -- `merge-bot-pull-request.yml` - - 6: Blog, ESPHome-Config, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, PhotoCleaner - - 3: ESPHome-NonRoot, NxWitness, homeassistant-purpleair - - 1: KiCadLibrary - - 1: LanguageTags - - 2: MediaTools, aiopurpleair - - 3: PlexCleaner, Utilities, Vantage-Config + - 1: PlexCleaner - 1: VSCode-Server-DotNetCore + - 1: aiopurpleair + - 1: homeassistant-purpleair - `publish-release.yml` - 1: Blog - 1: ESPHome-Config - 1: ESPHome-NonRoot - 1: Financial-Modeling - - 2: HomeAssistant-Config, HomeAutomation-Config + - 2: HomeAssistant-Config, Vantage-Config + - 1: HomeAutomation-Config - 1: KiCadLibrary - - 2: LanguageTags, MediaTools + - 3: LanguageTags, MediaTools, Utilities - 1: NxWitness - 1: PhotoCleaner - 1: PlexCleaner - - 1: Utilities - 1: VSCode-Server-DotNetCore - - 1: Vantage-Config - 1: aiopurpleair - 1: homeassistant-purpleair +- `merge-bot-pull-request.yml` + - 15: Blog, ESPHome-Config, Financial-Modeling, HomeAssistant-Config, HomeAutomation-Config, KiCadLibrary, LanguageTags, MediaTools, NxWitness, PlexCleaner, Utilities, VSCode-Server-DotNetCore, Vantage-Config, aiopurpleair, homeassistant-purpleair + - 1: ESPHome-NonRoot + - 1: PhotoCleaner - `validate-task.yml` - - 3: AudioCleaner, MediaTools, Utilities + - 1: AudioCleaner - 1: ESPHome-NonRoot - - 1: LanguageTags - - 1: NxWitness - 1: PlexCleaner - - 1: VSCode-Server-DotNetCore - - 1: aiopurpleair +- `build-release-task.yml` + - 1: ESPHome-NonRoot +- `check-upstream-version-task.yml` + - 1: ESPHome-NonRoot - `build-docker-task.yml` - 1: ESPHome-NonRoot - - 1: NxWitness - - 1: PlexCleaner - - 1: VSCode-Server-DotNetCore -- `get-version-task.yml` - - 4: ESPHome-NonRoot, NxWitness, PlexCleaner, VSCode-Server-DotNetCore - - 1: KiCadLibrary - - 1: aiopurpleair - - 1: homeassistant-purpleair - `publish-plan-task.yml` - - 3: ESPHome-NonRoot, NxWitness, Utilities -- `run-codegen-pull-request-task.yml` - - 2: LanguageTags, NxWitness -- `check-upstream-version-task.yml` + - 1: ESPHome-NonRoot +- `get-version-task.yml` - 1: ESPHome-NonRoot - `run-periodic-codegen-pull-request.yml` - 1: LanguageTags - 1: NxWitness -- `publish-docker-readme-task.yml` - - 1: VSCode-Server-DotNetCore ## Per Repo | Repo | Files | Lines | Identical to hub | Callers | Repo-local files | | --- | --- | --- | --- | --- | --- | | AudioCleaner | 2 | 134 | 57 | 0 | - | -| Blog | 4 | 220 | 97 | 4 | `deploy-site.yml` | -| DevKitCIoT | 1 | 58 | 25 | 0 | - | -| ESPHome-Config | 3 | 327 | 113 | 3 | - | -| ESPHome-NonRoot | 11 | 1,154 | 457 | 0 | `check-upstream-dependency.yml`, `check-upstream-version.yml` | -| Financial-Modeling | 3 | 137 | 77 | 3 | - | -| HolidayLights | 1 | 53 | 25 | 0 | - | -| HomeAssistant-Config | 3 | 124 | 98 | 3 | - | -| HomeAutomation-Config | 4 | 164 | 99 | 3 | `test-homelab-runner.yml` | -| KiCadLibrary | 6 | 772 | 201 | 0 | `build-datebadge-task.yml` | -| LanguageTags | 7 | 724 | 280 | 0 | - | -| MediaTools | 5 | 530 | 188 | 0 | - | -| NxWitness | 10 | 1,102 | 375 | 0 | `build-base-images-task.yml` | -| PhotoCleaner | 3 | 203 | 85 | 3 | - | -| PlexCleaner | 8 | 805 | 362 | 0 | `build-executable-task.yml` | -| Utilities | 6 | 621 | 266 | 0 | - | -| VSCode-Server-DotNetCore | 8 | 563 | 294 | 0 | - | -| Vantage-Config | 3 | 229 | 59 | 0 | - | -| aiopurpleair | 6 | 611 | 220 | 0 | - | -| homeassistant-purpleair | 7 | 1,061 | 135 | 0 | `check-ha-version.yml`, `test-release-task.yml` | +| Blog | 4 | 227 | 99 | 4 | `deploy-site.yml` | +| DevKitCIoT | 1 | 58 | 33 | 0 | - | +| ESPHome-Config | 3 | 277 | 122 | 3 | - | +| ESPHome-NonRoot | 11 | 1,153 | 461 | 0 | `check-upstream-dependency.yml`, `check-upstream-version.yml` | +| Financial-Modeling | 3 | 143 | 107 | 3 | - | +| HolidayLights | 1 | 53 | 33 | 0 | - | +| HomeAssistant-Config | 3 | 123 | 94 | 3 | - | +| HomeAutomation-Config | 4 | 196 | 100 | 3 | `test-homelab-runner.yml` | +| KiCadLibrary | 3 | 205 | 89 | 3 | - | +| LanguageTags | 4 | 302 | 106 | 4 | - | +| MediaTools | 3 | 268 | 87 | 3 | - | +| NxWitness | 4 | 330 | 104 | 4 | - | +| PhotoCleaner | 3 | 230 | 90 | 3 | - | +| PlexCleaner | 4 | 335 | 120 | 3 | - | +| Utilities | 3 | 263 | 85 | 3 | - | +| VSCode-Server-DotNetCore | 3 | 169 | 86 | 3 | - | +| Vantage-Config | 3 | 122 | 92 | 3 | - | +| aiopurpleair | 3 | 250 | 87 | 3 | - | +| homeassistant-purpleair | 4 | 544 | 81 | 3 | `check-ha-version.yml` | ## Repo-Local Workflows A workflow no hub canonical names. Each is either genuinely repo-specific, and stays, or a candidate for a hub task with a hook, and the design doc lists which. -- **Blog** `deploy-site.yml` (70 lines) +- **Blog** `deploy-site.yml` (73 lines) - **ESPHome-NonRoot** `check-upstream-dependency.yml` (111 lines) - **ESPHome-NonRoot** `check-upstream-version.yml` (41 lines) -- **HomeAutomation-Config** `test-homelab-runner.yml` (43 lines) -- **KiCadLibrary** `build-datebadge-task.yml` (37 lines) -- **NxWitness** `build-base-images-task.yml` (89 lines) -- **PlexCleaner** `build-executable-task.yml` (107 lines) -- **homeassistant-purpleair** `check-ha-version.yml` (316 lines) -- **homeassistant-purpleair** `test-release-task.yml` (248 lines) +- **HomeAutomation-Config** `test-homelab-runner.yml` (51 lines) +- **homeassistant-purpleair** `check-ha-version.yml` (315 lines) From ddd7ddb19f9f4204fc1a4117687bbfdb23ddfb8c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 16 Oct 2026 01:00:37 -0700 Subject: [PATCH 15/18] Read CodeRabbit's Command-Reply Rate Limit in pr_review.py (#3128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `scripts/pr_review.py` read CodeRabbit's rate limit only from the auto-review notice's `auto-generated comment: rate limited by -->` marker. An explicit `@coderabbitai review` that CodeRabbit cannot run comes back in a second shape, an `auto-generated reply` marker over an `Action not completed` block saying `Review rate limited.`. That shape never matched. On a repository where CodeRabbit never auto-reviews, it is the only rate limit the digest can meet, so `other_rate_limited` read the same for "asked and limited" as for "never asked". - `RATE_LIMITED_REPLY` reads the reply shape from its markers and the block's fixed text alone, never the prose around them. - `rate_limited_by` checks the existing marker first, then the reply shape. The reply's marker says `CodeRabbit` rather than `coderabbit.ai`, so `RATE_LIMIT_SERVICE` names the service by login. That gives the same `other_rate_limited=coderabbitai:coderabbit.ai` reading as the auto-review notice. A login missing from that map stays unread. - Tests in `tests/test_pr_review.py` cover these cases, all built from constructed comment bodies: - the reply shape reading as rate limited, both through `rate_limited_by` and through the digest field - the auto-review marker reading as before - an ordinary CodeRabbit summary marker, and two near misses, reading as not rate limited - an unmapped login's reply staying unread With the reply branch disabled, the new tests fail. The issue's side question, whether a limit with no stated retry time should read differently from one with a countdown, is out of scope here, and that reading is unchanged. Closes on promotion: #1425 ## Local review findings from the opening push The pre-push pass's edit budget was spent on the opening push. It left these `introduced` findings open. Each is fixed in 243f6748, and the pass over that push raised none: - `introduced`: scripts/pr_review.py, three `#` comments near `RATE_LIMITED` and in `digest` still describe one marker shape and service names taken from the marker. - `introduced`: scripts/pr_review.py, in the `rate_limited_by` docstring, "both observed shapes ride a plain PR comment" is given as the reason for reading both connections, which it does not support. - `introduced`: tests/test_pr_review.py, a test docstring says the reply "names no service", which contradicts the code docstring. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** * Rate-limit notices in CodeRabbit’s automatic review replies are now recognized alongside existing notices. * Digest comments now reflect the latest rate-limit notices from tracked reviewers, including supported automatic replies. Replies from unrecognized reviewer accounts are not attributed to a service. --------- Co-authored-by: Claude Opus 5.5 --- scripts/pr_review.py | 49 +++++++++++++++++++++++++------------- tests/test_pr_review.py | 52 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/scripts/pr_review.py b/scripts/pr_review.py index 6cba24a0..a2d5ae1a 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -139,10 +139,13 @@ format and its own future task per bot. Where either has posted anything at all on the current head, `other_reviewed` names it, identity and commit only, no verdict read from what it said. `other_rate_limited` names one whose newest comment or review - carries its own rate-limit marker instead, a structural + carries its own rate-limit marker instead. That marker is a structural `` convention rather than free-text prose (observed on - CodeRabbit), so reading it needs no per-bot wording model the way - Copilot's quota refusal needed `QUOTA` built for its own text. + CodeRabbit). Reading it needs no per-bot wording model the way Copilot's quota refusal + needed `QUOTA` built for its own text. CodeRabbit's reply to an explicit review command + lands in the same field, read by a pattern of its own for CodeRabbit's fixed wording. + That reply is an `auto-generated reply` marker over an `Action not completed` block + saying `Review rate limited.`, and its service is named from the login. Two of their own finding shapes are read, though, each its own blind spot a thread poll alone cannot see. `cr_outside_diff=N (on_head=X earlier=Y)` counts CodeRabbit's own "outside diff range" findings, collapsed into the review body rather @@ -476,8 +479,15 @@ # A structural marker rather than prose, so reading it needs no per-bot wording model the way `QUOTA` above needs one for Copilot's free-text refusal. # Observed on CodeRabbit, a plain PR comment rather than a formal review: "". # The service name is captured rather than assumed. -# A second bot using the same auto-generated-comment convention is read without a new pattern, and one that does not use it stays unread rather than guessed at. +# A second bot using the same auto-generated-comment convention is read without a new pattern. +# Any other shape stays unread unless a pattern of its own, like the reply one below, reads it. +# That reply's marker names the product rather than the `coderabbit.ai` service, so `RATE_LIMIT_SERVICE` supplies the service by login. RATE_LIMITED = re.compile(r"auto-generated comment:\s*rate limited by\s*(\S+?)\s*-->") +RATE_LIMITED_REPLY = re.compile( + r"\s*(?:\s*)*" + r"
\s*[^<\n]*Action not completed\s*\s*Review rate limited\." +) +RATE_LIMIT_SERVICE = {CODERABBIT_LOGIN: "coderabbit.ai"} # Anchored to the `

` tag rather than a bare substring, since prose elsewhere can mention the phrase without being the comment it names. QODO_REVIEW_HEADING = re.compile(r"

\s*Code Review by Qodo\s*

", re.IGNORECASE) # Only the numbered heading counts as a finding, told apart from Qodo's own nested sub-summaries by starting with a number and a period (`1.`, `2.`, ...). @@ -1307,20 +1317,28 @@ def standing_quota( def rate_limited_by(pr: dict, login: str) -> str | None: """The service name a rate-limit marker gives, where this login's newest comment or review - on this pull request carries one. `None` otherwise. - - Reads both connections since the one observed marker, CodeRabbit's, rides a - plain PR comment rather than a formal review, and a future bot using the same convention - might use either. Current pull request only, unlike Copilot's `quota_signal`: that one - generalizes from a pattern of silence across several pull requests, and this has one - observed instance to generalize from, not a history to read one out of. + on this pull request carries one. `None` otherwise. CodeRabbit's reply to an explicit review + command, `RATE_LIMITED_REPLY`, is read from its markers and its fixed `Action not completed` + block alone. Its marker says `CodeRabbit` rather than the `coderabbit.ai` the other shape + gives, so `RATE_LIMIT_SERVICE` names the service by login and both shapes read alike. A login + missing there stays unread. + + Reads both connections. Both observed shapes ride a plain PR comment, and a future bot using + the same convention might ride a formal review instead. A plain comment + carries no commit, so the reading is not scoped to the head. Current pull request + only, unlike Copilot's `quota_signal`. That one generalizes from a pattern of silence across + several pull requests, while this reads one notice rather than a history. """ nodes = reviewer_nodes(pr, "comments", login) + reviewer_nodes(pr, "reviews", login) if not nodes: return None newest = max(nodes, key=lambda n: n.get("createdAt") or n.get("submittedAt") or "") - match = RATE_LIMITED.search(newest.get("body") or "") - return match.group(1) if match else None + body = newest.get("body") or "" + if match := RATE_LIMITED.search(body): + return match.group(1) + if RATE_LIMITED_REPLY.search(body): + return RATE_LIMIT_SERVICE.get(login) + return None def newest_request_event(pr: dict) -> str | None: @@ -4727,8 +4745,7 @@ def digest( (n.get("commit") or {}).get("oid") == head for n in reviewer_nodes(pr, "reviews", login) ) ] - # The service name each tracked other-reviewer's own rate-limit marker gives, where its newest comment or review on this pull request carries one. - # Not head-scoped, unlike `other_on_head`, since the one observed marker rides a plain comment, which carries no commit to compare against the head at all. + # The service each tracked other-reviewer's rate-limit notice names, or its login supplies, where its newest comment or review here is one. other_limited = { login: name for login in OTHER_REVIEWERS if (name := rate_limited_by(pr, login)) } @@ -4753,7 +4770,7 @@ def digest( # Present only where at least one other tracked reviewer has posted on this exact head. # No verdict rides on it, unlike `review_on_head`, since nothing here reads what a CodeRabbit or qodo round said, only that one landed. + (f"other_reviewed={','.join(other_on_head)} " if other_on_head else "") - # Present only where a tracked other-reviewer's newest word is that marker. + # Present only where a tracked other-reviewer's newest word is one of those rate-limit notices. # The common case, neither is rate-limited right now, stays silent rather than printing `none`. + ( f"other_rate_limited={','.join(f'{login}:{name}' for login, name in other_limited.items())} " diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index 37f9d801..ed1061fd 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -323,6 +323,13 @@ def summarized(paths: list[str], covers: str = COVERED) -> str: "> [!WARNING]\n> ## Review limit reached\n" ) +RATE_LIMITED_REPLY_COMMENT = ( + "\n" + "\n" + "
\n\u26a0\ufe0f Action not completed\n\n" + "Review rate limited.\n
\n" +) + def thread( tid: str, @@ -957,9 +964,50 @@ def test_rate_limited_by_reads_either_connection_and_names_no_untracked_login(se self.assertEqual("coderabbit.ai", pr_review.rate_limited_by(pr, "coderabbitai")) self.assertIsNone(pr_review.rate_limited_by(pr, pr_review.QODO_LOGIN)) + def test_other_rate_limited_reads_the_command_reply_shape(self) -> None: + """The command reply's marker says CodeRabbit rather than the service, so the login names it.""" + self.answer( + payload( + [review()], + comments=[comment(login="coderabbitai", body=RATE_LIMITED_REPLY_COMMENT)], + ) + ) + out, _ = pr_review.digest("o", "r", 7) + self.assertIn("other_rate_limited=coderabbitai:coderabbit.ai", out) + + def test_rate_limited_by_reads_both_shapes_and_not_an_ordinary_comment(self) -> None: + """Constructed bodies, the reply shape's invocation id made up and its glyph escaped. + + The last two are near misses. One is the reply marker over another block, and the other + is the block's own words with no reply marker over them. + """ + other_reply = ( + "\n" + "
\nAction not completed\n\nSomething else.\n" + ) + unmarked = "
\nAction not completed\n\nReview rate limited.\n" + summary = "\n" + for body, expected in ( + (RATE_LIMITED_REPLY_COMMENT, "coderabbit.ai"), + (RATE_LIMITED_COMMENT, "coderabbit.ai"), + (summary, None), + (other_reply, None), + (unmarked, None), + ): + with self.subTest(body=body): + pr = payload([review()], comments=[comment(login="coderabbitai", body=body)]) + self.assertEqual(expected, pr_review.rate_limited_by(pr, "coderabbitai")) + + def test_rate_limited_by_leaves_the_reply_shape_unread_for_an_unmapped_login(self) -> None: + pr = payload( + [review()], + comments=[comment(login=pr_review.QODO_LOGIN, body=RATE_LIMITED_REPLY_COMMENT)], + ) + self.assertIsNone(pr_review.rate_limited_by(pr, pr_review.QODO_LOGIN)) + def test_rate_limited_by_reads_the_reviews_connection_too(self) -> None: - """The marker rides a plain comment on the one observed instance, but the reader - checks both connections, since a future bot's own marker might ride a formal review.""" + """Both observed shapes ride a plain comment, but the reader checks both connections, + since a future bot's own marker might ride a formal review.""" pr = payload( [review(), self.other_review("coderabbitai", body=RATE_LIMITED_COMMENT)], comments=[], From 722a458596f2ba3ea8881e0e74c6051a711f2703 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 16 Oct 2026 06:59:10 -0700 Subject: [PATCH 16/18] Match Tool Names and the all Keyword by Case in the Windows Agents Loaders (#3130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `bootstrap.ps1 -Agents ALL` took the `all` keyword because PowerShell's `-contains` ignores case, while `bootstrap.sh --agents ALL` passed `ALL` through and `install-agents.sh` refused it as an unknown tool. The Windows side now matches Linux: the keyword check in `bootstrap.ps1` uses `-ccontains`, and the tool-name comparisons in `install-agents.ps1` (`Get-Tool`, `Resolve-Selection`) use `-ceq`, `-cnotcontains` and `-ccontains`, so only lowercase `all` is the keyword and a tool name in another case is refused. ## Behavior of `ALL` on Windows after the change `bootstrap.ps1 -Agents ALL` hands `-Install ALL` to `install-agents.ps1`, which refuses it with `Unknown tool "ALL"`, the same refusal Linux gives. `-Agents Herdr` is refused the same way instead of being accepted. ## Tests - `tests/test_bootstrap.py`: `ALL` and `All` hand off as tool names on both the bash and the PowerShell loaders. - `tests/test_install_agents.py`: `Herdr` is refused as unknown on both sides. - Reverting the PowerShell change makes the PowerShell handoff test and the `-Report Herdr` refusal fail, and the change restores them. - Run: `tests/test_bootstrap.py` and `tests/test_install_agents.py` pass (171 tests, 5 skipped Windows-only), with PowerShell tests run under Linux pwsh, not Windows. The local review pass ran on the first commit, and the second commit only tightened the new tests to compare the whole step list, per its findings. Filed from the review: #3129 (a dash-prefixed `-Agents` name binds `-All` on both platforms, pre-existing). ## Windows-native check still owed Not run on Windows. For the maintainer, from a hub checkout in pwsh: ```powershell ./host-setup/bootstrap.ps1 -Agents ALL -DryRun # expect: Unknown tool "ALL" ./host-setup/bootstrap.ps1 -Agents all -DryRun # expect: -Install -All, every tool named ``` Closes #3065 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- host-setup/bootstrap.ps1 | 2 +- host-setup/windows/install-agents.ps1 | 6 +++--- tests/test_bootstrap.py | 18 ++++++++++++++++++ tests/test_install_agents.py | 2 ++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/host-setup/bootstrap.ps1 b/host-setup/bootstrap.ps1 index 664312c5..d9afb285 100644 --- a/host-setup/bootstrap.ps1 +++ b/host-setup/bootstrap.ps1 @@ -481,7 +481,7 @@ function Invoke-Upgrade { # Installs only what is named, since the agent tools are never installed as a set nobody chose. function Invoke-AgentsInstall { if ($script:AGENT_NAMES.Count -eq 0) { die "-Agents takes the tools to install, separated by commas, or 'all'" } - if ($script:AGENT_NAMES -contains 'all') { + if ($script:AGENT_NAMES -ccontains 'all') { if ($script:AGENT_NAMES.Count -gt 1) { die "-Agents takes 'all' alone, or a list of tool names" } Invoke-Tool -Tool 'install-agents.ps1' -Arguments '-Install', '-All' return diff --git a/host-setup/windows/install-agents.ps1 b/host-setup/windows/install-agents.ps1 index 3507ea9f..ba9c875f 100644 --- a/host-setup/windows/install-agents.ps1 +++ b/host-setup/windows/install-agents.ps1 @@ -372,7 +372,7 @@ $TOOLS = @( function Get-Tool { param([string]$ToolName) - return ($script:TOOLS | Where-Object { $_.Name -eq $ToolName } | Select-Object -First 1) + return ($script:TOOLS | Where-Object { $_.Name -ceq $ToolName } | Select-Object -First 1) } # --- Report --- @@ -564,7 +564,7 @@ function Show-List { function Resolve-Selection { $names = @($script:TOOLS | ForEach-Object { $_.Name }) foreach ($candidate in $script:WANT_TOOLS) { - if ($names -notcontains $candidate) { die "Unknown tool `"$candidate`", -List names the managed tools" } + if ($names -cnotcontains $candidate) { die "Unknown tool `"$candidate`", -List names the managed tools" } } if ($script:WANT_TOOLS.Count -eq 0) { if ($script:MODE -eq 'install' -and -not $script:INSTALL_ALL) { @@ -573,7 +573,7 @@ function Resolve-Selection { return , $names } # Sorted into registry order rather than the order they were typed, so a run reads the same however it was asked for. - return , @($names | Where-Object { $script:WANT_TOOLS -contains $_ }) + return , @($names | Where-Object { $script:WANT_TOOLS -ccontains $_ }) } # The order the actions were given is not a contract, so more than one is a refusal rather than the last one winning. diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index cb99492d..859a12ee 100755 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1699,6 +1699,15 @@ def test_agents_a_name_holding_a_space_and_all_is_not_all(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertIn(f"install-agents.sh --install <{given}>", self.agents_steps()) + def test_agents_all_in_another_case_is_a_tool_name_not_the_keyword(self) -> None: + expected: list[str] = [] + for given in ("ALL", "All"): + with self.subTest(given=given): + result = self.run_main(action="--agents", args=(given,)) + self.assertEqual(result.returncode, 0, result.stderr) + expected.append(f"install-agents.sh --install {given}") + self.assertEqual(self.agents_steps(), expected) + def test_agents_drops_empty_names_from_the_list(self) -> None: result = self.run_main(action="--agents", args=("herdr,,cswap,",)) self.assertEqual(result.returncode, 0, result.stderr) @@ -1879,6 +1888,15 @@ def test_all_alone_hands_off_as_an_install_of_all(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(self.steps(), ["install-agents.ps1 -Install -All"], result.stdout) + def test_all_in_another_case_is_a_tool_name_not_the_keyword(self) -> None: + expected: list[str] = [] + for given in ("ALL", "All"): + with self.subTest(given=given): + result = self.run_agents([given]) + self.assertEqual(result.returncode, 0, result.stderr) + expected.append(f"install-agents.ps1 -Install {given}") + self.assertEqual(self.steps(), expected, result.stdout) + def test_all_beside_other_names_is_refused(self) -> None: result = self.run_agents(["all,herdr"]) self.assertNotEqual(result.returncode, 0) diff --git a/tests/test_install_agents.py b/tests/test_install_agents.py index 669bc443..38fe9d08 100755 --- a/tests/test_install_agents.py +++ b/tests/test_install_agents.py @@ -238,6 +238,7 @@ def test_refusals(self) -> None: (("--upgrade", "--json"), "applies only to --report"), (("--upgrade", "--all"), "--all names every tool for --install"), (("--report", "nosuch"), 'Unknown tool "nosuch"'), + (("--report", "Herdr"), 'Unknown tool "Herdr"'), (("--report", "--upgrade"), "More than one action"), ]: with self.subTest(arguments=arguments): @@ -367,6 +368,7 @@ def test_refusals(self) -> None: (("-Upgrade", "-All"), "-All names every tool for -Install"), (("-Install", "-All", "herdr"), "-All and a tool name were both given"), (("-Report", "nosuch"), 'Unknown tool "nosuch"'), + (("-Report", "Herdr"), 'Unknown tool "Herdr"'), ]: with self.subTest(arguments=arguments): result = self.run_script(*arguments) From 19cfddc5e0db240948b9ac82e1fe8a1c5c3f80ef Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 16 Oct 2026 08:23:44 -0700 Subject: [PATCH 17/18] Release the Bootstrap Lock When a bootstrap.ps1 Run Ends (#3139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #3137. A `bootstrap.ps1` run typed at an interactive pwsh prompt runs inside the caller's own process, so the lock stream `Lock-Directory` opened was left for the process to release and outlived the run. The next run in that session was refused the lock, with an error blaming "another bootstrap run". - `Unlock-Directory` disposes `$script:LOCK` and clears it. - `main`'s `finally` calls it after the cleanup, as `try { Invoke-Cleanup } finally { Unlock-Directory }`, so the cleanup still runs under the lock and the release happens even if the cleanup throws. - `die`'s path is covered by the same `finally`, since PowerShell runs a `finally` on `exit` from inside a function (checked under pwsh 7.6 and Windows PowerShell 5.1). Every `exit` before `Lock-Directory` holds no lock. - The stale `$LOCK` comment ("lasts until this process ends") is removed. `bootstrap.sh` takes its flock in its own process and is unaffected. ## Test `test_a_run_ending_inside_its_callers_process_releases_the_directory_lock` drives two runs in one pwsh process through a driver script, the first through the loader's own `main` ending in `die`, the second taking `Lock-Directory` in the same mode. It fails against the pre-fix loader with the issue's "Could not lock" error, and passes with the fix. The harness's extracted function list gains `Unlock-Directory` and `main`. Verified on Windows: `test_bootstrap` and `test_cli_usage` pass, PSScriptAnalyzer and ruff are clean, and the local strict review pass is recorded at the head. ## Comments This carries the `comments` label for the two new comment lines above `Unlock-Directory` and the one in `main`'s `finally`, each stating a reason the code alone does not show. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- host-setup/bootstrap.ps1 | 12 ++++++-- tests/test_bootstrap.py | 66 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/host-setup/bootstrap.ps1 b/host-setup/bootstrap.ps1 index d9afb285..71b722e5 100644 --- a/host-setup/bootstrap.ps1 +++ b/host-setup/bootstrap.ps1 @@ -86,7 +86,6 @@ $MODE = '' $RESOLVED = '' $TREE = '' $PWSH_PATH = '' -# Held rather than disposed, since the lock it carries lasts until this process ends. $LOCK = $null function usage { @@ -286,6 +285,14 @@ function Lock-Directory { } } +# A run typed at an interactive prompt runs in the caller's process. +# A lock left for that process to release outlives the run and refuses the next one in the same session. +function Unlock-Directory { + if (-not $script:LOCK) { return } + $script:LOCK.Dispose() + $script:LOCK = $null +} + # Refuses to remove a tree this run did not create, rather than trusting the name. function Remove-Owned { param([string]$Path) @@ -651,7 +658,8 @@ function main { 'dev' { Invoke-StandUp -Kind 'dev' } } } finally { - Invoke-Cleanup + # Released after the cleanup, so the cleanup still runs under the lock, and on die's exit too, which runs this finally on its way out. + try { Invoke-Cleanup } finally { Unlock-Directory } } step 'Done' diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 859a12ee..ac1fe7f4 100755 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -932,7 +932,7 @@ def test_the_directory_the_skills_installer_registers_outlives_the_run(self) -> 'Test-KeepsTree', 'Get-TreeName', 'Get-TreePath', 'Get-StagingPath', 'Get-RetiredPath', 'Get-ArchivePath', 'Get-LockPath', 'Lock-Directory', 'Test-Ownership', 'Remove-Owned', 'Get-Tree', 'Remove-Tree', 'Move-Tree', 'Invoke-SwapIn', 'Invoke-Cleanup', 'Resolve-Directory', 'Invoke-Tool', 'Invoke-SkillsInstall', - 'Show-Report', 'Test-Elevated', 'Test-AgentsUser', 'Invoke-Upgrade', 'Invoke-AgentsInstall' + 'Show-Report', 'Test-Elevated', 'Test-AgentsUser', 'Invoke-Upgrade', 'Invoke-AgentsInstall', 'Unlock-Directory', 'main' ) foreach ($definition in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $wanted -contains $node.Name }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) @@ -1326,6 +1326,70 @@ def test_the_directory_lock_refuses_a_second_run_and_frees_when_the_first_ends(s result = self.run_loader("Lock-Directory") self.assertEqual(result.returncode, 0, result.stderr) + def test_a_run_ending_inside_its_callers_process_releases_the_directory_lock(self) -> None: + """Two runs in one pwsh process, as two typed at one interactive prompt are, the first ending through `die`'s exit. + + The exit ends only the script it is in, so the process and anything the run left open outlive it. + """ + with tempfile.TemporaryDirectory() as directory: + work = Path(directory) + harness = work / "harness.ps1" + harness.write_text(POWERSHELL_TREE_HARNESS, encoding="utf-8") + first = work / "first.ps1" + first.write_text( + "$script:WANT_HELP = $false\n" + "function Test-Prerequisite {}\n" + "function Resolve-Mode { 'report' }\n" + "function Resolve-Ref {}\n" + "function Get-Tree { die 'the download failed' }\n" + "main", + encoding="utf-8", + ) + second = work / "second.ps1" + # The same mode as the first run's, since the mode names the lock file and the harness defaults to another. + second.write_text( + "$script:MODE = 'report'\nLock-Directory\n[Console]::Out.WriteLine('locked')", + encoding="utf-8", + ) + driver = work / "driver.ps1" + driver.write_text( + "param([string]$Harness, [string]$Loader, [string]$Dir, [string]$First, [string]$Second)\n" + "& $Harness -Loader $Loader -Dir $Dir -BodyFile $First\n" + '[Console]::Out.WriteLine("first exited $LASTEXITCODE")\n' + "$global:LASTEXITCODE = 0\n" + "& $Harness -Loader $Loader -Dir $Dir -BodyFile $Second\n" + "exit $LASTEXITCODE\n", + encoding="utf-8", + ) + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(driver), + "-Harness", + str(harness), + "-Loader", + str(BOOTSTRAP_PS), + "-Dir", + str(self.dir), + "-First", + str(first), + "-Second", + str(second), + ], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + timeout=120, + ) + self.assertIn("the download failed", result.stderr) + self.assertIn("first exited 1", result.stdout) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("locked", result.stdout) + @unittest.skipUnless(sys.platform == "linux", "the lock bootstrap.sh takes is a Linux flock") def test_a_lock_bootstrap_sh_holds_refuses_this_loader(self) -> None: """FileShare.None is a flock on Linux, which is the one thing that makes the two loaders' locks one lock.""" From 4ce0bf51418ec2c673180f68698869fd82d8e6b5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 16 Oct 2026 09:47:18 -0700 Subject: [PATCH 18/18] Read a gh Write's Subcommand and Target the Way gh Parses Them (#3135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The write guard found a gh write by matching `gh ` as adjacent text. It read a target only from `-R`, `--repo`, and their attached forms. That left six holes in one class, each a gh write whose target or subcommand it misread. This change gives the guard one reading in place of six local patches. - **The subcommand is found the way cobra finds it** (`_gh_path`). The reading goes past each flag before the command and before the subcommand, and an unknown flag takes the next word as its value. A write is then looked up in one table, `_GH_WRITE_SUBS`, which the old text pattern is now built from. This closes the misses where a repo flag sits before the command (#3093) or between `pr` and its subcommand (#3080). It also reads `gh pr --squash 5 merge "$(...)"` as the merge it is, and `gh --hostname github.com api -X POST ...` as the `gh api` call it is. - **An `R` in a short-option cluster is read as a target**, as pflag reads `-dR x` as `-d -R x` (#3067). Each cluster is read by the value options its own subcommand defines, so `-BRelease/2.0` stays a base branch. - **`GH_REPO` is a target** (#3089). It is read through the `_command_prefix`, `_export_assignments`, and `_wrapper_env_assignments` primitives the git readings use. A prefix reaches only the gh word it prefixes, an `export` reaches only the gh words after it, and a repo flag on the argv overrides it, as gh reads it. - **A run-time repository owner on a write fails closed** (#3079). That covers `--repo "$T"`, `GH_REPO="$T"`, and `repos/$O/...`. A literal owner, as in `--repo "example/$R"`, is compared as usual, since the owner is all rule 3 compares. A read naming a run-time repository, and heredoc data, are left alone. - **A run-time word where `pr` or `merge` goes may be a merge** (#3069). It fails closed beside a literal `--admin`, or where word splitting may split it, as in `gh pr $S 5`. So does a run-time command word before `pr merge`, as in `G=gh; $G pr merge` or `$(which gh) pr merge`, and a run-time word read before `pr merge`, as in `gh $REPO_ARGS pr merge 5`. A quoted single word in exactly the `pr` or `merge` slot, with no `--admin`, is read as the one word it is. gh's reading was checked live with read-only calls on gh 2.102. An unknown flag before a subcommand takes the next word, as `gh pr --json number view 1` runs `view`. `--help` is the only boolean inherited at the `pr` level. `--` ends the subcommand search. ## Differential develop's guard and this branch's `classify` were run over 1,821 constructed commands. Origin was `example/repo`, with empty branch rules, no aliases, and no config. The commands cover gh writes and reads, including `pr merge`, `create`, `comment`, `edit`, `view`, `list`, `diff`, `checks`, `issue` writes and reads, `release`, `label`, and `api` reads and mutations. Each carries repo flags in every position and spelling, clusters, `GH_REPO` prefixes, exports, `env` forms, run-time words in every position, heredocs, and wrappers. 461 verdicts flip, every one explained below. | Issue | Flip | Count | Example | | --- | --- | --- | --- | | #3093 | allow -> deny | 54 | `gh -R stranger/example pr merge 5 --squash` | | #3080 | allow -> deny | 50 | `gh pr -R stranger/example merge 5 --squash` | | #3067 | allow -> deny | 30 | `gh pr merge 5 --squash -dR stranger/example` | | #3089 | allow -> deny | 135 | `GH_REPO=stranger/example gh pr merge 5 --squash` | | #3079 | allow -> deny | 167 | `gh pr create --title t --body b --repo "$T"` | | #3069 | allow -> deny | 21 | `gh pr $S 5 --admin` | | gh's own reading | deny -> allow | 4 | `gh --foo pr merge 5 "$X"` | The #3093 row counts one wrapper case and two `api` writes whose flags precede `api`. One case in the #3089 row is a known over-deny, listed under Remaining Findings below. Two reads flip to deny, each the #3069 shape. In `for s in view diff; do gh pr $s 5; done` the unquoted `$s` where the subcommand goes may split into `merge --admin`. In `gh pr "$s" "$PR"` the `$PR` beside a possible merge may be `--admin`. The deny says how to write each out. `gh pr "$s" 5` and every common read with a run-time number, such as `gh api repos/example/repo/pulls/$N` and `gh pr view $PR`, stay allowed. The four deny-to-allow flips are each a command gh itself refuses. develop read them as merges because it took the first word after `pr`'s flags as the subcommand. - `gh --foo pr merge 5 "$X"`: `--foo` takes `pr` as its value, so gh fails with an unknown command, `merge`. - `gh pr --squash merge 5 "$X"` and `gh pr -d merge 5 "$X"`: the flag takes `merge` as its value, so `5` is an unknown command. - `gh pr -- merge 5 "$X"`: `--` ends the search, so gh fails with an unknown command. ## Tests `_GH_PATH_CASES` adds 55 `classify` cases, origin `example/repo`, 32 expecting a deny and 23 an allow. - With the fix reverted to develop's guard, 33 of them fail. Four of those are the allow cases pinning the shapes gh refuses, which develop denied. - 16 more each fail on the branch commit before the review fix they pin, while develop passes them. They cover a run-time name under a literal owner, `GH_REPO` scoping, heredoc data, the merge slot, cluster value letters, and common reads with an unquoted run-time endpoint word. Some are denies develop already gave that an earlier branch commit lost, such as `gh $REPO_ARGS pr merge 5 $MERGE_FLAGS`. - The other 6 are allow cases on reads and arguments, guard rails that pass everywhere. Gates run locally on the final commit: ruff check and format, mypy, the unittest suite, `spec/audit.py --selftest`, `spec/workflow_reuse.py --selftest`, `gh-write-guard.py --selftest`, `test_install.py`, `build_dist.py --check`, `canonical_review.py status`, `repo_gate.py`, both prose invocations and the diff-scoped one, the jq sweep, `spec/validate.py`, and `docker_lint.py`. ## Local Review Seven frontier passes ran, each recorded: three before the opening push, three before the first fix push, and one before the second. Every `introduced` finding is fixed, declined, or filed below. Declined with reasons: - **Mid-cluster `R` and `-dR=` forms**, flagged as over-hardening. #3067 lists `-sdRstranger/example` itself. - **A literal `GH_REPO` on a read compared across the whole command.** This matches develop's reading of a literal `-R` on a read. - **The comment naming `_is_gh_write` for `gh api` handling.** `_is_gh_write` still reaches it, through `_gh_argv_is_write`. - **A run-time target with no resolvable origin.** Requirement 3 states it does not fire without an origin, for a literal target as much as a run-time one. - **The argv built per `$` token.** It costs about a second at 1,500 variables, far past any agent command. - **A possible write through a quoted run-time subcommand reaching rule 1**, as `gh pr "$S" 5 2>/dev/null` does. Reading a possible write as a write is the design, and the `_is_gh_write` docstring states it. Filed as `introduced` rather than fixed here, each a low-realism edge of a new reading: - #3144: a `GH_REPO` reaching one wrapper is read in every wrapper body, so `bash -c 'gh issue comment 5 --body hi'; GH_REPO=stranger/example bash -c 'gh pr view 5'` is denied. - #3145: the merge deny message omits a quoted run-time word where gh itself goes from its allowed shapes, `gh api`'s older `:owner` placeholder is unread, and a `[HOST/]OWNER/REPO` value is split at its first slash, as develop already does for `--repo`. Filed as gaps develop already has: #3131, #3132, #3133, #3134, #3136. The new cluster reader is noted on #3071 as a site for its shared primitive. Closes #3093 Closes #3080 Closes #3067 Closes #3089 Closes #3079 Closes #3069 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- host-setup/agent-safety/README.md | 10 +- host-setup/agent-safety/claude/README.md | 6 +- .../agent-safety/claude/gh-write-guard.py | 728 +++++++++++++++--- 3 files changed, 643 insertions(+), 101 deletions(-) diff --git a/host-setup/agent-safety/README.md b/host-setup/agent-safety/README.md index d27ccdab..622c7923 100644 --- a/host-setup/agent-safety/README.md +++ b/host-setup/agent-safety/README.md @@ -51,7 +51,13 @@ text says, because the harm it covers was never in the text. same call must not satisfy this). **When no `origin` resolves at all** (a non-git directory, or a checkout whose remote can't be read), this requirement has nothing to compare the target against and does not fire -- requirements 1 and 2 still apply regardless, and this is the same - precision-over-recall stance every requirement but 4 takes. + precision-over-recall stance every requirement but 4 takes. A write is recognized by its command + and subcommand as gh's own parser finds them, past the flags before each. So a repo flag before + the command or between `pr` and its subcommand still names the target. The target is also the + `R` in a short-option cluster such as `-dR`, and a `GH_REPO` value that reaches the gh word. One + exception departs from that stance. A write whose repository owner is a run-time word fails + closed, since it leaves nothing to compare. That covers `--repo "$T"`, `GH_REPO="$T"`, and + `repos/$O/...`, while `--repo "example/$R"` names its owner and is compared as usual. 4. **A git operation that would only succeed by bypassing an active branch rule is denied**: a direct push to a branch whose rules require a pull request, a force-push where history is protected, a delete where deletion is blocked, or an explicit-bypass flag (`--admin` on a merge, @@ -59,7 +65,7 @@ text says, because the harm it covers was never in the text. override on a commit or push is denied as one too. The file it pulls in may set `core.hooksPath` where the hook cannot read it. These flags are read in a command the hook parses, a wrapper shell's body and a command substitution's body among them. Those bodies are - read up to a few levels of nesting, and a git commit or push nested deeper is denied. `--admin` is also read inside a quoted `eval` argument, as the command it runs. A quoted `"--admin"` is read too where the shell passes it to gh as a flag. The other flags are not read inside a quoted `eval` argument. A run-time word among a `gh pr merge`'s words, such as `$A` or `"$(...)"`, may expand to `--admin`, so it fails closed. An option's value that word splitting cannot split is the exception, as in `--body "$BODY"`. A run-time `--repo` value fails closed too, since the target check cannot read it. `--no-verify` is also read cut short, as in `--no-veri`. A commit's `-n` is also read inside a short-option cluster such as `-an`, while the `n` in `-mn` is the message. Judge branch-rule cases against that branch's *live* rules, so a + read up to a few levels of nesting, and a git commit or push nested deeper is denied. `--admin` is also read inside a quoted `eval` argument, as the command it runs. A quoted `"--admin"` is read too where the shell passes it to gh as a flag. The other flags are not read inside a quoted `eval` argument. A run-time word among a `gh pr merge`'s words, such as `$A` or `"$(...)"`, may expand to `--admin`, so it fails closed. An option's value that word splitting cannot split is the exception, as in `--body "$BODY"`. A run-time `--repo` value fails closed too, since the target check cannot read it. A run-time word where gh's command or `pr`'s subcommand goes may expand to `pr merge`. It fails closed beside a literal `--admin`, or where word splitting may split it. So does a run-time command word before `pr merge`, as in `G=gh; $G pr merge`. Any other run-time word among such a command's words fails closed as it does on a merge, so `gh pr "$S" "$PR"` is denied. `--no-verify` is also read cut short, as in `--no-veri`. A commit's `-n` is also read inside a short-option cluster such as `-an`, while the `n` in `-mn` is the message. Judge branch-rule cases against that branch's *live* rules, so a code-style `develop` denies and a config-style `develop` allows with no per-repo configuration. Each refspec's operation is judged on its own, so a `+src` or a `:dst` refspec forces or deletes only its own branch. A push's force, delete, all, mirror, and prune options are read as diff --git a/host-setup/agent-safety/claude/README.md b/host-setup/agent-safety/claude/README.md index ad8bbe5b..b935436e 100644 --- a/host-setup/agent-safety/claude/README.md +++ b/host-setup/agent-safety/claude/README.md @@ -12,7 +12,7 @@ installation and operational detail. Codex and opencode have no equivalent hook Into `~/.claude/` (or `%USERPROFILE%\.claude\` on Windows): -- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push, a force-push, or a branch delete to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged per refspec against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push's force, delete, all, mirror, and prune options are read as git reads them. A short-option cluster such as `-uf` or `-ud` forces or deletes. A long option cut short, such as `--del`, `--al`, or `--mirr`, is the option it starts, and `--branches` is `--all`. Of `--force`, `--delete`, `--all`, `--mirror`, `--tags`, or `--prune` and its `--no-` form, the later one wins. `--no-force` leaves a `--force-with-lease` in place, as git does. A refspec pattern whose destination can match under `refs/heads/`, such as `refs/*:refs/*`, is read as pushing every protected-default branch. Beside such a pattern, a lone `:` refspec, `--all`, or a bare push, a `--prune` is read as deleting every protected-default branch. A `--mirror` push with no refspec is read as forcing and deleting every protected-default branch. So is a push with no refspec whose remote has a persisted `remote..mirror` set true, or whose setting the hook cannot read. A push naming no remote reads every remote's setting. Git reads every word after a push's first `--` or `--end-of-options` as the remote or a refspec, and so does the hook. A bare push's destination is also read from the branch's own configuration. That is its own name under a `push.default` of `current` or `simple`, and each `branch..merge` branch under `upstream`. An unset `push.default` is read as `simple`. Both are read where `push.default` holds a value git does not define, or where its read exits with any other error. Git may complete a destination outside `refs/` that starts `heads/` under `refs/`, as in `HEAD:heads/develop` or `:heads/develop`. So such a destination is read as the branch after `heads/` as well as by its literal name. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. A push that `-C` or a leading `cd` runs in another checkout is judged against that checkout's origin as well as the session's. One whose checkout or origin the hook cannot name has undetermined rules, as under `sudo -D` or in a wrapper shell. A push whose target the hook cannot read is denied whatever branch it names. That covers a push in text it does not parse, or carrying a `-c` config override. Config set through `GIT_CONFIG_COUNT` counts as one. It covers a push in interpreter code that can start a process, in text piped to a shell, or run by a persisted alias. An alias set in the environment is followed into the persisted config. A git subcommand whose aliases reach a `!` shell alias is denied in any checkout, whatever that alias runs. The hook does not read its shell text. A persisted alias is read from the directory git runs in. It is also read from a `--git-dir` or `GIT_DIR` repository, through a linked worktree's `.git` file as git reads it. A git command nested past the wrapper reading cap is denied where its subcommand may be an alias. Any git command under an `include.path`/`includeIf` override is denied too. So is a config value or environment variable git runs as a command, where it names its program by a path or an expansion. So is one from `--config-env`. The hook reads none of those. A shell whose command string runs git is denied where its prefix or an earlier `export` sets config for it. That is a `GIT_CONFIG_GLOBAL` or `GIT_CONFIG_SYSTEM` file other than `/dev/null`, or config through `GIT_CONFIG_COUNT`. A variable git runs as a command, set there, counts too, unless it names a plain program that runs no git. The hook reads that string without those settings. So is a bare push whose push target resolves while `git remote` cannot list the remotes. So is one where any read of its branch, its push target, or `push.default` times out or cannot run. So is a word the shell rewrites or drops before git reads it, where it can change what git runs. Such a word is a `$`, backtick, or brace-expansion refspec, subcommand, or commit argument, or a comment among a push's words. So is a `-C`, `--git-dir`, or `--work-tree` value that word splitting may split, that holds a brace expansion, or holds an expansion whose quoting the guard cannot read, on every git subcommand, and a command word holding a `$` or backtick where the subcommand is `push` or `commit`. So is a directory value holding a double-quoted list expansion such as `"$@"`, or an indirect `"${!name}"`, on every git subcommand. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks, as does a `git -c core.hooksPath=` override on either, which points git at another hooks directory, and an `include.path`/`includeIf` override on either, whose file may do the same. These are read in a command the hook parses, a wrapper shell's body and a command substitution's body among them. A substitution inside a double-quoted `${...}` or an unquoted-delimiter heredoc counts. So does a command string `pwsh`, `fish`, `script`, `cmd`, or another shell runs. So does a config or environment value git runs as a command, such as a `core.pager` or `GIT_EDITOR` value naming its program plainly. Those bodies are read up to a few levels of nesting, and a git commit or push nested deeper is denied. `--admin` is also read inside a quoted `eval` argument, as the command it runs. A quoted `"--admin"` is read too where the shell passes it to gh as a flag. The other flags are not read inside a quoted `eval` argument. A run-time word among a `gh pr merge`'s words, such as `$A` or `"$(...)"`, may expand to `--admin`, so it fails closed. An option's value that word splitting cannot split is the exception, as in `--body "$BODY"`. A run-time `--repo` value fails closed too, since the target check cannot read it. `--no-verify` is also read cut short, as in `--no-veri`. A commit's `-n` is also read inside a short-option cluster such as `-an`, while the `n` in `-mn` is the message. Third, a **mutating git operation run directly against a primary checkout** (`checkout`/`switch`/`pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`add`/`commit`/`rm`/`mv`/`apply`/`am`/`push`/`stash` (anything but `list`/`show`)/`clean -f`/`worktree remove -f`), the harm behind a second incident where an agent reused the maintainer's own primary checkout instead of a worktree despite having read the prose rule against it. "Primary" is decided by comparing `git rev-parse --git-dir` against `--git-common-dir`, not a `.git`-is-a-directory guess. `worktree add`/`list`/`prune`, an unforced `worktree remove`, `merge --ff-only`/`pull --ff-only`, and a `checkout `/`switch ` carrying no force flag (bundled or attached forms like `-qf`/`-Bname` recognized, not only an exact token) stay exempt even in a primary checkout, matching the documented base-clone cleanup step the `repo-worktree` skill already carries. A subcommand this rule does not recognize is resolved through a chain of git aliases, inline and persisted, before falling through, so a custom alias cannot hide a denied builtin behind an unrecognized name. A `!`-prefixed shell alias is denied outright rather than interpreted. A directory named with a `..` component is not resolved, so a mutating command naming one is denied. That holds for `-C`, any `cd`, `pushd`, `popd`, or `chdir` word in the command, `--work-tree`, `--git-dir`, and a `GIT_DIR=`/`GIT_WORK_TREE=` prefix. The target directory follows real git's own `-C`/`--work-tree`/`--git-dir` priority, not a last-option-wins scan. A different owner-style grant, `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, follows the same channel as `GH_WRITE_GUARD_ALLOW`. Reads and everything else pass through. Fourth, an **unbounded shell wait**, which the root spec's requirement 7 defines in full rather than this bullet redefining it. In outline: a loop that sleeps, carrying no bound the rule recognizes. It is the shape an agent writes when a brief tells it to poll. It is also the harm behind a third incident, where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. A shell started by a tool call runs in a session of its own, so the agent could not have reaped it even had it tried. A nested loop is judged on its own terms, and a command that forks anything out of a `timeout`'s reach, by backgrounding it or by `setsid`, is bounded by nothing. A heredoc body is data rather than a command line unless a shell is what it is fed to, so a document quoting the forbidden shape is written rather than denied. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 6 and 7 for the exact rules. +- **`hooks/gh-write-guard.py`**: a PreToolUse hook that denies four classes of dangerous action. First, the GitHub **write** footguns behind the cross-repo comment incident: a state-changing `gh` call whose output is discarded, a GraphQL mutation passing a **literal** node id instead of a `$variable`, and a `gh` write whose explicit target is under an owner other than the checkout origin's. The write and its target are read where gh reads them. That includes a repo flag before the subcommand, an `R` ending a short-option cluster, and a `GH_REPO` value. A write whose repository owner is a run-time word fails closed. Sibling repositories under the same owner are allowed, since the harm this guards is reaching a stranger's repository rather than working across one maintainer's own fleet, and a different owner is allowed only when the maintainer names it in `GH_WRITE_GUARD_ALLOW` (an `owner/repo` list, where `owner/*` grants a whole owner). That variable is read from the environment the session was launched with, which is the one channel an agent cannot set for itself: a hook runs as its own process, so an inline `VAR=x cmd` prefix or an `export` inside a Bash call never reaches it. Second, a **git operation that bypasses a repository protection**. The branch-rule cases (a direct push, a force-push, or a branch delete to a branch that requires a pull request, a force-push where history is protected, a branch delete where deletion is blocked) are judged per refspec against the branch's **live** rules, so a code-style `develop` is denied while a config-style `develop` is allowed with no per-repo configuration. A push's force, delete, all, mirror, and prune options are read as git reads them. A short-option cluster such as `-uf` or `-ud` forces or deletes. A long option cut short, such as `--del`, `--al`, or `--mirr`, is the option it starts, and `--branches` is `--all`. Of `--force`, `--delete`, `--all`, `--mirror`, `--tags`, or `--prune` and its `--no-` form, the later one wins. `--no-force` leaves a `--force-with-lease` in place, as git does. A refspec pattern whose destination can match under `refs/heads/`, such as `refs/*:refs/*`, is read as pushing every protected-default branch. Beside such a pattern, a lone `:` refspec, `--all`, or a bare push, a `--prune` is read as deleting every protected-default branch. A `--mirror` push with no refspec is read as forcing and deleting every protected-default branch. So is a push with no refspec whose remote has a persisted `remote..mirror` set true, or whose setting the hook cannot read. A push naming no remote reads every remote's setting. Git reads every word after a push's first `--` or `--end-of-options` as the remote or a refspec, and so does the hook. A bare push's destination is also read from the branch's own configuration. That is its own name under a `push.default` of `current` or `simple`, and each `branch..merge` branch under `upstream`. An unset `push.default` is read as `simple`. Both are read where `push.default` holds a value git does not define, or where its read exits with any other error. Git may complete a destination outside `refs/` that starts `heads/` under `refs/`, as in `HEAD:heads/develop` or `:heads/develop`. So such a destination is read as the branch after `heads/` as well as by its literal name. A push to a protected-default branch fails closed when its rules cannot be determined, either because the API is unreachable or because the checkout's origin cannot be resolved to query them. A push that `-C` or a leading `cd` runs in another checkout is judged against that checkout's origin as well as the session's. One whose checkout or origin the hook cannot name has undetermined rules, as under `sudo -D` or in a wrapper shell. A push whose target the hook cannot read is denied whatever branch it names. That covers a push in text it does not parse, or carrying a `-c` config override. Config set through `GIT_CONFIG_COUNT` counts as one. It covers a push in interpreter code that can start a process, in text piped to a shell, or run by a persisted alias. An alias set in the environment is followed into the persisted config. A git subcommand whose aliases reach a `!` shell alias is denied in any checkout, whatever that alias runs. The hook does not read its shell text. A persisted alias is read from the directory git runs in. It is also read from a `--git-dir` or `GIT_DIR` repository, through a linked worktree's `.git` file as git reads it. A git command nested past the wrapper reading cap is denied where its subcommand may be an alias. Any git command under an `include.path`/`includeIf` override is denied too. So is a config value or environment variable git runs as a command, where it names its program by a path or an expansion. So is one from `--config-env`. The hook reads none of those. A shell whose command string runs git is denied where its prefix or an earlier `export` sets config for it. That is a `GIT_CONFIG_GLOBAL` or `GIT_CONFIG_SYSTEM` file other than `/dev/null`, or config through `GIT_CONFIG_COUNT`. A variable git runs as a command, set there, counts too, unless it names a plain program that runs no git. The hook reads that string without those settings. So is a bare push whose push target resolves while `git remote` cannot list the remotes. So is one where any read of its branch, its push target, or `push.default` times out or cannot run. So is a word the shell rewrites or drops before git reads it, where it can change what git runs. Such a word is a `$`, backtick, or brace-expansion refspec, subcommand, or commit argument, or a comment among a push's words. So is a `-C`, `--git-dir`, or `--work-tree` value that word splitting may split, that holds a brace expansion, or holds an expansion whose quoting the guard cannot read, on every git subcommand, and a command word holding a `$` or backtick where the subcommand is `push` or `commit`. So is a directory value holding a double-quoted list expansion such as `"$@"`, or an indirect `"${!name}"`, on every git subcommand. The explicit-bypass flags are denied **unconditionally**, since the flag is itself the bypass and needs no branch query: `gh pr merge --admin` overrides the server-side merge gate, and `git commit`/`git push --no-verify` skips the local git hooks, as does a `git -c core.hooksPath=` override on either, which points git at another hooks directory, and an `include.path`/`includeIf` override on either, whose file may do the same. These are read in a command the hook parses, a wrapper shell's body and a command substitution's body among them. A substitution inside a double-quoted `${...}` or an unquoted-delimiter heredoc counts. So does a command string `pwsh`, `fish`, `script`, `cmd`, or another shell runs. So does a config or environment value git runs as a command, such as a `core.pager` or `GIT_EDITOR` value naming its program plainly. Those bodies are read up to a few levels of nesting, and a git commit or push nested deeper is denied. `--admin` is also read inside a quoted `eval` argument, as the command it runs. A quoted `"--admin"` is read too where the shell passes it to gh as a flag. The other flags are not read inside a quoted `eval` argument. A run-time word among a `gh pr merge`'s words, such as `$A` or `"$(...)"`, may expand to `--admin`, so it fails closed. An option's value that word splitting cannot split is the exception, as in `--body "$BODY"`. A run-time `--repo` value fails closed too, since the target check cannot read it. A run-time word where gh's command or `pr`'s subcommand goes may expand to `pr merge`. It fails closed beside a literal `--admin`, or where word splitting may split it. So does a run-time command word before `pr merge`, as in `G=gh; $G pr merge`. Any other run-time word among such a command's words fails closed as it does on a merge, so `gh pr "$S" "$PR"` is denied. `--no-verify` is also read cut short, as in `--no-veri`. A commit's `-n` is also read inside a short-option cluster such as `-an`, while the `n` in `-mn` is the message. Third, a **mutating git operation run directly against a primary checkout** (`checkout`/`switch`/`pull`/`reset`/`rebase`/`merge`/`cherry-pick`/`revert`/`restore`/`add`/`commit`/`rm`/`mv`/`apply`/`am`/`push`/`stash` (anything but `list`/`show`)/`clean -f`/`worktree remove -f`), the harm behind a second incident where an agent reused the maintainer's own primary checkout instead of a worktree despite having read the prose rule against it. "Primary" is decided by comparing `git rev-parse --git-dir` against `--git-common-dir`, not a `.git`-is-a-directory guess. `worktree add`/`list`/`prune`, an unforced `worktree remove`, `merge --ff-only`/`pull --ff-only`, and a `checkout `/`switch ` carrying no force flag (bundled or attached forms like `-qf`/`-Bname` recognized, not only an exact token) stay exempt even in a primary checkout, matching the documented base-clone cleanup step the `repo-worktree` skill already carries. A subcommand this rule does not recognize is resolved through a chain of git aliases, inline and persisted, before falling through, so a custom alias cannot hide a denied builtin behind an unrecognized name. A `!`-prefixed shell alias is denied outright rather than interpreted. A directory named with a `..` component is not resolved, so a mutating command naming one is denied. That holds for `-C`, any `cd`, `pushd`, `popd`, or `chdir` word in the command, `--work-tree`, `--git-dir`, and a `GIT_DIR=`/`GIT_WORK_TREE=` prefix. The target directory follows real git's own `-C`/`--work-tree`/`--git-dir` priority, not a last-option-wins scan. A different owner-style grant, `GH_WRITE_GUARD_ALLOW_PRIMARY_CHECKOUT`, follows the same channel as `GH_WRITE_GUARD_ALLOW`. Reads and everything else pass through. Fourth, an **unbounded shell wait**, which the root spec's requirement 7 defines in full rather than this bullet redefining it. In outline: a loop that sleeps, carrying no bound the rule recognizes. It is the shape an agent writes when a brief tells it to poll. It is also the harm behind a third incident, where seven such loops outlived the subagents that wrote them and the run that dispatched those subagents. A shell started by a tool call runs in a session of its own, so the agent could not have reaped it even had it tried. A nested loop is judged on its own terms, and a command that forks anything out of a `timeout`'s reach, by backgrounding it or by `setsid`, is bounded by nothing. A heredoc body is data rather than a command line unless a shell is what it is fed to, so a document quoting the forbidden shape is written rather than denied. It fires even in autonomous / bypass-permissions sessions, which is how the first incident happened. See the root spec's requirements 6 and 7 for the exact rules. - **`hooks/tool-containment.py`**: a shell prefix, registered as `CLAUDE_CODE_SHELL_PREFIX` in the `env` block of `settings.json`, that runs every Bash tool call inside a transient systemd user scope carrying `TasksMax`, `MemoryMax`, and `MemorySwapMax=0`, the last so a runaway at the memory ceiling is killed rather than spilled into the host's swap. A runaway fan-out then fails at the ceiling with `fork: Resource temporarily unavailable` instead of growing until the host has to be reset, the harm behind the probe-shim incident in the root spec's requirement 9. The guard above reads the command text, and that incident's recursion lived in a script file the command only named. The ceilings default to 8192 tasks and 25% of RAM. A `claude` session started from a tool call is nested in the session that ran it. Every scope of such a chain also joins one slice, `claude-tree-.slice`. That slice carries a total ceiling for the whole tree, 32768 tasks and 50% of RAM. The root's id reaches nested sessions as `AGENT_CONTAINMENT_ROOT`, which every contained command inherits. The tree's ceiling is a runtime drop-in put on when its slice is first created, and the sweep below reverts it. The maintainer raises any ceiling in the same `env` block. An inline `VAR=x` in a command cannot change one, since the prefix reads it before the command runs. The variables are `AGENT_CONTAINMENT_TASKS_MAX` and `AGENT_CONTAINMENT_MEMORY_MAX` for a scope, and `AGENT_CONTAINMENT_TREE_TASKS_MAX` and `AGENT_CONTAINMENT_TREE_MEMORY_MAX` for the tree. A tree ceiling set below a scope's binds first. Each scope is named `claude-tool---`, so the sweep below can stop one session's tool calls and nothing else. Its description records the pid of the agent that ran it. That record is how the sweep tells a scope whose session has ended from one whose session still runs. The installer sets the prefix only where it can contain, per the Linux bullet under "Scope and Limits", and never over a `CLAUDE_CODE_SHELL_PREFIX` value that names anything else. - **`hooks/stray-process-sweep.py`**: a SessionEnd hook in two phases. First, it stops every `claude-tool--*` scope this session left loaded and reports what each held, since the scope's name proves which session started it. It then stops every scope in this session's tree whose recorded agent is no longer running, and names each one's session. That is how a nested session's scopes end, since stopping the parent's scopes kills the nested `claude` before its own sweep runs. Each stop can end an agent nested deeper, so it judges again, up to four rounds. The root session then stops its tree's slice and reverts the ceiling put on it, once nothing is left in it. Second, it reports, and never ends, the process trees outside those scopes that outlive the session. The guard above stops a leak from being written. This finds the ones already running: the ones started before the guard was installed, and the shapes the guard deliberately does not reach. It reports every descendant of the agent process running outside the agent's own session. It names each subtree root's command and age, and hands over the `kill` line. This phase kills nothing itself. A long build backgrounded on purpose reads identically to a leaked wait from there. What is still wanted on the machine is the maintainer's call. A SessionEnd hook cannot block, and its JSON output is discarded, so stderr on exit 2 is the one channel that reaches the maintainer. The sweep uses that for a report rather than for a refusal. It registers under a group carrying no matcher, which fires on every exit reason. It also carries an explicit timeout, since the event's own budget is 1.5 seconds and only a declared per-hook timeout raises it. See the root spec's requirement 8. - **The whole of `CLAUDE.md`, rendered on every run**, loaded into every session on the machine (including ad-hoc work outside any project). It holds a header, two marker-delimited blocks of host-wide content, and the host-local file. Nothing else survives an install. Content outside the blocks was never written or checked, and it restated fleet rules in wording the fleet had since changed. @@ -153,9 +153,9 @@ Every other key in the file is left as it stands, `permissions.allow` and the re ## Scope and Limits - **Per-machine.** `~/.claude/` does not travel, so run the installer on each box. This is the rollout that [#365][issue-365] tracks. -- **Precision over recall for the write footguns.** The hook denies the specific dangerous write shapes with high confidence rather than gating every write, so it is written to avoid blocking legitimate work -- a known false-positive shape is a parser defect to fix, not an accepted cost. A shape it does not catch still falls under the behavioral rules. +- **Precision over recall for the write footguns.** The hook denies the specific dangerous write shapes with high confidence rather than gating every write, so it is written to avoid blocking legitimate work -- a known false-positive shape is a parser defect to fix, not an accepted cost. The exception is a write whose repository owner is a run-time word, denied because it leaves nothing to compare. A shape it does not catch still falls under the behavioral rules. - **The branch-bypass rule fails closed.** Unlike the write-footgun rules, a push to `main`/`master`/`develop` is denied even when its rules cannot be determined (the API is unreachable, or the checkout's origin cannot be resolved to query them), because the harm there is a silent success under the maintainer's admin bypass. The rule reads each branch's live rules, so it adapts to every repo (a code-style `develop` denies, a config-style `develop` allows) with no per-repo configuration, and hands the exact command to the maintainer to run when a bypass is genuinely intended. -- **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos//` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. +- **Opaque targets are unseen.** The hook cannot see the repository behind a GraphQL node id, which is exactly why rule 2 blocks a *literal* id at all, since a captured `$variable` is trusted. Likewise, the cross-origin check only runs when an `origin` can be resolved and the write names an explicit `-R`/`repos//`/`GH_REPO` target. A write from a non-git directory, or one whose target is only a node id, is evaluated by rules 1 and 2 alone. - **A write or a wait inside a script file is unseen, so never put either in a script to run it.** The hook reads the command the agent runs, which for `bash deploy.sh` is that one string. A `git push` or a `gh` mutation inside the script therefore reaches the server unparsed. An unbounded wait inside that same script is equally unseen, and worse in one way. A denied write fails loudly. A wait the guard never saw runs until something ends it, which is one of the leaks the SessionEnd sweep exists to find afterwards. This is the widest hole in the kit, and an agent opens it by accident. Collecting fifteen repetitive pushes into a loop looks like tidiness, and so does moving a poll into a script to keep a tool call short. Neither looks like disabling a guard. Issue each write and each wait as its own command. A script that only reads, computes, or prepares local commits is fine, because the boundary is the write and the wait rather than the script. - **Not a credential control.** A fine-grained PAT limited to owned repositories is a separate, stronger structural guard (a hard `403` on any non-owned repo) and is left to per-machine credential setup, out of this kit. - **Rule 6's ref-checkout and `--ff-only` exemptions are deliberate, not a gap.** The #1073 incident's own literal commands (an unforced `git checkout main`, then `git pull --ff-only`) are exactly this shape, so this rule does not deny the incident's own commands specifically. Denying them would also break the documented base-clone cleanup step in the `repo-worktree` skill, which needs precisely those two. The concurrent-access hazard those commands still carry (switching HEAD, or fast-forwarding, in a checkout another task might be relying on) is not decidable from the command text alone, so it stays the prose rule's job. A non-force flag alongside the ref, such as `--detach`, is exempt too, not only a strictly zero-flags invocation -- git's own overwrite-refusal on a dirty tracked file is unaffected by it, verified live. diff --git a/host-setup/agent-safety/claude/gh-write-guard.py b/host-setup/agent-safety/claude/gh-write-guard.py index 91f7828d..956f5d7e 100755 --- a/host-setup/agent-safety/claude/gh-write-guard.py +++ b/host-setup/agent-safety/claude/gh-write-guard.py @@ -106,19 +106,27 @@ from urllib.parse import quote, urlsplit # --- What counts as a GitHub write ------------------------------------------------------------------- +_GH_WRITE_SUBS = { + "pr": ( + *("create", "comment", "close", "merge", "edit"), + *("review", "reopen", "ready", "lock", "unlock"), + ), + "issue": ( + *("create", "comment", "close", "edit", "reopen", "delete"), + *("lock", "unlock", "pin", "unpin", "transfer"), + ), + "release": ("create", "edit", "delete", "upload"), + "repo": ("create", "delete", "edit", "rename", "archive"), + **{g: ("create", "delete", "edit", "set") for g in ("label", "secret", "variable", "ruleset")}, + "gist": ("create", "edit", "delete"), +} # The gh subcommands that mutate. # A path-qualified or `.exe`-suffixed `gh` still starts a shell word this matches, the same recognition `_is_gh_exe` gives it for argv-position parsing. # The `gh api` command is handled separately, in `_is_gh_write`, since it needs argv-aware method and GraphQL-query inspection rather than a fixed subcommand list. _GH_WRITE_SUB = re.compile( - r"""\bgh(?:\.exe)?\s+(?: - pr\s+(?:create|comment|close|merge|edit|review|reopen|ready|lock|unlock) - | issue\s+(?:create|comment|close|edit|reopen|delete|lock|unlock|pin|unpin|transfer) - | release\s+(?:create|edit|delete|upload) - | repo\s+(?:create|delete|edit|rename|archive) - | (?:label|secret|variable|ruleset)\s+(?:create|delete|edit|set) - | gist\s+(?:create|edit|delete) - )\b""", - re.VERBOSE, + r"\bgh(?:\.exe)?\s+(?:" + + "|".join(rf"{g}\s+(?:{'|'.join(subs)})" for g, subs in _GH_WRITE_SUBS.items()) + + r")\b" ) _GRAPHQL = re.compile(r"\bgh(?:\.exe)?\s+api\b.*\bgraphql\b", re.DOTALL) _MUTATION = re.compile(r"\bmutation\b") @@ -202,32 +210,112 @@ def _is_gh_write(cmd): """True when `cmd` is a GitHub write: a known-mutating `gh` subcommand, a `git push`, or a `gh api` call whose effective method is not GET. A GraphQL call is a write only when its query is a mutation, - or when its body is supplied by `--input` and so cannot be read at all. + or when its body is supplied by `--input` and so cannot be read at all. A gh whose command or + subcommand is a run-time word some write could stand for is a write too (see `_gh_path_writes`). + Rules 1 to 5 then read it, as in `gh pr "$S" 5 2>/dev/null`. """ # Argv-aware for the `gh api` half, reading a flag or a GraphQL query only from where it actually sits in one invocation's own argv, not a raw substring search over the whole command. # A substring search reads a write-method spelling out of an opaque flag value too, such as a # `--jq` expression that merely contains the text `-XPOST` as data, misclassifying a harmless read. if _GH_WRITE_SUB.search(cmd) or _push_arg_lists(cmd): return True - for args in _all_gh_arg_lists(cmd): - if not args or args[0] != "api": - continue - path = _gh_api_path(args) - if path == "graphql": - # --input checked before trusting any -f/-F query=... value. - # A -f/-F field becomes a URL query-string parameter rather than a body field whenever --input is also present. - # A harmless-looking inline query alongside --input therefore has no effect on the actual request, and the real body is the uninspectable input file. - if _gh_has_input(args): - return True # uninspectable body, treated cautiously so rules 1-5 can look closer - q = _gh_graphql_query(args) - if q: - if _MUTATION.search(q): - return True - continue # a genuine read-only query, not a mutation - continue - if _gh_effective_method(args) != "GET": - return True - return False + return any(_gh_argv_is_write(args) for args in _all_gh_arg_lists(cmd)) + + +def _gh_argv_is_write(args): + """True when one `gh` argv is or may be a write, its subcommand read by `_gh_path`. + + A GraphQL call's `--input` is checked before any `-f`/`-F` `query=` value is trusted. A field + becomes a URL query-string parameter rather than a body field whenever `--input` is present. + An inline query beside it then has no effect on the request, whose real body is the input + file the guard cannot read. + """ + if not args: + return False + if args[0] != "api": + return _gh_path_writes(_gh_path(args)) + path = _gh_api_path(args) + if path == "graphql": + if _gh_has_input(args): + return True # uninspectable body, treated cautiously so rules 1-5 can look closer + q = _gh_graphql_query(args) + return bool(q and _MUTATION.search(q)) + return _gh_effective_method(args) != "GET" + + +_GH_PATH_BOOL_FLAGS = frozenset({"--help", "--version"}) + + +def _gh_path(args): + """gh's command and subcommand words in an argv, at most two, read the way cobra finds them. + + Cobra reads past each flag before them. A flag word with no `=` that is `--long` or one letter + takes the next word as its value, unless it is a known boolean. An unknown flag is no boolean + to it, so `gh pr --squash 5 merge` runs a merge. And `gh -R example/repo pr merge` names its + repository before the command. A longer short form such as `-Rx` is one word, and `--` ends + the search. + """ + return [args[i] for i in _gh_path_at(args)] + + +def _gh_path_at(args): + """The index in `args` of each word `_gh_path` reads.""" + at = [] + i = 0 + while i < len(args) and len(at) < 2: + a = args[i] + if a == "--": + break + if not a.startswith("-"): + if a: + at.append(i) + i += 1 + elif "=" in a or a in _GH_PATH_BOOL_FLAGS or not (a.startswith("--") or len(a) == 2): + i += 1 + else: + i += 2 + return at + + +def _gh_path_writes(words): + """True when gh's command and subcommand words, from `_gh_path`, are or may be a write.""" + return _gh_path_may_be(words, _GH_WRITE_SUBS) + + +def _gh_may_merge(args): + """True when a `gh` argv may be `pr merge` through a run-time word the shell rewrites. + + That is a run-time word where `pr` or `merge` goes, or one where gh's command goes beside a + literal `--admin`. It is also a run-time word read as gh's command or subcommand before a + `merge` word after `pr`. Such a word may expand to no words, to a flag and its value, or to + several words, so it may shift either. + """ + words = _gh_path(args) + if words == ["pr", "merge"]: + return False + if _gh_path_may_be(words, {"pr": ("merge",)}): + return True + if words and _is_run_time_word(words[0]) and _names_admin(args): + return True + if not _is_gh_pr_merge(args) or not any(_is_run_time_word(w) for w in words): + return False + upto = args.index("merge", args.index("pr")) + return any(_is_run_time_word(a) for a in args[:upto]) + + +def _gh_path_may_be(words, table): + """True when gh's command and subcommand words are or may be a pair `table` lists. + + A run-time word in either place may expand to any word, unquoted to several, so it counts + wherever some pair could take its place. + """ + if not words: + return False + cmd, sub = words[0], (words[1] if len(words) > 1 else None) + if _is_run_time_word(cmd): + return sub is None or _is_run_time_word(sub) or any(sub in s for s in table.values()) + subs = table.get(cmd, ()) + return sub is not None and bool(subs) and (sub in subs or _is_run_time_word(sub)) def _origin_owner_repo(cwd): @@ -1076,6 +1164,12 @@ def _collect_arglist(toks, start, split_fused=False): Returns (args, index_after_this_invocation). """ + at, k = _arg_positions(toks, start, split_fused) + return [toks[p] for p in at], k + + +def _arg_positions(toks, start, split_fused=False): + """`_collect_arglist` as the index in `toks` of each argv word, with the index after them.""" n = len(toks) k = start args = [] @@ -1096,7 +1190,7 @@ def _collect_arglist(toks, start, split_fused=False): if k < n and not _is_shell_op(toks[k]): k += 1 continue - args.append(t) + args.append(k) k += 1 return args, k @@ -2310,11 +2404,23 @@ def _gh_arg_lists(cmd): i += 1 continue args, k = _collect_arglist(toks, i + 1) - out.append(args) + out.append(_api_first(args)) i = k return out +def _api_first(args): + """`args` with an `api` command word that flags precede moved to the front. + + Cobra passes the flags before a command to the command it finds, so `gh --hostname + github.com api -X POST ...` runs `gh api` with those flags. The `api` readers read `args[0]`. + """ + at = _gh_path_at(args) + if at and at[0] and args[at[0]] == "api": + return ["api", *args[: at[0]], *args[at[0] + 1 :]] + return args + + _SHELL_WRAPPER_EXE = ("sh", "bash", "zsh", "ksh", "dash") @@ -2569,21 +2675,21 @@ def _repo_flag_value(tok): return None -def _gh_target_readings(cmd): - """Each `gh` argv in `cmd` with a value-option table to read it by, once or more per argv. +def _gh_target_readings(argvs): + """Each of the `gh` argvs with a value-option table to read it by, once or more per argv. `-f` alone is value-taking only inside `api`. On `pr create` it is the boolean `--fill`, so reading it as taking a value there would swallow a real following `--repo` flag whole. On `pr merge`, `-m` is the boolean `--merge`. So a merge argv (see `_runs_gh_pr_merge`) is read again, by `_GH_MERGE_TEXT_FLAGS`, the merge value options less the repo flags. A target either - reading finds counts. + reading finds counts. An argv that may merge (see `_gh_may_merge`) is read both ways too. """ - for args in _all_gh_arg_lists(cmd): + for args in argvs: if args and args[0] == "api": yield args, _GH_API_VALUE_FLAGS continue yield args, _GH_CREATE_TEXT_VALUE_FLAGS - if _runs_gh_pr_merge(args): + if _runs_gh_pr_merge(args) or _gh_may_merge(args): yield args, _GH_MERGE_TEXT_FLAGS @@ -2592,10 +2698,22 @@ def _gh_write_targets(cmd): embedded in a `sh -c`/`bash -c` wrapper): a `--repo`/`-R` flag value, or a `repos//` API path token. Argv-position parsing, the way `_push_targets` reads a git push target, so a --repo/repos mention that is only prose, inside an unrelated --body/--title value or a commit message, is never - read as one. + read as one. A `GH_REPO` assignment's value is one too (see `_gh_repo_assignments`). """ - targets = [] - for args, flags in _gh_target_readings(cmd): + words = [(w, is_path) for w, is_path, _ in _gh_repo_words(_all_gh_arg_lists(cmd))] + words += [(w, False) for w, _ in _gh_repo_assignments(cmd)] + return [t for w, is_path in words for t in [_named_target(w, is_path)] if t] + + +def _gh_repo_words(argvs): + """Each word one of the `gh` argvs may name its repository with, as (word, is_path, argv). + + That is a repo flag's value, as the next word or attached as in `--repo=x`, `-R=x`, or `-Rx`. + It is also an `R` in a short-option cluster, which pflag reads one letter at a time, so `-dR x` + is `-d -R x` (see `_cluster_repo_value`). Every other word comes back as an API path + candidate, normalized, with is_path set. + """ + for args, flags in _gh_target_readings(argvs): n = len(args) i = 0 while i < n: @@ -2605,27 +2723,163 @@ def _gh_write_targets(cmd): continue if t in _REPO_FLAG_BARE: if i + 1 < n: - val = args[i + 1] - if "/" in val and "<" not in val: - o, r = val.split("/", 1) - targets.append((o.lower(), r.lower())) + yield args[i + 1], False, args i += 2 continue val = _repo_flag_value(t) + if val is None and t[:1] == "-" and t[1:2] not in ("", "-"): + val, ends = _cluster_repo_value(t, _gh_short_value_letters(args, flags)) + if ends and i + 1 < n: + yield args[i + 1], False, args if val is not None: - if "/" in val and "<" not in val: - o, r = val.split("/", 1) - targets.append((o.lower(), r.lower())) + yield val, False, args i += 1 continue # A full URL (`gh api https://api.github.com/repos/o/r/...` works exactly like the bare path form) is normalized the same way `_gh_api_path` normalizes it, so a URL-wrapped cross-owner target is not missed. # The placeholder check runs on the normalized path, not the raw token: a real URL's own query string or fragment (discarded by normalization) can carry a `<` with no bearing on whether the path itself is a real target. - normalized = _normalize_api_path(t) - m = _REPOS_PATH_TOKEN.match(normalized) - if m and "<" not in normalized: - targets.append((m.group("owner").lower(), m.group("repo").lower())) + yield _normalize_api_path(t), True, args i += 1 - return targets + + +def _cluster_repo_value(tok, letters): + """pflag's reading of a short-option cluster for its `R`, as (value, ends). + + Each letter is a boolean in turn until one takes a value, which is the rest of the word, past + one `=`. An `R` there names the repository, and `ends` says it ends the word, so the value is + the next word. That word is still read for itself as well, since that can only add a target. + A letter in `letters` takes a value, so the rest of the word is its value. Any other letter is + read as a boolean, which can only read more of the word as a target. + """ + for x in range(1, len(tok)): + c = tok[x] + if c != "R" and c not in letters: + continue + rest = tok[x + 1 :] + rest = rest[1:] if len(rest) > 1 and rest[0] == "=" else rest + if c != "R": + return None, False + return (rest or None), not rest + return None, False + + +_GH_SHORT_VALUE_LETTERS = { + ("pr", "merge"): "AbFt", + ("pr", "create"): "aBbFHlmprTt", + ("pr", "comment"): "bF", + ("pr", "edit"): "BbFmt", + ("pr", "close"): "c", + ("pr", "review"): "bF", + ("pr", "reopen"): "c", + ("pr", "lock"): "r", + ("issue", "create"): "abFlmpTt", + ("issue", "comment"): "bF", + ("issue", "edit"): "bFmt", + ("issue", "close"): "cr", + ("issue", "reopen"): "c", + ("issue", "lock"): "r", + ("release", "create"): "nFt", + ("release", "edit"): "nFt", + ("label", "create"): "cd", + ("label", "edit"): "cdn", + ("secret", "set"): "abeforv", + ("variable", "set"): "beforv", +} + + +def _gh_short_value_letters(args, flags): + """The short options taking a value in a gh argv, from its subcommand, or else the reading's table. + + `_GH_SHORT_VALUE_LETTERS` lists them, `-R` aside, for each pr, issue, release, label, secret, + and variable write it names, as gh's help does. So an attached value such as `-BRelease/2.0` is + read as the base branch it is. Any other subcommand takes the reading's table. A value there + holding an `R` and a `/` may then read as a target. + """ + known = _GH_SHORT_VALUE_LETTERS.get(tuple(_gh_path(args))) + if known is not None: + return known + return "".join(f[1] for f in flags if len(f) == 2) + + +def _named_target(word, is_path): + """The (owner, repo) a repository word or a normalized API path names, or None.""" + if is_path: + m = _REPOS_PATH_TOKEN.match(word) + if m and "<" not in word: + return m.group("owner").lower(), m.group("repo").lower() + return None + if "/" in word and "<" not in word: + o, r = word.split("/", 1) + return o.lower(), r.lower() + return None + + +_REPOS_PATH_OWNER = re.compile(r"^/?repos/[^/]*") + + +def _gh_run_time_target(cmd): + """The first run-time word a gh write in `cmd` names its repository's owner with, or None. + + The shell rewrites it before gh reads it, so the target check has nothing to compare. That is + a repo flag's value, the owner segment of a `repos/` API path, or a `GH_REPO` value. A literal + owner is all the check compares, so `--repo "example/$R"` is left alone. So is a read naming + one, as in `gh pr view -R "$T"`, and a heredoc body that is data, wrapper bodies in it included. + """ + argvs = [a for t in _data_free_texts(cmd) for a in _gh_arg_lists(t)] + for word, is_path, args in _gh_repo_words(argvs): + m = _REPOS_PATH_OWNER.match(word) if is_path else None + owner = (m.group(0) if m else "") if is_path else word.partition("/")[0] + if _is_run_time_word(owner) and _gh_argv_is_write(args): + return word + for value, args in _gh_repo_assignments(cmd): + if _is_run_time_word(value.partition("/")[0]) and _gh_argv_is_write(args): + return value + return None + + +def _data_free_texts(cmd): + """Each reading of `cmd` with its heredoc data removed, then each wrapper body in it.""" + return [t for r in _heredoc_readings(cmd) or [cmd] for t in [r, *_embedded_wrapper_commands(r)]] + + +def _gh_repo_assignments(cmd): + """Each `GH_REPO` value that reaches a gh word in `cmd`, as (value, argv), wrapper bodies included. + + gh reads its repository from GH_REPO where no repo flag names one, so a gh word whose argv + names one takes none. `gh api` reads it only to fill an `{owner}` or `{repo}` placeholder. A + value reaches a gh word from the prefix of the simple command it runs (see `_command_prefix`), + or else from the latest `export` before it. A wrapper body takes what + `_wrapper_env_assignments` says reaches a wrapper, the latest such value for every body. A + heredoc body that is data is left out, and an argument such as `echo GH_REPO=x` does not count. + """ + out = [] + for reading in _heredoc_readings(cmd) or [cmd]: + toks = _shell_tokens(reading) + out += _gh_word_repo_values(toks, []) + reaching = _wrapper_env_assignments(toks, lambda body: bool(_all_gh_arg_lists(body))) + inherited = [v for name, v in reaching if name == "GH_REPO"][-1:] + for inner in _embedded_wrapper_commands(reading): + out += _gh_word_repo_values(_shell_tokens(inner), inherited) + return out + + +def _gh_word_repo_values(toks, inherited): + """The `GH_REPO` value reaching each gh word in `toks`, as (value, argv), past `inherited`.""" + out, exported = [], list(inherited) + for x, t in enumerate(toks): + if t == "export" and _opens_command(toks, x): + exported += [v for name, v in _export_assignments(toks, x) if name == "GH_REPO"] + continue + if not _is_gh_exe(t): + continue + reached, pairs = _command_prefix(toks, x) + argv = _api_first(_collect_arglist(toks, x + 1)[0]) + if not reached or any(not is_path for _w, is_path, _a in _gh_repo_words([argv])): + continue + if argv[:1] == ["api"] and not any("{owner}" in a or "{repo}" in a for a in argv): + continue + prefix = [v for name, v in pairs if name == "GH_REPO"] + out += [(v, argv) for v in prefix or exported[-1:]] + return out def _normalize_api_path(raw): @@ -3499,9 +3753,10 @@ def _unread_env_setting(name, value): return None -def _wrapper_env_assignments(toks): +def _wrapper_env_assignments(toks, runs=None): """The assignments reaching a shell whose command string runs git (see `_runs_git`), as (name, value). + `runs` stands in for `_runs_git` where another command's reach is read, as gh's is. The shell is a `sh -c`-style wrapper or one `_FOREIGN_SHELL_FLAGS` names, as its simple command's command. That is its own prefix (see `_command_prefix`), and each `export` argument before it. Behind a launcher `_command_prefix` does not know, it is the assignments before that launcher (see `_launcher_prefix`). @@ -3513,7 +3768,7 @@ def _wrapper_env_assignments(toks): exported.extend(_export_assignments(toks, x)) continue found = _shell_bodies_at(toks, x) - if found and any(_runs_git(body) for body in found[0]): + if found and any((runs or _runs_git)(body) for body in found[0]): reached, prefix = _command_prefix(toks, x) if not reached: reached, prefix = _launcher_prefix(toks, x) @@ -3790,29 +4045,11 @@ def _dir_value_split_free(x, toks, mask, raw, text): def _run_time_command_sub(toks, k, mask): """The index of the `push` or `commit` a run-time word `toks[k]` runs as git, else None. - The word is read as a command word right after an unquoted `(`, as in `<($G push)`. - Elsewhere it is one where `_runs_as_command` says it runs. - No `git` or `gh` word before it in its run may take it as an argument. - A `case` run, or a `for` or `select` run holding `in`, lists words rather than running one. - An array assignment, as in `a=($X push)`, and any other assignment is no command word. + The word is one `_is_run_time_command` reads as a command word. The subcommand word is the first past git's global options and any redirection. A `$(...)` or backtick span holding a space is read through first. """ - t = toks[k] - if "$" not in t and "`" not in t or _ENV_ASSIGN_RE.match(t): - return None - if k and toks[k - 1].endswith("(") and not (mask and mask[k - 1]): - if toks[k - 1] == "(" and k > 1 and toks[k - 2].endswith("="): - return None - start = k - elif _runs_as_command(toks, k, mask): - start = k - while start > 0 and not (_is_separator(toks[start - 1]) and not (mask and mask[start - 1])): - start -= 1 - else: - return None - listed = toks[start] == "case" or (toks[start] in _LIST_OPENERS and "in" in toks[start:k]) - if listed or any(_is_git_exe(w) or _is_gh_exe(w) for w in toks[start:k]): + if not _is_run_time_command(toks, k, mask): return None end = _region_end(toks, k, mask) j = _span_end(toks, k, end) @@ -3831,6 +4068,32 @@ def _run_time_command_sub(toks, k, mask): return j if j < end and toks[j] in ("push", "commit") else None +def _is_run_time_command(toks, k, mask): + """True when a run-time word `toks[k]` runs as a command word, so it may expand to git or gh. + + The word is read as a command word right after an unquoted `(`, as in `<($G push)`. + Elsewhere it is one where `_runs_as_command` says it runs. + No `git` or `gh` word before it in its run may take it as an argument. + A `case` run, or a `for` or `select` run holding `in`, lists words rather than running one. + An array assignment, as in `a=($X push)`, and any other assignment is no command word. + """ + t = toks[k] + if "$" not in t and "`" not in t or _ENV_ASSIGN_RE.match(t): + return False + if k and toks[k - 1].endswith("(") and not (mask and mask[k - 1]): + if toks[k - 1] == "(" and k > 1 and toks[k - 2].endswith("="): + return False + start = k + elif _runs_as_command(toks, k, mask): + start = k + while start > 0 and not (_is_separator(toks[start - 1]) and not (mask and mask[start - 1])): + start -= 1 + else: + return False + listed = toks[start] == "case" or (toks[start] in _LIST_OPENERS and "in" in toks[start:k]) + return not (listed or any(_is_git_exe(w) or _is_gh_exe(w) for w in toks[start:k])) + + def _outside_heredoc_data(text, window): """True when the run of words `window` sits in `text` outside every heredoc body that is data. @@ -4392,29 +4655,27 @@ def _is_gh_pr_merge(args): def _runs_gh_pr_merge(args): - """True when a `gh` argv's subcommand under `pr` is `merge`, past `pr`'s own flags. + """True when a `gh` argv's command and subcommand, read by `_gh_path`, are `pr merge`. - A repo flag's value is passed too, as in `gh pr -R example/repo merge`. This is narrower - than `_is_gh_pr_merge`, so a `merge` word in another subcommand's value, as in - `gh pr comment 5 --body merge`, is not read as one. + This is narrower than `_is_gh_pr_merge`, so a `merge` word in another subcommand's value, as + in `gh pr comment 5 --body merge`, is not read as one. """ - if "pr" not in args: - return False - j = args.index("pr") + 1 - while j < len(args) and args[j].startswith("-"): - j += 2 if args[j] in _REPO_FLAG_BARE else 1 - return j < len(args) and args[j] == "merge" + return _gh_path(args) == ["pr", "merge"] def _gh_admin_merge(args): - """True when a `gh` argv is `pr merge` carrying `--admin`, however the shell quoted it. + """True when a `gh` argv is or may be `pr merge` carrying `--admin`, however the shell quoted it. The shell drops the quotes of `"--admin"` before gh reads it, so the argv is read rather than the text. A word that is another option's value is text, as in `--body "--admin"`. gh reads its - flags wherever they sit, so `--admin` is looked for in every word. + flags wherever they sit, so `--admin` is looked for in every word. A run-time word where `pr` + or `merge` goes may be either (see `_gh_may_merge`). """ - if not _is_gh_pr_merge(args): - return False + return (_is_gh_pr_merge(args) or _gh_may_merge(args)) and _names_admin(args) + + +def _names_admin(args): + """True when a `gh` argv holds `--admin` as a flag rather than as another option's value.""" rest = iter(args) for a in rest: if a in _GH_MERGE_VALUE_FLAGS: @@ -4435,20 +4696,62 @@ def _gh_merge_run_time_word(text): word inside a heredoc body that is data is left alone too. Only a gh word in a command's position counts, the test `_rewritten_git_words` gives git, so `echo gh pr merge $N` is no merge. + + A gh whose `pr` or `merge` is a run-time word may merge too (see `_gh_may_merge`). So may a + run-time command word before `pr`, as in `G=gh; $G pr merge`. Such a word comes back where it + may split, or where a literal `--admin` sits among the words. One that word splitting cannot + split is read as the one word it is, as in `gh pr "$S" 5`. """ toks = _shell_tokens(text) mask = raw = None for k, t in enumerate(toks): - if not _is_gh_exe(t) or not _runs_gh_pr_merge(_collect_arglist(toks, k + 1)[0]): + if not _is_gh_exe(t) and ("$" not in t and "`" not in t): continue + start = k + 1 + if not _is_gh_exe(t): + if mask is None: + mask, raw = _quoted_mask(text, toks), _quote_kept_tokens(text, toks) + start = _span_end(toks, k, _region_end(toks, k, mask)) or start + at = _arg_positions(toks, start)[0] + args = [toks[p] for p in at] + merges = _runs_gh_pr_merge(args) + path = _gh_path_at(args) + unsure = [at[i] for i in path if _is_run_time_word(args[i])] + if _is_gh_exe(t) and not merges and not _gh_may_merge(args): + head = args[path[0]] if path else "" + if not unsure or head != "pr" and not _is_run_time_word(head): + continue + if mask is None: + mask, raw = _quoted_mask(text, toks), _quote_kept_tokens(text, toks) + if all(_split_free(x, toks, mask, raw) for x in unsure): + continue + if _is_gh_exe(t): + unsure = [] if merges else unsure + elif merges or _gh_path(args)[:1] == ["pr"] and _gh_may_merge(args): + unsure = [k, *unsure] + else: + continue + slots = _gh_merge_slots(args, path, at) | {k} if mask is None: mask, raw = _quoted_mask(text, toks), _quote_kept_tokens(text, toks) opened = k and toks[k - 1].endswith("(") and not (mask and mask[k - 1]) - if not opened and not _runs_as_command(toks, k, mask): + if _is_gh_exe(t) and not opened and not _runs_as_command(toks, k, mask): + continue + if not _is_gh_exe(t) and not _is_run_time_command(toks, k, mask): continue end = _region_end(toks, k, mask) - x = k + 1 + hit = next( + (x for x in unsure if x not in slots or not _split_free(x, toks, mask, raw)), None + ) + if unsure and _names_admin(args): + hit = unsure[0] + if hit is not None and _outside_heredoc_data(text, toks[k : hit + 1]): + return toks[hit] + x = start while x < end: + if x in unsure: + x += 1 + continue word = toks[x] if _is_redir_op(word): x += 2 if x + 1 < end and not _is_shell_op(toks[x + 1]) else 1 @@ -4466,6 +4769,25 @@ def _gh_merge_run_time_word(text): return None +def _gh_merge_slots(args, path, at): + """The token index of a run-time word sitting in exactly the place `pr` or `merge` goes. + + That is the word right after a literal `pr`, or right before a literal `merge`. A word there that + word splitting cannot split is the one word it is. A run-time word elsewhere before the + subcommand may be a flag and its value, such as `-R stranger/example`, so it is no slot. + """ + words = [args[i] for i in path] + if len(words) < 2: + return set() + if path[1] != path[0] + 1: + return set() + if words[0] == "pr" and _is_run_time_word(words[1]): + return {at[path[1]]} + if _is_run_time_word(words[0]) and words[1] == "merge": + return {at[path[0]]} + return set() + + def _gh_merge_attached_value(word): """True when `word` is a `_GH_MERGE_TEXT_FLAGS` option with its value attached. @@ -4590,12 +4912,14 @@ def _check_bypass_flags(cmd): ) if hidden_admin: return "deny", ( - f"This runs `gh pr merge` with a run-time word, `{hidden_admin}`, among its words. The " - "shell rewrites it before gh reads it. It may expand to `--admin`, which merges past " - "required reviews and status checks, or name a repository the target check cannot " - "read. Failing closed. Write every word of the merge out. Only a quoted value of an " - 'option other than --repo, as in --body "$BODY", may be a run-time word.' - + _handoff(cmd) + f"This runs or may run `gh pr merge` with a run-time word, `{hidden_admin}`, among its " + "words, where gh's command or subcommand goes, or where gh itself goes. The shell " + "rewrites it before gh reads " + "it. It may expand to `--admin`, which merges past required reviews and status checks, " + "to `pr merge` itself, or name a repository the target check cannot read. Failing " + "closed. Write every word of the merge out. Only a quoted value of an option other " + 'than --repo, as in --body "$BODY", may be a run-time word, and a quoted one where ' + "gh's command or subcommand goes when no --admin is among the words." + _handoff(cmd) ) # The --no-verify flag and `commit -n` skip the git hooks, so they only matter as an actual argument to a git commit or push. # Other tools use --no-verify for unrelated things, and shlex keeps a quoted mention out of the argv. @@ -6376,6 +6700,14 @@ def classify( "Safety', or the same section of the user-level CLAUDE.md where the repo has no " "GOVERNANCE.md." ) + hidden_target = _gh_run_time_target(cmd) + if hidden_target: + return "deny", ( + f"This write names its repository with a run-time word, `{hidden_target}`, which " + "the shell rewrites before gh reads it, so the guard cannot tell which repository " + "it writes to. Failing closed. Write the repository out, as in --repo owner/repo, " + "GH_REPO=owner/repo, or repos/owner/repo." + _handoff(cmd) + ) # 5. Hand-rolled reply/resolve for a review thread, bypassing scripts/pr_review.py's one-call helper. dec, reason = _check_reply_resolve_helper(cmd, environ) @@ -6512,6 +6844,197 @@ def classify( ), ] +_GH_PATH_CASES: list[tuple[str, str, str]] = [ + ("gh -R stranger/example pr merge 5 --squash", "deny", "a repo flag before gh's command"), + ( + "gh --repo=stranger/example issue comment 5 --body hi", + "deny", + "an attached --repo= before gh's command", + ), + ( + "gh -Rstranger/example pr create --title t --body b", + "deny", + "an attached -R before gh's command", + ), + ("gh pr -R stranger/example merge 5", "deny", "a repo flag between pr and merge"), + ( + "gh pr --repo=stranger/example comment 5 --body hi", + "deny", + "an attached --repo= between pr and comment", + ), + ( + "gh pr -R example/repo merge 5 -R stranger/example", + "deny", + "a second repo flag past a merge whose first flag sits before merge", + ), + ( + 'gh pr --squash 5 merge "$(printf %s --admin)"', + "deny", + "an unknown flag before merge takes the next word, so this is a merge with a run-time word", + ), + ("gh pr merge 5 -dR stranger/example", "deny", "a short-option cluster ending in R"), + ("gh pr merge 5 -sdRstranger/example", "deny", "an R inside a cluster takes the rest of it"), + ("gh pr create -fR stranger/example", "deny", "a cluster ending in R on pr create"), + ("GH_REPO=stranger/example gh pr merge 5 --squash", "deny", "a GH_REPO prefix"), + ('GH_REPO="$T" gh pr merge 5 --squash', "deny", "a run-time GH_REPO prefix"), + ( + "export GH_REPO=stranger/example; gh issue comment 5 --body hi", + "deny", + "an exported GH_REPO earlier in the command", + ), + ( + "GH_REPO=stranger/example gh api -X POST repos/{owner}/{repo}/issues/1/comments -f body=x", + "deny", + "a GH_REPO that fills gh api placeholders", + ), + ('gh issue comment 5 --repo "$T" --body hi', "deny", "a run-time --repo value on a comment"), + ("gh pr comment 5 -R $T --body hi", "deny", "a run-time -R value on a comment"), + ( + 'gh api -X POST "repos/$O/$R/issues/1/comments" -f body=x', + "deny", + "a run-time owner in a repos/ API path on a write", + ), + ("gh pr $S 5 --admin", "deny", "a run-time word where merge goes beside --admin"), + ("gh $P merge 5 --admin", "deny", "a run-time word where pr goes beside --admin"), + ("G=gh; $G pr merge 5 --admin", "deny", "a run-time command word before pr merge --admin"), + ("gh pr $S 5", "deny", "an unquoted run-time subcommand word may split into merge --admin"), + ( + "gh --hostname github.com api -X POST repos/stranger/example/issues/1/comments -f body=x", + "deny", + "an api write whose flags precede the api command", + ), + ( + 'gh "$X" --squash pr merge 5', + "deny", + "a quoted run-time word two words before merge is no slot", + ), + ("gh $X 5", "deny", "an unquoted run-time command word may hold pr merge --admin"), + ( + "gh pr create --fill -BRelease/2.0", + "allow", + "an R inside a base branch value is no repo flag", + ), + ( + "GH_REPO=stranger/example gh api -X POST repos/example/repo/issues/1/comments -f body=x", + "allow", + "GH_REPO fills no placeholder in an api path written out", + ), + ( + "gh api repos/example/repo/pulls/$N", + "allow", + "an unquoted run-time word in an api read's endpoint", + ), + ( + "gh issue $ACTION 5", + "allow", + "an unquoted run-time subcommand under a command other than pr", + ), + ( + 'GH_REPO="$T" gh --hostname github.com api -X POST repos/{owner}/{repo}/issues/1/comments -f body=x', + "deny", + "a run-time GH_REPO filling the placeholders of an api write whose flags precede api", + ), + ( + 'gh --foo pr merge 5 "$X"', + "allow", + "gh reads pr as the unknown flag's value, so no merge runs", + ), + ( + 'gh pr --squash merge 5 "$X"', + "allow", + "gh reads merge as the flag's value, so no merge runs", + ), + ( + 'gh pr -d merge 5 "$X"', + "allow", + "gh reads merge as the short flag's value, so no merge runs", + ), + ('gh pr -- merge 5 "$X"', "allow", "-- ends gh's subcommand search, so no merge runs"), + ("gh -R stranger/example pr view 5", "allow", "a repo flag before a read's command"), + ('gh pr view 5 -R "$T"', "allow", "a run-time repo value on a read"), + ("GH_REPO=stranger/example gh pr view 5", "allow", "a GH_REPO prefix on a read"), + ( + "echo GH_REPO=stranger/example; gh pr merge 5 --squash", + "allow", + "a GH_REPO word that is an argument assigns nothing", + ), + ('gh pr "$S" 5', "allow", "a quoted run-time subcommand word with no --admin"), + ("env GH_REPO=stranger/example gh pr merge 5", "deny", "a GH_REPO an env precommand sets"), + ( + "$(which gh) pr merge 5 --admin", + "deny", + "a command substitution as the command word before pr merge --admin", + ), + ( + "export GH_REPO=stranger/example; bash -c 'gh issue comment 5 --body hi'", + "deny", + "an exported GH_REPO reaches a write in a wrapper body", + ), + ( + 'gh pr comment 5 --body x; export GH_REPO="$T"', + "allow", + "an export after a write does not reach it", + ), + ("gh $REPO_ARGS pr merge 5 $MERGE_FLAGS", "deny", "a run-time word before gh's command"), + ('gh "$R" pr merge 5 --squash', "deny", "a quoted run-time word before pr may be a repo flag"), + ( + 'X="pr merge"; gh $X 5 --admin', + "deny", + "a run-time command word that may split into pr merge", + ), + ( + "GH_REPO=stranger/example bash -c 'gh issue comment 5 --body hi'", + "deny", + "a GH_REPO prefix on a wrapper reaches the write in its body", + ), + ( + "GH_REPO=stranger/example gh pr comment 5 -R example/repo --body hi", + "allow", + "a repo flag overrides GH_REPO, as gh reads it", + ), + ( + "cat > n.md <<'EOF'\nbash -c 'gh pr comment 5 -R \"$T\" --body x'\nEOF", + "allow", + "a wrapper body inside heredoc data is no write", + ), + ( + 'gh issue comment 5 --repo "example/$R" --body hi', + "allow", + "a run-time repository name under a literal origin owner", + ), + ( + 'gh api -X POST "repos/example/$R/issues/1/comments" -f body=x', + "allow", + "a run-time repository segment under a literal origin owner in an API path", + ), + ( + 'env -u GH_TOKEN gh pr comment 5 --body "GH_REPO=stranger/example is read"', + "allow", + "a GH_REPO word in a body past an env precommand assigns nothing", + ), + ( + 'GH_REPO="$T" gh pr view 5; gh pr comment 5 --body x', + "allow", + "a run-time GH_REPO prefixing a read does not reach a later write", + ), + ( + "gh pr comment 5 --body-file - <<'EOF'\nrun gh pr merge 5 -R \"$T\"\nEOF", + "allow", + "a run-time repo value inside heredoc data is no write", + ), + ( + "cat > notes.txt <<'EOF'\nGH_REPO=stranger/example\nEOF\ngh pr comment 5 --body hi", + "allow", + "a GH_REPO line inside heredoc data assigns nothing", + ), + ( + "gh -R example/repo pr merge 5 --squash", + "allow", + "a repo flag before the command naming origin", + ), +] + + # Rule-5 cases, covering the hand-rolled reply/resolve denial and its cross-owner grant escape. # Each carries the environment the grant is read from, matching the _SCOPE_CASES convention below. _REPLY_RESOLVE_CASES = [ @@ -13222,6 +13745,19 @@ def _selftest(): if got != want: ok = False print(f" {mark} [{got:5}] want={want:5} {label}") + for cmd, want, label in _GH_PATH_CASES: + got, _ = classify( + cmd, + origin=("example", "repo"), + current_branch="feature/x", + rules_lookup=lambda br: set(), + environ={}, + primary_checkout_lookup=lambda d: False, + ) + mark = "ok " if got == want else "FAIL" + if got != want: + ok = False + print(f" {mark} [{got:5}] want={want:5} {label}") for cmd, env, want, label in _REPLY_RESOLVE_CASES: got, _ = classify( cmd,