From c57d291f65bb7a0010ac9ab1398454c9415b8a5c Mon Sep 17 00:00:00 2001 From: NathanAlex7 <18898599977@163.com> Date: Thu, 10 Sep 2026 11:35:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=95=99=E7=97=95=E4=B8=8EDocker=E4=BA=91=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/verify.yml | 39 ++++++ .gitignore | 1 + AGENTS.md | 4 +- DEVELOPMENT_WORKFLOW.md | 86 ++++++++++++ backend/test_openweather.py | 3 +- deploy/aliyun.env.example | 16 +++ deploy/docker-compose.aliyun.yml | 32 +++++ package.json | 4 +- scripts/deploy-aliyun.sh | 18 +++ scripts/publish-acr.ps1 | 26 ++++ scripts/publish-acr.sh | 20 +++ scripts/verify.py | 233 +++++++++++++++++++++++++++++++ 12 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/verify.yml create mode 100644 DEVELOPMENT_WORKFLOW.md create mode 100644 deploy/aliyun.env.example create mode 100644 deploy/docker-compose.aliyun.yml create mode 100644 scripts/deploy-aliyun.sh create mode 100644 scripts/publish-acr.ps1 create mode 100644 scripts/publish-acr.sh create mode 100644 scripts/verify.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..dab49ce --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,39 @@ +name: verify + +on: + push: + pull_request: + +jobs: + test-and-build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: backend/requirements.txt + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: python -m pip install -r backend/requirements.txt + - run: npm ci + - name: Generate test plan, verify and retain evidence + run: python scripts/verify.py + env: + DEEPSEEK_API_KEY: test-only-placeholder + XINGBANG_LOCAL_MODEL_URL: "" + XINGBANG_REQUIRE_AUTH: "0" + - name: Upload test evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-evidence-${{ github.sha }} + path: test-evidence/ + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 981513b..5c5500c 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ backend/verify_offline.py _backup*/ *.pid *.tsbuildinfo +test-evidence/ backend/raw_logs/ backend/backend/ backend/xingbang_data/ diff --git a/AGENTS.md b/AGENTS.md index b0b8fff..e3f6d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ 2. `git log --oneline -6` → 接到最近提交那条线上(当前主线:兴邦四 Agent v2 / xingbang 工作台整合)。 3. 当前处于**_重构进行中**:`src/app/` 下多个独立页正在向 **xingbang-workbench / review-workbench / report-workbench / collateral-workbench / data-access / trajectory** 等合并。**改代码前先确认该页还在不在、已合并到哪里,别对旧页做无效修改。** +4. 每次行为修改必须新增或更新对应测试,并运行 `python scripts/verify.py`。测试计划和完整日志保留在 `test-evidence/`;失败时不允许发布镜像。 + ⚠️ 关键惯例:本项目也是「多轮迭代、每步有 `_backup_pre_*` 备份目录」。**改哪里先确认现状,不破坏已跑通逻辑;大改动前默认应该先在你的分支/工作区留能回滚的点。** ## 项目是什么 @@ -66,4 +68,4 @@ python test_review_assist.py 2>/dev/null || echo "无该测试,视改动定" ## 说明 -- 项目有完整 `ARCHITECTURE.md` / `DEV_*` 文档,遇到"这是什么/怎么拼"先去读,别臆造。 \ No newline at end of file +- 项目有完整 `ARCHITECTURE.md` / `DEV_*` 文档,遇到"这是什么/怎么拼"先去读,别臆造。 diff --git a/DEVELOPMENT_WORKFLOW.md b/DEVELOPMENT_WORKFLOW.md new file mode 100644 index 0000000..8954cc6 --- /dev/null +++ b/DEVELOPMENT_WORKFLOW.md @@ -0,0 +1,86 @@ +# 开发、验证与 Docker 部署流程 + +这套流程把“修改—测试—证据—本地 Docker—阿里云”串成同一条可回滚链路。任何密钥只放在被 Git 忽略的环境文件中。 + +## 1. 本地修改 + +```powershell +git switch -c codex/<功能名> +git status --short +``` + +修改前先阅读 `AGENTS.md`,一次只处理一个需求。每个行为修改必须同时新增或更新对应的 `backend/test_*.py`;纯前端修改至少必须通过生产构建。 + +## 2. 生成测试计划、执行并保留证据 + +```powershell +python scripts/verify.py +``` + +验证器会根据 Git 变更文件生成测试计划,并执行: + +1. Git diff 格式检查; +2. 全部后端 `unittest`; +3. Next.js 生产构建(包含 TypeScript 检查); +4. 本地和阿里云 Compose 配置校验。 + +每次结果保存在 `test-evidence/<时间>-/`:`REPORT.md` 供人阅读,`summary.json` 供系统处理,每条命令有独立日志。该目录不提交 Git;GitHub Actions 会保留 30 天构建证据。 + +通过后创建可回滚提交: + +```powershell +git add <本次文件> +git commit -m "feat: 说明修改与结果" +``` + +## 3. 本地 Docker 部署与真实容器验证 + +先启动 Docker Desktop,并确保本机 8002 端口没有原生后端占用。 + +```powershell +$env:DEEPSEEK_API_KEY="" +python scripts/verify.py --docker --keep-docker +``` + +验证通过后: + +- 前端:`http://localhost:3200` +- 后端健康检查:`http://localhost:8002/api/xingbang/health` +- 停止:`docker compose down` + +去掉 `--keep-docker` 时,验证结束会自动停止容器,但保留证据。 + +## 4. 推送到阿里云 ACR + +在 ACR 控制台创建实例、命名空间和镜像仓库,复制控制台给出的完整 Registry 域名。新版个人版通常为 `crpi-*.cn-.personal.cr.aliyuncs.com`,不要在脚本中硬编码密码。 + +```powershell +docker login --username= +.\scripts\publish-acr.ps1 -Registry -Namespace <命名空间> +``` + +脚本只有在全部验证通过后才会构建和推送镜像,默认使用 Git 短 SHA 作为不可变版本标签。Linux/macOS 使用 `scripts/publish-acr.sh`。 + +## 5. 在阿里云 ECS 上运行 + +1. 在 ECS 安装 Docker Engine 和 Compose V2,并设置开机启动。 +2. 安全组只开放需要的端口:前端默认 80;当前前端架构还需 8002 API 端口。正式 HTTPS 环境建议在两者之前增加 Nginx/SLB 同源反向代理。 +3. 把 `deploy/` 和 `scripts/deploy-aliyun.sh` 复制到 ECS。 +4. 在 ECS 上登录 ACR,并创建私密配置: + +```bash +cp deploy/aliyun.env.example deploy/aliyun.env +chmod 600 deploy/aliyun.env +# 编辑 APP_IMAGE、DEEPSEEK_API_KEY 和可选 ERP 连接 +docker login --username= +bash scripts/deploy-aliyun.sh +``` + +发布新版本时把 `APP_IMAGE` 改为新 SHA 标签,再执行部署脚本。回滚时把 `APP_IMAGE` 改回上一个 SHA 标签并重新执行,数据卷不会被删除。 + +## 发布门禁 + +- 没有 `REPORT.md` 或验证失败:不发布。 +- 不使用 `latest`;镜像必须用 Git SHA 标签。 +- 不把 `.env`、ACR 密码、DeepSeek Key 或 ERP 密码提交 Git。 +- 发布前必须确认 CORS、身份认证、TLS 和数据备份策略符合目标环境。 diff --git a/backend/test_openweather.py b/backend/test_openweather.py index 158bd12..d192aaa 100644 --- a/backend/test_openweather.py +++ b/backend/test_openweather.py @@ -88,8 +88,7 @@ if __name__ == "__main__": else: print("❌ 测试失败,请检查配置") - print(" -💡 使用说明:") + print("\n💡 使用说明:") print("1. 在 https://openweathermap.org/api 注册并获取 API 密钥") print("2. 在 .env 文件中设置 OPENWEATHER_API_KEY=your_api_key") print("3. 运行:python test_openweather.py [城市名]") diff --git a/deploy/aliyun.env.example b/deploy/aliyun.env.example new file mode 100644 index 0000000..1a58615 --- /dev/null +++ b/deploy/aliyun.env.example @@ -0,0 +1,16 @@ +# Full image name copied from Alibaba Cloud ACR, including an immutable tag. +APP_IMAGE=crpi-example.cn-hangzhou.personal.cr.aliyuncs.com/example/mini-openclaw:git-sha + +FRONTEND_PORT=80 +BACKEND_PORT=8002 + +DEEPSEEK_API_KEY=replace-me +DEEPSEEK_MODEL=deepseek-chat +DEEPSEEK_BASE_URL=https://api.deepseek.com + +ERP_DB_HOST= +ERP_DB_PORT=5432 +ERP_DB_NAME= +ERP_DB_USER= +ERP_DB_PASSWORD= +ERP_DB_SSL_MODE=require diff --git a/deploy/docker-compose.aliyun.yml b/deploy/docker-compose.aliyun.yml new file mode 100644 index 0000000..d89fcd1 --- /dev/null +++ b/deploy/docker-compose.aliyun.yml @@ -0,0 +1,32 @@ +services: + xingbang-app: + image: ${APP_IMAGE:?Set APP_IMAGE to the full Alibaba Cloud ACR image reference} + container_name: mini-openclaw + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-80}:3000" + - "${BACKEND_PORT:-8002}:8002" + environment: + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?Set DEEPSEEK_API_KEY} + DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat} + DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com} + ERP_DB_HOST: ${ERP_DB_HOST:-} + ERP_DB_PORT: ${ERP_DB_PORT:-5432} + ERP_DB_NAME: ${ERP_DB_NAME:-} + ERP_DB_USER: ${ERP_DB_USER:-} + ERP_DB_PASSWORD: ${ERP_DB_PASSWORD:-} + ERP_DB_SSL_MODE: ${ERP_DB_SSL_MODE:-require} + RISK_AGENT_BACKEND_URL: http://127.0.0.1:8002 + volumes: + - xingbang-data:/app/backend/xingbang_data + - xingbang-logs:/app/backend/logs + healthcheck: + test: ["CMD", "node", "-e", "Promise.all([fetch('http://127.0.0.1:3000/'),fetch('http://127.0.0.1:8002/')]).then(rs=>process.exit(rs.every(r=>r.ok)?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 120s + retries: 3 + +volumes: + xingbang-data: + xingbang-logs: diff --git a/package.json b/package.json index ac76ee7..8b69d38 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev -H 0.0.0.0", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "verify": "python scripts/verify.py", + "verify:docker": "python scripts/verify.py --docker" }, "dependencies": { "@monaco-editor/react": "^4.6.0", diff --git a/scripts/deploy-aliyun.sh b/scripts/deploy-aliyun.sh new file mode 100644 index 0000000..72e57a9 --- /dev/null +++ b/scripts/deploy-aliyun.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +env_file="${1:-$root/deploy/aliyun.env}" +compose_file="$root/deploy/docker-compose.aliyun.yml" + +[[ -f "$env_file" ]] || { echo "Missing environment file: $env_file" >&2; exit 2; } +docker compose --env-file "$env_file" -f "$compose_file" config --quiet +docker compose --env-file "$env_file" -f "$compose_file" pull +docker compose --env-file "$env_file" -f "$compose_file" up -d --wait +docker compose --env-file "$env_file" -f "$compose_file" ps + +frontend_port="$(sed -n 's/^FRONTEND_PORT=//p' "$env_file" | tail -1)" +backend_port="$(sed -n 's/^BACKEND_PORT=//p' "$env_file" | tail -1)" +curl --fail --show-error --silent "http://127.0.0.1:${frontend_port:-80}/" >/dev/null +curl --fail --show-error --silent "http://127.0.0.1:${backend_port:-8002}/api/xingbang/health" >/dev/null +echo "Alibaba Cloud deployment is healthy" diff --git a/scripts/publish-acr.ps1 b/scripts/publish-acr.ps1 new file mode 100644 index 0000000..e56e424 --- /dev/null +++ b/scripts/publish-acr.ps1 @@ -0,0 +1,26 @@ +param( + [Parameter(Mandatory = $true)][string]$Registry, + [Parameter(Mandatory = $true)][string]$Namespace, + [string]$Repository = "mini-openclaw", + [string]$Tag = "" +) + +$ErrorActionPreference = "Stop" +$root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +if (-not $Tag) { + $Tag = (git -C $root rev-parse --short HEAD).Trim() +} +if ($Registry -notmatch "\.aliyuncs\.com$") { + throw "Registry must be an Alibaba Cloud ACR domain ending in .aliyuncs.com" +} +$image = "$Registry/$Namespace/$Repository`:$Tag" + +python (Join-Path $root "scripts/verify.py") +if ($LASTEXITCODE -ne 0) { throw "Verification failed; image was not published" } + +docker build --pull -t $image $root +if ($LASTEXITCODE -ne 0) { throw "Docker build failed" } +docker push $image +if ($LASTEXITCODE -ne 0) { throw "Docker push failed" } + +Write-Output "Published immutable image: $image" diff --git a/scripts/publish-acr.sh b/scripts/publish-acr.sh new file mode 100644 index 0000000..e8e0435 --- /dev/null +++ b/scripts/publish-acr.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [[ $# -lt 2 || $# -gt 4 ]]; then + echo "Usage: $0 [repository] [tag]" >&2 + exit 2 +fi + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +registry="$1" +namespace="$2" +repository="${3:-mini-openclaw}" +tag="${4:-$(git -C "$root" rev-parse --short HEAD)}" +[[ "$registry" == *.aliyuncs.com ]] || { echo "ACR registry must end in .aliyuncs.com" >&2; exit 2; } +image="${registry}/${namespace}/${repository}:${tag}" + +python "$root/scripts/verify.py" +docker build --pull -t "$image" "$root" +docker push "$image" +echo "Published immutable image: $image" diff --git a/scripts/verify.py b/scripts/verify.py new file mode 100644 index 0000000..0da4b9f --- /dev/null +++ b/scripts/verify.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Run project verification and retain machine-readable test evidence.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BACKEND = ROOT / "backend" + + +def executable(name: str) -> str: + found = shutil.which(name) + if not found: + raise RuntimeError(f"Required executable is unavailable: {name}") + return found + + +def project_python() -> str: + candidates = [ + BACKEND / "venv" / "Scripts" / "python.exe", + BACKEND / "venv" / "bin" / "python", + ] + return str(next((p for p in candidates if p.exists()), Path(sys.executable))) + + +def git_output(*args: str) -> str: + result = subprocess.run( + [executable("git"), *args], cwd=ROOT, text=True, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, + ) + return result.stdout.strip() + + +def changed_files(base: str | None) -> list[str]: + command = ["diff", "--name-only"] + if base: + command.append(f"{base}...HEAD") + files = set(filter(None, git_output(*command).splitlines())) + files.update(filter(None, git_output("ls-files", "--others", "--exclude-standard").splitlines())) + return sorted(files) + + +def make_plan(files: list[str], docker_runtime: bool) -> list[dict[str, str]]: + plan = [ + {"case": "repository-diff", "reason": "Reject whitespace and patch formatting errors"}, + {"case": "backend-unit", "reason": "Run all deterministic API and agent unit tests"}, + {"case": "frontend-build", "reason": "Type-check and create the production Next.js bundle"}, + {"case": "compose-config", "reason": "Validate local and Alibaba Cloud Compose manifests"}, + ] + if any(path.startswith("backend/") for path in files): + plan.append({"case": "backend-change-coverage", "reason": "Backend files changed; full backend suite is mandatory"}) + if any(path.startswith("src/") for path in files): + plan.append({"case": "frontend-change-coverage", "reason": "Frontend files changed; production build is mandatory"}) + if any("docker" in path.lower() or path.startswith("deploy/") for path in files): + plan.append({"case": "deployment-change-coverage", "reason": "Deployment files changed; both manifests are validated"}) + if docker_runtime: + plan.append({"case": "docker-runtime-smoke", "reason": "Build, start and probe the real container"}) + return plan + + +def run_case(name: str, command: list[str], cwd: Path, evidence_dir: Path) -> dict: + started = time.monotonic() + result = subprocess.run( + command, cwd=cwd, text=True, encoding="utf-8", errors="replace", + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, + ) + duration = round(time.monotonic() - started, 3) + log_path = evidence_dir / f"{name}.log" + log_path.write_text(result.stdout, encoding="utf-8") + print(f"[{name}] {'PASS' if result.returncode == 0 else 'FAIL'} ({duration}s)") + return { + "name": name, + "status": "passed" if result.returncode == 0 else "failed", + "exit_code": result.returncode, + "duration_seconds": duration, + "command": command, + "working_directory": str(cwd), + "log": log_path.name, + } + + +def wait_for(url: str, timeout: int = 150) -> None: + deadline = time.monotonic() + timeout + last_error = "not attempted" + while time.monotonic() < deadline: + try: + with urllib.request.urlopen(url, timeout=5) as response: + if 200 <= response.status < 400: + return + except Exception as exc: # evidence captures the eventual timeout + last_error = str(exc) + time.sleep(2) + raise RuntimeError(f"Timed out waiting for {url}: {last_error}") + + +def docker_smoke(docker: str, evidence_dir: Path, keep: bool) -> dict: + started = time.monotonic() + output: list[str] = [] + status = "passed" + exit_code = 0 + compose = [docker, "compose", "-f", str(ROOT / "docker-compose.yml")] + try: + for args in (["build"], ["up", "-d", "--wait"]): + result = subprocess.run(compose + args, cwd=ROOT, text=True, encoding="utf-8", + errors="replace", stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, check=False) + output.append(f"$ {' '.join(compose + args)}\n{result.stdout}") + if result.returncode: + raise RuntimeError(f"Docker Compose {' '.join(args)} failed with {result.returncode}") + wait_for("http://127.0.0.1:3200/") + wait_for("http://127.0.0.1:8002/api/xingbang/health") + output.append("HTTP smoke checks passed for frontend and backend") + except Exception as exc: + status, exit_code = "failed", 1 + output.append(f"ERROR: {exc}") + finally: + ps = subprocess.run(compose + ["ps"], cwd=ROOT, text=True, encoding="utf-8", + errors="replace", stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, check=False) + output.append(f"$ {' '.join(compose + ['ps'])}\n{ps.stdout}") + if not keep: + down = subprocess.run(compose + ["down"], cwd=ROOT, text=True, encoding="utf-8", + errors="replace", stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, check=False) + output.append(f"$ {' '.join(compose + ['down'])}\n{down.stdout}") + log_path = evidence_dir / "docker-runtime-smoke.log" + log_path.write_text("\n\n".join(output), encoding="utf-8") + duration = round(time.monotonic() - started, 3) + print(f"[docker-runtime-smoke] {status.upper()} ({duration}s)") + return {"name": "docker-runtime-smoke", "status": status, "exit_code": exit_code, + "duration_seconds": duration, "command": compose, "working_directory": str(ROOT), + "log": log_path.name} + + +def write_report(evidence_dir: Path, summary: dict) -> None: + (evidence_dir / "summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" + ) + rows = "\n".join( + f"| {item['name']} | {item['status']} | {item['duration_seconds']}s | [{item['log']}]({item['log']}) |" + for item in summary["results"] + ) + report = f"""# Verification Evidence + +- Run ID: `{summary['run_id']}` +- Commit: `{summary['commit']}` +- Branch: `{summary['branch']}` +- Started: `{summary['started_at']}` +- Result: **{summary['status'].upper()}** + +## Generated test plan + +```json +{json.dumps(summary['test_plan'], ensure_ascii=False, indent=2)} +``` + +## Results + +| Check | Result | Duration | Evidence | +| --- | --- | ---: | --- | +{rows} + +## Changed files used to generate the plan + +```text +{os.linesep.join(summary['changed_files']) or '(clean working tree)'} +``` +""" + (evidence_dir / "REPORT.md").write_text(report, encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", help="Git base revision used to generate the change-aware test plan") + parser.add_argument("--docker", action="store_true", help="Build and smoke-test the local Docker deployment") + parser.add_argument("--keep-docker", action="store_true", help="Keep containers running after Docker smoke tests") + args = parser.parse_args() + + run_id = datetime.now().strftime("%Y%m%d-%H%M%S") + evidence_dir = ROOT / "test-evidence" / f"{run_id}-{git_output('rev-parse', '--short', 'HEAD') or 'uncommitted'}" + evidence_dir.mkdir(parents=True, exist_ok=False) + files = changed_files(args.base) + summary = { + "run_id": run_id, + "started_at": datetime.now(timezone.utc).isoformat(), + "commit": git_output("rev-parse", "HEAD"), + "branch": git_output("branch", "--show-current"), + "changed_files": files, + "test_plan": make_plan(files, args.docker), + "results": [], + } + + try: + git = executable("git") + npm = executable("npm") + docker = executable("docker") + checks = [ + ("repository-diff", [git, "diff", "--check"], ROOT), + ("backend-unit", [project_python(), "-m", "unittest", "discover", "-s", ".", "-p", "test_*.py", "-v"], BACKEND), + ("frontend-build", [npm, "run", "build"], ROOT), + ("compose-local-config", [docker, "compose", "-f", "docker-compose.yml", "config", "--quiet"], ROOT), + ("compose-aliyun-config", [docker, "compose", "--env-file", "deploy/aliyun.env.example", "-f", "deploy/docker-compose.aliyun.yml", "config", "--quiet"], ROOT), + ] + for name, command, cwd in checks: + summary["results"].append(run_case(name, command, cwd, evidence_dir)) + if args.docker: + summary["results"].append(docker_smoke(docker, evidence_dir, args.keep_docker)) + except Exception as exc: + error_log = evidence_dir / "runner-error.log" + error_log.write_text(str(exc), encoding="utf-8") + summary["results"].append({"name": "runner", "status": "failed", "exit_code": 1, + "duration_seconds": 0, "command": [], "working_directory": str(ROOT), + "log": error_log.name}) + + summary["status"] = "passed" if all(x["status"] == "passed" for x in summary["results"]) else "failed" + write_report(evidence_dir, summary) + print(f"Evidence: {evidence_dir / 'REPORT.md'}") + return 0 if summary["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())