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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 3 additions & 0 deletions .github/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pytest==9.1.1
PyYAML==6.0.3
ruff==0.16.1
2 changes: 2 additions & 0 deletions .github/ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# CI 脚本和测试与后端使用相同的 Python 风格。
extend = "../ruoyi-fastapi-backend/ruff.toml"
72 changes: 72 additions & 0 deletions .github/scripts/check_compose.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import argparse
import json
import os
import subprocess
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[2]
CONFIGURATIONS = (
('docker.env', 'docker-compose.my.yml', 'my'),
('docker.env', 'docker-compose.pg.yml', 'pg'),
('ruoyi-fastapi-test/docker.env', 'ruoyi-fastapi-test/docker-compose.test.my.yml', 'my'),
('ruoyi-fastapi-test/docker.env', 'ruoyi-fastapi-test/docker-compose.test.pg.yml', 'pg'),
)


def validate_configuration(config: dict[str, Any], framework: str, engine: str, root: Path) -> None:
"""
核对 Compose 实际解析后的前后端框架及 nginx 来源。

:param config: docker compose config 的 JSON 结果
:param framework: 本次选择的框架
:param engine: my 或 pg
:param root: 仓库根目录
:return: None
"""
expected = (root / 'ruoyi-fastapi-frontend' / framework / 'web').resolve()
services = config['services']
frontend = services['ruoyi-frontend']
build = frontend['build']
if Path(build['context']).resolve() != expected:
raise ValueError(f'{framework}: 前端构建目录不一致')
if not (expected / build.get('dockerfile', 'Dockerfile')).is_file():
raise ValueError(f'{framework}: 前端 Dockerfile 不存在')
volumes = [
volume for volume in frontend.get('volumes', []) if volume.get('target') == '/etc/nginx/conf.d/default.conf'
]
nginx = expected / 'bin' / f'nginx.docker{engine}.conf'
if len(volumes) != 1 or Path(volumes[0]['source']).resolve() != nginx or not nginx.is_file():
raise ValueError(f'{framework}: nginx 配置未选择同一框架')
backend = services[f'ruoyi-backend-{engine}']
if backend['environment'].get('RUOYI_PLUGIN_FRONTEND_FRAMEWORK') != framework:
raise ValueError(f'{framework}: 后端插件框架与前端不一致')


def main() -> None:
"""
使用两种框架解析全部生产和测试 Compose,无需启动容器。

:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('--docker', default='docker')
args = parser.parse_args()
for framework in ('vue2', 'vue3'):
for env_file, compose_file, engine in CONFIGURATIONS:
result = subprocess.run(
[args.docker, 'compose', '--env-file', env_file, '-f', compose_file, 'config', '--format', 'json'],
cwd=ROOT,
env={**os.environ, 'FRONTEND_FRAMEWORK': framework},
check=True,
capture_output=True,
text=True,
encoding='utf-8',
timeout=60,
)
validate_configuration(json.loads(result.stdout), framework, engine, ROOT)
print(f'{framework}: {compose_file} PASS')


if __name__ == '__main__':
main()
39 changes: 39 additions & 0 deletions .github/scripts/check_test_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import argparse
import xml.etree.ElementTree as ET
from pathlib import Path


def check_report(path: Path, *, allow_skips: bool = False) -> tuple[int, int]:
"""
验证测试确实执行,专用验收任务不允许缺少依赖而全部跳过。

:param path: pytest JUnit 报告路径
:param allow_skips: 是否允许普通跨平台回归中的预期跳过
:return: 实际执行及跳过的测试数量
"""
root = ET.parse(path).getroot()
cases = root.findall('.//testcase')
skipped = sum(case.find('skipped') is not None for case in cases)
executed = len(cases) - skipped
failures = sum(case.find('failure') is not None or case.find('error') is not None for case in cases)
if executed == 0 or failures or (skipped and not allow_skips):
raise ValueError(f'{path}: 执行 {executed},跳过 {skipped},失败 {failures}')
return executed, skipped


def main() -> None:
"""
检查报告并将执行数量写入 CI 摘要。

:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('report', type=Path)
parser.add_argument('--allow-skips', action='store_true')
args = parser.parse_args()
executed, skipped = check_report(args.report, allow_skips=args.allow_skips)
print(f'{args.report}: executed={executed}, skipped={skipped}')


if __name__ == '__main__':
main()
245 changes: 245 additions & 0 deletions .github/scripts/ci_plan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import argparse
import json
import os
import subprocess
from pathlib import Path
from typing import Any

