Files
zulip-platform-plugins/ci_check.py
Mumuni 9edc258245
CI / validate (pull_request) Failing after 14s
fix(ci): make the validate job actually able to run — it never had
ci.yml has been invalid YAML since introduction: the 'Config validation'
and old inline checks dedented out of their run:| block scalar, so Gitea
could never parse the workflow — CI never ran on any PR despite
CI_STATUS.md claiming 'Active'. The old 'No secrets check' also always
passed (|| echo swallows the grep hit) and never scanned *.yml — where
six embedded credentials were living.

- validation logic moved to ci_check.py (testable locally: python3 ci_check.py all)
- secrets check now FAILS on embedded http-basic URLs and long api_keys,
  across .py/.ts/.yaml/.yml/.cjs/.sh, with placeholder allowlist
- added workflow-YAML parse gate so this class of breakage can't recur
- py_compile steps no longer swallow errors with '|| echo skipped'
- removed ci.yml's duplicate deploy job: deploy.yml is the sole deploy
  pipeline (rc tags → Tanko canary only; stable → all agents). The ci.yml
  copy would have deployed Mumuni on rc tags too, breaking canary policy,
  and never ran anyway.
- CI_STATUS.md rewritten with the real state + caveats (history still
  contains the old creds — rotation is a server-side task)
2026-09-25 15:50:16 +00:00

106 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""CI validation checks for zulip-platform-plugins.
The inline validation steps this script replaced were never valid YAML in the
first place (the `run: |` blocks dedented out of their block scalar), so the
whole `validate` job silently never ran. Keeping the logic in a real file
means it is testable locally — run `python3 ci_check.py all` before pushing.
Usage: python3 ci_check.py {workflows|config|secrets|all}
"""
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
CODE_GLOBS = ("*.py", "*.ts", "*.yaml", "*.yml", "*.cjs", "*.sh")
# scheme://user:pass@host — embedded HTTP Basic credentials
CRED_RE = re.compile(r"://[^/ \"']+:[^/ \"'@]+@")
API_KEY_RE = re.compile(r"""api_key["']?\s*[:=]\s*["']?[A-Za-z0-9]{20,}""")
# Allowlist: placeholder patterns, not real secrets
PLACEHOLDER = ("${", "{{", "user:pass", "USER:TOKEN", "user:token", "example")
def iter_code_files():
for path in sorted(ROOT.rglob("*")):
if not path.is_file():
continue
if any(part in {".git", "__pycache__", "node_modules"} for part in path.parts):
continue
if path.name == Path(__file__).name: # this file documents the patterns
continue
if any(path.match(glob) for glob in CODE_GLOBS):
yield path
def check_workflows() -> bool:
try:
import yaml
except ModuleNotFoundError:
print("⚠️ pyyaml not installed — workflow parse check skipped")
return True
ok = True
for f in sorted((ROOT / ".gitea" / "workflows").glob("*.yml")):
try:
doc = yaml.safe_load(f.read_text())
assert isinstance(doc, dict) and "jobs" in doc, "missing 'jobs' mapping"
print(f"✅ {f.relative_to(ROOT)} valid — jobs: {list(doc['jobs'])}")
except Exception as e:
print(f"❌ {f.relative_to(ROOT)}: {e}")
ok = False
return ok
def check_config() -> bool:
try:
import yaml
except ModuleNotFoundError:
print("⚠️ pyyaml not installed — config check skipped")
return True
try:
cfg = yaml.safe_load((ROOT / "config.yaml.example").read_text())
assert isinstance(cfg, dict) and "zulip" in cfg, "missing 'zulip' section"
print("✅ config.yaml.example valid")
return True
except Exception as e:
print(f"❌ config.yaml.example: {e}")
return False
def check_secrets() -> bool:
hits = []
for path in iter_code_files():
try:
text = path.read_text(errors="ignore")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
for rx in (CRED_RE, API_KEY_RE):
if rx.search(line) and not any(p in line for p in PLACEHOLDER):
hits.append(f"{path.relative_to(ROOT)}:{lineno}: {line.strip()[:100]}")
break
if hits:
print("❌ Embedded credentials detected:")
for h in hits:
print(f" {h}")
return False
print("✅ No embedded credentials in tracked code/workflow files")
return True
CHECKS = {"workflows": check_workflows, "config": check_config, "secrets": check_secrets}
def main() -> int:
args = sys.argv[1:] or ["all"]
names = list(CHECKS) if "all" in args else args
failed = [n for n in names if n not in CHECKS or not CHECKS[n]()]
if failed:
print(f"❌ CI checks failed: {', '.join(failed)}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())