ci: harden quality gates and clean test warnings

This commit is contained in:
胡绍康 committed 2026-03-10 22:04:18 +08:00
1 parent 1d4d848702
commit bd9bafeb83
10 files changed
+359 -63

No files matched your search

+50 -9
View File
@@ -5,11 +5,13 @@ on:
branches: [ "main", "master" ]
paths:
- "**.py"
- "pyproject.toml"
- "requirements.txt"
- ".github/workflows/code-quality.yml"
pull_request:
paths:
- "**.py"
- "pyproject.toml"
- "requirements.txt"
- ".github/workflows/code-quality.yml"
workflow_dispatch:
@@ -30,19 +32,38 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install flake8 black isort mypy
- name: Run Black (Code Formatter Check)
run: black --check --diff .
run: black --check --diff outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py
- name: Run isort (Import Sorting Check)
run: isort --check-only --diff --profile black .
run: isort --check-only --diff --profile black outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py
- name: Run Flake8 (Linting)
run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
run: flake8 outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py --count --select=E9,F63,F7,F82 --show-source --statistics
- name: Run Flake8 (Complexity Check)
run: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
run: >
flake8
outlook_web/repositories/settings.py
outlook_web/services/external_api.py
outlook_web/controllers/system.py
web_outlook_app.py
--count
--max-complexity=10
--max-line-length=127
--statistics
- name: Run mypy (Core Modules)
run: >
mypy
--config-file pyproject.toml
outlook_web/repositories/settings.py
outlook_web/services/external_api.py
outlook_web/controllers/system.py
web_outlook_app.py
security:
name: Security Scan
@@ -63,13 +84,33 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install bandit[toml]
- name: Run Bandit Security Scan
run: bandit -r . -f json -o bandit-report.json || true
- name: Display Bandit Results
shell: bash
run: |
if [ -f bandit-report.json ]; then
cat bandit-report.json
set +e
bandit -r outlook_web web_outlook_app.py outlook_mail_reader.py start.py -f sarif -o bandit-report.sarif
report_status=$?
bandit -r outlook_web web_outlook_app.py outlook_mail_reader.py start.py -lll
gate_status=$?
echo "bandit_report_exit_code=$report_status" >> "$GITHUB_ENV"
echo "bandit_gate_exit_code=$gate_status" >> "$GITHUB_ENV"
exit 0
- name: Upload Bandit SARIF
if: always() && hashFiles('bandit-report.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: bandit-report.sarif
- name: Fail on Bandit Findings
if: always()
shell: bash
run: |
status="${bandit_gate_exit_code:-1}"
if [ "$status" -ne 0 ]; then
echo "Bandit found high-severity issues, exit code=$status"
exit "$status"
fi
+38
View File
@@ -7,9 +7,11 @@ on:
- master
paths:
- '**.py'
- 'pyproject.toml'
- 'requirements.txt'
- 'Dockerfile'
- 'templates/**'
- 'static/**'
- '.github/workflows/docker-build-push.yml'
workflow_dispatch:
@@ -18,7 +20,43 @@ env:
DOCKERHUB_IMAGE: guangshanshui/outlook-email-plus
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install flake8 black isort mypy bandit[toml]
- name: Run formatter checks
run: |
black --check outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py
isort --check-only --profile black outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py
- name: Run static analysis gate
run: |
flake8 outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 outlook_web/repositories/settings.py outlook_web/services/external_api.py outlook_web/controllers/system.py web_outlook_app.py --count --max-complexity=10 --max-line-length=127 --statistics
mypy --config-file pyproject.toml outlook_web/repositories/settings.py outlook_web/services/external_api.py outlook_web/controllers/system.py web_outlook_app.py
- name: Run security gate
run: bandit -r outlook_web web_outlook_app.py outlook_mail_reader.py start.py -lll
- name: Run publish gate tests
run: python -m unittest discover -s tests -v
build-and-push:
needs: quality-gate
runs-on: ubuntu-latest
permissions:
contents: read
+19 -4
View File
@@ -5,14 +5,20 @@ on:
branches: [ "main", "master" ]
paths:
- "**.py"
- "pyproject.toml"
- "requirements.txt"
- "tests/**"
- "templates/**"
- "static/**"
- ".github/workflows/python-tests.yml"
pull_request:
paths:
- "**.py"
- "pyproject.toml"
- "requirements.txt"
- "tests/**"
- "templates/**"
- "static/**"
- ".github/workflows/python-tests.yml"
workflow_dispatch:
@@ -37,12 +43,21 @@ jobs:
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install coverage
- name: Syntax check
run: |
python -m py_compile web_outlook_app.py
python -m py_compile outlook_mail_reader.py
python -m compileall -q outlook_web web_outlook_app.py outlook_mail_reader.py start.py tests
- name: Run unit tests
run: python -m unittest discover -s tests -v
- name: Run unit tests with coverage
run: |
coverage run -m unittest discover -s tests -v
coverage xml
coverage report
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-python-${{ matrix.python-version }}
path: coverage.xml
+6
View File
@@ -31,6 +31,12 @@ jobs:
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
python -m pip install coverage
- name: Run tests with coverage
run: |
coverage run -m unittest discover -s tests -v
coverage xml
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@v5
@@ -0,0 +1,184 @@
# LOG-00009|CI/CD 门禁加固 — 执行日志
## 目标
- 解决当前 CI/CD “有流程但门禁不硬”的问题
- 降低坏代码进入镜像仓库与主分支的概率
- 补齐类型检查、覆盖率与安全扫描的实际执行链路
## 问题分析
### 1) Docker 发布未绑定质量门禁
- 现状:
- Docker workflow 只要推送到 `main/master` 就会执行构建并推送镜像
- 与 Python Tests / Code Quality workflow 没有依赖关系
- 风险:
- 单元测试失败时,镜像仍可能被推送到 GHCR / Docker Hub
### 2) 安全扫描不阻断
- 现状:
- Bandit 使用 `|| true` 吞掉退出码
- 只输出 JSON,不上传 SARIF,也不作为失败门禁
- 风险:
- 安全问题只会显示在日志中,不会阻止合并或发布
### 3) `mypy` 名义存在但未执行
- 现状:
- Workflow 安装了 `mypy`
- 但没有任何实际 `mypy` 步骤
- 风险:
- 类型回归无法在 CI 中被发现
### 4) Sonar 无覆盖率输入
- 现状:
- Sonar workflow 只安装依赖并扫描
- 未生成 `coverage.xml`
- `sonar-project.properties` 未声明 Python coverage report
- 风险:
- Sonar 只能做静态分析,无法给出真实测试覆盖信号
### 5) 路径过滤过窄
- 现状:
- `static/**`、`templates/**`、`pyproject.toml` 等变更在部分 workflow 下不会触发
- 风险:
- 实际影响镜像内容或检查规则的改动,可能绕过对应流水线
## 本轮整改(2026-03-10)
### A. Python Tests
- 补充触发路径:
- `pyproject.toml`
- `templates/**`
- `static/**`
- 将语法检查从仅入口文件升级为:
- `compileall` 检查 `outlook_web/`、入口文件与 `tests/`
- 新增 coverage 执行:
- `coverage run -m unittest discover -s tests -v`
- `coverage xml`
- `coverage report`
- 上传 `coverage.xml` 作为 artifact,便于后续排查
### B. Code Quality
- 质量检查范围收敛到真实项目代码,避免把 `.venv/`、示例目录等一起扫进去
- `black` / `isort` / `flake8` 统一针对:
- `outlook_web`
- `tests`
- `web_outlook_app.py`
- `outlook_mail_reader.py`
- `start.py`
- 复杂度阻断首期仅覆盖:
- `repositories/settings.py`
- `services/external_api.py`
- `controllers/system.py`
- `web_outlook_app.py`
- 增加 `mypy` 实际执行,首期先覆盖核心模块:
- `repositories/settings.py`
- `services/external_api.py`
- `controllers/system.py`
- `web_outlook_app.py`
- 在 `pyproject.toml` 增加基础 mypy 配置:
- `ignore_missing_imports = true`
- `warn_unused_ignores = true`
- `follow_imports = "skip"`
- 说明:
- 当前仓库历史类型债较重,首期采用“核心模块 + skip imports”方式渐进接入
- 暂不把 `graph.py`、`emails.py` 等类型债较重模块纳入阻断集合
### C. Security Scan
- Bandit 改为扫描主代码入口,而不是整个仓库
- 输出格式改为 `sarif`
- 新增 SARIF 上传到 GitHub Security
- 不再直接完全放行;改为:
1. 先运行 Bandit 并保存退出码
2. 始终上传 SARIF
3. 额外执行高危门禁扫描
4. 仅在高严重度问题出现时阻断 workflow
- 说明:
- 当前仓库已有较多历史低/中危告警,首期先将高危问题设为阻断条件
### D. Docker Build Push
- 增加 `quality-gate` job
- `build-and-push` 必须 `needs: quality-gate`
- 发布前现在保证:
- `black` / `isort` 格式门禁通过
- `flake8` 语法与首批复杂度门禁通过
- `mypy` 核心模块类型检查通过
- `bandit -lll` 高危安全门禁通过
- `python -m unittest discover -s tests -v` 通过
- 同时补充触发路径:
- `pyproject.toml`
- `static/**`
### E. Sonar
- Sonar workflow 在扫描前先执行:
- `coverage run -m unittest discover -s tests -v`
- `coverage xml`
- `sonar-project.properties` 增加:
- `sonar.python.coverage.reportPaths=coverage.xml`
### F. 测试日志清理
- 修复以下测试文件中多个响应对象未及时关闭的问题:
- `tests/test_ui_redesign_bugs.py`
- `tests/test_ui_settings_external_api_key.py`
- 目标:
- 清除 CI 中 `ResourceWarning: unclosed file` 噪音
- 让测试日志更干净,便于定位真实失败原因
## 本轮尚未彻底解决的点
### 1) Docker 发布仍存在“检查重复执行”
- 当前做法:
- 通过在 Docker workflow 内重复执行核心质量检查与测试来保证发布门禁
- 原因:
- GitHub Actions 原生 `workflow_run` 不适合同时精确依赖多个独立 workflow 的成功结果
- 后续优化方向:
- 进一步抽象为 reusable workflow 或单一主流水线
### 2) `mypy` 仍是“核心模块优先”,不是全仓强校验
- 当前策略是渐进式引入,避免一次性把类型债全部打爆
- 后续应逐步扩大到更多模块
### 3) Bandit 当前仅对高危问题阻断
- 低/中危问题会进入 SARIF 与日志,但不会在首期直接打断 CI
- 后续应结合告警治理逐步收紧到中危
## 建议验证步骤
```bash
python -m black --check outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py
python -m isort --check-only --profile black outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py
python -m flake8 outlook_web tests web_outlook_app.py outlook_mail_reader.py start.py --count --select=E9,F63,F7,F82 --show-source --statistics
python -m flake8 outlook_web/repositories/settings.py outlook_web/services/external_api.py outlook_web/controllers/system.py web_outlook_app.py --count --max-complexity=10 --max-line-length=127 --statistics
python -m mypy --config-file pyproject.toml outlook_web/repositories/settings.py outlook_web/services/external_api.py outlook_web/controllers/system.py web_outlook_app.py
python -m bandit -r outlook_web web_outlook_app.py outlook_mail_reader.py start.py -lll
python -m unittest discover -s tests -v
```
## 结论
本轮整改后,CI/CD 从“有工作流”提升到了“关键路径开始具备真实门禁”:
- 发布前至少会跑测试
- 安全扫描不再默默放行
- 类型检查开始进入 CI
- 覆盖率开始进入流水线
仍然建议后续继续推进:
1. 合并为单一主流水线或 reusable workflow
2. 扩大 mypy 覆盖范围
3. 完整接入 Sonar coverage report
+8
View File
@@ -4,3 +4,11 @@ line-length = 127
[tool.isort]
profile = "black"
line_length = 127
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
warn_unused_ignores = true
show_error_codes = true
pretty = true
follow_imports = "skip"
+1
View File
@@ -19,3 +19,4 @@ sonar.exclusions=.git/**,.venv/**,venv/**,__pycache__/**,**/__pycache__/**,.pyte
# Exclude non-production and generated files from duplication and coverage calculations.
sonar.cpd.exclusions=docs/**,img/**,dist/**,exeample/**,tests/**,tests/layout-system/coverage/**
sonar.coverage.exclusions=tests/**,templates/**,static/**,docs/**,img/**,dist/**,data/**,exeample/**,web_outlook_app.py,start.py,fix_format.py,outlook_mail_reader.py
sonar.python.coverage.reportPaths=coverage.xml
+2 -4
View File
@@ -119,14 +119,12 @@ class ExternalApiBaseTest(unittest.TestCase):
from outlook_web.db import get_db
db = get_db()
rows = db.execute(
"""
rows = db.execute("""
SELECT action, resource_id, details
FROM audit_logs
WHERE resource_type = 'external_api'
ORDER BY id ASC
"""
).fetchall()
""").fetchall()
return [dict(row) for row in rows]
+39 -40
View File
@@ -34,13 +34,28 @@ class TestUIRedesignBugFixes(unittest.TestCase):
self._login(client)
return client
def _get_text(self, client, path):
"""Read response text and close the underlying file handle promptly."""
resp = client.get(path)
try:
return resp.status_code, resp.data.decode("utf-8")
finally:
resp.close()
def _get_json(self, client, path):
"""Read JSON payload and close the response object."""
resp = client.get(path)
try:
return resp.status_code, resp.get_json()
finally:
resp.close()
# ==================== BUG-002: 账号栏显示 ====================
def test_bug002_current_account_bar_exists(self):
"""BUG-002: index.html 中存在 currentAccountBar 元素"""
client = self._get_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
_, html = self._get_text(client, "/")
self.assertIn('id="currentAccountBar"', html)
self.assertIn('id="currentAccountEmail"', html)
self.assertIn('id="currentAccount"', html)
@@ -48,8 +63,7 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_bug002_accounts_js_shows_account_bar(self):
"""BUG-002: accounts.js 中 selectAccount 应操作 currentAccountBar 而非 currentAccount"""
client = self._get_client()
resp = client.get("/static/js/features/accounts.js")
js = resp.data.decode("utf-8")
_, js = self._get_text(client, "/static/js/features/accounts.js")
self.assertIn("currentAccountBar", js, "selectAccount() 应操作 #currentAccountBar 元素")
# ==================== BUG-003: 设置页面 ====================
@@ -57,16 +71,14 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_bug003_settings_page_has_content(self):
"""BUG-003: 设置页面应有实质性内容,而非仅占位文字"""
client = self._get_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
_, html = self._get_text(client, "/")
settings_section = re.search(r'id="page-settings".*?(?=id="page-|$)', html, re.DOTALL)
self.assertIsNotNone(settings_section, "找不到 page-settings 区域")
def test_bug003_navigate_triggers_settings_load(self):
"""BUG-003: navigate('settings') 应触发设置加载"""
client = self._get_client()
resp = client.get("/static/js/main.js")
js = resp.data.decode("utf-8")
_, js = self._get_text(client, "/static/js/main.js")
self.assertIn("settings", js)
# ==================== BUG-004: 弹窗居中 ====================
@@ -74,23 +86,20 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_bug004_modal_css_centering(self):
"""BUG-004: CSS 中 .modal 应有 flex 居中属性"""
client = self._get_client()
resp = client.get("/static/css/main.css")
css = resp.data.decode("utf-8")
_, css = self._get_text(client, "/static/css/main.css")
self.assertIn("align-items: center", css)
self.assertIn("justify-content: center", css)
def test_bug004_modal_content_max_width(self):
"""BUG-004: .modal-content 应有 max-width 限制"""
client = self._get_client()
resp = client.get("/static/css/main.css")
css = resp.data.decode("utf-8")
_, css = self._get_text(client, "/static/css/main.css")
self.assertIn("max-width: 560px", css)
def test_bug004_all_modals_use_modal_class(self):
"""BUG-004: modals.html 中所有弹窗应使用 class='modal'"""
client = self._get_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
_, html = self._get_text(client, "/")
modal_ids = re.findall(r'id="(\w+Modal)"', html)
for modal_id in modal_ids:
if modal_id == "fullscreenEmailModal":
@@ -110,14 +119,13 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_bug005_verification_extract_api_exists(self):
"""BUG-005: 验证码提取 API 端点存在"""
client = self._get_client()
resp = client.get("/api/emails/nonexistent@test.com/extract-verification")
self.assertIn(resp.status_code, [200, 404], "验证码提取 API 应该返回 200 或 404")
status_code, _ = self._get_json(client, "/api/emails/nonexistent@test.com/extract-verification")
self.assertIn(status_code, [200, 404], "验证码提取 API 应该返回 200 或 404")
def test_bug005_copy_verification_function_exists(self):
"""BUG-005: JS 中存在 copyVerificationInfo 函数"""
client = self._get_client()
resp = client.get("/static/js/features/groups.js")
js = resp.data.decode("utf-8")
_, js = self._get_text(client, "/static/js/features/groups.js")
self.assertIn("function copyVerificationInfo", js)
# ==================== BUG-006: 卡片颜色 ====================
@@ -125,8 +133,7 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_bug006_card_color_array_exists(self):
"""BUG-006: 临时邮箱渲染应有多种颜色"""
client = self._get_client()
resp = client.get("/static/js/features/temp_emails.js")
js = resp.data.decode("utf-8")
_, js = self._get_text(client, "/static/js/features/temp_emails.js")
self.assertIn("renderTempEmailList", js)
# ==================== BUG-010: 仪表盘 ====================
@@ -134,8 +141,7 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_bug010_dashboard_elements_exist(self):
"""BUG-010: 仪表盘统计元素存在"""
client = self._get_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
_, html = self._get_text(client, "/")
for el_id in [
"statTotalAccounts",
"statValidTokens",
@@ -147,23 +153,20 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_bug010_dashboard_group_list_exists(self):
"""BUG-010: 仪表盘分组概览列表存在"""
client = self._get_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
_, html = self._get_text(client, "/")
self.assertIn('id="dashboardGroupList"', html)
def test_bug010_load_dashboard_function(self):
"""BUG-010: loadDashboard 函数存在且调用正确 API"""
client = self._get_client()
resp = client.get("/static/js/main.js")
js = resp.data.decode("utf-8")
_, js = self._get_text(client, "/static/js/main.js")
self.assertIn("function loadDashboard", js)
self.assertIn("/api/groups", js)
def test_bug010_groups_api_returns_data(self):
"""BUG-010: 分组 API 返回有效数据"""
client = self._get_client()
resp = client.get("/api/groups")
data = resp.get_json()
_, data = self._get_json(client, "/api/groups")
self.assertTrue(data.get("success"), "分组 API 应返回 success=true")
self.assertIn("groups", data, "分组 API 应包含 groups 字段")
@@ -172,8 +175,7 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_all_pages_exist_in_html(self):
"""所有页面容器存在于 index.html"""
client = self._get_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
_, html = self._get_text(client, "/")
pages = [
"page-dashboard",
"page-mailbox",
@@ -188,8 +190,7 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_sidebar_navigation_items(self):
"""侧边栏导航项完整"""
client = self._get_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
_, html = self._get_text(client, "/")
nav_pages = [
"dashboard",
"mailbox",
@@ -204,8 +205,7 @@ class TestUIRedesignBugFixes(unittest.TestCase):
def test_mailbox_three_column_structure(self):
"""邮箱管理三栏布局结构完整"""
client = self._get_client()
resp = client.get("/")
html = resp.data.decode("utf-8")
_, html = self._get_text(client, "/")
self.assertIn('class="mailbox-layout"', html)
self.assertIn('class="groups-column"', html)
self.assertIn('class="accounts-column"', html)
@@ -222,16 +222,15 @@ class TestUIRedesignBugFixes(unittest.TestCase):
"/static/js/features/temp_emails.js",
]
for js_path in js_files:
resp = client.get(js_path)
self.assertEqual(resp.status_code, 200, f"{js_path} 应返回 200")
self.assertGreater(len(resp.data), 100, f"{js_path} 内容不应为空")
status_code, js = self._get_text(client, js_path)
self.assertEqual(status_code, 200, f"{js_path} 应返回 200")
self.assertGreater(len(js), 100, f"{js_path} 内容不应为空")
def test_css_loads_successfully(self):
"""CSS 文件可正常加载"""
client = self._get_client()
resp = client.get("/static/css/main.css")
self.assertEqual(resp.status_code, 200)
css = resp.data.decode("utf-8")
status_code, css = self._get_text(client, "/static/css/main.css")
self.assertEqual(status_code, 200)
self.assertIn("--clr-primary", css)
self.assertIn("--bg-sidebar", css)
self.assertIn("--bg-hover", css)
+12 -6
View File
@@ -13,23 +13,29 @@ class ExternalApiKeySettingsUITests(unittest.TestCase):
resp = client.post("/login", json={"password": password})
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.get_json().get("success"))
resp.close()
def _get_text(self, client, path):
resp = client.get(path)
try:
return resp.status_code, resp.data.decode("utf-8")
finally:
resp.close()
def test_settings_page_has_external_api_key_input(self):
client = self.app.test_client()
self._login(client)
resp = client.get("/")
self.assertEqual(resp.status_code, 200)
html = resp.data.decode("utf-8")
status_code, html = self._get_text(client, "/")
self.assertEqual(status_code, 200)
self.assertIn('id="settingsExternalApiKey"', html)
def test_main_js_loads_masked_external_api_key_fields(self):
client = self.app.test_client()
self._login(client)
resp = client.get("/static/js/main.js")
self.assertEqual(resp.status_code, 200)
js = resp.data.decode("utf-8")
status_code, js = self._get_text(client, "/static/js/main.js")
self.assertEqual(status_code, 200)
self.assertIn("external_api_key_masked", js)
self.assertIn("settingsExternalApiKey", js)
self.assertIn("dataset.maskedValue", js)