// ===================================================================== // BITFORUM Frontend Jenkinsfile (Next.js 15 standalone + WinSW + IIS reverse proxy) // - Branches: main / dev -> deploy // others -> build/test only // - Agent: CTL-Window-Server (Node v24) // - Deploy path: C:\workspace\IIS\bitforum-front (?쒕쾭 愿€濡€) // - Process: WinSW service 'bitforum-frontend' (node server.js on :3001) // - IIS: bitforum-front site (URL Rewrite) reverse-proxies :80 -> 127.0.0.1:3001 // ===================================================================== pipeline { agent { label 'CTL-Window-Server' } options { timestamps() disableConcurrentBuilds() buildDiscarder(logRotator(numToKeepStr: '20')) } environment { DEPLOY_ROOT = 'C:\\workspace\\IIS\\bitforum-front' RELEASES = 'C:\\workspace\\IIS\\_releases' SVC_NAME = 'bitforum-frontend' } stages { stage('Install') { steps { bat 'chcp 65001 >NUL && node -v && npm -v' script { // package-lock.json 蹂€寃?+ node_modules 議댁옱 ?щ? 議고빀?쇰줈 npm ci ?ㅽ궢. // 泥?鍮뚮뱶 ?먮뒗 lock 蹂€寃??쒖뿉留?full install (罹먯떆 ?쒖슜 ?꾪빐 --prefer-offline). def lockChanged = bat(returnStatus: true, script: '@git diff --quiet HEAD~1 HEAD -- package-lock.json 2>nul') != 0 def hasModules = fileExists('node_modules/.package-lock.json') if (!hasModules || lockChanged) { echo "Installing (lockChanged=${lockChanged}, hasModules=${hasModules})" bat 'chcp 65001 >NUL && npm ci --no-audit --no-fund --prefer-offline' } else { echo 'package-lock.json unchanged + node_modules present ??skip npm ci' } } } } stage('Inject Env (Secret File)') { when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } } steps { script { // Backend ?⑦꽩怨??숈씪: 釉뚮옖移섎퀎濡?Jenkins Secret File 二쇱엯 // dev ??DEV-BITFORUM-FRONTEND-ENV (api.bitforum.io ??dev ?쒕쾭 ?댁쁺 ?꾨찓?? // main ??PROD-BITFORUM-FRONTEND-ENV (api.bitforum.io) // .env.production ?€ .gitignore ??(Backend ??appsettings.Production.json 泥섎읆 鍮뚮뱶 ?쒖젏?먮쭔 議댁옱). def credId = (env.BRANCH_NAME == 'main') ? 'PROD-BITFORUM-FRONTEND-ENV' : 'DEV-BITFORUM-FRONTEND-ENV' echo "[env] Using Jenkins Secret File: ${credId}" withCredentials([file(credentialsId: credId, variable: 'CFG')]) { bat 'chcp 65001 >NUL && copy /Y "%CFG%" .env.production >NUL && for %%I in (.env.production) do @echo [env] .env.production injected (%%~zI bytes)' } } } } stage('Build') { steps { bat 'chcp 65001 >NUL && npm run build' } } stage('Deploy') { when { expression { !env.BRANCH_NAME || env.BRANCH_NAME in ['main', 'dev'] } } steps { script { deployFrontend() } } } } post { success { echo "Frontend build & deploy completed (branch=${env.BRANCH_NAME ?: 'unknown'})" } failure { echo 'Frontend build/deploy failed - check stage logs' } } } def deployFrontend() { powershell """ \$ErrorActionPreference = 'Stop' \$ts = Get-Date -Format 'yyyyMMdd-HHmmss' \$dest = '${env.DEPLOY_ROOT}' \$backup = Join-Path '${env.RELEASES}' ('${env.SVC_NAME}-' + \$ts) if (-not (Test-Path '${env.RELEASES}')) { New-Item -ItemType Directory -Path '${env.RELEASES}' -Force | Out-Null } Write-Host '[${env.SVC_NAME}] Stopping service...' Stop-Service ${env.SVC_NAME} -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 # Preserve infrastructure files (WinSW + web.config + logs) \$keep = @('${env.SVC_NAME}.exe', '${env.SVC_NAME}.xml', 'web.config', '${env.SVC_NAME}.err.log', '${env.SVC_NAME}.out.log', '${env.SVC_NAME}.wrapper.log') if ((Test-Path \$dest) -and ((Get-ChildItem \$dest -Force | Measure-Object).Count -gt 0)) { Write-Host ('[${env.SVC_NAME}] Backing up current to ' + \$backup) New-Item -ItemType Directory -Path \$backup -Force | Out-Null Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } | Move-Item -Destination \$backup -Force } elseif (-not (Test-Path \$dest)) { New-Item -ItemType Directory -Path \$dest -Force | Out-Null } # Next.js standalone layout: # .next/standalone/ -> server.js + minimal node_modules + .next folder (server bundles) # .next/static/ -> static assets (must be copied to /.next/static) # public/ -> public assets (must be copied to /public) Write-Host '[${env.SVC_NAME}] Copying standalone server...' Copy-Item '.next\\standalone\\*' \$dest -Recurse -Force Write-Host '[${env.SVC_NAME}] Copying static assets...' New-Item -ItemType Directory -Path (Join-Path \$dest '.next\\static') -Force | Out-Null Copy-Item '.next\\static\\*' (Join-Path \$dest '.next\\static') -Recurse -Force Write-Host '[${env.SVC_NAME}] Copying public assets...' New-Item -ItemType Directory -Path (Join-Path \$dest 'public') -Force | Out-Null if (Test-Path 'public') { Copy-Item 'public\\*' (Join-Path \$dest 'public') -Recurse -Force } # Server-side env (.env.production) ??Next.js standalone runtime reads from cwd. # NEXT_PUBLIC_* ?€ 鍮뚮뱶 ??inline ?섏?留? server-side (API_URL, SIGNALR_CHAT_URL ?? ???고???濡쒕뱶. if (Test-Path '.env.production') { Write-Host '[${env.SVC_NAME}] Copying .env.production...' Copy-Item '.env.production' (Join-Path \$dest '.env.production') -Force } else { Write-Warning '[${env.SVC_NAME}] .env.production missing in workspace - server-side env will fall back to OS/WinSW env' } # Server-side env (.env.production) ??Next.js standalone runtime reads from cwd # NEXT_PUBLIC_* ?€ 鍮뚮뱶 ??inline ?섏?留? server-side (API_URL, SIGNALR_CHAT_URL ?? ???고???濡쒕뱶 if (Test-Path '.env.production') { Write-Host '[${env.SVC_NAME}] Copying .env.production...' Copy-Item '.env.production' (Join-Path \$dest '.env.production') -Force } else { Write-Warning '[${env.SVC_NAME}] .env.production missing in workspace - server-side env will fall back to OS/WinSW env' } # Infra files (IIS reverse-proxy web.config + WinSW service xml) - carried in repo, placed into dest. Write-Host '[${env.SVC_NAME}] Copying infra files (web.config + WinSW xml)...' if (Test-Path 'web.config') { Copy-Item 'web.config' (Join-Path \$dest 'web.config') -Force } if (Test-Path '${env.SVC_NAME}.xml') { Copy-Item '${env.SVC_NAME}.xml' (Join-Path \$dest '${env.SVC_NAME}.xml') -Force } # Auto-provision WinSW binary from antooza-front (same WinSW exe, renamed) if missing. \$svcExe = Join-Path \$dest '${env.SVC_NAME}.exe' if (-not (Test-Path \$svcExe)) { \$srcExe = 'C:\\workspace\\IIS\\antooza-front\\antooza-frontend.exe' if (Test-Path \$srcExe) { Write-Host '[${env.SVC_NAME}] Provisioning WinSW binary from antooza-front'; Copy-Item \$srcExe \$svcExe -Force } } # First-deploy auto-install WinSW if (-not (Get-Service ${env.SVC_NAME} -ErrorAction SilentlyContinue)) { Write-Host '[${env.SVC_NAME}] Service not registered - installing' \$installer = Join-Path \$dest '${env.SVC_NAME}.exe' if (-not (Test-Path \$installer)) { throw ('[${env.SVC_NAME}] WinSW binary missing at ' + \$installer + ' ??place ${env.SVC_NAME}.exe (renamed WinSW) + ${env.SVC_NAME}.xml in ' + \$dest) } & \$installer install if (\$LASTEXITCODE -ne 0) { throw ('[${env.SVC_NAME}] install failed (exit ' + \$LASTEXITCODE + ')') } Start-Sleep -Seconds 2 } Write-Host '[${env.SVC_NAME}] Starting service...' Start-Service ${env.SVC_NAME} # Health check: HTTP 200/3xx on http://127.0.0.1:3001/ \$ok = \$false 1..15 | ForEach-Object { Start-Sleep -Seconds 3 try { \$r = Invoke-WebRequest -Uri 'http://127.0.0.1:3001/' -UseBasicParsing -TimeoutSec 5 -MaximumRedirection 0 if (\$r.StatusCode -ge 200 -and \$r.StatusCode -lt 400) { Write-Host ('[${env.SVC_NAME}] Healthy (HTTP ' + \$r.StatusCode + ') on attempt ' + \$_) \$ok = \$true; return } } catch { \$resp = \$_.Exception.Response if (\$resp) { \$code = [int]\$resp.StatusCode if (\$code -ge 200 -and \$code -lt 400) { Write-Host ('[${env.SVC_NAME}] Healthy (HTTP ' + \$code + ') on attempt ' + \$_) \$ok = \$true; return } } Write-Host ('[${env.SVC_NAME}] attempt ' + \$_ + ' failed: ' + \$_.Exception.Message) } } if (-not \$ok) { Write-Warning '[${env.SVC_NAME}] Verification failed - rolling back' Stop-Service ${env.SVC_NAME} -Force -ErrorAction SilentlyContinue if (Test-Path \$backup) { Get-ChildItem \$dest -Force | Where-Object { \$_.Name -notin \$keep } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue Copy-Item (Join-Path \$backup '*') \$dest -Recurse -Force Start-Service ${env.SVC_NAME} } throw '${env.SVC_NAME} deployment failed; rolled back.' } # Retain only 5 recent backups Get-ChildItem '${env.RELEASES}' -Directory -ErrorAction SilentlyContinue | Where-Object { \$_.Name -like '${env.SVC_NAME}-*' } | Sort-Object LastWriteTime -Descending | Select-Object -Skip 5 | Remove-Item -Recurse -Force """ }