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..8c70c82 --- /dev/null +++ b/.github/actions/check-embedder/check.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +import difflib +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 + diff: 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 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): + 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: + diff = fragment_diff(name, fragment.path, fragment.content, "") + results.append(FragmentResult(name, fragment.path, "failed", error, diff)) + elif fragment.content not in actual: + 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")) + + return results + + +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"])) + 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") + 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}") + + 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/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/.github/dependabot.yml b/.github/dependabot.yml index 1f00a90..724c02b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,8 +1,32 @@ +# baseline fragment: dependabot 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" + timezone: "Europe/Berlin" +# /baseline fragment: dependabot 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..35dd2a1 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 @@ -16,10 +17,36 @@ 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, 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 + +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 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 @@ -38,10 +65,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..6044485 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,41 @@ 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. Ownership +markers are part of the configured content, so the checker remains independent +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: + +```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 +142,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: @@ -118,12 +152,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 new file mode 100644 index 0000000..f2d8ad4 --- /dev/null +++ b/config/embedder.yml @@ -0,0 +1,88 @@ +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 + - `🥽` GitHub Copilot + - `🧩` unknown or other agent + + 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: | + + ## 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 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`. + + dependabot: + path: .github/dependabot.yml + content: | + # baseline fragment: dependabot + 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" + timezone: "Europe/Berlin" + # /baseline fragment: dependabot 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_embedder_config.py b/test/test_embedder_config.py new file mode 100644 index 0000000..78def0a --- /dev/null +++ b/test/test_embedder_config.py @@ -0,0 +1,150 @@ +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", "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): + 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"], + ) + 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): + 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() 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"):