Skip to content
Merged
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
118 changes: 118 additions & 0 deletions .github/workflows/validate-plugin-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
name: Validate Plugin Smoke

on:
pull_request:
paths:
- "plugins.json"
- "scripts/validate_plugins/**"
- ".github/workflows/validate-plugin-smoke.yml"
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
inputs:
plugin_names:
description: "Comma-separated plugin keys from plugins.json"
required: false
default: ""
plugin_limit:
description: "Validate the first N plugins when plugin_names is empty. Leave blank or use -1 for all plugins"
required: false
default: ""
astrbot_ref:
description: "AstrBot git ref to validate against"
required: false
default: "master"
max_workers:
description: "Maximum concurrent plugin validations"
required: false
default: "8"

jobs:
validate-plugin-smoke:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Set manual validation inputs
if: github.event_name == 'workflow_dispatch'
run: |
echo "ASTRBOT_REF=${{ inputs.astrbot_ref }}" >> "$GITHUB_ENV"
echo "PLUGIN_NAME_LIST=${{ inputs.plugin_names }}" >> "$GITHUB_ENV"
echo "PLUGIN_LIMIT=${{ inputs.plugin_limit }}" >> "$GITHUB_ENV"
echo "MAX_WORKERS=${{ inputs.max_workers }}" >> "$GITHUB_ENV"
echo "SHOULD_VALIDATE=true" >> "$GITHUB_ENV"

- name: Set scheduled validation inputs
if: github.event_name == 'schedule'
run: |
echo "ASTRBOT_REF=master" >> "$GITHUB_ENV"
echo "PLUGIN_NAME_LIST=" >> "$GITHUB_ENV"
echo "PLUGIN_LIMIT=" >> "$GITHUB_ENV"
echo "MAX_WORKERS=8" >> "$GITHUB_ENV"
echo "SHOULD_VALIDATE=true" >> "$GITHUB_ENV"
echo "VALIDATION_NOTE=Running scheduled full plugin validation." >> "$GITHUB_ENV"

- name: Detect changed plugins from pull request
if: github.event_name == 'pull_request'
run: python scripts/validate_plugins/detect_changed_plugins.py

- name: Show PR diff selection
if: github.event_name == 'pull_request'
run: |
if [ "$SHOULD_VALIDATE" != "true" ]; then
printf '%s\n' "${VALIDATION_NOTE:-Smoke validation skipped.}"
else
printf 'Selected plugins: %s\n' "$PLUGIN_NAME_LIST"
fi

- name: Set up Python
if: env.SHOULD_VALIDATE == 'true'
uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install validator dependencies
if: env.SHOULD_VALIDATE == 'true'
run: python -m pip install --upgrade pip pyyaml

- name: Clone AstrBot
if: env.SHOULD_VALIDATE == 'true'
run: git clone --depth 1 --branch "$ASTRBOT_REF" "https://github.com/AstrBotDevs/AstrBot" ".cache/AstrBot"

- name: Install AstrBot dependencies
if: env.SHOULD_VALIDATE == 'true'
run: python -m pip install -r ".cache/AstrBot/requirements.txt"

- name: Run plugin smoke validator
if: env.SHOULD_VALIDATE == 'true'
run: |
args=(
--astrbot-path ".cache/AstrBot"
--report-path "validation-report.json"
)

if [ -n "${PLUGIN_NAME_LIST:-}" ]; then
args+=(--plugin-name-list "$PLUGIN_NAME_LIST")
fi

if [ -n "${PLUGIN_LIMIT:-}" ]; then
args+=(--limit "$PLUGIN_LIMIT")
fi

if [ -n "${MAX_WORKERS:-}" ]; then
args+=(--max-workers "$MAX_WORKERS")
fi

python scripts/validate_plugins/run.py "${args[@]}"

- name: Upload validation report
if: always()
uses: actions/upload-artifact@v7
with:
name: validation-report
path: validation-report.json
if-no-files-found: warn
Empty file.
120 changes: 120 additions & 0 deletions scripts/validate_plugins/detect_changed_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env python3

from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path

if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from scripts.validate_plugins.plugins_map import load_plugins_map_text


DEFAULT_ASTRBOT_REF = "master"
ASTRBOT_REMOTE_URL = "https://github.com/AstrBotDevs/AstrBot"


