From d2944b3d4e1410d5d639e3e4886e3e5b4b6d80de Mon Sep 17 00:00:00 2001 From: weiqingy Date: Tue, 4 Aug 2026 23:36:05 -0700 Subject: [PATCH 1/2] [ci] Guard the coding-agent skill's bundled YAML schema against drift The flink-agents-dev skill ships a copy of docs/yaml-schema.json and records the blob SHA it was taken from in yaml-contracts.yaml. Nothing regenerates either, so both go stale the moment the schema is re-exported: agents are then taught a schema the repository no longer has, under a SHA that misreports which revision it describes. Add tools/check-skill-schema.py, run from the existing lint job, asserting the bundled copy and the recorded SHA both match docs/yaml-schema.json. The versioned schemas beside it pin released refs and are deliberately excluded. --- .github/workflows/ci.yml | 2 + docs/content/docs/development/yaml.md | 2 + tools/check-skill-schema.py | 101 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 tools/check-skill-schema.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8af40c191c..93d69000ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,8 @@ jobs: run: ./tools/lint.sh -c - name: Check AGENTS.md freshness run: python3 tools/check-agents-md.py + - name: Check bundled YAML schema freshness + run: python3 tools/check-skill-schema.py install_sh_tests: name: install.sh tests (${{ matrix.os }}) diff --git a/docs/content/docs/development/yaml.md b/docs/content/docs/development/yaml.md index 15c2a5c54b..c5ad990b3a 100644 --- a/docs/content/docs/development/yaml.md +++ b/docs/content/docs/development/yaml.md @@ -636,6 +636,8 @@ Because Pydantic models are easier to author and evolve than raw JSON Schema, th python -m flink_agents.api.yaml.specs > docs/yaml-schema.json ``` +The coding-agent skill under `dev/agent-skills/` bundles a copy of the schema so it can work offline, so refresh that copy in the same change. `python3 tools/check-skill-schema.py` reports whether it is current and prints the two edits needed when it is not. + Continuous tests then verify cross-runtime consistency: - the JSON Schema exported by the Pydantic specs matches the checked-in `docs/yaml-schema.json`; diff --git a/tools/check-skill-schema.py b/tools/check-skill-schema.py new file mode 100644 index 0000000000..95c28c5902 --- /dev/null +++ b/tools/check-skill-schema.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +################################################################################ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################# +"""Assert the coding-agent skill still bundles the current YAML schema. + +The skill ships a copy of docs/yaml-schema.json so it can answer YAML questions +offline, and records in yaml-contracts.yaml the git blob SHA that copy was taken +from. Nothing regenerates either one, so both drift silently the moment the +schema is re-exported. A stale copy teaches agents a schema the repository no +longer has, and a stale blob SHA misreports which revision the copy describes. + +Only the unversioned "main" contract is checked. The versioned schemas beside it +pin released refs, so they are expected to differ from the working tree. + +Regex-only and dependency-free so it runs on any Python 3 without a build step. +""" + +import hashlib +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DOCS_SCHEMA = REPO_ROOT / "docs" / "yaml-schema.json" +ASSETS = REPO_ROOT / "dev" / "agent-skills" / "flink-agents-dev" / "assets" +BUNDLED_SCHEMA = ASSETS / "yaml-schema.json" +MANIFEST = ASSETS / "yaml-contracts.yaml" + + +def blob_sha(path: Path) -> str: + """Return the git blob SHA of a file, matching `git hash-object `.""" + data = path.read_bytes() + return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + + +def recorded_sha(manifest: str) -> str: + """Return the blob SHA the manifest's "main" contract was copied from.""" + # Bounded to the "main" block so a sibling contract's SHA cannot be read by + # mistake when the main entry loses its own. + block = re.search(r"^ main:\n((?: .*\n|\n)*)", manifest, re.MULTILINE) + if not block: + sys.exit(f"error: no 'main' contract in {MANIFEST.relative_to(REPO_ROOT)}") + match = re.search(r"^\s+blob_sha:\s*([0-9a-f]{40})\s*$", block.group(1), re.MULTILINE) + if not match: + sys.exit(f"error: 'main' contract has no blob_sha in {MANIFEST.relative_to(REPO_ROOT)}") + return match.group(1) + + +def main() -> int: + for path in (DOCS_SCHEMA, BUNDLED_SCHEMA, MANIFEST): + if not path.is_file(): + sys.exit(f"error: {path.relative_to(REPO_ROOT)} does not exist") + + expected = blob_sha(DOCS_SCHEMA) + bundled = blob_sha(BUNDLED_SCHEMA) + recorded = recorded_sha(MANIFEST.read_text(encoding="utf-8")) + + failures = [] + if bundled != expected: + failures.append( + f"{BUNDLED_SCHEMA.relative_to(REPO_ROOT)} is {bundled}, expected {expected}" + ) + if recorded != expected: + failures.append( + f"{MANIFEST.relative_to(REPO_ROOT)} records {recorded}, expected {expected}" + ) + + if failures: + print("The bundled YAML schema is out of sync with docs/yaml-schema.json:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + print( + f"\nRefresh both:\n" + f" cp {DOCS_SCHEMA.relative_to(REPO_ROOT)} {BUNDLED_SCHEMA.relative_to(REPO_ROOT)}\n" + f" # then set blob_sha: {expected} under the 'main' contract in\n" + f" # {MANIFEST.relative_to(REPO_ROOT)}", + file=sys.stderr, + ) + return 1 + + print(f"Bundled YAML schema matches docs/yaml-schema.json ({expected}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 498ec16729709cfb6f9d3a3870a8ffd2be0b26c6 Mon Sep 17 00:00:00 2001 From: weiqingy Date: Sat, 8 Aug 2026 15:16:52 -0700 Subject: [PATCH 2/2] [ci] Address review: hash the schema through git, scope the check to main Hashing the worktree bytes is only the git blob SHA where no clean filter applies. Under core.autocrlf the schema files are checked out CRLF, so the recorded LF SHA read as stale and the printed remediation prescribed a SHA git would never store. blob_sha() now delegates to git hash-object, which applies whatever filter the path's attributes select. The bundled skill exists only on main, so the workflow step is now scoped to main pushes and PRs targeting main rather than running wherever the workflow triggers. --- .github/workflows/ci.yml | 1 + tools/check-skill-schema.py | 32 +++++++++++++++++++++++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93d69000ad..1bfdbae054 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,7 @@ jobs: - name: Check AGENTS.md freshness run: python3 tools/check-agents-md.py - name: Check bundled YAML schema freshness + if: github.ref == 'refs/heads/main' || github.base_ref == 'main' run: python3 tools/check-skill-schema.py install_sh_tests: diff --git a/tools/check-skill-schema.py b/tools/check-skill-schema.py index 95c28c5902..360be864ca 100644 --- a/tools/check-skill-schema.py +++ b/tools/check-skill-schema.py @@ -27,11 +27,12 @@ Only the unversioned "main" contract is checked. The versioned schemas beside it pin released refs, so they are expected to differ from the working tree. -Regex-only and dependency-free so it runs on any Python 3 without a build step. +Regex-only and stdlib-only, hashing through git, so it runs on any Python 3 +without a build step. """ -import hashlib import re +import subprocess import sys from pathlib import Path @@ -43,9 +44,30 @@ def blob_sha(path: Path) -> str: - """Return the git blob SHA of a file, matching `git hash-object `.""" - data = path.read_bytes() - return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() + """Return the blob SHA git would store for a file. + + Hashing the worktree bytes directly is only correct where no clean filter + applies. Under core.autocrlf these JSON files are checked out CRLF, and + their hash then never matches the LF blob SHA recorded in the manifest, so + the check reports a current pin as stale and prescribes a SHA git would not + store. Delegating to git applies whatever filter the path's attributes + select. + """ + try: + result = subprocess.run( + ["git", "hash-object", "--", str(path)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + except FileNotFoundError: + sys.exit("error: git is required to hash the schema files, but is not on PATH") + if result.returncode != 0: + sys.exit( + f"error: git hash-object failed for {path.relative_to(REPO_ROOT)}: " + f"{result.stderr.strip()}" + ) + return result.stdout.strip() def recorded_sha(manifest: str) -> str: