fix(ci): make the validate job actually able to run — it never had
CI / validate (pull_request) Failing after 14s
CI / validate (pull_request) Failing after 14s
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)
This commit is contained in:
+105
@@ -0,0 +1,105 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user