diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index 10298df..0000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: CI - -on: - push: - branches: [main, develop] - pull_request: - branches: [main] - -jobs: - test: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pytest pytest-cov - - - name: pytest unit tests - env: - QT_QPA_PLATFORM: offscreen - run: pytest tests -v - - - name: integration tests - run: python _integration_test.py - - - name: i18n GUI tests - env: - QT_QPA_PLATFORM: offscreen - run: python _i18n_gui_test.py diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml deleted file mode 100644 index 98dd81d..0000000 --- a/.gitea/workflows/release.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' # v1.0.0, v2.1.0 등 태그 push 시 트리거 - -jobs: - build-and-release: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install dependencies + PyInstaller - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Run unit tests - env: - QT_QPA_PLATFORM: offscreen - run: | - pip install pytest - pytest tests -q - - - name: Run integration tests - run: python _integration_test.py - - - name: Build main.exe - run: python -m PyInstaller --clean main.spec - - - name: Build updater.exe - run: python -m PyInstaller --clean updater.spec - - - name: Verify both exe exist - run: | - if (-not (Test-Path dist/main.exe)) { throw "main.exe missing" } - if (-not (Test-Path dist/updater.exe)) { throw "updater.exe missing" } - - - name: Create release archive - run: | - New-Item -ItemType Directory -Path dist/release -Force - Copy-Item dist/main.exe dist/release/ - Copy-Item dist/updater.exe dist/release/ - Compress-Archive -Path dist/release/* -DestinationPath dist/ClockOutCalculator.zip -Force - - - name: Publish Release (Gitea) - uses: actions/release-action@main - with: - api_key: ${{ secrets.RELEASE_TOKEN }} - files: | - dist/main.exe - dist/updater.exe - dist/ClockOutCalculator.zip - - # GitHub Actions 호환 fallback (Gitea Actions에서 동작 안 할 경우) - # 위 release-action이 실패하면 아래를 활용: - # - # - name: Publish Release (manual via API) - # shell: pwsh - # env: - # GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }} - # GITEA_HOST: https://kindnick-git.duckdns.org - # OWNER: kindnick - # REPO: Clock_out_Time_Calculator - # run: | - # $tag = $env:GITHUB_REF -replace 'refs/tags/', '' - # $body = @{ tag_name = $tag; name = $tag; draft = $false } | ConvertTo-Json - # $headers = @{ Authorization = "token $env:GITEA_TOKEN" } - # $release = Invoke-RestMethod -Uri "$env:GITEA_HOST/api/v1/repos/$env:OWNER/$env:REPO/releases" ` - # -Method Post -Headers $headers -ContentType 'application/json' -Body $body - # $uploadUrl = "$env:GITEA_HOST/api/v1/repos/$env:OWNER/$env:REPO/releases/$($release.id)/assets" - # foreach ($f in @('dist/main.exe', 'dist/updater.exe')) { - # $name = [System.IO.Path]::GetFileName($f) - # Invoke-RestMethod -Uri "$uploadUrl?name=$name" -Method Post ` - # -Headers $headers -InFile $f -ContentType 'application/octet-stream' - # } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4d8a543..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: CI - -on: - push: - branches: [main, develop] - pull_request: - branches: [main] - -jobs: - test: - runs-on: windows-latest - strategy: - matrix: - python-version: ['3.10', '3.11', '3.12'] - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache pip - uses: actions/cache@v4 - with: - path: ~\AppData\Local\pip\Cache - key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('requirements.txt') }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pytest pytest-cov - - - name: Run integration tests - run: python _integration_test.py - - - name: Run i18n GUI tests (offscreen) - env: - QT_QPA_PLATFORM: offscreen - run: python _i18n_gui_test.py - - - name: Run pytest (if tests/ exists) - env: - QT_QPA_PLATFORM: offscreen - run: | - if (Test-Path tests) { pytest tests --cov=core --cov=utils } - - build: - needs: test - runs-on: windows-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - - - name: Install dependencies + PyInstaller - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Build exe - run: python -m PyInstaller --clean main.spec - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ClockOutCalculator-exe - path: dist/main.exe - retention-days: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index eb230ce..a1907a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`core/version.py`** — `__version__` 상수 - **`updater.py` + `updater.spec`** — 독립 자가 업데이터 (Python 표준 라이브러리만, 6MB) - **`utils/updater_client.py`** — Gitea/GitHub 호환 Releases API 클라이언트 -- **Gitea Actions 워크플로** — `.gitea/workflows/{ci,release}.yml` - - `v*` 태그 push 시 두 .exe 자동 빌드 + Releases 첨부 + ZIP 패키징 +- **`release.ps1`** — 로컬 원클릭 릴리스 스크립트 (Runner 불필요) + - 빌드 + 태그 push + Gitea Release 생성 + 자산 업로드 자동화 + - CHANGELOG에서 릴리스 노트 자동 추출 + - `--DryRun` / `--SkipTests` 옵션 ### Changed - `Settings → 데이터 관리`에 "버전 표시 + 업데이트 확인" 추가 diff --git a/README.md b/README.md index 3b924e2..43d65dc 100644 --- a/README.md +++ b/README.md @@ -141,22 +141,32 @@ python -m PyInstaller --clean updater.spec # → dist/updater.exe (~6MB) 배포 시 두 .exe를 같은 폴더에 둬야 자동 업데이트가 동작합니다. 빌드 시 `dist/main.exe`가 실행 중이면 PermissionError가 발생 — 종료 후 재실행하세요. -## 릴리스 (Gitea Actions) +## 릴리스 ([release.ps1](release.ps1)) -태그 push로 자동 릴리스: -```bash -# version.py 업데이트 후 -git tag v2.2.0 -git push origin v2.2.0 +로컬에서 한 줄로 빌드 → 태그 push → Gitea Release 생성 → 자산 업로드까지: + +```powershell +# 최초 1회: PAT 환경변수 등록 +[Environment]::SetEnvironmentVariable('GITEA_TOKEN', 'your_pat', 'User') + +# 릴리스 실행 +.\release.ps1 v2.2.0 ``` -[.gitea/workflows/release.yml](.gitea/workflows/release.yml)이 자동으로: -1. 단위/통합 테스트 실행 -2. main.exe + updater.exe 빌드 -3. ZIP 패키징 -4. Gitea Releases에 첨부 +스크립트 동작: +1. `core/version.py` 업데이트 +2. pytest + 통합 테스트 실행 +3. main.exe + updater.exe 빌드 +4. ZIP 패키징 +5. git commit + tag + push +6. Gitea API로 Release 생성 (CHANGELOG.md에서 노트 자동 추출) +7. 두 .exe + ZIP을 Release 자산으로 업로드 -⚠️ Gitea Actions 활성화 + `RELEASE_TOKEN` secret(저장소 쓰기 권한) 등록 필요. +옵션: +- `--SkipTests`: 테스트 건너뛰기 (긴급 핫픽스) +- `--DryRun`: 실제 push/release 없이 미리보기 + +PAT 발급: Gitea → Settings → Applications → Generate New Token (권한: `repository: Read and Write`). ## 주의사항 diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..f6433e0 --- /dev/null +++ b/release.ps1 @@ -0,0 +1,226 @@ +<# +.SYNOPSIS + Local build + Gitea Releases auto-publish. + +.DESCRIPTION + One-shot release: version bump -> tests -> build -> tag push -> Release -> upload. + No Runner needed. Run on your local PC. + +.PARAMETER Version + Tag in 'v2.2.0' format. + +.PARAMETER SkipTests + Skip pytest + integration (emergency hotfix only). + +.PARAMETER DryRun + Preview without pushing or creating release. + +.EXAMPLE + # First time only: register PAT as env var + [Environment]::SetEnvironmentVariable('GITEA_TOKEN', 'your_pat', 'User') + + .\release.ps1 v2.2.0 + +.NOTES + - Kill any running main.exe before running. + - PAT scope: repository (read+write). +#> +param( + [Parameter(Mandatory=$true, Position=0)] + [ValidatePattern('^v\d+\.\d+\.\d+$')] + [string]$Version, + + [switch]$SkipTests, + [switch]$DryRun +) + +# PowerShell 5.1: native command stderr triggers NativeCommandError under 'Stop'. +# Use 'Continue' and explicitly check $LASTEXITCODE after each native call. +$ErrorActionPreference = 'Continue' + +function Invoke-Native { + param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Args) + & $Args[0] $Args[1..($Args.Count-1)] 2>&1 | Out-Host + return $LASTEXITCODE +} + +# ====== Config ====== +$GiteaHost = 'https://kindnick-git.duckdns.org' +$Owner = 'kindnick' +$Repo = 'Clock_out_Time_Calculator' +$ApiBase = "$GiteaHost/api/v1/repos/$Owner/$Repo" +$VersionRaw = $Version.TrimStart('v') + +function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan } +function Info($msg) { Write-Host " $msg" -ForegroundColor Gray } +function OkMsg($msg) { Write-Host " [OK] $msg" -ForegroundColor Green } +function Fail($msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red; exit 1 } + +# ====== 0. Pre-checks ====== +Step "Pre-checks" + +if (-not $env:GITEA_TOKEN) { + Fail "GITEA_TOKEN env var missing. Set it first: `$env:GITEA_TOKEN = 'your_pat'" +} + +$running = Get-Process -Name 'main' -ErrorAction SilentlyContinue +if ($running) { + Fail "main.exe is running (PID: $($running.Id -join ', ')). Kill it first." +} + +$gitStatus = git status --porcelain +if ($gitStatus -and -not $DryRun) { + Write-Host " WARN: uncommitted changes:" -ForegroundColor Yellow + $gitStatus | ForEach-Object { Write-Host " $_" } + $confirm = Read-Host " Continue anyway? (y/N)" + if ($confirm -ne 'y') { Fail "User cancelled" } +} + +$existingTag = git tag -l $Version +if ($existingTag) { + Fail "Tag $Version already exists. Bump version.py/CHANGELOG and use a new tag." +} + +OkMsg "All checks passed (Version: $Version)" + +# ====== 1. Bump version.py ====== +Step "1/7 Bump core/version.py" +$verFile = 'core/version.py' +$verContent = Get-Content $verFile -Raw +$newContent = $verContent -replace "__version__ = '[^']+'", "__version__ = '$VersionRaw'" +if ($verContent -eq $newContent) { + Info "Already at $VersionRaw (no change)" +} else { + if (-not $DryRun) { Set-Content $verFile -Value $newContent -NoNewline } + OkMsg "$verFile -> $VersionRaw" +} + +# ====== 2. Tests ====== +if (-not $SkipTests) { + Step "2/7 Run tests" + Info "pytest unit tests..." + $rc = Invoke-Native python -m pytest tests -q + if ($rc -ne 0) { Fail "pytest failed (exit $rc)" } + + Info "integration scenarios..." + $rc = Invoke-Native python _integration_test.py + if ($rc -ne 0) { Fail "integration tests failed (exit $rc)" } + + OkMsg "All tests passed" +} else { + Step "2/7 Skipping tests (--SkipTests)" +} + +# ====== 3. Build ====== +Step "3/7 PyInstaller build" + +Info "Building main.exe..." +$rc = Invoke-Native python -m PyInstaller --clean main.spec +if ($rc -ne 0) { Fail "main.exe build failed (exit $rc)" } +if (-not (Test-Path 'dist/main.exe')) { Fail "dist/main.exe missing" } + +Info "Building updater.exe..." +$rc = Invoke-Native python -m PyInstaller --clean updater.spec +if ($rc -ne 0) { Fail "updater.exe build failed (exit $rc)" } +if (-not (Test-Path 'dist/updater.exe')) { Fail "dist/updater.exe missing" } + +$mainSize = "{0:N1}MB" -f ((Get-Item dist/main.exe).Length / 1MB) +$updaterSize = "{0:N1}MB" -f ((Get-Item dist/updater.exe).Length / 1MB) +OkMsg "main.exe ($mainSize) + updater.exe ($updaterSize)" + +# ====== 4. ZIP ====== +Step "4/7 ZIP packaging" +$zipPath = "dist/ClockOutCalculator-$Version.zip" +if (Test-Path $zipPath) { Remove-Item $zipPath } +Compress-Archive -Path 'dist/main.exe', 'dist/updater.exe' -DestinationPath $zipPath +$zipSize = "{0:N1}MB" -f ((Get-Item $zipPath).Length / 1MB) +OkMsg "$zipPath ($zipSize)" + +# ====== 5. Git commit + tag + push ====== +Step "5/7 Git commit + tag + push" + +if ($DryRun) { + Info "DryRun mode - skipping git ops" +} else { + git diff --quiet $verFile + $needsCommit = $LASTEXITCODE -ne 0 + if ($needsCommit) { + $rc = Invoke-Native git add $verFile CHANGELOG.md + $rc = Invoke-Native git commit -m "Release $Version" + if ($rc -ne 0) { Fail "git commit failed (exit $rc)" } + OkMsg "Committed version bump" + } + + $rc = Invoke-Native git tag $Version + if ($rc -ne 0) { Fail "git tag failed (exit $rc)" } + + Info "Pushing main + tag..." + $rc = Invoke-Native git push origin main + if ($rc -ne 0) { Fail "git push main failed (exit $rc)" } + $rc = Invoke-Native git push origin $Version + if ($rc -ne 0) { Fail "git push tag failed (exit $rc)" } + OkMsg "Pushed main + $Version" +} + +# ====== 6. Create Gitea Release ====== +Step "6/7 Create Gitea Release" + +if ($DryRun) { + Info "DryRun mode - skipping API call" + Info "Would POST $ApiBase/releases (tag=$Version)" + exit 0 +} + +$headers = @{ Authorization = "token $env:GITEA_TOKEN" } + +# Extract notes from CHANGELOG.md +$notes = "Release $Version" +if (Test-Path CHANGELOG.md) { + $changelog = Get-Content CHANGELOG.md -Raw + $pattern = "## \[$([regex]::Escape($VersionRaw))\][\s\S]*?(?=`n## \[|\z)" + $regexMatch = [regex]::Match($changelog, $pattern) + if ($regexMatch.Success) { $notes = $regexMatch.Value.Trim() } +} + +$bodyObj = @{ + tag_name = $Version + target_commitish = 'main' + name = $Version + body = $notes + draft = $false + prerelease = $false +} +$bodyJson = $bodyObj | ConvertTo-Json -Compress +$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyJson) + +try { + $release = Invoke-RestMethod -Uri "$ApiBase/releases" -Method Post -Headers $headers -ContentType 'application/json' -Body $bodyBytes + OkMsg "Release created (id=$($release.id))" +} catch { + Fail "Release creation failed: $($_.Exception.Message)" +} + +# ====== 7. Upload assets ====== +Step "7/7 Upload assets" + +$assets = @('dist/main.exe', 'dist/updater.exe', $zipPath) +foreach ($f in $assets) { + if (-not (Test-Path $f)) { Info "Skip (missing): $f"; continue } + $name = Split-Path $f -Leaf + Info "Uploading $name..." + try { + $uploadUrl = "$ApiBase/releases/$($release.id)/assets?name=$name" + $null = Invoke-RestMethod -Uri $uploadUrl -Method Post -Headers $headers -ContentType 'application/octet-stream' -InFile $f + OkMsg $name + } catch { + Fail "Upload failed ($name): $($_.Exception.Message)" + } +} + +Write-Host "" +Write-Host "========================================" -ForegroundColor Green +Write-Host " Release $Version published!" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Green +Write-Host " URL: $GiteaHost/$Owner/$Repo/releases/tag/$Version" -ForegroundColor White +Write-Host "" +Write-Host " Users will see the update on next app launch." -ForegroundColor Gray