-
Notifications
You must be signed in to change notification settings - Fork 0
Add embedded content checks #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
381fef6
feat: add embedded content checks
ineedjet 3f3e680
refactor: rename GitHub config root
ineedjet eaf02b2
feat: mark baseline-owned fragments
ineedjet 0c8cb89
feat: report embedded fragment diffs
ineedjet b9cfdf1
chore: disable Dependabot labels
ineedjet 890cdb2
refactor: merge embedded fragment guidance
ineedjet ef14507
feat: add agent message suffix
ineedjet 2d7c1df
feat: add GitHub Copilot agent emoji
ineedjet File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| name: Embedder | ||
| on: | ||
| push: | ||
| branches: ["main"] | ||
| pull_request: | ||
| jobs: | ||
| embedded-content-check: | ||
| uses: ./.github/workflows/embedder-shared.yml |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 [rubykatzen/baseline]: When a consumer commits a target file with CRLF line endings, the configured LF fragment contains the same text but
fragment.content not in actualstill marks it as failed. Becausefragment_diff()then callssplitlines(), the reported diff is empty, so this cross-repository check blocks CI without showing any corrective delta; normalize line endings consistently for matching and diffing.🪟 📄⚠️
AGENTS.md reference: AGENTS.md:L39-L45
Useful? React with 👍 / 👎.