ci: Windows 자동 검증 추가 (Gitea Actions + 스모크 스크립트)
Some checks failed
Windows / verify (push) Has been cancelled
Some checks failed
Windows / verify (push) Has been cancelled
개발이 리눅스에서 이뤄지다 보니 Windows 전용 경로는 한 번도 실행해 본 적이
없다. 오디오 캡처, 전역 단축키, 모니터 열거, exe 빌드가 전부 여기 해당하고,
"리눅스에서 다 통과했는데 윈도우에서 안 되더라"가 나올 수 있는 지점이다.
- .gitea/workflows/windows.yml
push 때마다 Windows 러너에서 테스트 -> 스모크 -> 포터블 exe 빌드 ->
빌드된 exe 기동 확인까지 하고 exe·스크린샷을 artifact 로 올린다.
의존성을 packaging/requirements-portable.txt 로 깔아 torch 없는 포터블
조건을 그대로 재현하고, torch 가 섞이면 바로 실패하게 했다.
- packaging/windows_smoke.py
리눅스에서 확인 불가능한 항목만 모은 점검기. WASAPI 장치 열거, 소리 내는
프로그램 열거, 전역 단축키 등록/해제, 실제 모니터 열거, GPU 인식,
사용 가능 티어, 자막 오버레이 실제 렌더링(스크린샷 저장).
한 항목이 실패해도 나머지를 계속 돌려 전체 그림을 한 번에 보여준다.
러너 환경에 따라 없을 수 있는 항목(오디오 장치·보조 exe·GPU)은 경고로만
두고, 코드 문제인 항목만 빌드를 실패시킨다.
- docs/WINDOWS-TESTING.md
러너 등록 절차와, 러너 없이 직접 돌려보는 방법.
러너가 없으면 워크플로는 대기만 하므로 지금 상태에 영향이 없다.
검증: pytest 189개 통과, ruff clean, 워크플로 YAML 파싱 확인,
스모크 스크립트를 리눅스에서 실행해 항목별 판정과 종료코드 확인
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
87
.gitea/workflows/windows.yml
Normal file
87
.gitea/workflows/windows.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
# Windows 검증 — Gitea Actions
|
||||
#
|
||||
# 이 저장소에 push 하면 Windows 러너가 자동으로
|
||||
# 1) 테스트를 돌리고
|
||||
# 2) 포터블 exe 를 빌드하고
|
||||
# 3) 앱이 실제로 뜨는지 확인한 뒤
|
||||
# 4) exe 와 스크린샷을 artifact 로 올립니다.
|
||||
#
|
||||
# 러너 등록 방법은 docs/WINDOWS-TESTING.md 참고.
|
||||
# 러너가 없으면 이 워크플로는 그냥 대기 상태로 남고 아무 일도 하지 않습니다.
|
||||
|
||||
name: Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: windows
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: 소스 받기
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Python 준비
|
||||
shell: pwsh
|
||||
run: |
|
||||
python --version
|
||||
python -m venv .venv-ci
|
||||
.\.venv-ci\Scripts\python -m pip install --upgrade pip --quiet
|
||||
|
||||
# torch 없이 설치한다. 포터블 빌드와 같은 조건을 만들어야
|
||||
# "포터블에서 1~3티어가 된다"는 주장을 실제로 검증할 수 있다.
|
||||
- name: 의존성 설치 (포터블 조건 = torch 없음)
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\.venv-ci\Scripts\python -m pip install -r packaging\requirements-portable.txt
|
||||
.\.venv-ci\Scripts\python -m pip install pytest ruff
|
||||
.\.venv-ci\Scripts\python -c "import importlib.util; assert importlib.util.find_spec('torch') is None; print('torch 없음 - 포터블 조건 OK')"
|
||||
|
||||
- name: 테스트
|
||||
shell: pwsh
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
.\.venv-ci\Scripts\python -m pytest tests -q
|
||||
.\.venv-ci\Scripts\python -m ruff check src tests scripts
|
||||
|
||||
# 여기부터가 리눅스에서 못 하던 것들이다.
|
||||
- name: Windows 전용 경로 확인 (오디오 장치·단축키·프로세스 열거)
|
||||
shell: pwsh
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: .\.venv-ci\Scripts\python packaging\windows_smoke.py
|
||||
|
||||
- name: 프로그램별 캡처 보조 프로그램 빌드
|
||||
shell: pwsh
|
||||
continue-on-error: true # VS Build Tools 가 없는 러너도 있을 수 있다
|
||||
run: |
|
||||
cmake -S native\process_loopback -B native\process_loopback\build -A x64
|
||||
cmake --build native\process_loopback\build --config Release
|
||||
|
||||
- name: 포터블 exe 빌드
|
||||
shell: pwsh
|
||||
run: |
|
||||
.\.venv-ci\Scripts\python -m pip install pyinstaller
|
||||
.\.venv-ci\Scripts\python -m PyInstaller packaging\livesub.spec --noconfirm
|
||||
$exe = Get-Item dist\LiveSub.exe
|
||||
"빌드 완료: {0:N1} MB" -f ($exe.Length / 1MB)
|
||||
|
||||
- name: 빌드된 exe 가 실제로 실행되는지
|
||||
shell: pwsh
|
||||
run: .\.venv-ci\Scripts\python packaging\windows_smoke.py --exe dist\LiveSub.exe
|
||||
|
||||
- name: 결과물 업로드
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: LiveSub-portable
|
||||
path: |
|
||||
dist/LiveSub.exe
|
||||
artifacts/*.png
|
||||
artifacts/*.txt
|
||||
retention-days: 14
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ transcripts/
|
||||
.idea/
|
||||
*.swp
|
||||
.DS_Store
|
||||
artifacts/
|
||||
|
||||
12
README.md
12
README.md
@@ -286,6 +286,18 @@ ruff check src tests scripts
|
||||
|
||||
구조는 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)를 보세요.
|
||||
|
||||
### Windows 자동 검증
|
||||
|
||||
개발은 리눅스에서 하므로 오디오 캡처·전역 단축키·exe 빌드는 여기서 테스트할 수
|
||||
없습니다. Windows 러너를 붙이면 push할 때마다 자동으로 확인되고 exe와
|
||||
스크린샷이 artifact로 올라옵니다 — [docs/WINDOWS-TESTING.md](docs/WINDOWS-TESTING.md).
|
||||
|
||||
러너 없이 그때그때 확인만 하려면:
|
||||
|
||||
```powershell
|
||||
.\.venv\Scripts\python packaging\windows_smoke.py
|
||||
```
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT. 사용하는 모델은 각자의 라이선스를 따릅니다
|
||||
|
||||
111
docs/WINDOWS-TESTING.md
Normal file
111
docs/WINDOWS-TESTING.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Windows에서 자동 검증 붙이기
|
||||
|
||||
개발은 리눅스에서 하고 있어서 **Windows 전용 경로는 테스트가 불가능**합니다.
|
||||
오디오 캡처, 전역 단축키, 모니터 열거, exe 빌드가 전부 여기에 해당합니다.
|
||||
그래서 "리눅스에서 다 통과했는데 윈도우에서 안 되더라"가 나올 수 있습니다.
|
||||
|
||||
Windows 러너를 하나 붙이면 push할 때마다 이 경로들이 자동으로 검증됩니다.
|
||||
|
||||
## 무엇이 자동으로 확인되나
|
||||
|
||||
| 항목 | 리눅스에서 | Windows 러너에서 |
|
||||
|---|---|---|
|
||||
| 단위 테스트 189개 | ✅ | ✅ |
|
||||
| WASAPI 루프백 장치 열거 | ❌ | ✅ |
|
||||
| 소리 내는 프로그램 열거 | ❌ | ✅ |
|
||||
| 전역 단축키 등록/해제 | ❌ | ✅ |
|
||||
| 모니터 열거·자막 배치 | 가상 화면만 | ✅ 실제 모니터 |
|
||||
| 포터블 exe 빌드 | ❌ | ✅ |
|
||||
| exe가 실제로 뜨는지 | ❌ | ✅ |
|
||||
| GPU 인식 (CUDA) | 이 서버 GPU | ✅ 실제 게임 PC GPU |
|
||||
| 실제 화면 스크린샷 | offscreen | ✅ 진짜 화면 |
|
||||
|
||||
결과물(exe, 스크린샷, 로그)은 Gitea Actions의 artifact로 올라오므로
|
||||
원격에서도 눈으로 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 설치 (약 10분, 한 번만)
|
||||
|
||||
### 1. 필요한 것 설치
|
||||
|
||||
```powershell
|
||||
winget install Python.Python.3.12
|
||||
winget install Kitware.CMake
|
||||
winget install Microsoft.VisualStudio.2022.BuildTools # C++ 데스크톱 워크로드 선택
|
||||
```
|
||||
|
||||
### 2. Gitea 러너 내려받기
|
||||
|
||||
<https://gitea.com/gitea/act_runner/releases> 에서
|
||||
`act_runner-<버전>-windows-amd64.exe` 를 받아 `C:\gitea-runner\act_runner.exe` 로 둡니다.
|
||||
|
||||
### 3. 등록 토큰 받기
|
||||
|
||||
Gitea 웹에서 저장소 → **설정 → Actions → 러너 → 등록 토큰 생성**
|
||||
(주소: `https://git.tkrmagid.kr/tkrmagid/live-app-translator/settings/actions/runners`)
|
||||
|
||||
### 4. 등록하고 실행
|
||||
|
||||
```powershell
|
||||
cd C:\gitea-runner
|
||||
.\act_runner.exe register --no-interactive `
|
||||
--instance https://git.tkrmagid.kr `
|
||||
--token <위에서 받은 토큰> `
|
||||
--name windows-gaming-pc `
|
||||
--labels windows
|
||||
|
||||
.\act_runner.exe daemon
|
||||
```
|
||||
|
||||
`--labels windows` 가 중요합니다. 워크플로의 `runs-on: windows` 와 맞아야 합니다.
|
||||
|
||||
### 5. 확인
|
||||
|
||||
아무거나 push 하면 저장소 **Actions** 탭에 `Windows` 워크플로가 돌기 시작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 항상 켜두고 싶다면 (선택)
|
||||
|
||||
부팅할 때 자동으로 뜨게 하려면 서비스로 등록합니다.
|
||||
|
||||
```powershell
|
||||
# 관리자 PowerShell
|
||||
sc.exe create gitea-runner binPath= "C:\gitea-runner\act_runner.exe daemon --config C:\gitea-runner\config.yaml" start= auto
|
||||
sc.exe start gitea-runner
|
||||
```
|
||||
|
||||
게임할 때 방해되면 그냥 꺼두고, 확인이 필요할 때만 `daemon` 을 켜도 됩니다.
|
||||
러너가 꺼져 있으면 워크플로는 대기만 하고 아무 일도 하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 안전에 대해
|
||||
|
||||
- 러너는 **저장소에 커밋된 워크플로 파일만** 실행합니다. 대화로 임의 명령을
|
||||
넣을 수 없고, 무엇이 실행됐는지는 git 기록에 그대로 남습니다.
|
||||
- 실행되는 내용은 `.gitea/workflows/windows.yml` 한 파일이며 언제든 읽고
|
||||
고칠 수 있습니다. 테스트·빌드·스크린샷 외의 일은 하지 않습니다.
|
||||
- 개인 PC가 부담스러우면 Proxmox에 Windows 11 VM을 만들어 거기에 러너를
|
||||
붙여도 됩니다. GPU와 실제 게임 소리 확인은 안 되지만, 빌드·단축키·
|
||||
모니터·UI는 전부 검증됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 러너 없이 직접 돌려보기
|
||||
|
||||
러너를 안 붙이고 그때그때 확인만 하고 싶다면 이것만 실행하고 결과를
|
||||
보내주셔도 됩니다.
|
||||
|
||||
```powershell
|
||||
git clone https://git.tkrmagid.kr/tkrmagid/live-app-translator
|
||||
cd live-app-translator
|
||||
py -3.12 -m venv .venv
|
||||
.\.venv\Scripts\python -m pip install -r packaging\requirements-portable.txt pytest
|
||||
$env:PYTHONPATH="src"
|
||||
.\.venv\Scripts\python -m pytest tests -q
|
||||
.\.venv\Scripts\python packaging\windows_smoke.py
|
||||
```
|
||||
|
||||
`artifacts\windows-smoke.txt` 와 `artifacts\*.png` 를 보내주시면 됩니다.
|
||||
218
packaging/windows_smoke.py
Normal file
218
packaging/windows_smoke.py
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Windows 전용 경로 점검.
|
||||
|
||||
리눅스 개발 환경에서는 확인이 불가능한 것들만 모아놨다. 여기서 실패하면
|
||||
"리눅스에서 테스트 다 통과했는데 윈도우에서 안 되더라" 가 되는 지점이다.
|
||||
|
||||
python packaging/windows_smoke.py # 소스에서 점검
|
||||
python packaging/windows_smoke.py --exe dist\\LiveSub.exe # 빌드된 exe 점검
|
||||
|
||||
결과는 artifacts/ 에 로그와 스크린샷으로 남는다. CI 가 그걸 업로드하므로
|
||||
원격에서도 실제 화면을 눈으로 확인할 수 있다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ARTIFACTS = ROOT / "artifacts"
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
_results: list[tuple[str, bool, str]] = []
|
||||
|
||||
|
||||
def check(name: str):
|
||||
"""점검 하나. 예외가 나도 나머지는 계속 돈다."""
|
||||
|
||||
def wrapper(fn):
|
||||
try:
|
||||
detail = fn() or ""
|
||||
ok = True
|
||||
except Exception as exc: # noqa: BLE001 - 점검 결과로 기록하는 게 목적
|
||||
detail, ok = f"{type(exc).__name__}: {exc}", False
|
||||
_results.append((name, ok, str(detail)))
|
||||
print(f" [{'OK ' if ok else '실패'}] {name} {detail}")
|
||||
return fn
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# --- 소스 기준 점검 ---------------------------------------------------------
|
||||
|
||||
|
||||
def run_source_checks() -> None:
|
||||
print("\n=== Windows 전용 경로 ===")
|
||||
|
||||
@check("WASAPI 루프백 장치 열거")
|
||||
def _devices():
|
||||
from livesub.audio.wasapi_loopback import WasapiLoopbackCapture
|
||||
|
||||
if not WasapiLoopbackCapture.available():
|
||||
raise RuntimeError("PyAudioWPatch 없음 — 오디오 캡처 불가")
|
||||
sources = WasapiLoopbackCapture.list_sources()
|
||||
if not sources:
|
||||
raise RuntimeError("루프백 장치가 하나도 없음")
|
||||
return f"{len(sources)}개: {sources[0].label}"
|
||||
|
||||
@check("소리 내는 프로그램 열거 (pycaw)")
|
||||
def _processes():
|
||||
from livesub.audio.process_loopback import ProcessLoopbackCapture
|
||||
|
||||
sources = ProcessLoopbackCapture.list_sources()
|
||||
return f"{len(sources)}개" + (f" (예: {sources[0].label})" if sources else " (지금 소리 내는 앱 없음)")
|
||||
|
||||
@check("프로그램별 캡처 보조 프로그램")
|
||||
def _helper():
|
||||
from livesub.audio.process_loopback import helper_path
|
||||
|
||||
path = helper_path()
|
||||
if path is None:
|
||||
raise RuntimeError("livesub_capture.exe 없음 — 장치 전체 캡처로 폴백됨")
|
||||
return str(path)
|
||||
|
||||
@check("전역 단축키 등록/해제")
|
||||
def _hotkey():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from livesub.ui.hotkeys import GlobalHotkeys
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
keys = GlobalHotkeys()
|
||||
if not keys.supported:
|
||||
raise RuntimeError("이 플랫폼은 전역 단축키 미지원")
|
||||
fired = []
|
||||
if not keys.register("Ctrl+Alt+F12", lambda: fired.append(1)):
|
||||
raise RuntimeError(keys.last_error)
|
||||
keys.unregister_all()
|
||||
_ = app
|
||||
return "Ctrl+Alt+F12 등록·해제 성공"
|
||||
|
||||
@check("모니터 열거")
|
||||
def _screens():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from livesub.ui.placement import list_screens
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
screens = list_screens()
|
||||
if not screens:
|
||||
raise RuntimeError("모니터를 찾지 못함")
|
||||
_ = app
|
||||
return " / ".join(s.label for s in screens)
|
||||
|
||||
@check("GPU 인식")
|
||||
def _gpu():
|
||||
from livesub.models import detect_gpu
|
||||
|
||||
gpu = detect_gpu()
|
||||
if not gpu.available:
|
||||
raise RuntimeError(gpu.reason)
|
||||
return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB"
|
||||
|
||||
@check("사용 가능한 모델 티어")
|
||||
def _tiers():
|
||||
from livesub.models.manager import has_torch, tier_availability
|
||||
from livesub.models.tiers import ordered_tiers
|
||||
|
||||
usable = [t.name for t in ordered_tiers() if tier_availability(t)[0]]
|
||||
return f"torch={'있음' if has_torch() else '없음'} → {', '.join(usable)}"
|
||||
|
||||
@check("자막 오버레이 실제 렌더링")
|
||||
def _overlay():
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "windows")
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from livesub.config import AppConfig
|
||||
from livesub.ui.main_window import MainWindow
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
cfg = AppConfig()
|
||||
cfg.models.preload_on_start = False
|
||||
win = MainWindow(cfg)
|
||||
try:
|
||||
win.show()
|
||||
win.overlay.set_subtitle_visible(True)
|
||||
win.overlay.set_line("적이 왼쪽에서 들어온다, 지금 바로 빠져!", "Enemy from the left")
|
||||
for _ in range(10):
|
||||
app.processEvents()
|
||||
time.sleep(0.05)
|
||||
ARTIFACTS.mkdir(exist_ok=True)
|
||||
win.grab().save(str(ARTIFACTS / "windows-main.png"))
|
||||
win.overlay.grab().save(str(ARTIFACTS / "windows-overlay.png"))
|
||||
finally:
|
||||
win.engine.shutdown()
|
||||
win.overlay.close()
|
||||
win.close()
|
||||
return "스크린샷 2장 저장 (artifacts/)"
|
||||
|
||||
|
||||
# --- 빌드된 exe 점검 --------------------------------------------------------
|
||||
|
||||
|
||||
def run_exe_check(exe: Path) -> None:
|
||||
print(f"\n=== 빌드된 exe 점검: {exe} ===")
|
||||
|
||||
@check("exe 존재와 크기")
|
||||
def _size():
|
||||
if not exe.is_file():
|
||||
raise RuntimeError("파일 없음")
|
||||
mb = exe.stat().st_size / 1024 / 1024
|
||||
if mb > 1500:
|
||||
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
|
||||
return f"{mb:.1f} MB"
|
||||
|
||||
@check("exe 실행 후 8초 생존")
|
||||
def _launch():
|
||||
# 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다.
|
||||
proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
time.sleep(8)
|
||||
alive = proc.poll() is None
|
||||
if not alive:
|
||||
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
|
||||
raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return "정상 기동"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Windows 전용 점검")
|
||||
parser.add_argument("--exe", type=Path, help="빌드된 exe 를 점검")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.exe:
|
||||
run_exe_check(args.exe)
|
||||
else:
|
||||
run_source_checks()
|
||||
|
||||
ARTIFACTS.mkdir(exist_ok=True)
|
||||
report = "\n".join(
|
||||
f"[{'OK' if ok else 'FAIL'}] {name}: {detail}" for name, ok, detail in _results
|
||||
)
|
||||
(ARTIFACTS / "windows-smoke.txt").write_text(report, encoding="utf-8")
|
||||
|
||||
failed = [n for n, ok, _ in _results if not ok]
|
||||
print(f"\n{len(_results) - len(failed)}/{len(_results)} 통과")
|
||||
if failed:
|
||||
print("실패:", ", ".join(failed))
|
||||
# 오디오 장치·보조 프로그램은 러너 환경에 따라 없을 수 있어 경고로만 둔다.
|
||||
fatal = [n for n in failed if n not in {
|
||||
"프로그램별 캡처 보조 프로그램",
|
||||
"소리 내는 프로그램 열거 (pycaw)",
|
||||
"WASAPI 루프백 장치 열거",
|
||||
"GPU 인식",
|
||||
}]
|
||||
return 1 if fatal else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user