From 381fef62d07ce17084b82a1f0ab15a062492b22d Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 01:51:50 +0200 Subject: [PATCH 1/8] feat: add embedded content checks --- .github/actions/check-embedder/action.yml | 17 +++ .github/actions/check-embedder/check.py | 152 ++++++++++++++++++++++ .github/dependabot.yml | 18 +++ .github/workflows/embedder-shared.yml | 18 +++ .github/workflows/embedder.yml | 8 ++ AGENTS.md | 14 ++ README.md | 41 +++++- config/embedder.yml | 73 +++++++++++ test/test_embedder_config.py | 121 +++++++++++++++++ 9 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 .github/actions/check-embedder/action.yml create mode 100644 .github/actions/check-embedder/check.py create mode 100644 .github/workflows/embedder-shared.yml create mode 100644 .github/workflows/embedder.yml create mode 100644 config/embedder.yml create mode 100644 test/test_embedder_config.py diff --git a/.github/actions/check-embedder/action.yml b/.github/actions/check-embedder/action.yml new file mode 100644 index 0000000..faa5c76 --- /dev/null +++ b/.github/actions/check-embedder/action.yml @@ -0,0 +1,17 @@ +name: check-embedder +description: Check repository files for content required by the baseline embedder configuration. +inputs: + skip: + description: JSON array of embedded content fragments to skip. + default: "[]" +runs: + using: composite + steps: + - run: python3 -m pip install pyyaml + shell: bash + - run: python3 "${{ github.action_path }}/check.py" + shell: bash + env: + CONFIG_PATH: ${{ github.action_path }}/../../../config/embedder.yml + REPOSITORY_ROOT: ${{ github.workspace }} + SKIP: ${{ inputs.skip }} diff --git a/.github/actions/check-embedder/check.py b/.github/actions/check-embedder/check.py new file mode 100644 index 0000000..ea390b4 --- /dev/null +++ b/.github/actions/check-embedder/check.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +import yaml + + +@dataclass(frozen=True) +class Fragment: + path: str + content: str + + +@dataclass(frozen=True) +class FragmentResult: + name: str + path: str + status: str + message: str + + +def parse_json_list(value): + try: + items = json.loads(value) + except json.JSONDecodeError as error: + raise ValueError("skip must be a JSON array of strings") from error + + if not isinstance(items, list) or not all(isinstance(item, str) and item for item in items): + raise ValueError("skip must be a JSON array of strings") + + return set(items) + + +def validate_path(value): + if not isinstance(value, str) or not value: + raise ValueError("embedder check path must be a non-empty string") + + path = PurePosixPath(value) + if path.is_absolute() or ".." in path.parts or value != path.as_posix(): + raise ValueError(f"embedder check path must be a normalized relative path: {value!r}") + return value + + +def load_config(config_path): + try: + with open(config_path) as file: + config = yaml.safe_load(file) + except yaml.YAMLError as error: + raise ValueError("embedder config must be valid YAML") from error + + if not isinstance(config, dict): + raise ValueError("embedder config must be a mapping") + + raw_fragments = config.get("fragments") + if not isinstance(raw_fragments, dict) or not raw_fragments: + raise ValueError("embedder config must contain a non-empty fragments mapping") + + fragments = {} + for name, value in raw_fragments.items(): + if not isinstance(name, str) or not name: + raise ValueError("embedder fragment names must be non-empty strings") + if not isinstance(value, dict) or set(value) != {"path", "content"}: + raise ValueError(f"embedder fragment {name!r} must contain only path and content") + + path = validate_path(value["path"]) + content = value["content"] + if not isinstance(content, str) or not content: + raise ValueError(f"embedder fragment {name!r} content must be a non-empty string") + fragments[name] = Fragment(path=path, content=content) + + return fragments + + +def read_repository_file(repository_root, path): + target = Path(repository_root) / path + try: + return target.read_text(encoding="utf-8") + except FileNotFoundError: + raise RuntimeError("file does not exist") from None + except (OSError, UnicodeError) as error: + raise RuntimeError(f"file could not be read: {error}") from error + + +def evaluate_fragments(fragments, repository_root, skipped=(), read=read_repository_file): + skipped = set(skipped) + if unknown := skipped - set(fragments): + names = ", ".join(sorted(unknown)) + raise ValueError(f"Unknown skipped embedder fragments: {names}") + + files = {} + for name, fragment in fragments.items(): + if name in skipped: + continue + if fragment.path in files: + continue + try: + files[fragment.path] = (read(repository_root, fragment.path), None) + except RuntimeError as error: + files[fragment.path] = (None, str(error)) + + results = [] + for name, fragment in fragments.items(): + if name in skipped: + results.append(FragmentResult(name, fragment.path, "skipped", "skipped by workflow input")) + continue + + actual, error = files[fragment.path] + if error: + results.append(FragmentResult(name, fragment.path, "failed", error)) + elif fragment.content not in actual: + results.append(FragmentResult(name, fragment.path, "failed", "required content is missing")) + else: + results.append(FragmentResult(name, fragment.path, "passed", "required content found")) + + return results + + +def annotation_value(value): + return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def main(): + try: + fragments = load_config(Path(os.environ["CONFIG_PATH"])) + skipped = parse_json_list(os.environ.get("SKIP", "[]")) + repository_root = Path(os.environ.get("REPOSITORY_ROOT", ".")) + results = evaluate_fragments(fragments, repository_root, skipped) + except (KeyError, OSError, ValueError) as error: + print(f"::error::{annotation_value(error)}") + return 1 + + print(f"Embedder config: {len(fragments)} fragments") + for result in results: + if result.status == "failed": + title = annotation_value(f"Embedder fragment: {result.name}") + message = annotation_value(result.message) + print(f"::error file={result.path},title={title}::{message}") + else: + print(f"{result.status.upper()} {result.name} ({result.path}): {result.message}") + + failed = sum(result.status == "failed" for result in results) + passed = sum(result.status == "passed" for result in results) + skipped_count = sum(result.status == "skipped" for result in results) + print(f"Result: {passed} passed, {failed} failed, {skipped_count} skipped") + return int(failed > 0) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1f00a90..2da04e4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,21 @@ updates: interval: daily time: "10:00" timezone: "Europe/Berlin" + - package-ecosystem: pre-commit + directory: / + schedule: + interval: daily + time: "10:00" + timezone: "Europe/Berlin" + - package-ecosystem: pip + directory: / + schedule: + interval: daily + time: "10:00" + timezone: "Europe/Berlin" + - package-ecosystem: bundler + directory: / + schedule: + interval: daily + time: "10:00" + timezone: "Europe/Berlin" diff --git a/.github/workflows/embedder-shared.yml b/.github/workflows/embedder-shared.yml new file mode 100644 index 0000000..a37f8a1 --- /dev/null +++ b/.github/workflows/embedder-shared.yml @@ -0,0 +1,18 @@ +name: Embedder (shared) +on: + workflow_call: + inputs: + skip: + description: JSON array of embedded content fragments to skip. + type: string + default: "[]" +jobs: + embedded-content-check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: $/.github/actions/check-embedder + with: + skip: ${{ inputs.skip }} diff --git a/.github/workflows/embedder.yml b/.github/workflows/embedder.yml new file mode 100644 index 0000000..e37d5ea --- /dev/null +++ b/.github/workflows/embedder.yml @@ -0,0 +1,8 @@ +name: Embedder +on: + push: + branches: ["main"] + pull_request: +jobs: + embedded-content-check: + uses: ./.github/workflows/embedder-shared.yml diff --git a/AGENTS.md b/AGENTS.md index 26cbfd3..061bdac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,18 @@ Use the emoji to identify the agent: This applies to chat replies, PR comments, review comments, issue comments, status updates, and any other written communication. +## Embedded Fragment Policy + +This repository uses [Baseline](https://github.com/rubykatzen/baseline) to +verify shared content fragments across repositories. + +Do not change a required fragment only in the consuming repository. Change +the fragment in `config/embedder.yml` in Baseline, release it, and then update +the Baseline workflow version in consuming repositories. + +A repository-specific exception must be declared through the `skip` input of +`embedder-shared.yml`. + ## Purpose This repo is the single source of truth for linter configs across all rubykatzen repositories. The goal is identical linting everywhere — configs live here and nowhere else. @@ -38,10 +50,12 @@ tools to already be installed in the developer environment. - `lib/` — gem code (`Baseline::VERSION`, install stubs) - `exe/baseline-install` — writes project `.rubocop.yml` and `.erb_lint.yml` stubs - `.github/actions/lint-*/` — composite actions that run installed linters with baseline configs +- `.github/actions/check-embedder/` — validates required content fragments in consumer files - `.github/actions/detect-linters/` — composite action that selects applicable linters from tracked files - `.github/actions/check-precommit/` — composite action: verifies pre-commit hooks match detected CI linters - `.github/actions/setup-runtimes/` — installs Python packages, Ruby, and standalone binaries for requested linters; Python is provided by the runner - `.github/workflows/lint-shared.yml` — reusable workflow exported for consuming repos: setup + lint +- `.github/workflows/embedder-shared.yml` — reusable workflow exported for required content validation - `.github/workflows/lint.yml` — baseline self-lint (uses local `./` references, not `@vX`) - `.github/workflows/prepare-release.yml` — dispatch workflow: calls `rubykatzen/releaser` to prepare `release/vX.Y.Z` - `.github/workflows/publish-release.yml` — publishes merged `release/*` PRs via `rubykatzen/releaser` diff --git a/README.md b/README.md index d81ccbc..6b0c637 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Shared linter configs and thin wrappers. +## Embedded Fragments + +This repository uses [Baseline](https://github.com/rubykatzen/baseline) to +verify shared content fragments across repositories. Required fragments are +defined centrally in `config/embedder.yml`. + Baseline owns canonical configuration and runtime installation in CI. Consuming repositories install runtimes and linter binaries only for local pre-commit use. @@ -76,7 +82,38 @@ jobs: The `skip` input must be a JSON array. Unknown check names fail the workflow. -### 3. Pre-commit hooks +### 3. Embedded content + +Create `.github/workflows/embedder.yml`: + +```yaml +name: Embedder +on: + push: + branches: ["main"] + pull_request: +jobs: + embedded-content-check: + uses: rubykatzen/baseline/.github/workflows/embedder-shared.yml@VERSION +``` + +The shared workflow checks repository files against the required fragments in +`config/embedder.yml`. Each named fragment has a target path and content that +must occur in that file. Multiple fragments may target the same file. + +Skip fragments explicitly when a repository needs an exception: + +```yaml +jobs: + embedded-content-check: + uses: rubykatzen/baseline/.github/workflows/embedder-shared.yml@VERSION + with: + skip: '["message-prefix"]' +``` + +The `skip` input must be a JSON array. Unknown fragment names fail the workflow. + +### 4. Pre-commit hooks Copy `.pre-commit-config.yaml.example` to your repo or add to your existing config. Include only the hooks relevant to your stack: @@ -108,7 +145,7 @@ Ruby hooks use `bundle exec`; install Ruby and run `bundle install` in the consuming repository first. `rubocop` and `erb_lint` must be available through the [`rubykatzen-baseline`](#ruby-gem-rubocop--erb_lint) gem. -### 4. Dependabot +### 5. Dependabot Add `.github/dependabot.yml` to keep GitHub Actions and pre-commit pins current automatically: diff --git a/config/embedder.yml b/config/embedder.yml new file mode 100644 index 0000000..e070b54 --- /dev/null +++ b/config/embedder.yml @@ -0,0 +1,73 @@ +fragments: + message-prefix: + path: AGENTS.md + content: | + ## Message Prefix + + Prefix every user-visible agent message with the agent emoji followed by the + repository name in square brackets: + + `EMOJI [OWNER/REPO]:` + + Replace `OWNER/REPO` with the current GitHub repository name. + + Use the emoji to identify the agent: + + - `🤖` Codex + - `🧠` Claude Code + - `🖊️` Cursor + - `🧩` unknown or other agent + + This applies to chat replies, PR comments, review comments, issue comments, + status updates, and any other written communication. + embedded-fragment-policy: + path: AGENTS.md + content: | + ## Embedded Fragment Policy + + This repository uses [Baseline](https://github.com/rubykatzen/baseline) to + verify shared content fragments across repositories. + + Do not change a required fragment only in the consuming repository. Change + the fragment in `config/embedder.yml` in Baseline, release it, and then update + the Baseline workflow version in consuming repositories. + + A repository-specific exception must be declared through the `skip` input of + `embedder-shared.yml`. + embedded-fragments: + path: README.md + content: | + ## Embedded Fragments + + This repository uses [Baseline](https://github.com/rubykatzen/baseline) to + verify shared content fragments across repositories. Required fragments are + defined centrally in `config/embedder.yml`. + dependabot: + path: .github/dependabot.yml + content: | + version: 2 + updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: daily + time: "10:00" + timezone: "Europe/Berlin" + - package-ecosystem: pre-commit + directory: / + schedule: + interval: daily + time: "10:00" + timezone: "Europe/Berlin" + - package-ecosystem: pip + directory: / + schedule: + interval: daily + time: "10:00" + timezone: "Europe/Berlin" + - package-ecosystem: bundler + directory: / + schedule: + interval: daily + time: "10:00" + timezone: "Europe/Berlin" diff --git a/test/test_embedder_config.py b/test/test_embedder_config.py new file mode 100644 index 0000000..9172d73 --- /dev/null +++ b/test/test_embedder_config.py @@ -0,0 +1,121 @@ +import importlib.util +import tempfile +import unittest +from pathlib import Path + +BASELINE_ROOT = Path(__file__).parent.parent +SPEC = importlib.util.spec_from_file_location( + "check_embedder", BASELINE_ROOT / ".github" / "actions" / "check-embedder" / "check.py" +) +CHECK_EMBEDDER = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(CHECK_EMBEDDER) + + +class EmbedderConfigTest(unittest.TestCase): + def setUp(self): + self.fragments = { + "heading": CHECK_EMBEDDER.Fragment(path="AGENTS.md", content="# Agents\n"), + "rule": CHECK_EMBEDDER.Fragment(path="AGENTS.md", content="Required rule\n"), + } + + def test_loads_embedder_config(self): + fragments = CHECK_EMBEDDER.load_config(BASELINE_ROOT / "config" / "embedder.yml") + + self.assertEqual( + set(fragments), + {"message-prefix", "embedded-fragment-policy", "embedded-fragments", "dependabot"}, + ) + self.assertEqual(fragments["message-prefix"].path, "AGENTS.md") + self.assertEqual(fragments["embedded-fragment-policy"].path, "AGENTS.md") + + def test_rejects_invalid_config(self): + invalid_configs = ( + "fragments: []\n", + "fragments:\n example:\n path: /AGENTS.md\n content: required\n", + "fragments:\n example:\n path: AGENTS.md\n", + "fragments:\n example:\n path: AGENTS.md\n content: ''\n", + ) + for value in invalid_configs: + with self.subTest(value=value), tempfile.NamedTemporaryFile( + mode="w", suffix=".yml" + ) as config: + config.write(value) + config.flush() + + with self.assertRaises(ValueError): + CHECK_EMBEDDER.load_config(config.name) + + def test_rejects_malformed_yaml(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".yml") as config: + config.write("fragments: [\n") + config.flush() + + with self.assertRaisesRegex(ValueError, "must be valid YAML"): + CHECK_EMBEDDER.load_config(config.name) + + def test_parses_json_skip(self): + self.assertEqual(CHECK_EMBEDDER.parse_json_list('["heading"]'), {"heading"}) + + def test_rejects_invalid_json_skip(self): + for value in ('"heading"', "heading", '["heading", 1]'): + with self.subTest(value=value), self.assertRaisesRegex(ValueError, "JSON array of strings"): + CHECK_EMBEDDER.parse_json_list(value) + + def test_rejects_unknown_skip(self): + with self.assertRaisesRegex(ValueError, "Unknown skipped embedder fragments: typo"): + CHECK_EMBEDDER.evaluate_fragments(self.fragments, ".", {"typo"}) + + def test_multiple_fragments_can_target_one_file_and_reuse_read(self): + reads = [] + + def read(root, path): + reads.append((root, path)) + return "# Agents\n\nRequired rule\n" + + results = CHECK_EMBEDDER.evaluate_fragments(self.fragments, "/repo", read=read) + + self.assertEqual([result.status for result in results], ["passed", "passed"]) + self.assertEqual(reads, [("/repo", "AGENTS.md")]) + + def test_reports_each_missing_fragment(self): + results = CHECK_EMBEDDER.evaluate_fragments( + self.fragments, ".", read=lambda _root, _path: "unrelated\n" + ) + + self.assertEqual( + [result.name for result in results if result.status == "failed"], + ["heading", "rule"], + ) + + def test_reports_missing_file(self): + def read(_root, _path): + raise RuntimeError("file does not exist") + + results = CHECK_EMBEDDER.evaluate_fragments(self.fragments, ".", read=read) + + self.assertTrue(all(result.status == "failed" for result in results)) + self.assertTrue(all(result.message == "file does not exist" for result in results)) + + def test_skips_named_fragment(self): + results = CHECK_EMBEDDER.evaluate_fragments( + self.fragments, + ".", + {"rule"}, + read=lambda _root, _path: "# Agents\n", + ) + + self.assertEqual([result.status for result in results], ["passed", "skipped"]) + + def test_skipping_all_fragments_does_not_read_target(self): + results = CHECK_EMBEDDER.evaluate_fragments( + {"heading": self.fragments["heading"]}, + ".", + {"heading"}, + read=lambda _root, _path: self.fail("skipped check read its target"), + ) + + self.assertEqual(results[0].status, "skipped") + + +if __name__ == "__main__": + unittest.main() From 3f3e680372cd30fe43483aae23b1bc249c249c77 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 02:05:13 +0200 Subject: [PATCH 2/8] refactor: rename GitHub config root --- .github/actions/check-github-config/check.py | 4 ++-- config/github.yml | 2 +- test/test_github_config.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/actions/check-github-config/check.py b/.github/actions/check-github-config/check.py index 1bd6485..e29cfd2 100644 --- a/.github/actions/check-github-config/check.py +++ b/.github/actions/check-github-config/check.py @@ -41,9 +41,9 @@ def load_config(config_path): if not isinstance(config, dict): raise ValueError("github repo config must be a mapping") - checks = config.get("checks") + checks = config.get("config") if not isinstance(checks, dict) or not checks: - raise ValueError("github repo config must contain a non-empty checks mapping") + raise ValueError("github repo config must contain a non-empty config mapping") for field in checks: if not isinstance(field, str) or not FIELD_PATTERN.fullmatch(field): raise ValueError("github repo check names must be GraphQL field names") diff --git a/config/github.yml b/config/github.yml index 455d479..389a679 100644 --- a/config/github.yml +++ b/config/github.yml @@ -1,4 +1,4 @@ -checks: +config: hasWikiEnabled: false autoMergeAllowed: true deleteBranchOnMerge: true diff --git a/test/test_github_config.py b/test/test_github_config.py index f388900..2b80426 100644 --- a/test/test_github_config.py +++ b/test/test_github_config.py @@ -26,7 +26,7 @@ def test_loads_repository_config(self): def test_rejects_invalid_repository_config(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".yml") as config: - config.write("checks:\n invalid-field: false\n") + config.write("config:\n invalid-field: false\n") config.flush() with self.assertRaisesRegex(ValueError, "must be GraphQL field names"): @@ -34,7 +34,7 @@ def test_rejects_invalid_repository_config(self): def test_rejects_malformed_yaml(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".yml") as config: - config.write("checks: [\n") + config.write("config: [\n") config.flush() with self.assertRaisesRegex(ValueError, "must be valid YAML"): From eaf02b235ce27855efaebe496f4b660bd6b930a8 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 02:10:25 +0200 Subject: [PATCH 3/8] feat: mark baseline-owned fragments --- .github/dependabot.yml | 2 ++ AGENTS.md | 4 ++++ README.md | 6 +++++- config/embedder.yml | 8 ++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2da04e4..5811eee 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,4 @@ +# baseline fragment: dependabot version: 2 updates: - package-ecosystem: github-actions @@ -24,3 +25,4 @@ updates: interval: daily time: "10:00" timezone: "Europe/Berlin" +# /baseline fragment: dependabot diff --git a/AGENTS.md b/AGENTS.md index 061bdac..6878e90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ This file provides guidance to AI coding agents when working with this repository. + ## Message Prefix Prefix every user-visible agent message with the agent emoji followed by the @@ -20,7 +21,9 @@ Use the emoji to identify the agent: This applies to chat replies, PR comments, review comments, issue comments, status updates, and any other written communication. + + ## Embedded Fragment Policy This repository uses [Baseline](https://github.com/rubykatzen/baseline) to @@ -32,6 +35,7 @@ the Baseline workflow version in consuming repositories. A repository-specific exception must be declared through the `skip` input of `embedder-shared.yml`. + ## Purpose diff --git a/README.md b/README.md index 6b0c637..58caf45 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,13 @@ Shared linter configs and thin wrappers. + ## Embedded Fragments This repository uses [Baseline](https://github.com/rubykatzen/baseline) to verify shared content fragments across repositories. Required fragments are defined centrally in `config/embedder.yml`. + Baseline owns canonical configuration and runtime installation in CI. Consuming repositories install runtimes and linter binaries only for local pre-commit use. @@ -99,7 +101,9 @@ jobs: The shared workflow checks repository files against the required fragments in `config/embedder.yml`. Each named fragment has a target path and content that -must occur in that file. Multiple fragments may target the same file. +must occur in that file. Multiple fragments may target the same file. Ownership +markers are part of the configured content, so the checker remains independent +of the target file format. Skip fragments explicitly when a repository needs an exception: diff --git a/config/embedder.yml b/config/embedder.yml index e070b54..7868816 100644 --- a/config/embedder.yml +++ b/config/embedder.yml @@ -2,6 +2,7 @@ fragments: message-prefix: path: AGENTS.md content: | + ## Message Prefix Prefix every user-visible agent message with the agent emoji followed by the @@ -20,9 +21,11 @@ fragments: This applies to chat replies, PR comments, review comments, issue comments, status updates, and any other written communication. + embedded-fragment-policy: path: AGENTS.md content: | + ## Embedded Fragment Policy This repository uses [Baseline](https://github.com/rubykatzen/baseline) to @@ -34,17 +37,21 @@ fragments: A repository-specific exception must be declared through the `skip` input of `embedder-shared.yml`. + embedded-fragments: path: README.md content: | + ## Embedded Fragments This repository uses [Baseline](https://github.com/rubykatzen/baseline) to verify shared content fragments across repositories. Required fragments are defined centrally in `config/embedder.yml`. + dependabot: path: .github/dependabot.yml content: | + # baseline fragment: dependabot version: 2 updates: - package-ecosystem: github-actions @@ -71,3 +78,4 @@ fragments: interval: daily time: "10:00" timezone: "Europe/Berlin" + # /baseline fragment: dependabot From 0c8cb899a860cfe088aa731862d8a5c3a62c916f Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 02:17:30 +0200 Subject: [PATCH 4/8] feat: report embedded fragment diffs --- .github/actions/check-embedder/check.py | 58 +++++++++++++++++++++++-- README.md | 3 +- test/test_embedder_config.py | 28 ++++++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/.github/actions/check-embedder/check.py b/.github/actions/check-embedder/check.py index ea390b4..8c70c82 100644 --- a/.github/actions/check-embedder/check.py +++ b/.github/actions/check-embedder/check.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import difflib import json import os import sys @@ -20,6 +21,7 @@ class FragmentResult: path: str status: str message: str + diff: str = "" def parse_json_list(value): @@ -84,6 +86,38 @@ def read_repository_file(repository_root, path): raise RuntimeError(f"file could not be read: {error}") from error +def current_fragment(actual, expected): + expected_lines = expected.splitlines(keepends=True) + if len(expected_lines) < 2: + return None + + opening = expected_lines[0].rstrip("\r\n") + closing = expected_lines[-1].rstrip("\r\n") + actual_lines = actual.splitlines(keepends=True) + + for start, line in enumerate(actual_lines): + if line.rstrip("\r\n") != opening: + continue + for end in range(start + 1, len(actual_lines)): + if actual_lines[end].rstrip("\r\n") == closing: + return "".join(actual_lines[start : end + 1]) + return "".join(actual_lines[start:]) + + return None + + +def fragment_diff(name, path, expected, actual): + current = current_fragment(actual, expected) or "" + lines = difflib.unified_diff( + current.splitlines(), + expected.splitlines(), + fromfile=f"{path} ({name}, actual)", + tofile=f"{path} ({name}, expected)", + lineterm="", + ) + return "\n".join(lines) + + def evaluate_fragments(fragments, repository_root, skipped=(), read=read_repository_file): skipped = set(skipped) if unknown := skipped - set(fragments): @@ -109,9 +143,13 @@ def evaluate_fragments(fragments, repository_root, skipped=(), read=read_reposit actual, error = files[fragment.path] if error: - results.append(FragmentResult(name, fragment.path, "failed", error)) + diff = fragment_diff(name, fragment.path, fragment.content, "") + results.append(FragmentResult(name, fragment.path, "failed", error, diff)) elif fragment.content not in actual: - results.append(FragmentResult(name, fragment.path, "failed", "required content is missing")) + current = current_fragment(actual, fragment.content) + message = "required fragment is missing" if current is None else "required fragment differs" + diff = fragment_diff(name, fragment.path, fragment.content, actual) + results.append(FragmentResult(name, fragment.path, "failed", message, diff)) else: results.append(FragmentResult(name, fragment.path, "passed", "required content found")) @@ -122,6 +160,19 @@ def annotation_value(value): return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") +def print_report(results): + print("Embedder expectations:") + for result in results: + print(f"{result.status.upper()} {result.name} ({result.path}): {result.message}") + + for result in results: + if not result.diff: + continue + print(f"::group::Diff {result.name} ({result.path})") + print(result.diff) + print("::endgroup::") + + def main(): try: fragments = load_config(Path(os.environ["CONFIG_PATH"])) @@ -133,13 +184,12 @@ def main(): return 1 print(f"Embedder config: {len(fragments)} fragments") + print_report(results) for result in results: if result.status == "failed": title = annotation_value(f"Embedder fragment: {result.name}") message = annotation_value(result.message) print(f"::error file={result.path},title={title}::{message}") - else: - print(f"{result.status.upper()} {result.name} ({result.path}): {result.message}") failed = sum(result.status == "failed" for result in results) passed = sum(result.status == "passed" for result in results) diff --git a/README.md b/README.md index 58caf45..cea37ad 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ The shared workflow checks repository files against the required fragments in `config/embedder.yml`. Each named fragment has a target path and content that must occur in that file. Multiple fragments may target the same file. Ownership markers are part of the configured content, so the checker remains independent -of the target file format. +of the target file format. Failures report every fragment and file, followed by +a unified diff for each missing or outdated fragment. Skip fragments explicitly when a repository needs an exception: diff --git a/test/test_embedder_config.py b/test/test_embedder_config.py index 9172d73..22c8b1d 100644 --- a/test/test_embedder_config.py +++ b/test/test_embedder_config.py @@ -86,6 +86,34 @@ def test_reports_each_missing_fragment(self): [result.name for result in results if result.status == "failed"], ["heading", "rule"], ) + self.assertTrue(all(result.diff for result in results)) + + def test_diffs_existing_fragment_against_expectation(self): + fragment = CHECK_EMBEDDER.Fragment( + path="AGENTS.md", + content="\nnew\n\n", + ) + actual = "before\n\nold\n\nafter\n" + + result = CHECK_EMBEDDER.evaluate_fragments( + {"rule": fragment}, ".", read=lambda _root, _path: actual + )[0] + + self.assertEqual(result.message, "required fragment differs") + self.assertIn("-old", result.diff) + self.assertIn("+new", result.diff) + self.assertNotIn("before", result.diff) + self.assertNotIn("after", result.diff) + + def test_missing_fragment_diff_contains_full_expectation(self): + result = CHECK_EMBEDDER.evaluate_fragments( + {"heading": self.fragments["heading"]}, + ".", + read=lambda _root, _path: "unrelated\n", + )[0] + + self.assertEqual(result.message, "required fragment is missing") + self.assertIn("+# Agents", result.diff) def test_reports_missing_file(self): def read(_root, _path): From b9cfdf1930cb239b7c9db2b59381592cb3101d60 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 02:25:05 +0200 Subject: [PATCH 5/8] chore: disable Dependabot labels --- .github/dependabot.yml | 4 ++++ README.md | 2 ++ config/embedder.yml | 4 ++++ 3 files changed, 10 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5811eee..724c02b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,24 +3,28 @@ version: 2 updates: - package-ecosystem: github-actions directory: / + labels: [] schedule: interval: daily time: "10:00" timezone: "Europe/Berlin" - package-ecosystem: pre-commit directory: / + labels: [] schedule: interval: daily time: "10:00" timezone: "Europe/Berlin" - package-ecosystem: pip directory: / + labels: [] schedule: interval: daily time: "10:00" timezone: "Europe/Berlin" - package-ecosystem: bundler directory: / + labels: [] schedule: interval: daily time: "10:00" diff --git a/README.md b/README.md index cea37ad..271b0c4 100644 --- a/README.md +++ b/README.md @@ -160,12 +160,14 @@ version: 2 updates: - package-ecosystem: github-actions directory: / + labels: [] schedule: interval: daily time: "10:00" timezone: "Europe/Berlin" - package-ecosystem: pre-commit directory: / + labels: [] schedule: interval: daily time: "10:00" diff --git a/config/embedder.yml b/config/embedder.yml index 7868816..13ae378 100644 --- a/config/embedder.yml +++ b/config/embedder.yml @@ -56,24 +56,28 @@ fragments: updates: - package-ecosystem: github-actions directory: / + labels: [] schedule: interval: daily time: "10:00" timezone: "Europe/Berlin" - package-ecosystem: pre-commit directory: / + labels: [] schedule: interval: daily time: "10:00" timezone: "Europe/Berlin" - package-ecosystem: pip directory: / + labels: [] schedule: interval: daily time: "10:00" timezone: "Europe/Berlin" - package-ecosystem: bundler directory: / + labels: [] schedule: interval: daily time: "10:00" From 890cdb2260aad0381a238aeb728123f5a9af28e4 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 02:28:39 +0200 Subject: [PATCH 6/8] refactor: merge embedded fragment guidance --- AGENTS.md | 11 ++++++----- README.md | 8 -------- config/embedder.yml | 21 ++++++--------------- test/test_embedder_config.py | 4 ++-- 4 files changed, 14 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6878e90..317d2d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,19 +23,20 @@ This applies to chat replies, PR comments, review comments, issue comments, status updates, and any other written communication. - -## Embedded Fragment Policy + +## Embedded Fragments This repository uses [Baseline](https://github.com/rubykatzen/baseline) to verify shared content fragments across repositories. Do not change a required fragment only in the consuming repository. Change -the fragment in `config/embedder.yml` in Baseline, release it, and then update -the Baseline workflow version in consuming repositories. +the fragment in `config/embedder.yml` in Baseline and release it. Dependabot +will then update the Baseline workflow version in consuming repositories and +CI will show the required fragment diff. A repository-specific exception must be declared through the `skip` input of `embedder-shared.yml`. - + ## Purpose diff --git a/README.md b/README.md index 271b0c4..6044485 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,6 @@ Shared linter configs and thin wrappers. - -## Embedded Fragments - -This repository uses [Baseline](https://github.com/rubykatzen/baseline) to -verify shared content fragments across repositories. Required fragments are -defined centrally in `config/embedder.yml`. - - Baseline owns canonical configuration and runtime installation in CI. Consuming repositories install runtimes and linter binaries only for local pre-commit use. diff --git a/config/embedder.yml b/config/embedder.yml index 13ae378..54e237a 100644 --- a/config/embedder.yml +++ b/config/embedder.yml @@ -22,31 +22,22 @@ fragments: This applies to chat replies, PR comments, review comments, issue comments, status updates, and any other written communication. - embedded-fragment-policy: + embedded-fragments: path: AGENTS.md content: | - - ## Embedded Fragment Policy + + ## Embedded Fragments This repository uses [Baseline](https://github.com/rubykatzen/baseline) to verify shared content fragments across repositories. Do not change a required fragment only in the consuming repository. Change - the fragment in `config/embedder.yml` in Baseline, release it, and then update - the Baseline workflow version in consuming repositories. + the fragment in `config/embedder.yml` in Baseline and release it. Dependabot + will then update the Baseline workflow version in consuming repositories and + CI will show the required fragment diff. A repository-specific exception must be declared through the `skip` input of `embedder-shared.yml`. - - embedded-fragments: - path: README.md - content: | - - ## Embedded Fragments - - This repository uses [Baseline](https://github.com/rubykatzen/baseline) to - verify shared content fragments across repositories. Required fragments are - defined centrally in `config/embedder.yml`. dependabot: path: .github/dependabot.yml diff --git a/test/test_embedder_config.py b/test/test_embedder_config.py index 22c8b1d..614fef2 100644 --- a/test/test_embedder_config.py +++ b/test/test_embedder_config.py @@ -23,10 +23,10 @@ def test_loads_embedder_config(self): self.assertEqual( set(fragments), - {"message-prefix", "embedded-fragment-policy", "embedded-fragments", "dependabot"}, + {"message-prefix", "embedded-fragments", "dependabot"}, ) self.assertEqual(fragments["message-prefix"].path, "AGENTS.md") - self.assertEqual(fragments["embedded-fragment-policy"].path, "AGENTS.md") + self.assertEqual(fragments["embedded-fragments"].path, "AGENTS.md") def test_rejects_invalid_config(self): invalid_configs = ( From ef145079056809daa94d1b6183a178a73a0ef82e Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 02:32:32 +0200 Subject: [PATCH 7/8] feat: add agent message suffix --- AGENTS.md | 9 +++++++++ config/embedder.yml | 11 +++++++++++ test/test_embedder_config.py | 3 ++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 317d2d3..8def995 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,15 @@ This applies to chat replies, PR comments, review comments, issue comments, status updates, and any other written communication. + +## Message Suffix + +End every user-visible agent message with a blank line followed by a final +line containing exactly three emoji relevant to the message context: + +`EMOJI EMOJI EMOJI` + + ## Embedded Fragments diff --git a/config/embedder.yml b/config/embedder.yml index 54e237a..1b48410 100644 --- a/config/embedder.yml +++ b/config/embedder.yml @@ -22,6 +22,17 @@ fragments: This applies to chat replies, PR comments, review comments, issue comments, status updates, and any other written communication. + message-suffix: + path: AGENTS.md + content: | + + ## Message Suffix + + End every user-visible agent message with a blank line followed by a final + line containing exactly three emoji relevant to the message context: + + `EMOJI EMOJI EMOJI` + embedded-fragments: path: AGENTS.md content: | diff --git a/test/test_embedder_config.py b/test/test_embedder_config.py index 614fef2..78def0a 100644 --- a/test/test_embedder_config.py +++ b/test/test_embedder_config.py @@ -23,9 +23,10 @@ def test_loads_embedder_config(self): self.assertEqual( set(fragments), - {"message-prefix", "embedded-fragments", "dependabot"}, + {"message-prefix", "message-suffix", "embedded-fragments", "dependabot"}, ) self.assertEqual(fragments["message-prefix"].path, "AGENTS.md") + self.assertEqual(fragments["message-suffix"].path, "AGENTS.md") self.assertEqual(fragments["embedded-fragments"].path, "AGENTS.md") def test_rejects_invalid_config(self): From 2d7c1dffac927b0209ba5c6036c566e8485c9d59 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 12 Aug 2026 02:35:19 +0200 Subject: [PATCH 8/8] feat: add GitHub Copilot agent emoji --- AGENTS.md | 1 + config/embedder.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8def995..35dd2a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Use the emoji to identify the agent: - `🤖` Codex - `🧠` Claude Code - `🖊️` Cursor +- `🥽` GitHub Copilot - `🧩` unknown or other agent This applies to chat replies, PR comments, review comments, issue comments, diff --git a/config/embedder.yml b/config/embedder.yml index 1b48410..f2d8ad4 100644 --- a/config/embedder.yml +++ b/config/embedder.yml @@ -17,6 +17,7 @@ fragments: - `🤖` Codex - `🧠` Claude Code - `🖊️` Cursor + - `🥽` GitHub Copilot - `🧩` unknown or other agent This applies to chat replies, PR comments, review comments, issue comments,