def load_plugins_map(text: str, *, source_name: str) -> dict[str, dict]:
return load_plugins_map_text(text, source_name=source_name)


def detect_changed_plugin_names(*, base: dict[str, dict], head: dict[str, dict]) -> list[str]:
return [name for name, payload in head.items() if base.get(name) != payload]


def fetch_base_ref(base_ref: str) -> None:
subprocess.run(["git", "fetch", "origin", base_ref, "--depth", "1"], check=True)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.


def read_base_plugins_json(base_ref: str) -> str:
return subprocess.check_output(
["git", "show", f"origin/{base_ref}:plugins.json"],
text=True,
stderr=subprocess.DEVNULL,
)


def resolve_astrbot_ref() -> str:
try:
default_head = subprocess.check_output(
["git", "ls-remote", "--symref", ASTRBOT_REMOTE_URL, "HEAD"],
text=True,
stderr=subprocess.DEVNULL,
)
except subprocess.CalledProcessError:
return DEFAULT_ASTRBOT_REF

for line in default_head.splitlines():
if line.startswith("ref: refs/heads/") and line.endswith("\tHEAD"):
return line.split("refs/heads/", 1)[1].split("\t", 1)[0]
return DEFAULT_ASTRBOT_REF


def detect_pull_request_selection(*, repo_root: Path, base_ref: str) -> dict[str, object]:
try:
fetch_base_ref(base_ref)
base = load_plugins_map(read_base_plugins_json(base_ref), source_name=f"base ref {base_ref}")
except (subprocess.CalledProcessError, ValueError):
base = {}

head_text = (repo_root / "plugins.json").read_text(encoding="utf-8")
try:
head = load_plugins_map(head_text, source_name="PR head")
except ValueError as exc:
raise ValueError(f"plugins.json is invalid on the PR head: {exc}") from exc

changed = detect_changed_plugin_names(base=base, head=head)
validation_note = ""
if not changed:
validation_note = "No plugin entries changed in plugins.json; skipping smoke validation."

return {
"changed": changed,
"should_validate": bool(changed),
"validation_note": validation_note,
}


def write_github_env(
*,
env_path: Path,
astrbot_ref: str,
changed: list[str],
should_validate: bool,
validation_note: str,
) -> None:
with env_path.open("a", encoding="utf-8") as handle:
handle.write(f"ASTRBOT_REF={astrbot_ref}\n")
handle.write(f"PLUGIN_NAME_LIST={','.join(changed)}\n")
handle.write("PLUGIN_LIMIT=\n")
handle.write(f"SHOULD_VALIDATE={'true' if should_validate else 'false'}\n")
handle.write(f"VALIDATION_NOTE={validation_note}\n")


def main() -> int:
base_ref = os.environ["GITHUB_BASE_REF"]
github_env = Path(os.environ["GITHUB_ENV"])
repo_root = Path.cwd()

try:
result = detect_pull_request_selection(repo_root=repo_root, base_ref=base_ref)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 1

write_github_env(
env_path=github_env,
astrbot_ref=resolve_astrbot_ref(),
changed=result["changed"],
should_validate=result["should_validate"],
validation_note=result["validation_note"],
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
34 changes: 34 additions & 0 deletions scripts/validate_plugins/plugins_map.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from __future__ import annotations

import json
from pathlib import Path


def validate_plugins_map(data: object, *, source_name: str) -> dict[str, dict]:
if not isinstance(data, dict):
raise ValueError("plugins.json must contain a JSON object")

for name, payload in data.items():
if not isinstance(name, str):
raise ValueError(
f"plugins.json on the {source_name} has a non-string key: {name!r}"
)
if not isinstance(payload, dict):
raise ValueError(
f"plugins.json entry {name!r} on the {source_name} must be a JSON object"
)

return data


def load_plugins_map_text(text: str, *, source_name: str) -> dict[str, dict]:
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(f"plugins.json is invalid on the {source_name}: {exc}") from exc

return validate_plugins_map(data, source_name=source_name)


def load_plugins_map_file(path: Path, *, source_name: str) -> dict[str, dict]:
return load_plugins_map_text(path.read_text(encoding="utf-8"), source_name=source_name)
Loading
Loading