diff --git a/.github/workflows/deployment-gate.yml b/.github/workflows/deployment-gate.yml new file mode 100644 index 0000000..c500d7e --- /dev/null +++ b/.github/workflows/deployment-gate.yml @@ -0,0 +1,69 @@ +name: Deployment Gate + +on: + workflow_dispatch: + inputs: + target: + description: staging 或 production + required: true + default: staging + type: choice + options: + - staging + - production + base_url: + description: TypeTwo Server HTTPS URL + required: true + type: string + expected_environment: + description: /health environment 預期值 + required: true + default: production + type: string + skip_smoke: + description: 只檢查 /health,不執行會寫入資料的 smoke + required: true + default: false + type: boolean + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + deployment-gate: + runs-on: windows-latest + timeout-minutes: 20 + environment: ${{ inputs.target }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run deployment gate + shell: powershell + env: + ADMIN_EMAIL: ${{ secrets.TYPETWO_ADMIN_EMAIL }} + ADMIN_PASSWORD: ${{ secrets.TYPETWO_ADMIN_PASSWORD }} + run: | + $args = @( + '-BaseUrl', '${{ inputs.base_url }}', + '-ExpectedEnvironment', '${{ inputs.expected_environment }}', + '-EvidencePath', 'deployment-evidence-${{ inputs.target }}.json' + ) + + if ('${{ inputs.skip_smoke }}' -eq 'true') { + $args += '-SkipSmoke' + } + + .\scripts\test_typetwo_deployment_gate.ps1 @args + + - name: Upload deployment evidence + uses: actions/upload-artifact@v4 + if: always() + with: + name: deployment-evidence-${{ inputs.target }} + path: deployment-evidence-${{ inputs.target }}.json + if-no-files-found: warn diff --git a/COMPLETION_AUDIT.md b/COMPLETION_AUDIT.md index 79ad503..57609c6 100644 --- a/COMPLETION_AUDIT.md +++ b/COMPLETION_AUDIT.md @@ -37,6 +37,9 @@ M0、M2、M3、M4 與 M5 的程式、文件、測試與本機 production-like - `master` CI run `26617148862`:`backend-quality` 與 `windows-quality` 皆通過。 - GitHub Release `v1.0.18`:release workflow run `26617247786` 通過,已附加 `setup_typetwo.exe`、`setup_typetwo.exe.sha256`、`typetwo-glossary-api-v1.0.18.tar`、`typetwo-glossary-api-v1.0.18.tar.sha256`。 - GitHub Release `v1.0.18` checksum:release assets 已下載後重新計算,installer 與 backend image checksum 皆吻合。 +- 已新增 `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 重跑。 ## 外部環境待驗證 @@ -45,6 +48,7 @@ M0、M2、M3、M4 與 M5 的程式、文件、測試與本機 production-like | 正式 HTTPS domain | 需要真實 DNS、TLS 憑證與可從公司外連線的主機 | `Invoke-RestMethod https:///health` 回傳 `ok=true`、`db=ok`、`environment=production` | | staging smoke | 需要 staging domain 與 staging DB | 對 staging URL 執行 `.\scripts\smoke_typetwo_glossary_api.ps1 -BaseUrl https://` | | production smoke | 需要 production domain 與 production 帳號策略 | 對 production URL 執行 smoke,確認 cleanup 後沒有測試詞彙與可登入測試帳號 | +| staging/production evidence | 已有腳本與 workflow,仍需要真實 URL 與 admin secrets | 執行 `test_typetwo_deployment_gate.ps1` 或 GitHub Actions `Deployment Gate` | | 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 ` 驗證成功 | @@ -60,3 +64,12 @@ M0、M2、M3、M4 與 M5 的程式、文件、測試與本機 production-like 5. GitHub Release event 產出 installer、backend image tar 與各自 checksum,並附加到 GitHub Release。 6. production 備份已還原到非 production volume 驗證。 7. rollback runbook 至少演練一次並記錄結果。 + +## 仍需提供的外部設定 + +repo 目前沒有部署 secrets、environment variables、遠端主機或 DNS 設定。若要讓 GitHub Actions `Deployment Gate` 直接完成 staging/production evidence,需先在 GitHub environment `staging` 與 `production` 設定: + +- `TYPETWO_ADMIN_EMAIL` +- `TYPETWO_ADMIN_PASSWORD` + +並準備可從 GitHub runner 連線的 HTTPS URL,例如 `https://typetwo-glossary.company.com`。 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 35db4b0..e12f0ed 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -51,6 +51,24 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build smoke script 會建立臨時 approved 詞彙、臨時 user、pending 建議詞,驗證 approve 後可進 approved 詞彙包,最後刪除 smoke 詞彙、停用 smoke user,並確認既有 token 已失效。 +也可以用 deployment gate 統一產出證據檔: + +```powershell +.\scripts\test_typetwo_deployment_gate.ps1 ` + -BaseUrl "https://staging.example.com" ` + -ExpectedEnvironment "production" ` + -AdminEmail $env:ADMIN_EMAIL ` + -AdminPassword $env:ADMIN_PASSWORD ` + -EvidencePath ".\evidence\staging-deployment-gate.json" +``` + +GitHub Actions 也提供手動 workflow `Deployment Gate`。使用前需在對應 environment 設定 secrets: + +- `TYPETWO_ADMIN_EMAIL` +- `TYPETWO_ADMIN_PASSWORD` + +執行 workflow 時輸入 staging 或 production URL;workflow 會上傳 `deployment-evidence-.json` artifact。 + ## Production 部署 1. 備份 production PostgreSQL。 @@ -79,6 +97,18 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d -AdminPassword $env:ADMIN_PASSWORD ``` +建議正式驗收改用 deployment gate,同時輸出 evidence: + +```powershell +.\scripts\test_typetwo_deployment_gate.ps1 ` + -BaseUrl $env:PUBLIC_BASE_URL ` + -ExpectedEnvironment "production" ` + -AdminEmail $env:ADMIN_EMAIL ` + -AdminPassword $env:ADMIN_PASSWORD ` + -BackupZip ".\backups\typetwo_.sql.zip" ` + -EvidencePath ".\evidence\production-deployment-gate.json" +``` + ## 備份與還原驗證 建立備份: diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 0ce83b8..27d76ea 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -52,6 +52,7 @@ - staging 備份已用 `verify_typetwo_postgres_backup.ps1` 還原到臨時 volume 驗證。 - Alembic migration 已在 staging 成功升級到 head。 - staging smoke 通過。 +- `test_typetwo_deployment_gate.ps1` 或 GitHub Actions `Deployment Gate` 已產出 staging evidence JSON。 - Flutter App 可連 staging TypeTwo Server 並完成登入、同步、pending review、匯入預覽。 ## Production @@ -62,6 +63,7 @@ - Alembic migration 已執行。 - service 已啟動,`/health` 為 `ok=true`、`db=ok`、`environment=production`。 - production smoke 通過且 cleanup 驗證通過。 +- `test_typetwo_deployment_gate.ps1` 或 GitHub Actions `Deployment Gate` 已產出 production evidence JSON。 - rollback owner 與 rollback artifact 已確認。 ## Release notes diff --git a/plan-20260526.md b/plan-20260526.md index c24db53..bee5131 100644 --- a/plan-20260526.md +++ b/plan-20260526.md @@ -565,6 +565,14 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml config - 已建立 GitHub Release `v1.0.18`,release workflow run `26617247786` 已通過,並成功附加 installer、backend image tar 與各自 SHA256。 - 已下載 GitHub Release `v1.0.18` assets 並重新計算 SHA256,確認 release 內附 checksum 皆吻合。 +### 2026-05-29:部署 evidence gate 增量 + +- 已新增 `scripts/test_typetwo_deployment_gate.ps1`,可對 staging/production URL 統一執行 `/health`、smoke、備份還原驗證,並輸出 JSON evidence。 +- 已新增 GitHub Actions `Deployment Gate` workflow,可手動輸入 staging/production URL,使用 environment secrets 執行遠端 smoke 並上傳 evidence artifact。 +- `DEPLOYMENT.md`、`RELEASE_CHECKLIST.md` 與 `COMPLETION_AUDIT.md` 已補上 deployment gate 使用方式與完成條件。 +- 已用本機 Docker 實測 deployment gate,`health` 與 `smoke` 皆通過,並確認 cleanup 成功。 +- repo 目前沒有 staging/production 主機、DNS、GitHub Secrets 或 Variables;最後外部 gate 仍需真實 HTTPS URL 與 admin credentials 才能執行。 + ## 下一步建議 下一輪優先補齊外部環境證據: diff --git a/scripts/test_typetwo_deployment_gate.ps1 b/scripts/test_typetwo_deployment_gate.ps1 new file mode 100644 index 0000000..b4492b6 --- /dev/null +++ b/scripts/test_typetwo_deployment_gate.ps1 @@ -0,0 +1,149 @@ +param( + [Parameter(Mandatory = $true)][string]$BaseUrl, + [string]$AdminEmail = $env:ADMIN_EMAIL, + [string]$AdminPassword = $env:ADMIN_PASSWORD, + [string]$ExpectedEnvironment = "", + [string]$BackupZip = "", + [string]$EvidencePath = ".\deployment-evidence.json", + [switch]$AllowHttp, + [switch]$SkipSmoke, + [switch]$SkipBackupVerify +) + +$ErrorActionPreference = "Stop" + +function Add-Step { + param( + [System.Collections.Generic.List[object]]$Steps, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$Status, + [object]$Details = $null + ) + + $Steps.Add([pscustomobject]@{ + name = $Name + status = $Status + details = $Details + checkedAt = (Get-Date).ToUniversalTime().ToString("o") + }) +} + +function Assert-Url { + param([string]$Value) + + try { + $uri = [Uri]$Value + } catch { + throw "BaseUrl 必須是有效 URL:$Value" + } + + if (-not $AllowHttp -and $uri.Scheme -ne "https") { + throw "BaseUrl 必須使用 https://;本機或臨時測試才可加 -AllowHttp" + } + + if ($uri.AbsolutePath -ne "/") { + return $Value.TrimEnd("/") + } + + return "$($uri.Scheme)://$($uri.Authority)" +} + +function Wait-TypeTwoHealth { + param( + [Parameter(Mandatory = $true)][string]$Url, + [int]$TimeoutSeconds = 90 + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $lastError = $null + + do { + try { + $body = Invoke-RestMethod -Method Get -Uri "$Url/health" + if ($body.ok -eq $true -and $body.db -eq "ok") { + return $body + } + $lastError = "/health 回傳異常:$($body | ConvertTo-Json -Compress -Depth 8)" + } catch { + $lastError = $_.Exception.Message + } + + Start-Sleep -Seconds 2 + } while ((Get-Date) -lt $deadline) + + throw "API healthcheck 未在期限內通過:$lastError" +} + +$normalizedBaseUrl = Assert-Url -Value $BaseUrl +$steps = New-Object "System.Collections.Generic.List[object]" +$summary = [ordered]@{ + ok = $false + baseUrl = $normalizedBaseUrl + expectedEnvironment = $ExpectedEnvironment + startedAt = (Get-Date).ToUniversalTime().ToString("o") + completedAt = $null + steps = $steps +} + +try { + $health = Wait-TypeTwoHealth -Url $normalizedBaseUrl + + if (-not [string]::IsNullOrWhiteSpace($ExpectedEnvironment) -and + $health.environment -ne $ExpectedEnvironment) { + throw "/health environment 不符合預期:expected=$ExpectedEnvironment actual=$($health.environment)" + } + + Add-Step -Steps $steps -Name "health" -Status "passed" -Details $health + + if ($SkipSmoke) { + Add-Step -Steps $steps -Name "smoke" -Status "skipped" -Details "已使用 -SkipSmoke" + } else { + if ([string]::IsNullOrWhiteSpace($AdminEmail) -or [string]::IsNullOrWhiteSpace($AdminPassword)) { + throw "執行 smoke 需要 AdminEmail/AdminPassword,或使用 -SkipSmoke" + } + + $smokeJson = & "$PSScriptRoot\smoke_typetwo_glossary_api.ps1" ` + -BaseUrl $normalizedBaseUrl ` + -AdminEmail $AdminEmail ` + -AdminPassword $AdminPassword + if ($LASTEXITCODE -ne 0) { + throw "smoke script 失敗" + } + + $smokeResult = $smokeJson | ConvertFrom-Json + if ($smokeResult.ok -ne $true) { + throw "smoke result 不符合預期:$($smokeResult | ConvertTo-Json -Compress -Depth 8)" + } + + Add-Step -Steps $steps -Name "smoke" -Status "passed" -Details $smokeResult + } + + if ($SkipBackupVerify) { + Add-Step -Steps $steps -Name "backup-restore" -Status "skipped" -Details "已使用 -SkipBackupVerify" + } elseif ([string]::IsNullOrWhiteSpace($BackupZip)) { + Add-Step -Steps $steps -Name "backup-restore" -Status "skipped" -Details "未提供 BackupZip" + } else { + & "$PSScriptRoot\verify_typetwo_postgres_backup.ps1" -BackupZip $BackupZip + if ($LASTEXITCODE -ne 0) { + throw "備份還原驗證失敗" + } + + Add-Step -Steps $steps -Name "backup-restore" -Status "passed" -Details @{ + backupZip = (Resolve-Path -LiteralPath $BackupZip).Path + } + } + + $summary.ok = $true +} catch { + Add-Step -Steps $steps -Name "deployment-gate" -Status "failed" -Details $_.Exception.Message + throw +} finally { + $summary.completedAt = (Get-Date).ToUniversalTime().ToString("o") + $evidenceDir = Split-Path -Parent $EvidencePath + if (-not [string]::IsNullOrWhiteSpace($evidenceDir)) { + New-Item -ItemType Directory -Force -Path $evidenceDir | Out-Null + } + [pscustomobject]$summary | ConvertTo-Json -Depth 12 | + Set-Content -Encoding utf8 -Path $EvidencePath + Write-Output "部署 gate 證據已寫入:$EvidencePath" +}