<# .SYNOPSIS One-command release script for skill repos. .DESCRIPTION - Optional auto-commit - Push current branch - Auto-increment semantic tag (vX.Y.Z) - Create & push tag - Fail fast on unsafe states .EXAMPLES # Safe mode (recommended): requires clean working tree .\release.ps1 # Auto commit tracked/untracked changes then release .\release.ps1 -AutoCommit -CommitMessage "chore: update skill config" # Dry run (show what would happen) .\release.ps1 -DryRun # Custom tag prefix .\release.ps1 -Prefix "v" -Message "正式发布" .NOTES Requires: git, PowerShell 5+ 加密与 ZIP 内容由 CI 工作流 reusable-release-skill.yaml 的「Encrypt Source Code」步骤执行: 对 scripts/ 递归 PyArmor(-r),输出到包内 scripts/(与源码目录树一致),并复制 SKILL.md、references/、assets/(若存在)。 本脚本在打 tag 前会做一次 scripts/ 结构自检,避免子目录未提交却仍发布。 #> [CmdletBinding()] param( [string]$Prefix = "v", [string]$Message = "正式发布", [switch]$AutoCommit, [switch]$RequireClean, [string]$CommitMessage, [switch]$DryRun ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Invoke-Git { param([Parameter(Mandatory = $true)][string]$Args) Write-Host ">> git $Args" -ForegroundColor DarkGray & cmd /c "git $Args" if ($LASTEXITCODE -ne 0) { throw "git command failed: git $Args" } } function Get-GitOutput { param([Parameter(Mandatory = $true)][string]$Args) $output = & cmd /c "git $Args" 2>$null if ($LASTEXITCODE -ne 0) { throw "git command failed: git $Args" } return @($output) } function Test-Repo { & git rev-parse --is-inside-work-tree *> $null return ($LASTEXITCODE -eq 0) } function Get-CurrentBranch { $b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim() return $b } function Get-StatusPorcelain { $lines = @(Get-GitOutput "status --porcelain") return $lines } function Parse-SemVerTag { param( [string]$Tag, [string]$TagPrefix ) $escaped = [regex]::Escape($TagPrefix) $m = [regex]::Match($Tag, "^${escaped}(\d+)\.(\d+)\.(\d+)$") if (-not $m.Success) { return $null } return [pscustomobject]@{ Raw = $Tag Major = [int]$m.Groups[1].Value Minor = [int]$m.Groups[2].Value Patch = [int]$m.Groups[3].Value } } function Get-NextTag { param([string]$TagPrefix) $tags = Get-GitOutput "tag --list" $parsed = @() foreach ($t in $tags) { $t = $t.Trim() if (-not $t) { continue } $obj = Parse-SemVerTag -Tag $t -TagPrefix $TagPrefix if ($null -ne $obj) { $parsed += $obj } } if ($parsed.Count -eq 0) { return "${TagPrefix}1.0.1" } $latest = $parsed | Sort-Object Major, Minor, Patch | Select-Object -Last 1 return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)" } function Assert-SkillReleasePackagingSources { param([Parameter(Mandatory = $true)][string]$SkillRoot) $scriptsDir = Join-Path $SkillRoot "scripts" $mainPy = Join-Path $scriptsDir "main.py" if (-not (Test-Path -LiteralPath $mainPy)) { return } $text = Get-Content -LiteralPath $mainPy -Raw -Encoding UTF8 -ErrorAction SilentlyContinue if ([string]::IsNullOrWhiteSpace($text)) { return } $need = @() if ($text -match '(?m)^\s*from\s+cli\.') { $need += 'cli' } if ($text -match '(?m)^\s*import\s+cli\b') { $need += 'cli' } if ($text -match '(?m)^\s*from\s+service\.') { $need += 'service' } if ($text -match '(?m)^\s*import\s+service\b') { $need += 'service' } if ($text -match '(?m)^\s*from\s+db\.') { $need += 'db' } if ($text -match '(?m)^\s*import\s+db\b') { $need += 'db' } if ($text -match '(?m)^\s*from\s+util\.') { $need += 'util' } if ($text -match '(?m)^\s*import\s+util\b') { $need += 'util' } foreach ($p in ($need | Select-Object -Unique)) { $folder = Join-Path $scriptsDir $p if (-not (Test-Path -LiteralPath $folder)) { throw "Release check failed: scripts/main.py imports from '$p' but folder is missing: $folder" } } $pyFiles = @(Get-ChildItem -LiteralPath $scriptsDir -Filter *.py -Recurse -File -ErrorAction SilentlyContinue) Write-Host "Packaging check: $($pyFiles.Count) Python file(s) under scripts/ (CI will obfuscate all recursively)." -ForegroundColor DarkGray } function Ensure-CleanOrAutoCommit { param( [switch]$DoAutoCommit, [switch]$NeedClean, [switch]$IsDryRun, [string]$Msg ) $status = @(Get-StatusPorcelain) if ($status.Length -eq 0) { return } if ($NeedClean) { Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow & git status --short throw "Abort: dirty working tree." } # 默认一键发布:有改动就自动提交;也可用 -AutoCommit 显式开启 $commitMsg = $Msg if ([string]::IsNullOrWhiteSpace($commitMsg)) { $commitMsg = "chore: auto release commit ($(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'))" } if (-not $DoAutoCommit) { Write-Host "Detected uncommitted changes, auto-committing before release..." -ForegroundColor Yellow } if ($IsDryRun) { Write-Host "[DryRun] Would run: git add -A" -ForegroundColor Yellow Write-Host "[DryRun] Would run: git commit -m `"$commitMsg`"" -ForegroundColor Yellow return } Invoke-Git "add -A" Invoke-Git "commit -m `"$commitMsg`"" } try { Write-Host "=== Release Script Start ===" -ForegroundColor Cyan if (-not (Test-Repo)) { throw "Current directory is not a git repository." } $branch = Get-CurrentBranch if ([string]::IsNullOrWhiteSpace($branch)) { throw "Unable to determine current branch." } if ($branch -notin @("main", "master")) { throw "Current branch is '$branch'. Release is only allowed from main/master." } Invoke-Git "fetch --tags --prune origin" Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage $skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim() if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) { Assert-SkillReleasePackagingSources -SkillRoot $skillRoot } $upstream = (& git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>$null) $hasUpstream = ($LASTEXITCODE -eq 0) if ($DryRun) { if ($hasUpstream) { Write-Host "[DryRun] Would run: git push" -ForegroundColor Yellow } else { Write-Host "[DryRun] Would run: git push -u origin $branch" -ForegroundColor Yellow } } else { if ($hasUpstream) { Invoke-Git "push" } else { Invoke-Git "push -u origin $branch" } } $nextTag = Get-NextTag -TagPrefix $Prefix Write-Host "Next tag: $nextTag" -ForegroundColor Green $existing = @(Get-GitOutput "tag --list `"$nextTag`"") if ($existing.Length -gt 0) { throw "Tag already exists: $nextTag" } if ($DryRun) { Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan exit 0 } Invoke-Git "tag -a $nextTag -m `"$Message`"" Invoke-Git "push origin $nextTag" Write-Host "Release success: $nextTag" -ForegroundColor Green Write-Host "=== Release Script Done ===" -ForegroundColor Cyan exit 0 } catch { Write-Host "Release failed: $($_.Exception.Message)" -ForegroundColor Red exit 1 }