diff --git a/COMPLETION_AUDIT.md b/COMPLETION_AUDIT.md index 57609c6..a1d985e 100644 --- a/COMPLETION_AUDIT.md +++ b/COMPLETION_AUDIT.md @@ -40,6 +40,10 @@ M0、M2、M3、M4 與 M5 的程式、文件、測試與本機 production-like - 已新增 `scripts/test_typetwo_deployment_gate.ps1`,可對 staging/production URL 執行 `/health`、smoke、備份還原驗證並輸出 evidence JSON。 - 已新增 GitHub Actions `Deployment Gate` workflow,設定 environment secrets 後可手動對 staging/production URL 產出 deployment evidence artifact。 - 本機 Docker 已實測 `test_typetwo_deployment_gate.ps1 -AllowHttp -ExpectedEnvironment development`,`health` 與 `smoke` 均通過,cleanup 成功;production/staging 仍需真實 HTTPS URL 重跑。 +- 已新增 `scripts/new_typetwo_prod_env.ps1`,可產生強 secret production `.env` 並立即套用 `check_typetwo_prod_env.ps1` 驗證。 +- 已新增 `scripts/test_typetwo_release_artifacts.ps1`,可下載 GitHub Release assets 並重新計算 installer/backend image SHA256。 +- 已實測 `new_typetwo_prod_env.ps1` 可產出 production env,且 production env guard 通過。 +- 已實測 `test_typetwo_release_artifacts.ps1 -Version v1.0.18`,GitHub Release assets 下載與 checksum 驗證通過。 ## 外部環境待驗證 @@ -52,6 +56,7 @@ M0、M2、M3、M4 與 M5 的程式、文件、測試與本機 production-like | GitHub Actions 綠燈 | 已在 PR 與 `master` 驗證通過 | `CI / backend-quality` 與 `CI / windows-quality` 皆通過 | | Release artifact 可下載 | 已在 GitHub Release `v1.0.18` 驗證通過 | Release 內有 `setup_typetwo.exe`、`setup_typetwo.exe.sha256`、`typetwo-glossary-api-v1.0.18.tar`、`typetwo-glossary-api-v1.0.18.tar.sha256` | | production 備份還原演練 | 需要 production DB 備份檔與非 production 還原環境 | 用 `.\scripts\verify_typetwo_postgres_backup.ps1 -BackupZip ` 驗證成功 | +| production env 產生 | 已有腳本,本機可驗證;正式值需保存到部署主機與密碼管理器 | `.\scripts\new_typetwo_prod_env.ps1 -GlossaryDomain -AcmeEmail -AdminEmail ` | ## 宣告全部完成的條件 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e12f0ed..2b4b4e7 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -37,6 +37,18 @@ Copy-Item .env.example.production .env notepad .env ``` +也可以用腳本產生強 secret 的 production env: + +```powershell +.\scripts\new_typetwo_prod_env.ps1 ` + -GlossaryDomain "typetwo-glossary.company.com" ` + -AcmeEmail "it-admin@company.com" ` + -AdminEmail "admin@company.com" ` + -OutputPath ".\.env.production" +``` + +腳本會產生 `POSTGRES_PASSWORD`、`JWT_SECRET`、`ADMIN_PASSWORD`,並立即執行 `check_typetwo_prod_env.ps1` 驗證;請把產出的 `ADMIN_PASSWORD` 保存到密碼管理器。 + ## Staging smoke 先建立或還原一份 staging DB,再執行: @@ -75,6 +87,13 @@ GitHub Actions 也提供手動 workflow `Deployment Gate`。使用前需在對 2. 確認 release artifact: - `setup_typetwo.exe` - `typetwo-glossary-api-.tar` + +可用下列命令下載 release assets 並驗證 SHA256: + +```powershell +.\scripts\test_typetwo_release_artifacts.ps1 -Version v1.0.18 +``` + 3. 載入或拉取 backend image,tag 必須是 release version 或 git SHA,不使用 `latest` 部署 production。 4. 執行 Alembic migration: diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 27d76ea..a456252 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -40,6 +40,7 @@ - `setup_typetwo.exe.sha256` 已由 release workflow 產出並附加到 GitHub Release。 - `typetwo-glossary-api-.tar` 已由 release workflow 產出並附加到 GitHub Release。 - `typetwo-glossary-api-.tar.sha256` 已由 release workflow 產出並附加到 GitHub Release。 +- `.\scripts\test_typetwo_release_artifacts.ps1 -Version ` 通過。 - backend image tag 至少包含: - `typetwo-glossary-api:` - `typetwo-glossary-api:` @@ -60,6 +61,7 @@ - production DB 已完成備份並記錄備份位置。 - production 備份已在非 production volume 驗證可還原。 - `check_typetwo_prod_env.ps1` 通過。 +- 若需產生新 env,`new_typetwo_prod_env.ps1` 產出的 `.env.production` 已保存到部署主機並限制權限。 - Alembic migration 已執行。 - service 已啟動,`/health` 為 `ok=true`、`db=ok`、`environment=production`。 - production smoke 通過且 cleanup 驗證通過。 diff --git a/plan-20260526.md b/plan-20260526.md index bee5131..45d59b2 100644 --- a/plan-20260526.md +++ b/plan-20260526.md @@ -573,6 +573,13 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml config - 已用本機 Docker 實測 deployment gate,`health` 與 `smoke` 皆通過,並確認 cleanup 成功。 - repo 目前沒有 staging/production 主機、DNS、GitHub Secrets 或 Variables;最後外部 gate 仍需真實 HTTPS URL 與 admin credentials 才能執行。 +### 2026-05-29:production env 與 release artifact 驗證腳本增量 + +- 已新增 `scripts/new_typetwo_prod_env.ps1`,可輸入 domain、ACME email、admin email 後產生強 secret `.env.production`,並自動執行 production env guard。 +- 已新增 `scripts/test_typetwo_release_artifacts.ps1`,可下載指定 GitHub Release 的 installer、backend image tar 與 SHA256 檔,並重新計算 checksum。 +- `DEPLOYMENT.md`、`RELEASE_CHECKLIST.md` 與 `COMPLETION_AUDIT.md` 已補上 production env 產生與 release artifact 驗證命令。 +- 已實測 production env 產生腳本與 release artifact 驗證腳本,兩者皆通過。 + ## 下一步建議 下一輪優先補齊外部環境證據: diff --git a/scripts/new_typetwo_prod_env.ps1 b/scripts/new_typetwo_prod_env.ps1 new file mode 100644 index 0000000..f18be4e --- /dev/null +++ b/scripts/new_typetwo_prod_env.ps1 @@ -0,0 +1,110 @@ +param( + [Parameter(Mandatory = $true)][string]$GlossaryDomain, + [Parameter(Mandatory = $true)][string]$AcmeEmail, + [Parameter(Mandatory = $true)][string]$AdminEmail, + [string]$OutputPath = ".\.env.production", + [switch]$Force +) + +$ErrorActionPreference = "Stop" + +function New-Secret { + param([int]$Bytes = 32) + + $buffer = New-Object byte[] $Bytes + [System.Security.Cryptography.RandomNumberGenerator]::Fill($buffer) + return [Convert]::ToBase64String($buffer).TrimEnd("=") +} + +function Assert-Email { + param( + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Value + ) + + if ($Value -notmatch "^[^@\s]+@[^@\s]+\.[^@\s]+$") { + throw "$Name 格式不正確:$Value" + } +} + +if ((Test-Path -LiteralPath $OutputPath) -and -not $Force) { + throw "$OutputPath 已存在;若要覆蓋請加 -Force" +} + +if ($GlossaryDomain -match "^https?://") { + throw "GlossaryDomain 只填 host,不要包含 https://" +} + +if ($GlossaryDomain -match "example\.com$") { + throw "GlossaryDomain 不應使用 example.com" +} + +Assert-Email -Name "AcmeEmail" -Value $AcmeEmail +Assert-Email -Name "AdminEmail" -Value $AdminEmail + +$postgresPassword = New-Secret -Bytes 24 +$jwtSecret = New-Secret -Bytes 48 +$adminPassword = New-Secret -Bytes 24 +$publicBaseUrl = "https://$GlossaryDomain" + +$content = @( + "# TypeTwo Server production env" + "# 產生時間:$((Get-Date).ToUniversalTime().ToString("o"))" + "ENVIRONMENT=production" + "POSTGRES_PASSWORD=$postgresPassword" + "JWT_SECRET=$jwtSecret" + "ADMIN_EMAIL=$AdminEmail" + "ADMIN_PASSWORD=$adminPassword" + "GLOSSARY_DOMAIN=$GlossaryDomain" + "ACME_EMAIL=$AcmeEmail" + "PUBLIC_BASE_URL=$publicBaseUrl" + "CORS_ALLOWED_ORIGINS=$publicBaseUrl" + "AUTO_CREATE_TABLES=false" +) + +$content | Set-Content -Encoding utf8 -Path $OutputPath + +$oldEnv = @{} +foreach ($name in @( + "ENVIRONMENT", + "POSTGRES_PASSWORD", + "JWT_SECRET", + "ADMIN_EMAIL", + "ADMIN_PASSWORD", + "GLOSSARY_DOMAIN", + "ACME_EMAIL", + "PUBLIC_BASE_URL", + "CORS_ALLOWED_ORIGINS", + "AUTO_CREATE_TABLES" +)) { + $oldEnv[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} + +try { + $env:ENVIRONMENT = "production" + $env:POSTGRES_PASSWORD = $postgresPassword + $env:JWT_SECRET = $jwtSecret + $env:ADMIN_EMAIL = $AdminEmail + $env:ADMIN_PASSWORD = $adminPassword + $env:GLOSSARY_DOMAIN = $GlossaryDomain + $env:ACME_EMAIL = $AcmeEmail + $env:PUBLIC_BASE_URL = $publicBaseUrl + $env:CORS_ALLOWED_ORIGINS = $publicBaseUrl + $env:AUTO_CREATE_TABLES = "false" + + & "$PSScriptRoot\check_typetwo_prod_env.ps1" + if (-not $?) { + throw "正式環境變數檢查失敗" + } +} finally { + foreach ($pair in $oldEnv.GetEnumerator()) { + if ($null -eq $pair.Value) { + [Environment]::SetEnvironmentVariable($pair.Key, $null, "Process") + } else { + [Environment]::SetEnvironmentVariable($pair.Key, $pair.Value, "Process") + } + } +} + +Write-Output "已產生正式環境檔:$OutputPath" +Write-Output "請立即把 ADMIN_PASSWORD 保存到密碼管理器,並限制此檔案權限。" diff --git a/scripts/test_typetwo_release_artifacts.ps1 b/scripts/test_typetwo_release_artifacts.ps1 new file mode 100644 index 0000000..a1f4086 --- /dev/null +++ b/scripts/test_typetwo_release_artifacts.ps1 @@ -0,0 +1,76 @@ +param( + [Parameter(Mandatory = $true)][string]$Version, + [string]$Repository = "JaplinChen/TypeTwo", + [string]$DownloadDir = ".\.tmp-release-artifacts", + [switch]$KeepDownloads +) + +$ErrorActionPreference = "Stop" + +if ($Version -notmatch "^v\d+\.\d+\.\d+$") { + throw "Version 必須是 vX.Y.Z 格式,例如 v1.0.18" +} + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "需要 GitHub CLI:gh" +} + +$backendTar = "typetwo-glossary-api-$Version.tar" +$backendSha = "$backendTar.sha256" +$required = @( + "setup_typetwo.exe", + "setup_typetwo.exe.sha256", + $backendTar, + $backendSha +) + +if (Test-Path -LiteralPath $DownloadDir) { + Remove-Item -LiteralPath $DownloadDir -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $DownloadDir | Out-Null + +try { + gh release view $Version --repo $Repository | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "找不到 GitHub Release:$Repository $Version" + } + + foreach ($asset in $required) { + gh release download $Version --repo $Repository --dir $DownloadDir --pattern $asset + if ($LASTEXITCODE -ne 0) { + throw "下載 release asset 失敗:$asset" + } + } + + foreach ($asset in $required) { + $path = Join-Path $DownloadDir $asset + if (-not (Test-Path -LiteralPath $path)) { + throw "缺少 release asset:$asset" + } + } + + $installerExpected = (Get-Content -Raw (Join-Path $DownloadDir "setup_typetwo.exe.sha256")).Split(" ", [System.StringSplitOptions]::RemoveEmptyEntries)[0] + $installerActual = (Get-FileHash -Algorithm SHA256 -Path (Join-Path $DownloadDir "setup_typetwo.exe")).Hash.ToLowerInvariant() + if ($installerExpected -ne $installerActual) { + throw "installer checksum mismatch" + } + + $backendExpected = (Get-Content -Raw (Join-Path $DownloadDir $backendSha)).Split(" ", [System.StringSplitOptions]::RemoveEmptyEntries)[0] + $backendActual = (Get-FileHash -Algorithm SHA256 -Path (Join-Path $DownloadDir $backendTar)).Hash.ToLowerInvariant() + if ($backendExpected -ne $backendActual) { + throw "backend image checksum mismatch" + } + + [pscustomobject]@{ + ok = $true + repository = $Repository + version = $Version + installerSha256 = $installerActual + backendImageSha256 = $backendActual + checkedAt = (Get-Date).ToUniversalTime().ToString("o") + } | ConvertTo-Json -Depth 4 +} finally { + if (-not $KeepDownloads -and (Test-Path -LiteralPath $DownloadDir)) { + Remove-Item -LiteralPath $DownloadDir -Recurse -Force + } +}