Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions COMPLETION_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 驗證通過。

## 外部環境待驗證

Expand All @@ -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-backup.zip>` 驗證成功 |
| production env 產生 | 已有腳本,本機可驗證;正式值需保存到部署主機與密碼管理器 | `.\scripts\new_typetwo_prod_env.ps1 -GlossaryDomain <domain> -AcmeEmail <email> -AdminEmail <email>` |

## 宣告全部完成的條件

Expand Down
19 changes: 19 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,再執行:
Expand Down Expand Up @@ -75,6 +87,13 @@ GitHub Actions 也提供手動 workflow `Deployment Gate`。使用前需在對
2. 確認 release artifact:
- `setup_typetwo.exe`
- `typetwo-glossary-api-<version>.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:

Expand Down
2 changes: 2 additions & 0 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- `setup_typetwo.exe.sha256` 已由 release workflow 產出並附加到 GitHub Release。
- `typetwo-glossary-api-<version>.tar` 已由 release workflow 產出並附加到 GitHub Release。
- `typetwo-glossary-api-<version>.tar.sha256` 已由 release workflow 產出並附加到 GitHub Release。
- `.\scripts\test_typetwo_release_artifacts.ps1 -Version <version>` 通過。
- backend image tag 至少包含:
- `typetwo-glossary-api:<git-sha>`
- `typetwo-glossary-api:<version>`
Expand All @@ -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 驗證通過。
Expand Down
7 changes: 7 additions & 0 deletions plan-20260526.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 驗證腳本,兩者皆通過。

## 下一步建議

下一輪優先補齊外部環境證據:
Expand Down
110 changes: 110 additions & 0 deletions scripts/new_typetwo_prod_env.ps1
Original file line number Diff line number Diff line change
@@ -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 保存到密碼管理器,並限制此檔案權限。"
76 changes: 76 additions & 0 deletions scripts/test_typetwo_release_artifacts.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading