<# .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" # Build updater FIRST so main.spec can embed it as data 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" } # Stage updater.exe outside dist/ so main.spec --clean does not wipe it $stagingDir = 'build/staging' if (-not (Test-Path $stagingDir)) { New-Item -ItemType Directory -Path $stagingDir | Out-Null } Copy-Item 'dist/updater.exe' "$stagingDir/updater.exe" -Force Info "Staged updater.exe -> $stagingDir/updater.exe" Info "Building main.exe (with embedded updater)..." $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" } # Restore updater.exe to dist/ for separate Release asset upload if (-not (Test-Path 'dist/updater.exe')) { Copy-Item "$stagingDir/updater.exe" 'dist/updater.exe' -Force Info "Restored updater.exe to dist/" } $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 # IMPORTANT: PowerShell 5.1 Get-Content defaults to ANSI; use .NET API for UTF-8 (Korean). $notes = "Release $Version" if (Test-Path CHANGELOG.md) { $changelogPath = (Resolve-Path CHANGELOG.md).Path $changelog = [System.IO.File]::ReadAllText($changelogPath, [System.Text.Encoding]::UTF8) $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