diff --git a/scripts/win-ci/build.sh b/scripts/win-ci/build.sh index 7133971..7ace5d0 100755 --- a/scripts/win-ci/build.sh +++ b/scripts/win-ci/build.sh @@ -37,7 +37,18 @@ fetch "$VIRTIO_URL" "$D/iso/virtio-win.iso" 500 # 무인 설치 미디어. Windows Setup 이 붙어 있는 모든 드라이브 루트에서 # autounattend.xml 을 찾으므로 CD 로 물려주면 된다. cp "$HERE/unattend/autounattend.xml" "$D/unattend-root/" -cp "$HERE/unattend/setup/"*.ps1 "$D/unattend-root/setup/" + +# ⚠️ .ps1 은 반드시 UTF-8 BOM 으로 넣는다. +# Windows PowerShell 5.1 은 BOM 이 없으면 현재 ANSI 코드페이지(en-US -> CP1252)로 +# 읽는다. 한글 주석의 UTF-8 바이트가 CP1252 에서 스마트따옴표(“ ” ‘)로 변하고, +# PowerShell 은 그걸 문자열 시작으로 보고 스크립트 뒤를 전부 삼킨다. 증상은 +# 엉뚱하게도 "Missing closing '}' in statement block" 이다. 실제로 이것 때문에 +# 첫 설치의 provision.ps1 이 통째로 안 돌았다. +for f in "$HERE/unattend/setup/"*.ps1; do + out="$D/unattend-root/setup/$(basename "$f")" + printf '\xef\xbb\xbf' > "$out" + cat "$f" >> "$out" +done xorriso -as mkisofs -V UNATTEND -J -joliet-long -R -o "$D/unattend.iso" "$D/unattend-root/" # 호스트 제어 채널이 내려줄 스크립트 diff --git a/scripts/win-ci/serve-host.py b/scripts/win-ci/serve-host.py index 43b7c7f..2975cec 100755 --- a/scripts/win-ci/serve-host.py +++ b/scripts/win-ci/serve-host.py @@ -27,6 +27,14 @@ class Handler(http.server.SimpleHTTPRequestHandler): def __init__(self, *a, **kw): super().__init__(*a, directory=ROOT, **kw) + def guess_type(self, path): + # .ps1 은 charset 을 반드시 명시한다. 없으면 PowerShell 쪽에서 + # ISO-8859-1 로 디코딩해 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 + # 스마트따옴표가 되어 파서가 스크립트를 통째로 삼킨다. + if str(path).endswith(".ps1"): + return "text/plain; charset=utf-8" + return super().guess_type(path) + def do_GET(self): if self.path.rstrip("/") == "/ping": body = b"pong\n" diff --git a/scripts/win-ci/serve/stage2.ps1 b/scripts/win-ci/serve/stage2.ps1 index 4213fe9..201b54c 100644 --- a/scripts/win-ci/serve/stage2.ps1 +++ b/scripts/win-ci/serve/stage2.ps1 @@ -13,7 +13,11 @@ $ErrorActionPreference = 'Stop' $Dl = 'C:\win-ci\dl' New-Item -ItemType Directory -Force -Path $Dl | Out-Null -function Log($m) { Write-Output "$m" } +# Write-Output 를 쓰면 안 된다. 함수 안에서 부르면 그 함수의 반환값에 +# 로그 문자열이 섞여 들어가 $f 가 배열이 되고, Start-Process 가 +# "Cannot convert System.Object[] to System.String" 으로 죽는다. +# 실제로 이것 때문에 Git 설치가 실패했다. +function Log($m) { Write-Host "$m" } function Get-File($url, $name) { $out = Join-Path $Dl $name @@ -195,3 +199,66 @@ try { Log "화면 열거 실패: $($_.Exception.Message)" } Log "=== stage2 완료 ===" + +# ================================================================== +# Windows 전용 경로 실제 검증 +# +# 러너 등록 토큰이 없어도 여기서 워크플로와 똑같은 단계를 직접 돌린다. +# 이게 VM 을 만든 진짜 이유다 - 리눅스에서 못 돌리던 경로(WASAPI, 전역 +# 단축키, 모니터 열거, PyInstaller exe)가 실제로 통과하는지 본다. +# +# 통째로 try/catch 로 감싼다. 검증이 실패해도 stage2 자체는 성공이어야 +# agent 가 무한 재시도에 빠지지 않는다. 결과는 로그로 남는다. +# ================================================================== +$Repo = 'C:\win-ci\live-app-translator' +function Step($name, $block) { + Log "--- [$name] 시작" + try { + & $block + Log "--- [$name] 통과" + return $true + } catch { + Log "--- [$name] 실패: $($_.Exception.Message)" + return $false + } +} + +$ErrorActionPreference = 'Continue' +try { + Sync-Path + if (Test-Path $Repo) { + Log "저장소 갱신" + Push-Location $Repo; & git fetch --all -q; & git reset --hard origin/main -q; Pop-Location + } else { + Log "저장소 clone" + & git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo + } + + Push-Location $Repo + $py = Join-Path $Repo '.venv-ci\Scripts\python.exe' + if (-not (Test-Path $py)) { & python -m venv .venv-ci } + & $py -m pip install --upgrade pip --quiet + & $py -m pip install -r packaging\requirements-portable.txt --quiet + & $py -m pip install pytest ruff --quiet + + # 워크플로와 같은 조건인지 - torch 가 없어야 "포터블에서 1~3티어가 된다"를 + # 실제로 검증한 셈이 된다. + & $py -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('torch') is None else 1)" + Log "포터블 조건(torch 없음) = $($LASTEXITCODE -eq 0)" + + $env:PYTHONPATH = 'src' + $r = @{} + $r['pytest'] = Step 'pytest' { & $py -m pytest tests -q 2>&1 | Select-Object -Last 8 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + $r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts 2>&1 | Select-Object -Last 5 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + $r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + $r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet; & $py -m PyInstaller packaging\livesub.spec --noconfirm 2>&1 | Select-Object -Last 4 | ForEach-Object { Log " $_" }; if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }; Log " exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB" } + $r['exe기동'] = Step 'exe 실행' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe 2>&1 | ForEach-Object { Log " $_" }; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + + Log "=== Windows 검증 결과 ===" + foreach ($k in $r.Keys) { Log ("{0,-14} {1}" -f $k, $(if ($r[$k]) { 'PASS' } else { 'FAIL' })) } + Log "=== 검증 끝 ===" + Pop-Location +} catch { + Log "검증 준비 단계에서 실패: $($_.Exception.Message)" + try { Pop-Location } catch {} +} diff --git a/scripts/win-ci/type.sh b/scripts/win-ci/type.sh new file mode 100755 index 0000000..a1bb390 --- /dev/null +++ b/scripts/win-ci/type.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# VM 게스트에 키보드 입력을 보낸다 (QMP send-key). +# +# ./type.sh text "hello world" 문자열 입력 +# ./type.sh key ret 단일 키 (qcode) +# ./type.sh key alt-f4 조합키 (하이픈으로 연결) +# +# 게스트에 네트워크가 없을 때 유일한 제어 수단이므로 지우지 말 것. +set -euo pipefail +python3 - "$@" <<'PY' +import json, socket, sys, time + +MODE = sys.argv[1] +ARG = sys.argv[2] + +s = socket.socket(socket.AF_UNIX); s.settimeout(30) +s.connect("/home/claude/win-ci/qmp.sock"); f = s.makefile("rwb"); f.readline() + +def cmd(o): + f.write((json.dumps(o) + "\n").encode()); f.flush() + while True: + m = json.loads(f.readline()) + if "return" in m or "error" in m: + if "error" in m: print("ERR", m, file=sys.stderr) + return m + +cmd({"execute": "qmp_capabilities"}) + +def send(qcodes): + cmd({"execute": "send-key", "arguments": { + "keys": [{"type": "qcode", "data": q} for q in qcodes]}}) + time.sleep(0.04) + +SHIFTED = { + '!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7', + '*': '8', '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left', + '}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe', + '<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent', +} +PLAIN = { + ' ': 'spc', '-': 'minus', '=': 'equal', '[': 'bracket_left', + ']': 'bracket_right', '\\': 'backslash', ';': 'semicolon', + "'": 'apostrophe', ',': 'comma', '.': 'dot', '/': 'slash', + '`': 'grave_accent', '\n': 'ret', '\t': 'tab', +} + +def char_keys(c): + if c.isalpha(): + q = c.lower() + return ['shift', q] if c.isupper() else [q] + if c.isdigit(): + return [c] + if c in SHIFTED: + return ['shift', SHIFTED[c]] + if c in PLAIN: + return [PLAIN[c]] + raise SystemExit("unsupported char: %r" % c) + +if MODE == "text": + for c in ARG: + send(char_keys(c)) +elif MODE == "key": + send(ARG.split("-")) +else: + raise SystemExit("usage: type.sh text | type.sh key ") +print("ok") +PY diff --git a/scripts/win-ci/unattend/setup/agent.ps1 b/scripts/win-ci/unattend/setup/agent.ps1 index 120f2f2..2f97fd6 100644 --- a/scripts/win-ci/unattend/setup/agent.ps1 +++ b/scripts/win-ci/unattend/setup/agent.ps1 @@ -24,10 +24,13 @@ function Say([string]$m) { try { Add-Content -Path $LogFile -Value $line -Encoding UTF8 } catch {} try { $p = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One' + # 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다 + $p.Encoding = [Text.Encoding]::UTF8 $p.Open(); $p.WriteLine($line); $p.Close() } catch {} try { - Invoke-RestMethod -Uri "$Host_/log" -Method Post -Body $line -TimeoutSec 5 | Out-Null + Invoke-RestMethod -Uri "$Host_/log" -Method Post -TimeoutSec 5 ` + -Body ([Text.Encoding]::UTF8.GetBytes($line)) -ContentType "text/plain; charset=utf-8" | Out-Null } catch {} } @@ -51,8 +54,17 @@ else { Say "ERROR 5분 동안 호스트에 못 붙었다. virtio-net 드 # 이후로는 계속 돌면서 stage2 가 바뀔 때마다 실행한다. while ($true) { try { - $body = (Invoke-WebRequest -Uri "$Host_/stage2.ps1" -TimeoutSec 20 -UseBasicParsing).Content - if ($body -is [byte[]]) { $body = [Text.Encoding]::UTF8.GetString($body) } + # 반드시 바이트로 받아 UTF-8 로 직접 디코딩한다. Invoke-WebRequest 의 + # .Content 는 Content-Type 에 charset 이 없으면 ISO-8859-1 로 디코딩해 + # 한글 주석이 깨지고, 깨진 바이트가 CP1252 의 스마트따옴표(0x93/0x94)가 + # 되어 PowerShell 파서가 그 뒤를 전부 문자열로 삼킨다. 실제로 겪은 사고다. + $wc = New-Object Net.WebClient + $body = [Text.Encoding]::UTF8.GetString($wc.DownloadData("$Host_/stage2.ps1")) + # 받은 쪽에 BOM 이 있으면 문자열 맨 앞에 U+FEFF 가 남는다. 아래에서 + # Set-Content -Encoding UTF8 이 BOM 을 또 붙이므로 BOM 이 두 개가 되고, + # PowerShell 은 첫 개만 벗겨내 남은 U+FEFF 때문에 1행이 주석으로 + # 인식되지 않는다. 그래서 여기서 반드시 떼어낸다. + $body = $body.TrimStart([char]0xFEFF) $sha = [BitConverter]::ToString( [Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($body)) diff --git a/scripts/win-ci/unattend/setup/provision.ps1 b/scripts/win-ci/unattend/setup/provision.ps1 index 369ae45..5e533d7 100644 --- a/scripts/win-ci/unattend/setup/provision.ps1 +++ b/scripts/win-ci/unattend/setup/provision.ps1 @@ -16,6 +16,8 @@ New-Item -ItemType Directory -Force -Path 'C:\win-ci' | Out-Null $script:Port = $null try { $script:Port = New-Object System.IO.Ports.SerialPort 'COM1', 115200, 'None', 8, 'One' + # 기본 인코딩이 ASCII 라 한글이 전부 '?' 가 된다 + $script:Port.Encoding = [Text.Encoding]::UTF8 $script:Port.Open() } catch { $script:Port = $null }