feat: 同步构建真实状态并支持在列表停止在途任务

- Gitea 运行未结束时探测作业级结论,修复前置作业失败后可复用工作流父作业/等待标签作业长期挂起导致状态永远构建中

- 打开我的构建/管理仪表盘时主动向构建平台同步在途任务状态;创建超 6 小时仍在途的任务判超时

- 构建中任务列表增加停止按钮(Gitea 需 1.28+ 取消 API,旧版本提示去运行页手动 Cancel),终态任务保留删除

- GitHub/Gitea 后端统一 cancel_run 接口
This commit is contained in:
naeeo
2026-09-29 23:45:25 +08:00
parent 7168c71d34
commit 4fd930aa0b
10 changed files with 199 additions and 13 deletions
+1
View File
@@ -20,6 +20,7 @@ urlpatterns = [
path('', views.generator_view, name='generator'), path('', views.generator_view, name='generator'),
path('my-builds/', web_views.my_builds, name='my_builds'), path('my-builds/', web_views.my_builds, name='my_builds'),
path('my-builds/<int:run_id>/retry/', web_views.retry_build, name='retry_build'), path('my-builds/<int:run_id>/retry/', web_views.retry_build, name='retry_build'),
path('my-builds/<int:run_id>/stop/', web_views.stop_build, name='stop_build'),
path('my-builds/<int:run_id>/delete/', web_views.delete_my_build, name='delete_my_build'), path('my-builds/<int:run_id>/delete/', web_views.delete_my_build, name='delete_my_build'),
path('configs/', web_views.saved_configs, name='saved_configs'), path('configs/', web_views.saved_configs, name='saved_configs'),
path('configs/save/', web_views.save_config, name='save_config'), path('configs/save/', web_views.save_config, name='save_config'),
+4
View File
@@ -61,6 +61,10 @@ class BuildBackend:
"""查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。""" """查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。"""
raise NotImplementedError raise NotImplementedError
def cancel_run(self, run_id, *, timeout=15):
"""请求平台取消一次运行;成功无返回,已结束时抛 BackendError(kind='finished')。"""
raise NotImplementedError
def log_url(self, run_id) -> str: def log_url(self, run_id) -> str:
raise NotImplementedError raise NotImplementedError
+71 -1
View File
@@ -12,7 +12,9 @@ from .. import app_settings, ghnet
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend
# Gitea 未结束状态(blocked=等待审批,waiting=等待 runner) # Gitea 未结束状态(blocked=等待审批,waiting=等待 runner)
_GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending'} _GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending', 'running'}
# Gitea 原生终态(部分版本 run.status 直接返回结论,不走 completed+conclusion)
_GITEA_TERMINAL = {'success', 'failure', 'cancelled', 'skipped', 'timed_out', 'stopped', 'dead'}
# Gitea 结论 -> rdgen 内部状态 # Gitea 结论 -> rdgen 内部状态
_CONCLUSION_MAP = { _CONCLUSION_MAP = {
'success': 'success', 'success': 'success',
@@ -20,7 +22,11 @@ _CONCLUSION_MAP = {
'cancelled': 'cancelled', 'cancelled': 'cancelled',
'skipped': 'skipped', 'skipped': 'skipped',
'timed_out': 'timed_out', 'timed_out': 'timed_out',
'stopped': 'cancelled',
'dead': 'failure',
} }
# 作业出现这些结论时,整个运行最终必然失败(cleanup 等收尾作业除外)
_JOB_BAD_CONCLUSIONS = {'failure', 'cancelled', 'timed_out', 'dead'}
class GiteaBackend(BuildBackend): class GiteaBackend(BuildBackend):
@@ -163,9 +169,73 @@ class GiteaBackend(BuildBackend):
status=_CONCLUSION_MAP.get(conclusion, 'failure'), status=_CONCLUSION_MAP.get(conclusion, 'failure'),
html_url=html_url, html_url=html_url,
) )
# 部分 Gitea 版本直接在 status 返回终态(无 completed 包裹)
if status in _GITEA_TERMINAL:
return RunInfo(
finished=True,
status=_CONCLUSION_MAP.get(status, 'failure'),
html_url=html_url,
)
# Gitea 已知行为:前置作业失败后,可复用工作流的父作业/等待匹配标签的
# 后续作业可能长期停留在 in_progress/waiting,运行永远不进入 completed。
# 这里检查作业级结论:只要非收尾作业已失败,即可判定整个运行失败。
bad = self._detect_failed_job(run_id)
if bad:
return RunInfo(finished=True, status=bad, html_url=html_url)
# blocked(等待审批)等也算进行中 # blocked(等待审批)等也算进行中
return RunInfo(finished=False, status=status or 'in_progress', html_url=html_url) return RunInfo(finished=False, status=status or 'in_progress', html_url=html_url)
def _detect_failed_job(self, run_id, *, timeout=10):
"""检查运行下各作业:有非 cleanup 作业失败/取消/超时时返回对应终态,否则 None。"""
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/jobs')
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()
jobs = data.get('jobs', data if isinstance(data, list) else [])
for job in jobs:
name = (job.get('name') or '').lower()
if name.startswith('cleanup'):
continue
conclusion = (job.get('conclusion') or '').lower()
if conclusion in _JOB_BAD_CONCLUSIONS:
return _CONCLUSION_MAP.get(conclusion, 'failure')
except Exception as e:
print(f'查询 Gitea 作业状态出错:{e}')
return None
# ---- 取消运行 ----
def cancel_run(self, run_id, *, timeout=15):
"""请求平台取消运行。成功无返回;409 表示运行已结束(调用方应改为拉取真实状态)。"""
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}/cancel')
try:
resp = ghnet.post(
url, 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 200 <= resp.status_code < 300:
return
if resp.status_code == 409:
raise BackendError('该构建运行已经结束,无法取消。', kind='finished')
if resp.status_code == 404:
raise BackendError(
'当前 Gitea 版本(1.27 及更早)未开放取消运行的 API,'
'请在 Gitea 的运行页面手动点击 Cancel 停止;升级到 Gitea 1.28+ 后可在此一键停止。',
kind='unsupported',
)
detail = ''
try:
detail = (resp.text or '')[:200]
except Exception:
pass
raise BackendError(f'Gitea 拒绝了取消请求(HTTP {resp.status_code}):{detail}')
# ---- 日志链接 ---- # ---- 日志链接 ----
def log_url(self, run_id): def log_url(self, run_id):
if not run_id: if not run_id:
+15
View File
@@ -75,6 +75,21 @@ class GitHubBackend(BuildBackend):
return RunInfo(finished=False, status=data.get('status') or 'in_progress', return RunInfo(finished=False, status=data.get('status') or 'in_progress',
html_url=data.get('html_url') or self.log_url(run_id)) html_url=data.get('html_url') or self.log_url(run_id))
# ---- 取消运行 ----
def cancel_run(self, run_id, *, timeout=15):
user = app_settings.get_value('GHUSER')
repo = app_settings.get_value('REPONAME')
url = f'{API}/repos/{user}/{repo}/actions/runs/{run_id}/cancel'
try:
resp = ghnet.post(url, token=app_settings.get_value('GHBEARER'), timeout=timeout)
except Exception as e:
raise BackendError(f'无法连接 GitHub 构建服务:{e}', kind='network')
if 200 <= resp.status_code < 300:
return
if resp.status_code == 409:
raise BackendError('该构建运行已经结束,无法取消。', kind='finished')
raise BackendError(f'GitHub 拒绝了取消请求(HTTP {resp.status_code})。')
# ---- 日志链接 ---- # ---- 日志链接 ----
def log_url(self, run_id): def log_url(self, run_id):
if not run_id: if not run_id:
@@ -367,6 +367,8 @@ input[type="file"].form-control::-webkit-file-upload-button {
.btn-secondary:hover { border-color: var(--primary-border); color: var(--primary); } .btn-secondary:hover { border-color: var(--primary-border); color: var(--primary); }
.btn-danger { background: #fff; color: var(--danger); border-color: #f3c2c2; } .btn-danger { background: #fff; color: var(--danger); border-color: #f3c2c2; }
.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); } .btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-warning { background: #fff; color: var(--warning); border-color: #f3d9a6; }
.btn-warning:hover { background: var(--warning); color: #fff; border-color: var(--warning); }
.btn-sm { min-height: 30px; padding: 0 12px; font-size: 12.5px; border-radius: 6px; } .btn-sm { min-height: 30px; padding: 0 12px; font-size: 12.5px; border-radius: 6px; }
.btn-sm .icon { font-size: 13px; } .btn-sm .icon { font-size: 13px; }
.btn-block { width: 100%; } .btn-block { width: 100%; }
+3
View File
@@ -76,6 +76,9 @@
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/> <path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>
</symbol> </symbol>
<symbol id="i-stop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="6" width="12" height="12" rx="2"/>
</symbol>
<symbol id="i-check-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <symbol id="i-check-circle" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="m8.5 12 2.5 2.5 5-5"/> <circle cx="12" cy="12" r="10"/><path d="m8.5 12 2.5 2.5 5-5"/>
</symbol> </symbol>
+9
View File
@@ -51,6 +51,15 @@
</button> </button>
</form> </form>
{% endif %} {% endif %}
{% if not run.is_finished %}
<form method="post" class="inline-form" action="{% url 'stop_build' run.id %}"
onsubmit="return confirm('确定停止构建任务「{{ run.filename|default:run.short_uuid|escapejs }}」吗?停止后可在列表中使用「重试」重新发起。');">
{% csrf_token %}
<button type="submit" class="btn btn-warning btn-sm">
<svg class="icon"><use href="#i-stop"/></svg> 停止
</button>
</form>
{% endif %}
{% if run.is_finished %} {% if run.is_finished %}
<form method="post" class="inline-form" action="{% url 'delete_my_build' run.id %}" <form method="post" class="inline-form" action="{% url 'delete_my_build' run.id %}"
onsubmit="return confirm('确定删除任务「{{ run.filename|default:run.short_uuid|escapejs }}」吗?将同时删除服务器上的安装包文件,且不可恢复。');"> onsubmit="return confirm('确定删除任务「{{ run.filename|default:run.short_uuid|escapejs }}」吗?将同时删除服务器上的安装包文件,且不可恢复。');">
+35 -8
View File
@@ -3,6 +3,7 @@ import json
import os import os
import re import re
import uuid import uuid
from datetime import timedelta
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@@ -22,12 +23,45 @@ from django.http import (
Http404, Http404,
) )
from django.shortcuts import render from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from . import app_settings, build_backends, versions as rdeskVersions from . import app_settings, build_backends, versions as rdeskVersions
from .forms import GenerateForm from .forms import GenerateForm
from .models import GithubRun, STATUS_LABELS, STATUS_BADGE from .models import GithubRun, STATUS_LABELS, STATUS_BADGE
# 终态状态集合
TERMINAL_STATUSES = ('success', 'failure', 'cancelled', 'timed_out', 'skipped')
# 正常构建约 30~45 分钟;超过该时长平台仍显示进行中的,视为 runner 离线/作业僵死
STALE_BUILD_HOURS = 6
def refresh_active_run(gh_run):
"""向构建平台同步一次任务状态,状态落库后返回 True;网络异常或仍在进行则 False。
同时兜底两种平台侧的僵死:
- Gitea 前置作业失败后可复用工作流父作业/无匹配标签的作业长期挂起;
- 自建 runner 离线导致运行一直 in_progress(超过 STALE_BUILD_HOURS 判超时)。
"""
if gh_run.is_finished() or not gh_run.github_run_id:
return False
backend = build_backends.get_backend(gh_run.backend or 'github')
try:
info = backend.get_run(gh_run.github_run_id)
except Exception as e:
print(f"查询构建状态出错:{e}")
return False
if info is None:
return False
new_status = info.status if info.finished else None
if new_status is None and timezone.now() - gh_run.created_at > timedelta(hours=STALE_BUILD_HOURS):
new_status = 'timed_out'
if new_status and new_status != gh_run.status:
gh_run.status = new_status
gh_run.save(update_fields=['status', 'updated_at'])
return True
return False
def _webhook_denied(request): def _webhook_denied(request):
"""配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。""" """配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。"""
@@ -390,14 +424,7 @@ def _get_run_status(uuid_val):
backend = build_backends.get_backend(gh_run.backend or 'github') backend = build_backends.get_backend(gh_run.backend or 'github')
github_log_url = backend.log_url(gh_run.github_run_id) if gh_run.github_run_id else '' github_log_url = backend.log_url(gh_run.github_run_id) if gh_run.github_run_id else ''
if gh_run.status not in ['success', 'failure', 'cancelled', 'timed_out', 'skipped'] and gh_run.github_run_id: refresh_active_run(gh_run)
try:
info = backend.get_run(gh_run.github_run_id)
if info is not None and info.finished:
gh_run.status = info.status
gh_run.save()
except Exception as e:
print(f"查询构建状态出错:{e}")
return { return {
"found": True, "found": True,
+56 -3
View File
@@ -31,7 +31,12 @@ def staff_required(view_func):
@login_required @login_required
def my_builds(request): def my_builds(request):
builds = GithubRun.objects.filter(created_by=request.user) builds = list(GithubRun.objects.filter(created_by=request.user))
# 列表打开时向构建平台同步在途任务的真实状态(Gitea 可能不主动回调终态)
from .views import refresh_active_run
for run in builds:
if not run.is_finished():
refresh_active_run(run)
return render(request, 'my_builds.html', {'builds': builds}) return render(request, 'my_builds.html', {'builds': builds})
@@ -48,7 +53,7 @@ def delete_my_build(request, run_id):
"""用户删除自己的已结束任务;进行中的任务不允许删除(避免与 GitHub 回调竞态)。""" """用户删除自己的已结束任务;进行中的任务不允许删除(避免与 GitHub 回调竞态)。"""
run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user) run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user)
if not run.is_finished(): if not run.is_finished():
messages.error(request, "该任务仍在进行中,请等待结束(或先在 GitHub Actions 中取消)后再删除。") messages.error(request, "该任务仍在进行中,请先点「停止」取消构建(或等待结束)后再删除。")
else: else:
_remove_run_artifacts(run) _remove_run_artifacts(run)
run.delete() run.delete()
@@ -56,6 +61,49 @@ def delete_my_build(request, run_id):
return redirect('my_builds') return redirect('my_builds')
@login_required
@require_POST
def stop_build(request, run_id):
"""停止自己的在途构建:调用构建平台取消运行,并把任务标记为已取消。"""
run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user)
if run.is_finished():
messages.info(request, "该任务已经结束,无需停止。")
return redirect('my_builds')
if not run.github_run_id:
messages.error(request, "该任务尚未获得构建平台的运行编号,暂时无法停止,请稍后重试。")
return redirect('my_builds')
from . import build_backends
from .build_backends.base import BackendError
backend = build_backends.get_backend(run.backend or 'github')
try:
backend.cancel_run(run.github_run_id)
except BackendError as e:
if e.kind == 'finished':
# 平台侧已结束:立即拉一次真实状态,避免本地仍显示构建中
from .views import refresh_active_run
refresh_active_run(run)
if run.is_finished():
messages.info(request, f"构建平台上该任务实际已结束({run.status_label()}),列表状态已同步。")
else:
messages.error(request, str(e))
elif e.kind == 'unsupported':
# 平台版本不支持远程取消(如 Gitea ≤ 1.27):给出运行页,本地状态保持构建中
log_link = backend.log_url(run.github_run_id)
messages.error(
request,
f"无法远程停止:{e.message} 运行页面:{log_link}",
)
else:
messages.error(request, f"停止失败:{e.message}")
return redirect('my_builds')
run.status = 'cancelled'
run.save(update_fields=['status', 'updated_at'])
messages.success(request, f"已请求{run.backend_label}停止构建任务「{run.filename or run.short_uuid()}」。")
return redirect('my_builds')
@login_required @login_required
@require_POST @require_POST
def retry_build(request, run_id): def retry_build(request, run_id):
@@ -118,7 +166,12 @@ def delete_token(request, key):
@staff_required @staff_required
def dashboard(request): def dashboard(request):
builds = GithubRun.objects.select_related('created_by').all()[:100] builds = list(GithubRun.objects.select_related('created_by').all()[:100])
# 打开仪表盘时同步在途任务的真实状态
from .views import refresh_active_run
for run in builds:
if not run.is_finished():
refresh_active_run(run)
stats = { stats = {
'total': GithubRun.objects.count(), 'total': GithubRun.objects.count(),
'in_progress': GithubRun.objects.exclude( 'in_progress': GithubRun.objects.exclude(
+3 -1
View File
@@ -104,7 +104,9 @@ action)。
### 1. Gitea 服务器与仓库 ### 1. Gitea 服务器与仓库
1. 部署 Gitea **1.27 或更高版本**(最低 1.23;1.27 随附 act_runner 2.0,对 1. 部署 Gitea **1.27 或更高版本**(最低 1.23;1.27 随附 act_runner 2.0,对
artifact/cache 与第三方 action 的兼容性最好)。 artifact/cache 与第三方 action 的兼容性最好)。注意:列表页的一键「停止」依赖
Gitea 1.28+ 的取消运行 API;1.27 及更早版本点击停止会给出运行页链接,需到
Gitea 界面手动 Cancel。
2. 把本仓库整体推送到 Gitea(保留 `.gitea/` 目录与默认分支名,后台填写的 2. 把本仓库整体推送到 Gitea(保留 `.gitea/` 目录与默认分支名,后台填写的
GITEA_BRANCH 必须与实际分支一致)。 GITEA_BRANCH 必须与实际分支一致)。
3. 进入仓库 **Settings**,勾选 **Enable Repository Actions**。 3. 进入仓库 **Settings**,勾选 **Enable Repository Actions**。