Skip to content
Open
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
80 changes: 75 additions & 5 deletions .github/workflows/jsoncheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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$"
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
113 changes: 113 additions & 0 deletions scripts/validate_json.py
Original file line number Diff line number Diff line change
@@ -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())