diff --git a/scripts/deploy-from-mac.sh b/scripts/deploy-from-mac.sh index 9d9c37bb..ec5d0180 100755 --- a/scripts/deploy-from-mac.sh +++ b/scripts/deploy-from-mac.sh @@ -28,6 +28,15 @@ # set -euo pipefail +release_includes_commit() { + git merge-base --is-ancestor "$1" "$2" 2>/dev/null && return 0 + # Squash 改变提交身份;仅接受历史中完整文件树一致的提交。 + local previous_tree release_trees + previous_tree="$(git rev-parse "${1}^{tree}" 2>/dev/null)" || return 1 + release_trees="$(git log --format=%T "$2" 2>/dev/null)" || return 1 + printf '%s\n' "$release_trees" | grep -Fx -- "$previous_tree" >/dev/null +} + server='218.93.190.75' key_path='' password_file='' @@ -522,7 +531,7 @@ if [ -n "$previous_sha" ]; then if [ "$previous_sha_exists" -eq 0 ]; then fail "线上当前版本 ${previous_sha} 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 --allow-rollback。" fi - if ! git merge-base --is-ancestor "$previous_sha" "$deploy_sha"; then + if ! release_includes_commit "$previous_sha" "$deploy_sha"; then fail "线上当前版本($(git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' "$previous_sha"))不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 --allow-rollback。" fi fi diff --git a/scripts/deploy-from-workstation.ps1 b/scripts/deploy-from-workstation.ps1 index 83682387..7bb70aeb 100644 --- a/scripts/deploy-from-workstation.ps1 +++ b/scripts/deploy-from-workstation.ps1 @@ -50,6 +50,21 @@ $buildkitConfig = Join-Path $repoRoot 'deploy/buildkitd.toml' $regctlVersion = 'v0.11.5' $regctlSha256 = '9b83975d464e4ee5a9f899a3ead26072ed67e9a8ed0f582897bfad8141b45938' +function Test-CaiwuReleaseIncludesCommit { + param([string]$PreviousSha, [string]$ReleaseSha) + + git merge-base --is-ancestor $PreviousSha $ReleaseSha 2>$null + if ($LASTEXITCODE -eq 0) { return $true } + + # Squash 会改变提交身份;只接受发布历史中完整文件树一致的提交。 + # 不按提交说明、部分文件或 patch 相似度放行。 + $previousTree = (git rev-parse "${PreviousSha}^{tree}" 2>$null) -join '' + if ($LASTEXITCODE -ne 0) { return $false } + $releaseTrees = @(git log --format=%T $ReleaseSha 2>$null) + if ($LASTEXITCODE -ne 0) { return $false } + return $releaseTrees -contains $previousTree +} + function Invoke-Native { param( [Parameter(Mandatory = $true)] @@ -440,8 +455,7 @@ try { if (-not $previousShaExists) { throw "线上当前版本 $previousSha 在本地和 origin 都找不到(可能是未推送的分支),发布会把它覆盖。请让发布人先推送并合并进 main;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。" } - git merge-base --is-ancestor $previousSha $deploySha 2>$null - if ($LASTEXITCODE -ne 0) { + if (-not (Test-CaiwuReleaseIncludesCommit -PreviousSha $previousSha -ReleaseSha $deploySha)) { $previousInfo = (git log -1 --format='%h %an %ad %s' --date=format:'%m-%d %H:%M' $previousSha) -join '' throw "线上当前版本($previousInfo)不在本次发布的版本里,继续发布会把它及之后别人上线的改动撤掉。请先 git fetch 并把最新 main(或线上热修复分支)合入当前分支后重试;确需撤回时显式添加 -AllowRollback。服务器未发生任何变更。" } diff --git a/scripts/test-release-inclusion.ps1 b/scripts/test-release-inclusion.ps1 new file mode 100644 index 00000000..637345c9 --- /dev/null +++ b/scripts/test-release-inclusion.ps1 @@ -0,0 +1,71 @@ +param([string]$BashPath='bash') +$ErrorActionPreference='Stop' +$repoRoot=Split-Path $PSScriptRoot -Parent +$psSource=[IO.File]::ReadAllText((Join-Path $repoRoot 'scripts/deploy-from-workstation.ps1')) +$parseErrors=$null; $tokens=$null +$ast=[Management.Automation.Language.Parser]::ParseInput($psSource,[ref]$tokens,[ref]$parseErrors) +if($parseErrors.Count){throw 'Deployment PowerShell syntax invalid'} +$function=$ast.Find({param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-CaiwuReleaseIncludesCommit'},$true) +if(-not $function){throw 'PowerShell inclusion guard missing'} +. ([ScriptBlock]::Create($function.Extent.Text)) +$bashSource=[IO.File]::ReadAllText((Join-Path $repoRoot 'scripts/deploy-from-mac.sh')).Replace("`r`n","`n") +$bashFunction=[regex]::Match($bashSource,'(?ms)^release_includes_commit\(\) \{\n.*?^\}').Value +if(-not $bashFunction){throw 'Bash inclusion guard missing'} + +$fixture=Join-Path ([IO.Path]::GetTempPath()) ('caiwu-release-inclusion-'+[guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $fixture | Out-Null +$expectedFixture=[IO.Path]::GetFullPath($fixture) +function Invoke-FixtureGit { + param([Parameter(ValueFromRemainingArguments=$true)][string[]]$GitArgs) + $output=@(git @GitArgs 2>&1) + if($LASTEXITCODE -ne 0){throw "Fixture Git failed: $($GitArgs[0])"} + return $output +} +function New-FixtureCommit { + param([string]$Value,[string]$Message) + [IO.File]::WriteAllText((Join-Path $fixture 'value.txt'),$Value) + Invoke-FixtureGit add value.txt | Out-Null + Invoke-FixtureGit commit -m $Message | Out-Null + return (Invoke-FixtureGit rev-parse HEAD) -join '' +} +Push-Location $fixture +try { + Invoke-FixtureGit init -b main | Out-Null + Invoke-FixtureGit config user.name 'Release guard fixture' | Out-Null + Invoke-FixtureGit config user.email 'release-guard@example.invalid' | Out-Null + $base=New-FixtureCommit 'base' 'base' + Invoke-FixtureGit switch -c hotfix | Out-Null + $hotfix=New-FixtureCommit 'hotfix content' 'hotfix' + Invoke-FixtureGit switch main | Out-Null + $squashed=New-FixtureCommit 'hotfix content' 'squashed identity' + $release=New-FixtureCommit 'next authorized change' 'new release' + Invoke-FixtureGit switch -c unrelated $base | Out-Null + $unrelated=New-FixtureCommit 'different hotfix content' 'hotfix' + $cases=@( + @{name='direct ancestor';previous=$base;release=$release;expected=$true}, + @{name='same commit';previous=$hotfix;release=$hotfix;expected=$true}, + @{name='identical squash tree at head';previous=$hotfix;release=$squashed;expected=$true}, + @{name='identical squash tree in release ancestry';previous=$hotfix;release=$release;expected=$true}, + @{name='real unmerged change';previous=$unrelated;release=$release;expected=$false}, + @{name='rollback to before hotfix';previous=$hotfix;release=$base;expected=$false}, + @{name='missing deployed commit';previous=('0'*40);release=$release;expected=$false}, + @{name='missing release commit';previous=$hotfix;release=('0'*40);expected=$false} + ) + foreach($case in $cases){ + $actual=Test-CaiwuReleaseIncludesCommit -PreviousSha $case.previous -ReleaseSha $case.release + if($actual -ne $case.expected){throw "PowerShell failed: $($case.name)"} + $code=$bashFunction+"`nrelease_includes_commit "+'"$1" "$2"' + & $BashPath --noprofile --norc -c $code guard $case.previous $case.release + $bashActual=$LASTEXITCODE -eq 0 + if($bashActual -ne $case.expected){throw "Bash failed: $($case.name)"} + } + "PASS: $($cases.Count) real Git cases for both PowerShell and Bash guards" +} finally { + Pop-Location + $resolved=(Resolve-Path -LiteralPath $fixture).Path + if([IO.Path]::GetFullPath($resolved) -ne $expectedFixture -or (Get-Item -LiteralPath $resolved).Attributes.HasFlag([IO.FileAttributes]::ReparsePoint)){ + throw 'Refusing cleanup outside the exact generated fixture directory' + } + Remove-Item -LiteralPath $resolved -Recurse -Force +} +exit 0