Skip to content

Commit 6147ad4

Browse files
authored
Merge pull request #716 from zouyonghe/main
feature: add plugin smoke validation workflow
2 parents 007c00a + 65556b0 commit 6147ad4

8 files changed

Lines changed: 2068 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
name: Validate Plugin Smoke
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- "plugins.json"
7+
- "scripts/validate_plugins/**"
8+
- ".github/workflows/validate-plugin-smoke.yml"
9+
schedule:
10+
- cron: "0 2 * * *"
11+
workflow_dispatch:
12+
inputs:
13+
plugin_names:
14+
description: "Comma-separated plugin keys from plugins.json"
15+
required: false
16+
default: ""
17+
plugin_limit:
18+
description: "Validate the first N plugins when plugin_names is empty. Leave blank or use -1 for all plugins"
19+
required: false
20+
default: ""
21+
astrbot_ref:
22+
description: "AstrBot git ref to validate against"
23+
required: false
24+
default: "master"
25+
max_workers:
26+
description: "Maximum concurrent plugin validations"
27+
required: false
28+
default: "8"
29+
30+
jobs:
31+
validate-plugin-smoke:
32+
runs-on: ubuntu-latest
33+
34+
steps:
35+
- name: Checkout repository
36+
uses: actions/checkout@v6
37+
with:
38+
fetch-depth: 0
39+
40+
- name: Set manual validation inputs
41+
if: github.event_name == 'workflow_dispatch'
42+
run: |
43+
echo "ASTRBOT_REF=${{ inputs.astrbot_ref }}" >> "$GITHUB_ENV"
44+
echo "PLUGIN_NAME_LIST=${{ inputs.plugin_names }}" >> "$GITHUB_ENV"
45+
echo "PLUGIN_LIMIT=${{ inputs.plugin_limit }}" >> "$GITHUB_ENV"
46+
echo "MAX_WORKERS=${{ inputs.max_workers }}" >> "$GITHUB_ENV"
47+
echo "SHOULD_VALIDATE=true" >> "$GITHUB_ENV"
48+
49+
- name: Set scheduled validation inputs
50+
if: github.event_name == 'schedule'
51+
run: |
52+
echo "ASTRBOT_REF=master" >> "$GITHUB_ENV"
53+
echo "PLUGIN_NAME_LIST=" >> "$GITHUB_ENV"
54+
echo "PLUGIN_LIMIT=" >> "$GITHUB_ENV"
55+
echo "MAX_WORKERS=8" >> "$GITHUB_ENV"
56+
echo "SHOULD_VALIDATE=true" >> "$GITHUB_ENV"
57+
echo "VALIDATION_NOTE=Running scheduled full plugin validation." >> "$GITHUB_ENV"
58+
59+
- name: Detect changed plugins from pull request
60+
if: github.event_name == 'pull_request'
61+
run: python scripts/validate_plugins/detect_changed_plugins.py
62+
63+
- name: Show PR diff selection
64+
if: github.event_name == 'pull_request'
65+
run: |
66+
if [ "$SHOULD_VALIDATE" != "true" ]; then
67+
printf '%s\n' "${VALIDATION_NOTE:-Smoke validation skipped.}"
68+
else
69+
printf 'Selected plugins: %s\n' "$PLUGIN_NAME_LIST"
70+
fi
71+
72+
- name: Set up Python
73+
if: env.SHOULD_VALIDATE == 'true'
74+
uses: actions/setup-python@v6
75+
with:
76+
python-version: "3.12"
77+
78+
- name: Install validator dependencies
79+
if: env.SHOULD_VALIDATE == 'true'
80+
run: python -m pip install --upgrade pip pyyaml
81+
82+
- name: Clone AstrBot
83+
if: env.SHOULD_VALIDATE == 'true'
84+
run: git clone --depth 1 --branch "$ASTRBOT_REF" "https://github.com/AstrBotDevs/AstrBot" ".cache/AstrBot"
85+
86+
- name: Install AstrBot dependencies
87+
if: env.SHOULD_VALIDATE == 'true'
88+
run: python -m pip install -r ".cache/AstrBot/requirements.txt"
89+
90+
- name: Run plugin smoke validator
91+
if: env.SHOULD_VALIDATE == 'true'
92+
run: |
93+
args=(
94+
--astrbot-path ".cache/AstrBot"
95+
--report-path "validation-report.json"
96+
)
97+
98+
if [ -n "${PLUGIN_NAME_LIST:-}" ]; then
99+
args+=(--plugin-name-list "$PLUGIN_NAME_LIST")
100+
fi
101+
102+
if [ -n "${PLUGIN_LIMIT:-}" ]; then
103+
args+=(--limit "$PLUGIN_LIMIT")
104+
fi
105+
106+
if [ -n "${MAX_WORKERS:-}" ]; then
107+
args+=(--max-workers "$MAX_WORKERS")
108+
fi
109+
110+
python scripts/validate_plugins/run.py "${args[@]}"
111+
112+
- name: Upload validation report
113+
if: always()
114+
uses: actions/upload-artifact@v7
115+
with:
116+
name: validation-report
117+
path: validation-report.json
118+
if-no-files-found: warn

scripts/validate_plugins/__init__.py

