Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/actions/check-embedder/action.yml
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 }}
202 changes: 202 additions & 0 deletions .github/actions/check-embedder/check.py
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize line endings before checking fragments

🤖 [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 actual still marks it as failed. Because fragment_diff() then calls splitlines(), 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 👍 / 👎.

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())
4 changes: 2 additions & 2 deletions .github/actions/check-github-config/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions .github/dependabot.yml
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
18 changes: 18 additions & 0 deletions .github/workflows/embedder-shared.yml
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 }}
8 changes: 8 additions & 0 deletions .github/workflows/embedder.yml
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
29 changes: 29 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

This file provides guidance to AI coding agents when working with this repository.

<!-- baseline fragment: message-prefix -->
## Message Prefix

Prefix every user-visible agent message with the agent emoji followed by the
Expand All @@ -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.
<!-- /baseline fragment: message-prefix -->

<!-- baseline fragment: message-suffix -->
## 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`
<!-- /baseline fragment: message-suffix -->

<!-- baseline fragment: embedded-fragments -->
## 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`.
<!-- /baseline fragment: embedded-fragments -->

## Purpose

Expand All @@ -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`
Expand Down
Loading