ROOT = Path(__file__).resolve().parents[2]
FRAMEWORKS = ('vue2', 'vue3')
PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13')
CATEGORIES = ('quality', 'backend', 'frontend', 'integration', 'e2e', 'native')
FRONTEND_FILE_PATH_PARTS = 4


# 将影响范围规则集中列出,便于逐条审核;拆散分支会掩盖任务之间的联动。
def build_plan(paths: list[str], *, full: bool = False) -> dict[str, Any]: # noqa: PLR0912, PLR0915
"""
根据源码、共享依赖及测试的影响范围生成 CI 执行计划。

:param paths: 变更文件,重命名需要同时传入旧路径和新路径
:param full: 是否执行完整兼容矩阵
:return: 分类开关、矩阵和必跑任务
"""
projects: set[tuple[str, str]] = set()
browsers: set[str] = set()
networks: set[str] = set()
backend = native = database = release = False
normalized = [path.replace('\\', '/').removeprefix('./') for path in paths]
shared = full or any(
path.startswith(('.github/', 'ruoyi-fastapi-backend/requirements'))
or path
in (
'ruoyi-fastapi-backend/pyproject.toml',
'ruoyi-fastapi-backend/tests/conftest.py',
'ruoyi-fastapi-test/requirements.txt',
'.gitignore',
)
for path in normalized
)
if shared:
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
browsers.update(FRAMEWORKS)
networks.update(FRAMEWORKS)
backend = native = database = release = True
for path in normalized:
if path.lower().endswith('.md'):
continue
parts = path.split('/')
if path.startswith('ruoyi-fastapi-backend/'):
backend = True
relative = path.removeprefix('ruoyi-fastapi-backend/')
if not relative.startswith(('tests/', 'ruff.toml')):
browsers.update(FRAMEWORKS)
if relative.startswith(('common/', 'config/', 'utils/', 'sql/', 'scripts/verify_timezone_database')):
database = True
if relative.startswith(
(
'plugins/',
'module_plugin/',
'cli/',
'tests/plugins/',
'tests/cli/',
'tests/module_plugin/',
'tests/sql/',
)
):
native = release = True
if relative == 'scripts/build_native_plugin.py':
native = True
if relative.startswith(
('plugins/', 'module_plugin/', 'common/', 'config/', 'middlewares/', 'utils/')
) or relative in ('server.py', 'app.py', 'module_admin/service/login_service.py'):
networks.update(FRAMEWORKS)
if relative.startswith(('config/', 'sql/', 'scripts/plugin_', 'tests/server/')) or relative in (
'server.py',
'app.py',
):
release = True
if relative.startswith('tests/plugins/integration/') or relative in (
'tests/scheduler_helpers.py',
'tests/plugins/core/runtime/test_browser_session.py',
):
networks.update(FRAMEWORKS)
if relative in (
'plugins/core/frontend.py',
'plugins/core/environment.py',
'plugins/core/manifest/schema.py',
):
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
if relative.startswith('plugins/examples/python/bundle_demo/web/'):
projects.update((framework, 'web') for framework in FRAMEWORKS)
elif (
len(parts) >= FRONTEND_FILE_PATH_PARTS
and parts[0] == 'ruoyi-fastapi-frontend'
and parts[1] in FRAMEWORKS
and parts[2] in ('web', 'mobile')
):
framework, target = parts[1:3]
projects.add((framework, target))
if target == 'web':
browsers.add(framework)
networks.add(framework)
if target == 'web' and (parts[-1].startswith('pluginBridge') or parts[-1] == 'package.json'):
backend = native = release = True
elif path.startswith('ruoyi-fastapi-frontend/'):
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
browsers.update(FRAMEWORKS)
networks.update(FRAMEWORKS)
elif path.startswith('ruoyi-fastapi-test/frontend/'):
projects.update((framework, 'mobile') for framework in FRAMEWORKS)
elif path.startswith('ruoyi-fastapi-test/time-contract/'):
projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile'))
database = True
browsers.update(FRAMEWORKS)
elif path.startswith('ruoyi-fastapi-test/') or path in (
'docker.env',
'docker-compose.my.yml',
'docker-compose.pg.yml',
):
browsers.update(FRAMEWORKS)
if 'docker' in path or path.endswith('.sql'):
database = True

required = {
'quality': True,
'backend': backend,
'frontend': bool(projects),
'integration': database or release or bool(networks),
'e2e': bool(browsers),
'native': native,
}
return {
'full': full,
'required': required,
'frontend_matrix': {
'include': [{'framework': framework, 'target': target} for framework, target in sorted(projects)]
},
'e2e_matrix': {
'include': [
{'framework': framework, 'database': engine, 'python': version}
for framework in sorted(browsers)
for engine in ('mysql', 'postgresql')
for version in (PYTHON_VERSIONS if full else ('3.12',))
]
},
'network_frameworks': sorted(networks),
'database': database,
'release': release,
'paths': normalized,
}


