Skip to content

Commit 780db1a

Browse files
committed
harden plugin smoke validation edge cases
1 parent 4f18133 commit 780db1a

3 files changed

Lines changed: 203 additions & 24 deletions

File tree

.github/workflows/validate-plugin-smoke.yml

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,32 +50,52 @@ jobs:
5050
5151
base_ref = os.environ["GITHUB_BASE_REF"]
5252
subprocess.run(["git", "fetch", "origin", base_ref, "--depth", "1"], check=True)
53-
base = json.loads(
54-
subprocess.check_output(
53+
54+
validation_note = ""
55+
56+
try:
57+
base_raw = subprocess.check_output(
5558
["git", "show", f"origin/{base_ref}:plugins.json"],
5659
text=True,
60+
stderr=subprocess.DEVNULL,
5761
)
58-
)
59-
head = json.loads(Path("plugins.json").read_text(encoding="utf-8"))
60-
61-
changed = [
62-
name
63-
for name, payload in head.items()
64-
if base.get(name) != payload
65-
]
62+
base = json.loads(base_raw)
63+
if not isinstance(base, dict):
64+
base = {}
65+
except (subprocess.CalledProcessError, json.JSONDecodeError):
66+
base = {}
67+
68+
try:
69+
head = json.loads(Path("plugins.json").read_text(encoding="utf-8"))
70+
if not isinstance(head, dict):
71+
raise ValueError("plugins.json must contain a JSON object")
72+
except (json.JSONDecodeError, ValueError) as exc:
73+
changed = []
74+
should_validate = False
75+
validation_note = f"Skipping smoke validation because plugins.json is invalid on the PR head: {exc}"
76+
else:
77+
changed = [
78+
name
79+
for name, payload in head.items()
80+
if base.get(name) != payload
81+
]
82+
should_validate = bool(changed)
83+
if not should_validate:
84+
validation_note = "No plugin entries changed in plugins.json; skipping smoke validation."
6685
6786
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as handle:
6887
handle.write("ASTRBOT_REF=master\n")
6988
handle.write(f"PLUGIN_NAME_LIST={','.join(changed)}\n")
7089
handle.write("PLUGIN_LIMIT=\n")
71-
handle.write(f"SHOULD_VALIDATE={'true' if changed else 'false'}\n")
90+
handle.write(f"SHOULD_VALIDATE={'true' if should_validate else 'false'}\n")
91+
handle.write(f"VALIDATION_NOTE={validation_note}\n")
7292
PY
7393
7494
- name: Show PR diff selection
7595
if: github.event_name == 'pull_request'
7696
run: |
7797
if [ "$SHOULD_VALIDATE" != "true" ]; then
78-
printf '%s\n' "No plugin entries changed in plugins.json; skipping smoke validation."
98+
printf '%s\n' "${VALIDATION_NOTE:-Smoke validation skipped.}"
7999
else
80100
printf 'Selected plugins: %s\n' "$PLUGIN_NAME_LIST"
81101
fi

scripts/validate_plugins/run.py

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import argparse
66
import asyncio
7+
import hashlib
78
import json
89
import os
910
import re
@@ -22,6 +23,7 @@
2223

2324

2425
REQUIRED_METADATA_FIELDS = ("name", "desc", "version", "author")
26+
DEFAULT_CLONE_TIMEOUT = 120
2527

2628

2729
def build_result(
@@ -205,7 +207,7 @@ def combine_requested_names(
205207
plugin_names: list[str] | None,
206208
plugin_name_list: str | None,
207209
) -> list[str]:
208-
names = list(plugin_names or [])
210+
names = [name.strip() for name in (plugin_names or [])]
209211
if plugin_name_list:
210212
names.extend(part.strip() for part in plugin_name_list.split(","))
211213
return [name for name in names if name]
@@ -216,12 +218,47 @@ def sanitize_name(name: str) -> str:
216218
return sanitized or "plugin"
217219

218220

219-
def clone_plugin_repo(repo_url: str, destination: Path) -> None:
221+
def build_plugin_clone_dir(work_dir: Path, plugin: str) -> Path:
222+
digest = hashlib.sha256(plugin.encode("utf-8")).hexdigest()[:8]
223+
return work_dir / f"{sanitize_name(plugin)}-{digest}"
224+
225+
226+
def _normalize_process_output(output: str | bytes | None) -> str | None:
227+
if output is None:
228+
return None
229+
if isinstance(output, bytes):
230+
output = output.decode("utf-8", errors="replace")
231+
normalized = output.strip()
232+
return normalized or None
233+
234+
235+
def build_process_output_details(
236+
*,
237+
stdout: str | bytes | None,
238+
stderr: str | bytes | None,
239+
) -> dict | None:
240+
details = {}
241+
stdout_text = _normalize_process_output(stdout)
242+
stderr_text = _normalize_process_output(stderr)
243+
if stdout_text:
244+
details["stdout"] = stdout_text
245+
if stderr_text:
246+
details["stderr"] = stderr_text
247+
return details or None
248+
249+
250+
def clone_plugin_repo(
251+
repo_url: str,
252+
destination: Path,
253+
*,
254+
timeout: int = DEFAULT_CLONE_TIMEOUT,
255+
) -> None:
220256
subprocess.run(
221257
["git", "clone", "--depth", "1", repo_url, str(destination)],
222258
check=True,
223259
capture_output=True,
224260
text=True,
261+
timeout=timeout,
225262
)
226263

227264

@@ -267,6 +304,7 @@ def validate_plugin(
267304
astrbot_path: Path,
268305
script_path: Path,
269306
work_dir: Path,
307+
clone_timeout: int,
270308
load_timeout: int,
271309
) -> dict:
272310
repo_url = plugin_data.get("repo")
@@ -292,9 +330,13 @@ def validate_plugin(
292330
message=str(exc),
293331
)
294332

295-
plugin_clone_dir = work_dir / sanitize_name(plugin)
333+
plugin_clone_dir = build_plugin_clone_dir(work_dir, plugin)
296334
try:
297-
clone_plugin_repo(normalized_repo_url, plugin_clone_dir)
335+
clone_plugin_repo(
336+
normalized_repo_url,
337+
plugin_clone_dir,
338+
timeout=clone_timeout,
339+
)
298340
except subprocess.CalledProcessError as exc:
299341
message = exc.stderr.strip() or exc.stdout.strip() or str(exc)
300342
return build_result(
@@ -305,6 +347,16 @@ def validate_plugin(
305347
stage="clone",
306348
message=message,
307349
)
350+
except subprocess.TimeoutExpired as exc:
351+
return build_result(
352+
plugin=plugin,
353+
repo=repo_url,
354+
normalized_repo_url=normalized_repo_url,
355+
ok=False,
356+
stage="clone_timeout",
357+
message=f"git clone timed out after {clone_timeout} seconds",
358+
details=build_process_output_details(stdout=exc.stdout, stderr=exc.stderr),
359+
)
308360

309361
precheck = precheck_plugin_directory(plugin_clone_dir)
310362
if not precheck["ok"]:
@@ -334,7 +386,7 @@ def validate_plugin(
334386
text=True,
335387
timeout=load_timeout,
336388
)
337-
except subprocess.TimeoutExpired:
389+
except subprocess.TimeoutExpired as exc:
338390
return build_result(
339391
plugin=plugin,
340392
repo=repo_url,
@@ -343,6 +395,7 @@ def validate_plugin(
343395
stage="timeout",
344396
message=f"worker timed out after {load_timeout} seconds",
345397
plugin_dir_name=plugin_dir_name,
398+
details=build_process_output_details(stdout=exc.stdout, stderr=exc.stderr),
346399
)
347400

348401
return parse_worker_output(
@@ -483,13 +536,10 @@ def run_worker(args: argparse.Namespace) -> int:
483536

484537
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
485538
os.environ.setdefault("TESTING", "true")
486-
for entry in reversed(
487-
build_worker_sys_path(
488-
astrbot_root=astrbot_root,
489-
astrbot_path=Path(args.astrbot_path),
490-
)
491-
):
492-
sys.path.insert(0, entry)
539+
sys.path[:0] = build_worker_sys_path(
540+
astrbot_root=astrbot_root,
541+
astrbot_path=Path(args.astrbot_path),
542+
)
493543

494544
result = asyncio.run(
495545
run_worker_load_check(args.plugin_dir_name, args.normalized_repo_url)
@@ -525,6 +575,7 @@ def build_parser() -> argparse.ArgumentParser:
525575
parser.add_argument("--astrbot-path")
526576
parser.add_argument("--report-path", default="validation-report.json")
527577
parser.add_argument("--work-dir")
578+
parser.add_argument("--clone-timeout", type=int, default=DEFAULT_CLONE_TIMEOUT)
528579
parser.add_argument("--load-timeout", type=int, default=300)
529580
parser.add_argument("--worker", action="store_true")
530581
parser.add_argument("--plugin-source-dir")
@@ -578,6 +629,7 @@ def main() -> int:
578629
astrbot_path=Path(args.astrbot_path).resolve(),
579630
script_path=Path(__file__).resolve(),
580631
work_dir=work_dir,
632+
clone_timeout=args.clone_timeout,
581633
load_timeout=args.load_timeout,
582634
)
583635
for plugin, plugin_data in selected

tests/test_validate_plugins.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,39 @@ def test_rejects_non_github_urls(self):
4040
with self.assertRaises(ValueError):
4141
module.normalize_repo_url("https://gitlab.com/example/demo-plugin")
4242

43+
def test_rejects_non_http_schemes(self):
44+
module = load_validator_module()
45+
46+
for url in (
47+
"git://github.com/example/demo-plugin",
48+
"ssh://github.com/example/demo-plugin",
49+
):
50+
with self.subTest(url=url):
51+
with self.assertRaisesRegex(ValueError, "repo URL must use http or https"):
52+
module.normalize_repo_url(url)
53+
54+
def test_rejects_missing_owner_or_repository(self):
55+
module = load_validator_module()
56+
57+
for url in (
58+
"https://github.com/",
59+
"https://github.com/example",
60+
"https://github.com/example/",
61+
"https://github.com//demo-plugin",
62+
"https://github.com/example//",
63+
):
64+
with self.subTest(url=url):
65+
with self.assertRaisesRegex(ValueError, "repo URL must include owner and repository"):
66+
module.normalize_repo_url(url)
67+
68+
def test_strips_leading_and_trailing_whitespace(self):
69+
module = load_validator_module()
70+
71+
self.assertEqual(
72+
module.normalize_repo_url(" https://github.com/example/demo-plugin "),
73+
"https://github.com/example/demo-plugin",
74+
)
75+
4376

4477
class SelectPluginsTests(unittest.TestCase):
4578
def test_returns_all_plugins_when_limit_is_none(self):
@@ -88,6 +121,80 @@ def test_prefers_explicit_names_in_requested_order(self):
88121

89122
self.assertEqual([item[0] for item in selected], ["plugin-c", "plugin-a"])
90123

124+
def test_respects_positive_limit_when_names_not_requested(self):
125+
module = load_validator_module()
126+
plugins = {
127+
"plugin-a": {"repo": "https://github.com/example/plugin-a"},
128+
"plugin-b": {"repo": "https://github.com/example/plugin-b"},
129+
"plugin-c": {"repo": "https://github.com/example/plugin-c"},
130+
}
131+
132+
selected = module.select_plugins(
133+
plugins=plugins,
134+
requested_names=None,
135+
limit=1,
136+
)
137+
138+
self.assertEqual([item[0] for item in selected], ["plugin-a"])
139+
140+
def test_raises_key_error_for_unknown_requested_plugin(self):
141+
module = load_validator_module()
142+
plugins = {
143+
"known-plugin": {"repo": "https://github.com/example/known-plugin"},
144+
}
145+
146+
with self.assertRaisesRegex(KeyError, "plugin not found: missing-plugin"):
147+
module.select_plugins(
148+
plugins=plugins,
149+
requested_names=["known-plugin", "missing-plugin"],
150+
limit=None,
151+
)
152+
153+
154+
class HelperFunctionTests(unittest.TestCase):
155+
def test_combine_requested_names_merges_trims_and_drops_empty_values(self):
156+
module = load_validator_module()
157+
158+
combined = module.combine_requested_names(
159+
plugin_names=["foo", " bar ", "", " "],
160+
plugin_name_list="baz, qux , ,foo ",
161+
)
162+
163+
self.assertEqual(combined, ["foo", "bar", "baz", "qux", "foo"])
164+
165+
def test_combine_requested_names_handles_none_inputs(self):
166+
module = load_validator_module()
167+
168+
self.assertEqual(module.combine_requested_names(None, None), [])
169+
170+
def test_sanitize_name_replaces_invalid_chars_and_falls_back_when_needed(self):
171+
module = load_validator_module()
172+
173+
self.assertEqual(module.sanitize_name(" -invalid name!*?- "), "invalid-name")
174+
self.assertEqual(module.sanitize_name("valid-name_123"), "valid-name_123")
175+
self.assertEqual(module.sanitize_name(" "), "plugin")
176+
self.assertEqual(module.sanitize_name("!!!"), "plugin")
177+
178+
def test_build_plugin_clone_dir_is_unique_for_colliding_sanitized_names(self):
179+
module = load_validator_module()
180+
181+
first = module.build_plugin_clone_dir(Path("/tmp/work"), "foo bar")
182+
second = module.build_plugin_clone_dir(Path("/tmp/work"), "foo/bar")
183+
184+
self.assertNotEqual(first, second)
185+
self.assertEqual(first.parent, Path("/tmp/work"))
186+
self.assertEqual(second.parent, Path("/tmp/work"))
187+
188+
def test_build_process_output_details_keeps_partial_timeout_logs(self):
189+
module = load_validator_module()
190+
191+
details = module.build_process_output_details(
192+
stdout="line one\nline two\n",
193+
stderr=b"warning\n",
194+
)
195+
196+
self.assertEqual(details, {"stdout": "line one\nline two", "stderr": "warning"})
197+
91198

92199
class MetadataValidationTests(unittest.TestCase):
93200
def test_reports_missing_required_metadata_fields(self):

0 commit comments

Comments
 (0)