Whitespace-only changes.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python3
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
if __package__ in {None, ""}:
12+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
13+
14+
from scripts.validate_plugins.plugins_map import load_plugins_map_text
15+
16+
17+
DEFAULT_ASTRBOT_REF = "master"
18+
ASTRBOT_REMOTE_URL = "https://github.com/AstrBotDevs/AstrBot"
19+
20+
21+
def load_plugins_map(text: str, *, source_name: str) -> dict[str, dict]:
22+
return load_plugins_map_text(text, source_name=source_name)
23+
24+
25+
def detect_changed_plugin_names(*, base: dict[str, dict], head: dict[str, dict]) -> list[str]:
26+
return [name for name, payload in head.items() if base.get(name) != payload]
27+
28+
29+
def fetch_base_ref(base_ref: str) -> None:
30+
subprocess.run(["git", "fetch", "origin", base_ref, "--depth", "1"], check=True)
31+
32+
33+
def read_base_plugins_json(base_ref: str) -> str:
34+
return subprocess.check_output(
35+
["git", "show", f"origin/{base_ref}:plugins.json"],
36+
text=True,
37+
stderr=subprocess.DEVNULL,
38+
)
39+
40+
41+
def resolve_astrbot_ref() -> str:
42+
try:
43+
default_head = subprocess.check_output(
44+
["git", "ls-remote", "--symref", ASTRBOT_REMOTE_URL, "HEAD"],
45+
text=True,
46+
stderr=subprocess.DEVNULL,
47+
)
48+
except subprocess.CalledProcessError:
49+
return DEFAULT_ASTRBOT_REF
50+
51+
for line in default_head.splitlines():
52+
if line.startswith("ref: refs/heads/") and line.endswith("\tHEAD"):
53+
return line.split("refs/heads/", 1)[1].split("\t", 1)[0]
54+
return DEFAULT_ASTRBOT_REF
55+
56+
57+
def detect_pull_request_selection(*, repo_root: Path, base_ref: str) -> dict[str, object]:
58+
try:
59+
fetch_base_ref(base_ref)
60+
base = load_plugins_map(read_base_plugins_json(base_ref), source_name=f"base ref {base_ref}")
61+
except (subprocess.CalledProcessError, ValueError):
62+
base = {}
63+
64+
head_text = (repo_root / "plugins.json").read_text(encoding="utf-8")
65+
try:
66+
head = load_plugins_map(head_text, source_name="PR head")
67+
except ValueError as exc:
68+
raise ValueError(f"plugins.json is invalid on the PR head: {exc}") from exc
69+
70+
changed = detect_changed_plugin_names(base=base, head=head)
71+
validation_note = ""
72+
if not changed:
73+
validation_note = "No plugin entries changed in plugins.json; skipping smoke validation."
74+
75+
return {
76+
"changed": changed,
77+
"should_validate": bool(changed),
78+
"validation_note": validation_note,
79+
}
80+
81+
82+
def write_github_env(
83+
*,
84+
env_path: Path,
85+
astrbot_ref: str,
86+
changed: list[str],
87+
should_validate: bool,
88+
validation_note: str,
89+
) -> None:
90+
with env_path.open("a", encoding="utf-8") as handle:
91+
handle.write(f"ASTRBOT_REF={astrbot_ref}\n")
92+
handle.write(f"PLUGIN_NAME_LIST={','.join(changed)}\n")
93+
handle.write("PLUGIN_LIMIT=\n")
94+
handle.write(f"SHOULD_VALIDATE={'true' if should_validate else 'false'}\n")
95+
handle.write(f"VALIDATION_NOTE={validation_note}\n")
96+
97+
98+
def main() -> int:
99+
base_ref = os.environ["GITHUB_BASE_REF"]
100+
github_env = Path(os.environ["GITHUB_ENV"])
101+
repo_root = Path.cwd()
102+
103+
try:
104+
result = detect_pull_request_selection(repo_root=repo_root, base_ref=base_ref)
105+
except ValueError as exc:
106+
print(str(exc), file=sys.stderr)
107+
return 1
108+
109+
write_github_env(
110+
env_path=github_env,
111+
astrbot_ref=resolve_astrbot_ref(),
112+
changed=result["changed"],
113+
should_validate=result["should_validate"],
114+
validation_note=result["validation_note"],
115+
)
116+
return 0
117+
118+
119+
if __name__ == "__main__":
120+
raise SystemExit(main())
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import json
4+
from pathlib import Path
5+
6+
7+
def validate_plugins_map(data: object, *, source_name: str) -> dict[str, dict]:
8+
if not isinstance(data, dict):
9+
raise ValueError("plugins.json must contain a JSON object")
10+
11+
for name, payload in data.items():
12+
if not isinstance(name, str):
13+
raise ValueError(
14+
f"plugins.json on the {source_name} has a non-string key: {name!r}"
15+
)
16+
if not isinstance(payload, dict):
17+
raise ValueError(
18+
f"plugins.json entry {name!r} on the {source_name} must be a JSON object"
19+
)
20+
21+
return data
22+
23+
24+
def load_plugins_map_text(text: str, *, source_name: str) -> dict[str, dict]:
25+
try:
26+
data = json.loads(text)
27+
except json.JSONDecodeError as exc:
28+
raise ValueError(f"plugins.json is invalid on the {source_name}: {exc}") from exc
29+
30+
return validate_plugins_map(data, source_name=source_name)
31+
32+
33+
def load_plugins_map_file(path: Path, *, source_name: str) -> dict[str, dict]:
34+
return load_plugins_map_text(path.read_text(encoding="utf-8"), source_name=source_name)

0 commit comments

Comments
 (0)