<# .SYNOPSIS Local git release for skill-template repo. .DESCRIPTION Handles git commit / push / semver tag / push tag only. Encryption, ZIP packaging, and marketplace sync are done by CI (.github/workflows/release_skill.yaml → reusable-release-skill.yaml@main). POLICY-RELEASE-001: before tagging, requires SKILL.md version and scripts/util/constants.py SKILL_VERSION to equal the next tag (without prefix). release.ps1 does not auto-edit those fields. Requires: git, PowerShell 5+ After fetch, runs default tests against the current working tree (before auto-commit): python tests/run_tests.py -v Use -SkipTests only for emergency diagnostics. -DryRun still runs tests. #> [CmdletBinding()] param( [string]$Prefix = "v", [string]$Message, [switch]$AutoCommit, [switch]$RequireClean, [string]$CommitMessage, [switch]$DryRun, [switch]$SkipTests ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" # Default tag message is 「正式发布」 from UTF-8 bytes (not a Chinese literal in # this file). Windows PowerShell 5.1 parses UTF-8 scripts without BOM as the # system ANSI code page, which would turn a source literal into 「姝e紡鍙戝竷」. if (-not $PSBoundParameters.ContainsKey("Message") -or [string]::IsNullOrWhiteSpace($Message)) { $Message = [System.Text.Encoding]::UTF8.GetString([byte[]](0xE6, 0xAD, 0xA3, 0xE5, 0xBC, 0x8F, 0xE5, 0x8F, 0x91, 0xE5, 0xB8, 0x83)) } function Remove-GitNoise { param([string[]]$Lines) return @($Lines | Where-Object { $_ -and ($_ -notmatch '^warning:\s+unable to access ') }) } function Convert-GitOutputLines { param($Output) return @( @($Output) | ForEach-Object { if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { "$_" } } ) } # Call git.exe directly. Never wrap with `cmd /c`: on Chinese Windows, cmd # re-encodes the command line through the OEM code page. # Also never pass Chinese via `git -m`: Windows PowerShell 5.1 encodes native # argv using the console code page, so 「正式发布」 becomes 「姝e紡鍙戝竷」 # in the annotated tag. Commit/tag messages go through a UTF-8 file + `-F`. function Invoke-GitRaw { param([Parameter(Mandatory = $true)][string[]]$GitArgs) $prev = $ErrorActionPreference $ErrorActionPreference = "Continue" try { $output = & git @GitArgs 2>&1 $code = $LASTEXITCODE } finally { $ErrorActionPreference = $prev } return [pscustomobject]@{ ExitCode = $code Lines = @(Remove-GitNoise -Lines @(Convert-GitOutputLines -Output $output)) } } function Invoke-Git { param([Parameter(Mandatory = $true)][string[]]$GitArgs) Write-Host (">> git " + ($GitArgs -join " ")) -ForegroundColor DarkGray $r = Invoke-GitRaw -GitArgs $GitArgs if ($r.ExitCode -ne 0) { throw "git command failed: git $($GitArgs -join ' ')" } } function Invoke-GitWithMessageFile { param( [Parameter(Mandatory = $true)][string[]]$GitArgsPrefix, [Parameter(Mandatory = $true)][string]$Message ) $msgFile = Join-Path ([System.IO.Path]::GetTempPath()) ("jiangchang-git-msg-" + [guid]::NewGuid().ToString("N") + ".txt") $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($msgFile, ($Message.TrimEnd() + "`n"), $utf8NoBom) try { Invoke-Git -GitArgs (@("-c", "i18n.commitEncoding=utf-8") + $GitArgsPrefix + @("-F", $msgFile)) } finally { Remove-Item -LiteralPath $msgFile -Force -ErrorAction SilentlyContinue } } function Get-GitOutput { param([Parameter(Mandatory = $true)][string[]]$GitArgs) $r = Invoke-GitRaw -GitArgs $GitArgs if ($r.ExitCode -ne 0) { throw "git command failed: git $($GitArgs -join ' ')" } return @($r.Lines) } function Test-Repo { $r = Invoke-GitRaw -GitArgs @("rev-parse", "--is-inside-work-tree") if ($r.ExitCode -ne 0) { return $false } return (($r.Lines | Select-Object -First 1) -eq "true") } function Get-CurrentBranch { $b = (Get-GitOutput -GitArgs @("branch", "--show-current") | Select-Object -First 1).Trim() return $b } function Get-StatusPorcelain { $lines = @(Get-GitOutput -GitArgs @("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 -GitArgs @("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 Read-SkillMdVersion { param([Parameter(Mandatory = $true)][string]$SkillRoot) $path = Join-Path $SkillRoot "SKILL.md" if (-not (Test-Path -LiteralPath $path)) { return $null } $text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue if ([string]::IsNullOrWhiteSpace($text)) { return $null } $m = [regex]::Match($text, '(?m)^version:\s*["'']?([0-9]+\.[0-9]+\.[0-9]+)["'']?\s*$') if (-not $m.Success) { return $null } return $m.Groups[1].Value.Trim() } function Read-SkillConstantsVersion { param([Parameter(Mandatory = $true)][string]$SkillRoot) $path = Join-Path $SkillRoot "scripts\util\constants.py" if (-not (Test-Path -LiteralPath $path)) { return $null } $text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue if ([string]::IsNullOrWhiteSpace($text)) { return $null } $m = [regex]::Match($text, '(?m)^SKILL_VERSION\s*=\s*["'']([0-9]+\.[0-9]+\.[0-9]+)["'']') if (-not $m.Success) { return $null } return $m.Groups[1].Value.Trim() } # POLICY-RELEASE-001: marketplace latest (git tag) must equal package manifest versions. function Assert-SkillReleaseVersionAlignment { param( [Parameter(Mandatory = $true)][string]$SkillRoot, [Parameter(Mandatory = $true)][string]$NextTag, [Parameter(Mandatory = $true)][string]$TagPrefix ) $skillMdPath = Join-Path $SkillRoot "SKILL.md" if (-not (Test-Path -LiteralPath $skillMdPath)) { return } $expected = $NextTag if ($TagPrefix -and $expected.StartsWith($TagPrefix)) { $expected = $expected.Substring($TagPrefix.Length) } $expected = $expected.Trim() if ($expected -notmatch '^\d+\.\d+\.\d+$') { throw "Release check failed (POLICY-RELEASE-001): cannot derive semver from Next tag '$NextTag'." } $mdVersion = Read-SkillMdVersion -SkillRoot $SkillRoot $constVersion = Read-SkillConstantsVersion -SkillRoot $SkillRoot $problems = @() if ([string]::IsNullOrWhiteSpace($mdVersion)) { $problems += "SKILL.md missing top-level version: X.Y.Z" } elseif ($mdVersion -ne $expected) { $problems += "SKILL.md version=$mdVersion (want $expected)" } if ([string]::IsNullOrWhiteSpace($constVersion)) { $problems += "scripts/util/constants.py missing SKILL_VERSION = `"X.Y.Z`"" } elseif ($constVersion -ne $expected) { $problems += "SKILL_VERSION=$constVersion (want $expected)" } if ($problems.Count -gt 0) { throw ( "Release aborted (POLICY-RELEASE-001): Next tag=$NextTag requires manifest version $expected, but " + ($problems -join "; ") + ". Bump SKILL.md version and scripts/util/constants.py SKILL_VERSION to $expected (and CHANGELOG), then re-run release.ps1. " + "release.ps1 does not auto-edit these fields." ) } Write-Host "Version alignment OK: tag=$NextTag == SKILL.md=$mdVersion == SKILL_VERSION=$constVersion" -ForegroundColor DarkGray } 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 Invoke-DefaultTests { param( [Parameter(Mandatory = $true)][string]$SkillRoot, [switch]$Skip ) if ($Skip) { Write-Host "WARNING: Skipping tests before release. This should only be used for emergency diagnostics." -ForegroundColor Yellow return } $runTests = Join-Path $SkillRoot "tests\run_tests.py" if (-not (Test-Path -LiteralPath $runTests)) { throw "Release check failed: tests/run_tests.py not found." } Write-Host ">> python tests/run_tests.py -v" -ForegroundColor DarkGray & python $runTests -v if ($LASTEXITCODE -ne 0) { throw "Release aborted: default tests failed (python tests/run_tests.py -v)." } } 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 (Invoke-GitRaw -GitArgs @("status", "--short")).Lines | ForEach-Object { Write-Host $_ } throw "Abort: dirty working tree." } $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 -GitArgs @("add", "-A") Invoke-GitWithMessageFile -GitArgsPrefix @("commit") -Message $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." } if ($DryRun) { Write-Host "[DryRun] Would run: git fetch --tags --prune origin" -ForegroundColor Yellow } else { Invoke-Git -GitArgs @("fetch", "--tags", "--prune", "origin") } # Git output can be decoded through the legacy console code page on Windows # PowerShell 5.1, corrupting a repository path that contains Chinese characters. $skillRoot = $PSScriptRoot if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) { Assert-SkillReleasePackagingSources -SkillRoot $skillRoot } $nextTag = Get-NextTag -TagPrefix $Prefix Write-Host "Next tag: $nextTag" -ForegroundColor Green Assert-SkillReleaseVersionAlignment -SkillRoot $skillRoot -NextTag $nextTag -TagPrefix $Prefix $existing = @(Get-GitOutput -GitArgs @("tag", "--list", $nextTag)) if ($existing.Length -gt 0) { throw "Tag already exists: $nextTag" } Invoke-DefaultTests -SkillRoot $skillRoot -Skip:$SkipTests Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage $upstream = Invoke-GitRaw -GitArgs @("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}") $hasUpstream = ($upstream.ExitCode -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 -GitArgs @("push") } else { Invoke-Git -GitArgs @("push", "-u", "origin", $branch) } } if ($DryRun) { Write-Host "[DryRun] Would run: git tag -a $nextTag -F ($Message)" -ForegroundColor Yellow Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan exit 0 } Invoke-GitWithMessageFile -GitArgsPrefix @("tag", "-a", $nextTag) -Message $Message Invoke-Git -GitArgs @("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 }