diff --git a/.github/workflows/jsoncheck.yml b/.github/workflows/jsoncheck.yml index 2dd7cf1..d8ea745 100644 --- a/.github/workflows/jsoncheck.yml +++ b/.github/workflows/jsoncheck.yml @@ -2,16 +2,86 @@ name: JSON check on: push: + branches: [main] paths: - '**.json' pull_request: + paths: + - '**.json' + +permissions: + contents: write jobs: - test: + check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: json-syntax-check - uses: limitusus/json-syntax-check@v2 + - name: Decide whether we may push a fix commit + id: pushable + run: | + if [ "${{ github.event_name }}" = "push" ] || [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo "value=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + fetch-depth: 0 + # The default GITHUB_TOKEN doesn't re-trigger workflow runs when it pushes, + # which would leave a required status check permanently unreported on the + # fix commit. Use the automation PAT whenever we might push one. + token: ${{ steps.pushable.outputs.value == 'true' && secrets.STORE_AUTOMATION_TOKEN || github.token }} + + - name: Set up Python + uses: actions/setup-python@v5 with: - pattern: "\\.json$" \ No newline at end of file + python-version: '3.x' + + - name: Install dependencies + run: pip install json_repair + + - name: Pick validation mode + id: mode + run: | + if [ "${{ github.event_name }}" = "push" ]; then + # Direct commit landed on main: try to repair it, and if that's not + # possible/safe, fall back to the last known-good version so the app + # is never left pointed at broken JSON. + echo "args=--fix --restore-fallback" >> "$GITHUB_OUTPUT" + elif [ "${{ steps.pushable.outputs.value }}" = "true" ]; then + # PR from a branch in this repo: safe to push an auto-fix commit back to it. + echo "args=--fix" >> "$GITHUB_OUTPUT" + else + # PR from a fork: we can't push to it, so just check and report. + echo "args=" >> "$GITHUB_OUTPUT" + fi + + - name: Validate JSON + id: validate + continue-on-error: true + run: python scripts/validate_json.py ${{ steps.mode.outputs.args }} + + - name: Commit auto-fix + if: always() && steps.pushable.outputs.value == 'true' + run: | + if ! git diff --quiet -- '*.json'; then + git config user.name "streamcontroller-bot" + git config user.email "actions@github.com" + git add -- '*.json' + if [ "${{ github.event_name }}" = "push" ]; then + git commit -m "fix(json): auto-repair invalid JSON pushed directly to main" + git push origin HEAD:${{ github.ref_name }} + else + git commit -m "fix(json): auto-repair invalid JSON" + git push origin HEAD:${{ github.head_ref }} + fi + else + echo "No changes to commit" + fi + + - name: Fail if invalid JSON remains + if: steps.validate.outcome == 'failure' + run: exit 1 diff --git a/scripts/validate_json.py b/scripts/validate_json.py new file mode 100644 index 0000000..cc38139 --- /dev/null +++ b/scripts/validate_json.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Validate the top-level *.json files in this repo, optionally repairing them in place. + +Modes: + (no flags) check only, exit 1 if any file is invalid JSON + --fix also try to repair invalid files with json_repair; if the + repaired result looks sane, write it back + --restore-fallback when a file can't be repaired (or the repair looks unsafe, + e.g. it dropped most of the entries), fall back to the last + version of that file in git history that was valid JSON +""" +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from json_repair import repair_json + +ROOT = Path(__file__).resolve().parent.parent +JSON_FILES = sorted(ROOT.glob("*.json")) + + +def git_show(ref: str, rel_path: str) -> str | None: + result = subprocess.run( + ["git", "show", f"{ref}:{rel_path}"], + cwd=ROOT, capture_output=True, text=True, + ) + return result.stdout if result.returncode == 0 else None + + +def last_good_version(rel_path: str): + """Walk commit history for this file and return (text, obj) of the newest valid revision.""" + log = subprocess.run( + ["git", "log", "--format=%H", "--", rel_path], + cwd=ROOT, capture_output=True, text=True, + ).stdout.split() + for commit in log: + content = git_show(commit, rel_path) + if content is None: + continue + try: + return content, json.loads(content) + except json.JSONDecodeError: + continue + return None + + +def looks_sane(repaired: object, reference: object) -> bool: + """Guard against a 'repair' that silently throws away most of the data.""" + if type(repaired) is not type(reference): + return False + if isinstance(reference, (list, dict)): + return len(repaired) >= max(1, int(len(reference) * 0.8)) + return True + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--fix", action="store_true") + parser.add_argument("--restore-fallback", action="store_true") + args = parser.parse_args() + + changed = [] + failed = [] + + for path in JSON_FILES: + rel_path = str(path.relative_to(ROOT)) + original = path.read_text(encoding="utf-8") + try: + json.loads(original) + continue # already valid, nothing to do + except json.JSONDecodeError as exc: + print(f"::error file={rel_path}::invalid JSON: {exc}") + + if not args.fix: + failed.append(rel_path) + continue + + reference = last_good_version(rel_path) + fixed = False + try: + repaired_text = repair_json(original, ensure_ascii=False) + repaired_obj = json.loads(repaired_text) + if reference is None or looks_sane(repaired_obj, reference[1]): + path.write_text( + json.dumps(repaired_obj, indent=4, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print(f"Repaired {rel_path}") + changed.append(rel_path) + fixed = True + except Exception as exc: + print(f"repair attempt for {rel_path} failed: {exc}") + + if not fixed: + if args.restore_fallback and reference is not None: + path.write_text(reference[0], encoding="utf-8") + print(f"Restored {rel_path} to its last known-good version from git history") + changed.append(rel_path) + else: + failed.append(rel_path) + + if changed: + print("CHANGED:" + ",".join(changed)) + if failed: + print("FAILED:" + ",".join(failed)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())