def check_results(plan: dict[str, Any], needs: dict[str, Any]) -> list[str]:
"""
核验必跑任务,拒绝失败、取消、缺失和意外跳过。

:param plan: 选路任务生成的计划
:param needs: GitHub needs 上下文
:return: 不满足门禁的原因
"""
errors = []
if needs.get('plan', {}).get('result') != 'success':
errors.append('CI 计划未成功生成')
required = plan.get('required', {})
if set(required) != set(CATEGORIES) or not all(isinstance(value, bool) for value in required.values()):
return [*errors, 'CI 计划缺少完整的任务分类']
for category in CATEGORIES:
result = needs.get(category, {}).get('result')
allowed = ('success',) if required[category] else ('success', 'skipped')
if result not in allowed:
errors.append(f'{category}: {result or "missing"},预期 {"/".join(allowed)}')
return errors


def changed_paths(base: str) -> list[str]:
"""
从 Git 获取变更文件,保留删除及重命名前后的路径。

:param base: 已检出的比较基准提交
:return: 相对仓库根目录的路径
"""
result = subprocess.run(
['git', 'diff', '--no-renames', '--name-only', '-z', base, 'HEAD'],
cwd=ROOT,
check=True,
capture_output=True,
timeout=60,
)
return [path for path in result.stdout.decode('utf-8').split('\0') if path]


def write_outputs(plan: dict[str, Any]) -> None:
"""
输出 GitHub 任务参数及可审阅的执行摘要。

:param plan: 本次运行计划
:return: None
"""
payload = {
**plan['required'],
**{key: value for key, value in plan.items() if key not in ('required', 'paths')},
'plan_json': plan,
}
if output := os.environ.get('GITHUB_OUTPUT'):
with Path(output).open('a', encoding='utf-8') as stream:
for key, value in payload.items():
stream.write(f'{key}={json.dumps(value, ensure_ascii=False, separators=(",", ":"))}\n')
if summary := os.environ.get('GITHUB_STEP_SUMMARY'):
with Path(summary).open('a', encoding='utf-8') as stream:
stream.write('## CI 执行计划\n\n| 分类 | 执行 |\n|---|---|\n')
for category, required in plan['required'].items():
stream.write(f'| {category} | {"运行" if required else "无相关改动"} |\n')
stream.write(f'\n完整矩阵:{plan["full"]}\n')
print(json.dumps(plan, ensure_ascii=False, indent=2))


def main() -> None:
"""
执行变更选路或最终结果校验。

:return: None
"""
parser = argparse.ArgumentParser()
parser.add_argument('command', choices=('plan', 'gate'))
args = parser.parse_args()
if args.command == 'gate':
plan = json.loads(os.environ.get('PLAN_JSON') or '{}')
needs = json.loads(os.environ.get('NEEDS_JSON') or '{}')
errors = check_results(plan, needs)
if errors:
raise SystemExit('\n'.join(errors))
print('所有应执行的 CI 分类均已通过。')
return
event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8'))
event_name = os.environ['GITHUB_EVENT_NAME']
full = event_name in ('push', 'schedule') or (
str(event.get('inputs', {}).get('full', 'true')).lower() == 'true' and event_name == 'workflow_dispatch'
)
base = event.get('pull_request', {}).get('base', {}).get('sha', '')
paths = changed_paths(base) if base else ['.github/workflows/ci.yml']
write_outputs(build_plan(paths, full=full))


if __name__ == '__main__':
main()
23 changes: 23 additions & 0 deletions .github/scripts/install_ai_test_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import subprocess
import sys
from pathlib import Path

import yaml


def main() -> None:
"""
从 AI 插件清单安装测试依赖,避免 CI 维护第二份版本列表。

:return: None
"""
backend = Path(__file__).resolve().parents[2] / 'ruoyi-fastapi-backend'
manifest = yaml.safe_load((backend / 'plugins/ai/plugin.yaml').read_text(encoding='utf-8'))
requirements = manifest['dependencies']['python']
if not requirements or not all(isinstance(item, str) and not item.startswith('-') for item in requirements):
raise ValueError('AI 插件 Python 依赖清单无效')
subprocess.run([sys.executable, '-m', 'pip', 'install', *requirements], check=True, timeout=900)


if __name__ == '__main__':
main()
Loading
Loading