Initial commit: rdgen with GitHub/Gitea dual build backend
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
构建后端抽象层:把 GitHub Actions / Gitea Actions 的触发、状态查询、
|
||||
日志链接、连接测试收敛为统一接口,业务代码不再感知平台差异。
|
||||
|
||||
通过 app_settings 的 BUILD_PLATFORM(后台配置 → 环境变量 → 默认值)选择后端;
|
||||
每个 GithubRun 记录自身 backend,切换平台后历史任务仍走原平台查询。
|
||||
"""
|
||||
from .. import app_settings
|
||||
from .github import GitHubBackend
|
||||
from .gitea import GiteaBackend
|
||||
|
||||
# 已支持的构建平台(Gitee Go 经评估不兼容,未纳入)
|
||||
BACKENDS = {
|
||||
'github': GitHubBackend,
|
||||
'gitea': GiteaBackend,
|
||||
}
|
||||
|
||||
PLATFORM_CHOICES = [
|
||||
('github', 'GitHub Actions'),
|
||||
('gitea', 'Gitea Actions(自建)'),
|
||||
]
|
||||
|
||||
|
||||
def current_platform():
|
||||
"""返回当前生效的构建平台标识(非法值回退 github)。"""
|
||||
name = app_settings.get_value('BUILD_PLATFORM').strip().lower()
|
||||
return name if name in BACKENDS else 'github'
|
||||
|
||||
|
||||
def get_backend(name=None):
|
||||
"""按名称获取后端实例;name 为空时取当前配置平台。
|
||||
|
||||
后端实例无状态(每次调用实时读取设置),直接新建即可,
|
||||
这样后台修改配置后无需重启即可生效。
|
||||
"""
|
||||
if not name:
|
||||
name = current_platform()
|
||||
cls = BACKENDS.get(name, GitHubBackend)
|
||||
return cls()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""构建后端统一接口与共享逻辑。"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
"""触发构建失败时抛出,message 为可直接展示给用户的中文提示。"""
|
||||
|
||||
def __init__(self, message, *, kind='error'):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
# rejected=平台明确拒绝(4xx/5xx);network=连不上平台
|
||||
self.kind = kind
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchResult:
|
||||
run_id: object = None # 平台返回的运行编号(int 或 None)
|
||||
html_url: str = '' # 平台上的运行详情页地址
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunInfo:
|
||||
finished: bool # 是否已进入终态
|
||||
status: str = '' # 终态结论:success/failure/cancelled/skipped;未结束为进行中状态
|
||||
html_url: str = ''
|
||||
|
||||
|
||||
# rdgen 目标平台 -> 工作流文件名(两平台同名)
|
||||
WORKFLOW_FILES = {
|
||||
'windows': 'generator-windows.yml',
|
||||
'windows-x86': 'generator-windows-x86.yml',
|
||||
'linux': 'generator-linux.yml',
|
||||
'android': 'generator-android.yml',
|
||||
'macos': 'generator-macos.yml',
|
||||
}
|
||||
|
||||
|
||||
class BuildBackend:
|
||||
"""各构建平台实现此接口。所有配置均通过 app_settings 实时读取。"""
|
||||
|
||||
name = ''
|
||||
label = ''
|
||||
|
||||
# ---- 配置健康度(后台页 / 全局告警条使用) ----
|
||||
def configured(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def warnings(self) -> list:
|
||||
"""配置隐患的中文提示列表;通用项(GENURL/ZIP_PASSWORD)各实现自行包含。"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ---- 构建任务 ----
|
||||
def workflow_file_for(self, platform, selfhosted=False) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def dispatch(self, workflow_file, inputs, *, timeout=20) -> DispatchResult:
|
||||
"""触发工作流。inputs 为完整输入字典(GitHub/Gitea 均为 {ref, inputs})。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_run(self, run_id, *, timeout=12):
|
||||
"""查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def log_url(self, run_id) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
# ---- 后台连接测试 ----
|
||||
def test_connection(self) -> dict:
|
||||
"""返回 {'ok': bool, 'message': 中文说明},不抛异常。"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ---- 共享的配置检查 ----
|
||||
def _shared_warnings(self, *, include_sh_secret=False):
|
||||
"""两平台共用的回调/加密检查。"""
|
||||
from .. import app_settings
|
||||
warnings = []
|
||||
if not app_settings.get_value('GENURL').strip():
|
||||
warnings.append('平台外部访问地址(GENURL)未配置,构建机将无法回传构建产物。')
|
||||
zip_password = app_settings.get_value('ZIP_PASSWORD')
|
||||
if zip_password in ('', 'insecure'):
|
||||
warnings.append(
|
||||
'配置压缩包密码为空或仍为默认值 insecure,建议设置随机强密码,'
|
||||
f'并同步更新{self.label}仓库 Secret。'
|
||||
)
|
||||
if include_sh_secret and app_settings.get_value('SH_SECRET') in ('', 'secret'):
|
||||
warnings.append('自托管 Runner 密钥为空或仍为默认值 secret;如使用自托管 Runner,建议设置独立强密钥,避免被冒用触发。')
|
||||
return warnings
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Gitea Actions 构建后端(自建 Gitea 1.23+,建议 1.27+)。
|
||||
|
||||
API 与 GitHub Actions 几乎同形(已对照 Gitea 官方 Swagger 核实):
|
||||
- 触发:POST /api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
|
||||
?return_run_details=true,body {ref, inputs},200 返回
|
||||
{workflow_run_id, html_url, run_url},不传该参数为 204;
|
||||
- 状态:GET /api/v1/repos/{owner}/{repo}/actions/runs/{id},
|
||||
status/conclusion 字段仿 GitHub;
|
||||
- 鉴权:Authorization: token <PAT>。
|
||||
"""
|
||||
from .. import app_settings, ghnet
|
||||
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend
|
||||
|
||||
# Gitea 未结束状态(blocked=等待审批,waiting=等待 runner)
|
||||
_GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending'}
|
||||
# Gitea 结论 -> rdgen 内部状态
|
||||
_CONCLUSION_MAP = {
|
||||
'success': 'success',
|
||||
'failure': 'failure',
|
||||
'cancelled': 'cancelled',
|
||||
'skipped': 'skipped',
|
||||
'timed_out': 'timed_out',
|
||||
}
|
||||
|
||||
|
||||
class GiteaBackend(BuildBackend):
|
||||
name = 'gitea'
|
||||
label = 'Gitea'
|
||||
|
||||
# ---- 取值辅助 ----
|
||||
def _server(self):
|
||||
return app_settings.get_value('GITEA_SERVER_URL').strip().rstrip('/')
|
||||
|
||||
def _owner(self):
|
||||
return app_settings.get_value('GITEA_OWNER').strip()
|
||||
|
||||
def _repo(self):
|
||||
return app_settings.get_value('GITEA_REPO').strip() or 'rdgen'
|
||||
|
||||
def _branch(self):
|
||||
return app_settings.get_value('GITEA_BRANCH').strip() or 'master'
|
||||
|
||||
def _ref(self):
|
||||
branch = self._branch()
|
||||
return branch if branch.startswith('refs/') else f'refs/heads/{branch}'
|
||||
|
||||
def _proxy(self):
|
||||
return app_settings.get_value('GITEA_PROXY').strip()
|
||||
|
||||
def _api(self, path):
|
||||
return f'{self._server()}/api/v1{path}'
|
||||
|
||||
# ---- 配置 ----
|
||||
def configured(self):
|
||||
return bool(self._server() and self._owner() and app_settings.get_value('GITEA_TOKEN').strip())
|
||||
|
||||
def warnings(self):
|
||||
warnings = []
|
||||
if not self._server():
|
||||
warnings.append('Gitea 服务器地址未配置,无法触发构建。')
|
||||
if not self._owner():
|
||||
warnings.append('Gitea 仓库属主未配置,无法触发构建。')
|
||||
if not app_settings.get_value('GITEA_TOKEN').strip():
|
||||
warnings.append('Gitea 访问令牌未配置,无法触发构建。')
|
||||
warnings.extend(self._shared_warnings())
|
||||
return warnings
|
||||
|
||||
# ---- 工作流选择 ----
|
||||
def workflow_file_for(self, platform, selfhosted=False):
|
||||
# Gitea 的 runner 全部为自建注册,无需 sh- 变体,统一用标准工作流
|
||||
return WORKFLOW_FILES.get(platform, 'generator-windows.yml')
|
||||
|
||||
# ---- 触发 ----
|
||||
def dispatch(self, workflow_file, inputs, *, timeout=20):
|
||||
if not self.configured():
|
||||
raise BackendError('Gitea 构建服务未完成配置(服务器地址 / 属主 / 令牌缺失)。', kind='config')
|
||||
url = self._api(
|
||||
f'/repos/{self._owner()}/{self._repo()}'
|
||||
f'/actions/workflows/{workflow_file}/dispatches?return_run_details=true'
|
||||
)
|
||||
payload = {'ref': self._ref(), 'inputs': inputs}
|
||||
try:
|
||||
resp = ghnet.post(
|
||||
url, json=payload,
|
||||
token=app_settings.get_value('GITEA_TOKEN'),
|
||||
timeout=timeout, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
except Exception as e:
|
||||
raise BackendError(f'无法连接 Gitea 构建服务:{e}', kind='network')
|
||||
|
||||
if resp.status_code in (200, 204):
|
||||
data = {}
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
pass
|
||||
run_id = data.get('workflow_run_id')
|
||||
html_url = data.get('html_url') or ''
|
||||
# 旧版 Gitea 或未回传详情时,按工作流最新一条运行兜底取 run id
|
||||
if not run_id and resp.status_code == 204:
|
||||
fallback = self._latest_run_id(workflow_file)
|
||||
if fallback:
|
||||
run_id, html_url = fallback
|
||||
return DispatchResult(run_id=run_id, html_url=html_url or self.log_url(run_id))
|
||||
|
||||
detail = ''
|
||||
try:
|
||||
detail = (resp.text or '')[:200]
|
||||
except Exception:
|
||||
pass
|
||||
if resp.status_code == 404:
|
||||
msg = f'Gitea 仓库或工作流 {workflow_file} 不存在(请确认仓库已启用 Repository Actions)。'
|
||||
elif resp.status_code in (401, 403):
|
||||
msg = 'Gitea 拒绝访问:请检查令牌是否有效且具备该仓库 Actions 读写权限。'
|
||||
elif resp.status_code == 422:
|
||||
msg = f'Gitea 校验失败(分支或工作流参数有误):{detail}'
|
||||
else:
|
||||
msg = f'Gitea 构建服务拒绝了启动请求(HTTP {resp.status_code}):{detail}'
|
||||
raise BackendError(msg, kind='rejected')
|
||||
|
||||
def _latest_run_id(self, workflow_file, *, timeout=10):
|
||||
"""204 兜底:查该工作流最新一条运行(仅在刚触发后调用)。"""
|
||||
url = self._api(
|
||||
f'/repos/{self._owner()}/{self._repo()}'
|
||||
f'/actions/workflows/{workflow_file}/runs?limit=1'
|
||||
)
|
||||
try:
|
||||
resp = ghnet.get(
|
||||
url, token=app_settings.get_value('GITEA_TOKEN'),
|
||||
timeout=timeout, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
runs = data.get('workflow_runs') if isinstance(data, dict) else data
|
||||
if runs:
|
||||
run = runs[0]
|
||||
return run.get('id'), run.get('html_url') or ''
|
||||
except Exception as e:
|
||||
print(f'查询 Gitea 最新运行失败:{e}')
|
||||
return None
|
||||
|
||||
# ---- 状态查询 ----
|
||||
def get_run(self, run_id, *, timeout=12):
|
||||
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}')
|
||||
try:
|
||||
resp = ghnet.get(
|
||||
url, token=app_settings.get_value('GITEA_TOKEN'),
|
||||
timeout=timeout, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'查询 Gitea 状态出错:{e}')
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
html_url = data.get('html_url') or self.log_url(run_id)
|
||||
status = (data.get('status') or '').lower()
|
||||
conclusion = (data.get('conclusion') or '').lower()
|
||||
if status == 'completed':
|
||||
return RunInfo(
|
||||
finished=True,
|
||||
status=_CONCLUSION_MAP.get(conclusion, 'failure'),
|
||||
html_url=html_url,
|
||||
)
|
||||
# blocked(等待审批)等也算进行中
|
||||
return RunInfo(finished=False, status=status or 'in_progress', html_url=html_url)
|
||||
|
||||
# ---- 日志链接 ----
|
||||
def log_url(self, run_id):
|
||||
if not run_id:
|
||||
return ''
|
||||
return f'{self._server()}/{self._owner()}/{self._repo()}/actions/runs/{run_id}'
|
||||
|
||||
# ---- 连接测试 ----
|
||||
def test_connection(self):
|
||||
server = self._server()
|
||||
owner = self._owner()
|
||||
token = app_settings.get_value('GITEA_TOKEN').strip()
|
||||
repo = self._repo()
|
||||
if not server or not owner or not token:
|
||||
return {'ok': False, 'message': 'Gitea 服务器地址、仓库属主或访问令牌为空,请先保存配置。'}
|
||||
if not (server.startswith('http://') or server.startswith('https://')):
|
||||
return {'ok': False, 'message': 'Gitea 服务器地址需以 http:// 或 https:// 开头。'}
|
||||
try:
|
||||
resp = ghnet.get(
|
||||
self._api(f'/repos/{owner}/{repo}'),
|
||||
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
return {'ok': False, 'message': '令牌无效或已过期(Gitea 返回 401)。'}
|
||||
if resp.status_code == 404:
|
||||
return {'ok': False,
|
||||
'message': f'找不到 Gitea 仓库 {owner}/{repo},或令牌无权访问该仓库。'}
|
||||
if resp.status_code != 200:
|
||||
return {'ok': False, 'message': f'Gitea 返回异常状态码:{resp.status_code}。'}
|
||||
|
||||
act = ghnet.get(
|
||||
self._api(f'/repos/{owner}/{repo}/actions/workflows'),
|
||||
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
if act.status_code != 200:
|
||||
return {'ok': False,
|
||||
'message': '可以访问仓库,但无法读取 Actions 工作流;请在仓库设置中启用 Repository Actions。'}
|
||||
|
||||
# 版本检查:1.23 起支持 workflow_dispatch;建议 1.27+(return_run_details 与缓存兼容性更好)
|
||||
version_msg = ''
|
||||
ver = ghnet.get(
|
||||
self._api('/version'),
|
||||
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
if ver.status_code == 200:
|
||||
raw = (ver.json().get('version') or '').strip()
|
||||
parts = raw.replace('+dev', '').split('.')
|
||||
try:
|
||||
major, minor = int(parts[0]), int(parts[1])
|
||||
if (major, minor) < (1, 23):
|
||||
return {'ok': False,
|
||||
'message': f'Gitea 版本为 {raw},低于 1.23,不支持 workflow_dispatch 远程触发,请先升级。'}
|
||||
if (major, minor) < (1, 27):
|
||||
version_msg = f'当前 Gitea 版本为 {raw},可触发构建,但建议升级到 1.27+(运行详情回传与缓存兼容性更好)。'
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
msg = f'连接正常:令牌可访问仓库 {owner}/{repo},Actions 已启用。'
|
||||
if version_msg:
|
||||
msg += version_msg
|
||||
return {'ok': True, 'message': msg}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'message': f'无法连接 Gitea API:{e}(请检查服务器地址与网络/代理配置)'}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""GitHub Actions 构建后端(迁自 views.py / web_views.py 的原有逻辑,行为保持一致)。"""
|
||||
from .. import app_settings, ghnet
|
||||
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend
|
||||
|
||||
API = 'https://api.github.com'
|
||||
|
||||
# 未进入 completed 的状态一律视为进行中
|
||||
_GITHUB_ACTIVE = {'queued', 'in_progress', 'waiting', 'requested', 'pending'}
|
||||
|
||||
|
||||
class GitHubBackend(BuildBackend):
|
||||
name = 'github'
|
||||
label = 'GitHub'
|
||||
|
||||
# ---- 配置 ----
|
||||
def configured(self):
|
||||
return bool(app_settings.get_value('GHUSER').strip()) \
|
||||
and bool(app_settings.get_value('GHBEARER').strip())
|
||||
|
||||
def warnings(self):
|
||||
warnings = []
|
||||
if not app_settings.get_value('GHUSER').strip():
|
||||
warnings.append('GitHub 用户名未配置,无法触发构建。')
|
||||
if not app_settings.get_value('GHBEARER').strip():
|
||||
warnings.append('GitHub 访问令牌未配置,无法触发构建。')
|
||||
warnings.extend(self._shared_warnings(include_sh_secret=True))
|
||||
return warnings
|
||||
|
||||
# ---- 工作流选择(windows selfhosted 走 sh-generator-windows.yml) ----
|
||||
def workflow_file_for(self, platform, selfhosted=False):
|
||||
if selfhosted and platform in ('windows',):
|
||||
return 'sh-generator-windows.yml'
|
||||
return WORKFLOW_FILES.get(platform, 'generator-windows.yml')
|
||||
|
||||
# ---- 触发 ----
|
||||
def dispatch(self, workflow_file, inputs, *, timeout=20):
|
||||
user = app_settings.get_value('GHUSER')
|
||||
repo = app_settings.get_value('REPONAME')
|
||||
url = f'{API}/repos/{user}/{repo}/actions/workflows/{workflow_file}/dispatches'
|
||||
payload = {
|
||||
'ref': app_settings.get_value('GHBRANCH'),
|
||||
'inputs': inputs,
|
||||
'return_run_details': True,
|
||||
}
|
||||
token = app_settings.get_value('GHBEARER')
|
||||
try:
|
||||
resp = ghnet.post(url, json=payload, token=token, timeout=timeout)
|
||||
except Exception as e:
|
||||
raise BackendError(f'无法连接 GitHub 构建服务:{e}', kind='network')
|
||||
if resp.status_code not in (200, 204):
|
||||
raise BackendError('GitHub 构建服务拒绝了启动请求,请检查服务配置或稍后重试。', kind='rejected')
|
||||
data = {}
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
pass
|
||||
return DispatchResult(run_id=data.get('workflow_run_id'), html_url=data.get('html_url') or '')
|
||||
|
||||
# ---- 状态查询 ----
|
||||
def get_run(self, run_id, *, timeout=12):
|
||||
user = app_settings.get_value('GHUSER')
|
||||
repo = app_settings.get_value('REPONAME')
|
||||
url = f'{API}/repos/{user}/{repo}/actions/runs/{run_id}'
|
||||
try:
|
||||
resp = ghnet.get(url, token=app_settings.get_value('GHBEARER'), timeout=timeout)
|
||||
except Exception as e:
|
||||
print(f'查询 GitHub 状态出错:{e}')
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
if data.get('status') == 'completed':
|
||||
return RunInfo(finished=True, status=data.get('conclusion') or 'failure',
|
||||
html_url=data.get('html_url') or self.log_url(run_id))
|
||||
return RunInfo(finished=False, status=data.get('status') or 'in_progress',
|
||||
html_url=data.get('html_url') or self.log_url(run_id))
|
||||
|
||||
# ---- 日志链接 ----
|
||||
def log_url(self, run_id):
|
||||
if not run_id:
|
||||
return ''
|
||||
return (
|
||||
f"https://github.com/{app_settings.get_value('GHUSER')}"
|
||||
f"/{app_settings.get_value('REPONAME')}/actions/runs/{run_id}"
|
||||
)
|
||||
|
||||
# ---- 连接测试 ----
|
||||
def test_connection(self):
|
||||
user = app_settings.get_value('GHUSER').strip()
|
||||
repo = app_settings.get_value('REPONAME').strip()
|
||||
token = app_settings.get_value('GHBEARER').strip()
|
||||
if not user or not token:
|
||||
return {'ok': False, 'message': 'GitHub 用户名或访问令牌为空,请先保存配置。'}
|
||||
try:
|
||||
resp = ghnet.get(f'{API}/repos/{user}/{repo}', token=token, timeout=10)
|
||||
if resp.status_code == 401:
|
||||
return {'ok': False, 'message': '令牌无效或已过期(GitHub 返回 401)。'}
|
||||
if resp.status_code == 404:
|
||||
return {'ok': False,
|
||||
'message': f'找不到仓库 {user}/{repo},或令牌无权访问该仓库(请确认仓库名与令牌的 Repository access)。'}
|
||||
if resp.status_code != 200:
|
||||
return {'ok': False, 'message': f'GitHub 返回异常状态码:{resp.status_code}。'}
|
||||
act = ghnet.get(f'{API}/repos/{user}/{repo}/actions/workflows', token=token, timeout=10)
|
||||
if act.status_code == 200:
|
||||
return {'ok': True, 'message': f'连接正常:令牌可访问仓库 {user}/{repo},且具备 Actions 权限。'}
|
||||
return {'ok': False,
|
||||
'message': '可以访问仓库,但令牌缺少 Actions 权限;请编辑令牌,授予 Actions 与 Workflows 的读写权限。'}
|
||||
except Exception as e:
|
||||
return {'ok': False,
|
||||
'message': f'无法连接 GitHub API:{e}(如服务器在国内网络,请在下方配置代理后重试)'}
|
||||
Reference in New Issue
Block a user