From 31bade60315ffe9ca4c0b8be092336498e97ed34 Mon Sep 17 00:00:00 2001 From: willwebster5 Date: Thu, 16 Apr 2026 16:16:23 -0400 Subject: [PATCH 1/7] style: apply ruff format to fix CI lint failures on master Co-Authored-By: Claude Sonnet 4.6 --- src/talonctl/providers/detection_provider.py | 27 +++++--------------- tests/unit/test_detection_provider.py | 18 +++---------- 2 files changed, 11 insertions(+), 34 deletions(-) diff --git a/src/talonctl/providers/detection_provider.py b/src/talonctl/providers/detection_provider.py index 7214e90..d3a24a6 100644 --- a/src/talonctl/providers/detection_provider.py +++ b/src/talonctl/providers/detection_provider.py @@ -69,8 +69,7 @@ def _validate_ads_ref_dict(self, ref: Dict[str, Any], field_path: str) -> List[s unknown = set(ref.keys()) - self.ADS_REF_ALLOWED_KEYS if unknown: errs.append( - f"Unknown ref dict key(s) in {field_path}: {', '.join(sorted(unknown))}. " - f"Known keys: path, label" + f"Unknown ref dict key(s) in {field_path}: {', '.join(sorted(unknown))}. Known keys: path, label" ) if "path" not in ref: errs.append(f"{field_path} ref dict missing required 'path' key") @@ -236,8 +235,7 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]: ) else: errors.append( - f"{path_tag} must be a string, inline FP dict, or " - f"ref dict ({{path, label?}})" + f"{path_tag} must be a string, inline FP dict, or ref dict ({{path, label?}})" ) # Per-entry validation for validation (list of string | ref-dict; no inline-dict form). @@ -253,9 +251,7 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]: # defined for this field, so those errors are sufficient. errors.extend(self._validate_ads_ref_dict(entry, path_tag)) else: - errors.append( - f"{path_tag} must be a string or ref dict ({{path, label?}})" - ) + errors.append(f"{path_tag} must be a string or ref dict ({{path, label?}})") # Type-union validation for response (string | ref-dict). if "response" in ads: @@ -265,9 +261,7 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]: elif isinstance(response, dict): errors.extend(self._validate_ads_ref_dict(response, "ads.response")) else: - errors.append( - "ads.response must be a string or ref dict ({path, label?})" - ) + errors.append("ads.response must be a string or ref dict ({path, label?})") # Validate metadata: block if present (optional block, strict when present). # See docs/superpowers/specs/2026-04-16-metadata-schema-and-ads-refs-design.md §1. @@ -280,10 +274,7 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]: unknown = set(metadata.keys()) - self.METADATA_ALLOWED_FIELDS if unknown: known = ", ".join(sorted(self.METADATA_ALLOWED_FIELDS)) - errors.append( - f"Unknown metadata key(s): {', '.join(sorted(unknown))}. " - f"Known keys: {known}" - ) + errors.append(f"Unknown metadata key(s): {', '.join(sorted(unknown))}. Known keys: {known}") # Validate date fields (YYYY-MM-DD; last_tuned additionally allows null). for field in self.METADATA_DATE_FIELDS: @@ -302,18 +293,14 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]: if "tune_count" in metadata: val = metadata["tune_count"] if isinstance(val, bool) or not isinstance(val, int) or val < 0: - errors.append( - f"metadata.tune_count must be a non-negative integer (got {val!r})" - ) + errors.append(f"metadata.tune_count must be a non-negative integer (got {val!r})") # Validate confidence enum. if "confidence" in metadata: val = metadata["confidence"] if val not in self.METADATA_CONFIDENCE_VALUES: allowed = ", ".join(sorted(self.METADATA_CONFIDENCE_VALUES)) - errors.append( - f"metadata.confidence must be one of: {allowed} (got {val!r})" - ) + errors.append(f"metadata.confidence must be one of: {allowed} (got {val!r})") return errors diff --git a/tests/unit/test_detection_provider.py b/tests/unit/test_detection_provider.py index ce33268..cbe4694 100644 --- a/tests/unit/test_detection_provider.py +++ b/tests/unit/test_detection_provider.py @@ -621,10 +621,7 @@ def test_validate_metadata_confidence_invalid(self, provider, minimal_detection) """Confidence value not in enum rejected, error names all allowed values.""" minimal_detection["metadata"] = {"confidence": "mature"} errors = provider.validate_template(minimal_detection) - assert any( - "metadata.confidence" in err and "low" in err and "validated" in err - for err in errors - ) + assert any("metadata.confidence" in err and "low" in err and "validated" in err for err in errors) def test_validate_metadata_errors_accumulate(self, provider, minimal_detection): """Multiple metadata errors produce multiple distinct errors (no short-circuit).""" @@ -784,10 +781,7 @@ def test_validate_ads_validation_inline_dict_rejected(self, provider, minimal_de "validation": [{"characteristics": "oops"}], } errors = provider.validate_template(minimal_detection) - assert any( - "ads.validation" in err and ("strings" in err or "ref" in err) - for err in errors - ) + assert any("ads.validation" in err and ("strings" in err or "ref" in err) for err in errors) def test_validate_ads_response_dict_unknown_key_rejected(self, provider, minimal_detection): """response dict treated as ref dict — unknown keys rejected.""" @@ -883,9 +877,7 @@ def test_hash_unchanged_when_metadata_mutates(self, provider, rich_template): "confidence": "validated", # changed }, } - assert provider.compute_content_hash(mutated) == baseline, ( - "metadata: mutation must not change content hash" - ) + assert provider.compute_content_hash(mutated) == baseline, "metadata: mutation must not change content hash" def test_hash_unchanged_when_ads_mutates(self, provider, rich_template): """Editing any ads: field (including ref dicts) must not change the content hash.""" @@ -903,9 +895,7 @@ def test_hash_unchanged_when_ads_mutates(self, provider, rich_template): "response": "Inline response now", }, } - assert provider.compute_content_hash(mutated) == baseline, ( - "ads: mutation must not change content hash" - ) + assert provider.compute_content_hash(mutated) == baseline, "ads: mutation must not change content hash" def test_hash_changes_when_real_field_mutates(self, provider, rich_template): """Sanity: content hash MUST change when a real CONTENT_FIELDS member mutates.""" From 053ec02fe54600a5f5a35c4f2188f6dde0adda9b Mon Sep 17 00:00:00 2001 From: willwebster5 Date: Thu, 16 Apr 2026 16:19:20 -0400 Subject: [PATCH 2/7] chore: add pre-commit framework with ruff hooks mirroring CI Co-Authored-By: Claude Sonnet 4.6 --- .pre-commit-config.yaml | 12 ++++++++++++ pyproject.toml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8b8fe9e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +# talonctl pre-commit hooks. Mirrors the CI lint gate so formatting drift +# cannot slip through to the ci.yml check. Install once with: +# pre-commit install +# After that, every `git commit` runs the hooks automatically. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.10 # keep in sync with the ruff pin used by .github/workflows/ci.yml + hooks: + - id: ruff-format + args: [--exclude, src/talonctl/_version.py] + - id: ruff + args: [--exclude, src/talonctl/_version.py] diff --git a/pyproject.toml b/pyproject.toml index 1e633c8..dd78a08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pytest>=7.0.0", "ruff>=0.8.0"] +dev = ["pre-commit>=3.7", "pytest>=7.0.0", "ruff>=0.8.0"] [project.scripts] talonctl = "talonctl.cli:cli" From ae7923f33b9edefc8e70e22bed285d0d2088a3bc Mon Sep 17 00:00:00 2001 From: willwebster5 Date: Thu, 16 Apr 2026 16:21:01 -0400 Subject: [PATCH 3/7] docs: document pre-commit install step in CLAUDE.md --- CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 6c17853..29c85ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,16 @@ pip install -e .[dev] pytest tests/ -v ``` +### Installing pre-commit hooks + +Once, after `pip install -e .[dev]`: + +```bash +pre-commit install +``` + +After that, `git commit` auto-runs `ruff format` and `ruff check`. The config in `.pre-commit-config.yaml` mirrors the CI lint gate — keeping both green locally means CI stays green. + ### Adding a New CLI Command 1. Create `src/talonctl/commands/mycommand.py` with a Click command or group From 293a35659eca8db0211d4f90ed5471e3a3876c17 Mon Sep 17 00:00:00 2001 From: willwebster5 Date: Thu, 16 Apr 2026 16:24:02 -0400 Subject: [PATCH 4/7] feat: add core/template_sanitizer shared helper (closes #7 bug class) --- src/talonctl/core/template_sanitizer.py | 50 +++++++++ tests/unit/test_template_sanitizer.py | 131 ++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/talonctl/core/template_sanitizer.py create mode 100644 tests/unit/test_template_sanitizer.py diff --git a/src/talonctl/core/template_sanitizer.py b/src/talonctl/core/template_sanitizer.py new file mode 100644 index 0000000..028e541 --- /dev/null +++ b/src/talonctl/core/template_sanitizer.py @@ -0,0 +1,50 @@ +"""Shared template-sanitization helpers. + +Every provider's API-payload prep and content-hash path calls into this module +as its FIRST step. Provider-specific stripping/transforms (e.g. dashboard's +tags->labels rename, or a provider deciding `description` is IaC-only for its +resource type) run AFTER this helper. + +Adding a new universally-IaC top-level field → update RESERVED_TOP_LEVEL_FIELDS +here, once. Adding a new internal field → use the `_` prefix convention, no +code change. Adding a provider-specific IaC-only field (like dashboard's +stripping of `description`) → keep that logic in the provider, not here. +""" + +from __future__ import annotations + +from typing import Any, Dict + +# Single source of truth for "what is universally IaC-only across ALL providers." +# A field belongs here only if there is no provider where it is an API field. +# Fields like `description` and `tags` are intentionally NOT in this set because +# they are API fields on detection/saved_search but not on dashboard — providers +# handle them per-type. +RESERVED_TOP_LEVEL_FIELDS = frozenset( + { + "resource_id", + "type", + "dependencies", + "metadata", + } +) + + +def strip_for_api(template: Dict[str, Any]) -> Dict[str, Any]: + """Return a shallow-copied dict with universally-IaC, internal, and metadata + fields removed. + + This is the FIRST step of every provider's payload prep. Providers then apply + their own provider-specific stripping and transforms on the returned dict. + Callers that need to mutate sub-dicts (e.g. widget UUID normalization in + dashboards) should apply their own copy.deepcopy AFTER calling this helper. + """ + return {k: v for k, v in template.items() if not k.startswith("_") and k not in RESERVED_TOP_LEVEL_FIELDS} + + +def strip_for_hash(template: Dict[str, Any]) -> Dict[str, Any]: + """Identical rules to strip_for_api — but named separately so callers can + reason about intent (deploy payload vs. content-hash input) without coupling. + If the two rules ever diverge, they diverge here, not across seven providers. + """ + return strip_for_api(template) diff --git a/tests/unit/test_template_sanitizer.py b/tests/unit/test_template_sanitizer.py new file mode 100644 index 0000000..1ea5c3b --- /dev/null +++ b/tests/unit/test_template_sanitizer.py @@ -0,0 +1,131 @@ +"""Unit tests for core/template_sanitizer.py. + +The helper strips universally-IaC top-level fields and any underscore-prefixed +tool-internal key. Every provider's API-payload prep and content-hash path +calls this helper as its first step, so these tests lock in the contract +between the helper and all seven providers. +""" + +from __future__ import annotations + +from talonctl.core.template_sanitizer import ( + RESERVED_TOP_LEVEL_FIELDS, + strip_for_api, + strip_for_hash, +) + + +class TestReservedFieldSet: + def test_reserved_set_is_exactly_these_four(self): + # Locked in — changing this set is a multi-provider contract change. + assert RESERVED_TOP_LEVEL_FIELDS == frozenset({"resource_id", "type", "dependencies", "metadata"}) + + def test_description_is_not_reserved(self): + # description is an API field on detection and saved_search — must NOT + # be stripped universally. + assert "description" not in RESERVED_TOP_LEVEL_FIELDS + + def test_tags_is_not_reserved(self): + # tags is provider-owned (renamed to labels on dashboard, kept by + # saved_search, etc.) — must NOT be stripped universally. + assert "tags" not in RESERVED_TOP_LEVEL_FIELDS + + +class TestStripForApi: + def test_empty_template_returns_empty(self): + assert strip_for_api({}) == {} + + def test_strips_resource_id(self): + assert strip_for_api({"resource_id": "x", "name": "n"}) == {"name": "n"} + + def test_strips_type(self): + assert strip_for_api({"type": "detection", "name": "n"}) == {"name": "n"} + + def test_strips_dependencies(self): + assert strip_for_api({"dependencies": ["a.b"], "name": "n"}) == {"name": "n"} + + def test_strips_metadata(self): + tmpl = {"metadata": {"maturity": {}, "ads": {}, "custom": {}}, "name": "n"} + assert strip_for_api(tmpl) == {"name": "n"} + + def test_strips_underscore_prefix_keys(self): + tmpl = { + "_template_path": "/tmp/x.yaml", + "_search_domain": "falcon", + "_future_internal": 42, + "name": "n", + } + assert strip_for_api(tmpl) == {"name": "n"} + + def test_preserves_description(self): + # Provider-owned — helper must not touch. + tmpl = {"description": "a desc", "resource_id": "x"} + assert strip_for_api(tmpl) == {"description": "a desc"} + + def test_preserves_tags(self): + tmpl = {"tags": ["a", "b"], "resource_id": "x"} + assert strip_for_api(tmpl) == {"tags": ["a", "b"]} + + def test_preserves_unknown_top_level_keys(self): + # Passthrough contract — if it's not reserved and doesn't start with + # `_`, it survives untouched. + tmpl = {"severity": 50, "search": {"filter": "x"}, "resource_id": "drop"} + assert strip_for_api(tmpl) == {"severity": 50, "search": {"filter": "x"}} + + def test_input_is_not_mutated(self): + tmpl = {"resource_id": "x", "name": "n", "metadata": {"maturity": {}}} + strip_for_api(tmpl) + # Original dict must retain all keys — helper returns a new dict. + assert "resource_id" in tmpl + assert "metadata" in tmpl + + def test_returns_shallow_copy_not_deep(self): + # Caller responsibility to deepcopy nested structures if they need to + # mutate them. The contract is shallow-copy. + nested = {"filter": "x"} + tmpl = {"search": nested, "resource_id": "drop"} + result = strip_for_api(tmpl) + assert result["search"] is nested # same object, shallow copy + + +class TestStripForHash: + def test_identical_rules_to_strip_for_api(self): + tmpl = { + "resource_id": "x", + "type": "detection", + "dependencies": ["a"], + "metadata": {"maturity": {"tune_count": 1}}, + "_template_path": "/tmp/x", + "name": "n", + "description": "d", + "tags": ["t"], + "severity": 50, + } + assert strip_for_hash(tmpl) == strip_for_api(tmpl) + + def test_hash_stable_across_metadata_edits(self): + base = {"name": "n", "severity": 50} + base_stripped = strip_for_hash(base) + + with_metadata = { + "name": "n", + "severity": 50, + "metadata": {"maturity": {"tune_count": 99}, "acme": {"x": 1}}, + } + assert strip_for_hash(with_metadata) == base_stripped + + +class TestIssue7Regression: + """Direct regression test for github.com/willwebster5/talonctl#7 — + _template_path leaked into Humio dashboard YAML validation.""" + + def test_template_path_stripped(self): + tmpl = { + "name": "My Dashboard", + "_template_path": "/home/user/project/resources/dashboards/my.yaml", + "sections": {}, + "widgets": {}, + } + result = strip_for_api(tmpl) + assert "_template_path" not in result + assert result == {"name": "My Dashboard", "sections": {}, "widgets": {}} From fae6b15556644ec295006582fab9b61fde648db6 Mon Sep 17 00:00:00 2001 From: willwebster5 Date: Thu, 16 Apr 2026 16:29:54 -0400 Subject: [PATCH 5/7] =?UTF-8?q?fix(#7):=20route=20DashboardProvider=20thro?= =?UTF-8?q?ugh=20template=5Fsanitizer=20=E2=80=94=20no=20more=20=5Ftemplat?= =?UTF-8?q?e=5Fpath=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/talonctl/providers/dashboard_provider.py | 50 ++++++++-------- tests/test_dashboard_provider.py | 61 ++++++++++++++++++++ 2 files changed, 88 insertions(+), 23 deletions(-) diff --git a/src/talonctl/providers/dashboard_provider.py b/src/talonctl/providers/dashboard_provider.py index d3722ea..cd3d897 100644 --- a/src/talonctl/providers/dashboard_provider.py +++ b/src/talonctl/providers/dashboard_provider.py @@ -17,12 +17,10 @@ import yaml from talonctl.core.base_provider import BaseResourceProvider, ResourceChange, ResourceAction +from talonctl.core.template_sanitizer import strip_for_api, strip_for_hash logger = logging.getLogger(__name__) -# IaC-only fields stripped before API calls and content hashing -IAC_ONLY_FIELDS = {"resource_id", "type", "description", "tags", "_search_domain", "dependencies"} - # Widget types that do NOT require a queryString NON_QUERY_WIDGET_TYPES = {"note", "parameterPanel"} @@ -85,34 +83,37 @@ def validate_template(self, template: Dict[str, Any]) -> List[str]: def _normalize_for_hash(template: Dict[str, Any]) -> Dict[str, Any]: """Normalize dashboard YAML for deterministic hashing. - 1. Strip IaC-only fields - 2. Re-key widgets by (section_order, position) to remove UUID sensitivity - 3. Update section widgetIds to match + 1. Strip universally-IaC and `_`-prefixed fields via template_sanitizer. + 2. Strip dashboard-specific IaC fields (`description` is not in the Humio + dashboard schema at top level). + 3. Re-key widgets by (section_order, position) to remove UUID sensitivity. + 4. Update section widgetIds to match. """ - data = copy.deepcopy(template) + data = copy.deepcopy(strip_for_hash(template)) - # Strip IaC-only fields - for field in IAC_ONLY_FIELDS: - data.pop(field, None) + # Dashboard-specific: preserve pre-helper behavior. The previous + # IAC_ONLY_FIELDS set excluded both `description` and `tags` from the hash + # input; keep doing that here to avoid a silent hash-behavior change when + # routing through the helper. (Whether tags *should* affect the hash is + # a separate design question — out of scope for this hotfix.) + data.pop("description", None) + data.pop("tags", None) sections = data.get("sections", {}) widgets = data.get("widgets", {}) - # Build ordered widget list: sort sections by order, then iterate widgetIds - ordered_widget_ids = [] + ordered_widget_ids: list[str] = [] for _section_id, section in sorted(sections.items(), key=lambda s: s[1].get("order", 0)): for wid in section.get("widgetIds", []): if wid not in ordered_widget_ids: ordered_widget_ids.append(wid) - # Include widgets not referenced by any section (append at end) for wid in widgets: if wid not in ordered_widget_ids: ordered_widget_ids.append(wid) - # Re-key widgets as widget-0, widget-1, ... - new_widgets = {} - id_map = {} + new_widgets: Dict[str, Any] = {} + id_map: Dict[str, str] = {} for i, old_id in enumerate(ordered_widget_ids): new_id = f"widget-{i}" id_map[old_id] = new_id @@ -121,7 +122,6 @@ def _normalize_for_hash(template: Dict[str, Any]) -> Dict[str, Any]: data["widgets"] = new_widgets - # Update section widgetIds for section in sections.values(): section["widgetIds"] = [id_map.get(wid, wid) for wid in section.get("widgetIds", [])] @@ -138,18 +138,22 @@ def compute_content_hash(self, template: Dict[str, Any]) -> str: def _prepare_yaml_payload(template: Dict[str, Any]) -> str: """Prepare dashboard YAML for API upload. - Strips IaC-only fields, converts tags->labels, preserves everything else. + Strips universally-IaC + `_`-prefixed fields via template_sanitizer, then + applies dashboard-specific transforms: tags→labels rename, description + strip (not in Humio dashboard schema). """ - data = copy.deepcopy(template) + data = strip_for_api(template) + # strip_for_api returns a shallow copy; we're about to mutate sub-keys, + # so deepcopy to avoid touching the caller's dict. + data = copy.deepcopy(data) - # Convert tags -> labels + # Dashboard-specific: tags -> labels rename. tags = data.pop("tags", []) if tags: data["labels"] = tags - # Strip IaC-only fields (except tags, already handled) - for field in IAC_ONLY_FIELDS - {"tags"}: - data.pop(field, None) + # Dashboard-specific: description is IaC-only for dashboards. + data.pop("description", None) return yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True) diff --git a/tests/test_dashboard_provider.py b/tests/test_dashboard_provider.py index 8f1c324..6988338 100644 --- a/tests/test_dashboard_provider.py +++ b/tests/test_dashboard_provider.py @@ -546,3 +546,64 @@ def test_provider_in_init_exports(self): from talonctl.providers import DashboardProvider assert DashboardProvider is not None + + +# --- Issue #7 regression: _template_path must not leak into Humio payload --- + + +class TestIssue7Regression: + """Direct regression for github.com/willwebster5/talonctl#7.""" + + def test_template_path_does_not_leak_into_yaml_payload(self, provider, valid_template): + tmpl = copy.deepcopy(valid_template) + tmpl["_template_path"] = "/home/user/project/resources/dashboards/my.yaml" + + yaml_str = provider._prepare_yaml_payload(tmpl) + + assert "_template_path" not in yaml_str, ( + "issue #7: _template_path leaked into dashboard YAML payload — " + "Humio schema validation will reject the upload." + ) + + def test_resource_id_and_type_and_dependencies_stripped_from_payload(self, provider, valid_template): + tmpl = copy.deepcopy(valid_template) + tmpl["dependencies"] = [] + yaml_str = provider._prepare_yaml_payload(tmpl) + + # Universally-IaC fields must not reach Humio. + assert "resource_id:" not in yaml_str + assert "dependencies:" not in yaml_str + # `type:` is a valid YAML key inside widgets (e.g. type: query), so only + # assert that top-level `type: dashboard` is absent. Parse to verify. + data = yaml.safe_load(yaml_str) + assert "type" not in data + assert "resource_id" not in data + assert "dependencies" not in data + + def test_description_still_stripped_for_dashboard_specifically(self, provider, valid_template): + # Dashboard-specific behavior preserved: Humio dashboard YAML schema + # does not carry `description` at the top level. + data = yaml.safe_load(provider._prepare_yaml_payload(valid_template)) + assert "description" not in data + + def test_tags_renamed_to_labels(self, provider, valid_template): + data = yaml.safe_load(provider._prepare_yaml_payload(valid_template)) + assert "tags" not in data + assert data.get("labels") == ["test"] + + def test_future_internal_field_stripped(self, provider, valid_template): + # Bug-class coverage: any future _-prefixed tool-internal field is + # stripped without needing a code change. + tmpl = copy.deepcopy(valid_template) + tmpl["_some_future_internal"] = "should-not-leak" + yaml_str = provider._prepare_yaml_payload(tmpl) + assert "_some_future_internal" not in yaml_str + + def test_normalize_for_hash_ignores_template_path(self, provider, valid_template): + hash_without = provider.compute_content_hash(valid_template) + + tmpl_with_path = copy.deepcopy(valid_template) + tmpl_with_path["_template_path"] = "/tmp/different/path.yaml" + hash_with = provider.compute_content_hash(tmpl_with_path) + + assert hash_without == hash_with From 7390fafdabe9392bbcbe3b298d762b7b1a8dd6b2 Mon Sep 17 00:00:00 2001 From: willwebster5 Date: Thu, 16 Apr 2026 16:33:30 -0400 Subject: [PATCH 6/7] docs: add v0.2.1 CHANGELOG entry --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ddde285 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## v0.2.1 — 2026-04-16 + +### Fixed + +- Dashboard apply no longer leaks `_template_path` into the Humio schema + validation payload (closes #7). `DashboardProvider._prepare_yaml_payload` + and `_normalize_for_hash` now route through a new + `core/template_sanitizer` helper that strips `_`-prefixed tool-internal + keys and the universally-IaC field set `{resource_id, type, dependencies, + metadata}`. Dashboard-specific transforms (`tags → labels`, `description` + strip) are preserved unchanged. + +### Chore + +- Added `.pre-commit-config.yaml` with ruff hooks mirroring the CI lint + gate. Install with `pre-commit install` after `pip install -e .[dev]`. + +### Skipped + +- **v0.2.0** was never released to PyPI. Its git tag was pushed malformed + (`v.0.2.0` with an extra dot) and the release job's VCS-versioning could + not parse it. The tag has been deleted; v0.2.1 is the first working + 0.2.x release. From 579fb5088801d988cae599e78bba2bd8eec61baa Mon Sep 17 00:00:00 2001 From: willwebster5 Date: Thu, 16 Apr 2026 16:35:12 -0400 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20CHANGELOG=20=E2=80=94=20malformed?= =?UTF-8?q?=20v.0.2.0=20tag=20stays=20on=20remote=20(deletion=20blocked=20?= =?UTF-8?q?by=20repo=20rule,=20but=20the=20tag=20is=20orphaned)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddde285..3f71326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,5 +21,7 @@ - **v0.2.0** was never released to PyPI. Its git tag was pushed malformed (`v.0.2.0` with an extra dot) and the release job's VCS-versioning could - not parse it. The tag has been deleted; v0.2.1 is the first working - 0.2.x release. + not parse it. The malformed tag remains on the remote — a GitHub + repository rule blocks tag deletion — but it is orphaned: hatchling's + VCS versioning cannot parse it, so no release will ever be cut from + it. v0.2.1 is the first working 0.2.x release.