[CmdletBinding()]
param(
[ValidateSet("nsis", "msi")]
[string]$Bundle = "nsis",
[switch]$NoBundle,
[switch]$CleanDist,
[switch]$Portable,
[string]$PortableDir,
[switch]$AllPlatforms,
[string]$GithubToken,
[string]$WorkflowFile = "release-all.yml",
[string]$GitRef,
[string]$ReleaseTag,
[ValidateSet("build-and-publish", "build-artifacts", "publish-artifacts")]
[string]$ReleaseMode = "build-and-publish",
[string]$ArtifactsRunId,
[bool]$PublishContainers = $true,
[ValidateSet("auto", "true", "false")]
[string]$Prerelease = "auto",
[bool]$DownloadArtifacts = $true,
[string]$ArtifactsDir,
[ValidateRange(5, 300)]
[int]$PollIntervalSec = 10,
[ValidateRange(1, 360)]
[int]$TimeoutMin = 60,
[switch]$DryRun
)
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Split-Path -Parent $scriptDir
$appsRoot = Join-Path $root "apps"
$frontendRoot = $appsRoot
$tauriDir = Join-Path $appsRoot "src-tauri"
$rootTarget = Join-Path $root "target"
$targetDir = if ($env:CARGO_TARGET_DIR) {
if ([IO.Path]::IsPathRooted($env:CARGO_TARGET_DIR)) {
$env:CARGO_TARGET_DIR
} else {
Join-Path $root $env:CARGO_TARGET_DIR
}
} else {
$rootTarget
}
$distDir = Join-Path $frontendRoot "out"
$tauriConfig = Join-Path $tauriDir "tauri.conf.json"
$appName = "CodexManager"
if (Test-Path $tauriConfig) {
$appName = (Get-Content $tauriConfig -Raw | ConvertFrom-Json).productName
}
$portableRoot = if ($PortableDir) { $PortableDir } else { Join-Path $root "portable" }
$portableExe = Join-Path $portableRoot "$appName-portable.exe"
$legacyPortableExe = Join-Path $portableRoot "$appName.exe"
$legacyPortableMarker = Join-Path $portableRoot ".codexmanager-portable"
$appExe = Join-Path $targetDir "release\\$appName.exe"
$artifactsRoot = if ($ArtifactsDir) { $ArtifactsDir } else { Join-Path $root "artifacts" }
函数 `Write-Step`
作者: gaohongshun
时间: 2026-04-02
# 参数
- Message: 参数 Message
# 返回
返回函数执行结果
#>
function Write-Step {
param([string]$Message)
Write-Output $Message
}
函数 `Remove-Dir`
作者: gaohongshun
时间: 2026-04-02
# 参数
- Path: 参数 Path
# 返回
返回函数执行结果
#>
function Remove-Dir {
param([string]$Path)
$workspacePath = [IO.Path]::GetFullPath($root).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
$candidatePath = [IO.Path]::GetFullPath($Path).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
$relativePath = [IO.Path]::GetRelativePath($workspacePath, $candidatePath)
$configuredTarget = if ($env:CARGO_TARGET_DIR) { [IO.Path]::GetFullPath($targetDir).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) } else { $null }
$isExplicitExternalTarget = $null -ne $configuredTarget -and $candidatePath -eq $configuredTarget
$workspaceFromCandidate = [IO.Path]::GetRelativePath($candidatePath, $workspacePath)
$candidateIsAncestor = $workspaceFromCandidate -eq "." -or
(-not $workspaceFromCandidate.StartsWith("..", [StringComparison]::Ordinal) -and -not [IO.Path]::IsPathRooted($workspaceFromCandidate))
$candidateIsFilesystemRoot = [string]::Equals($candidatePath, [IO.Path]::GetPathRoot($candidatePath).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)
if ($candidateIsFilesystemRoot -or $candidateIsAncestor -or
(-not $isExplicitExternalTarget -and ($relativePath.StartsWith("..", [StringComparison]::Ordinal) -or [IO.Path]::IsPathRooted($relativePath)))) {
throw "refusing to recursively remove a path outside the repository build tree: $candidatePath"
}
if (Test-Path -LiteralPath $candidatePath) {
$item = Get-Item -LiteralPath $candidatePath -Force
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "refusing to recursively remove a reparse point: $candidatePath"
}
}
if (-not (Test-Path $Path)) {
Write-Step "skip: $Path not found"
return
}
if ($DryRun) {
Write-Step "DRY RUN: remove $Path"
return
}
Remove-Item -LiteralPath $candidatePath -Recurse -Force
}
函数 `Run-Cargo`
作者: gaohongshun
时间: 2026-04-02
# 参数
- CommandLine: 参数 CommandLine
- Action: 参数 Action
# 返回
返回函数执行结果
#>
function Run-Cargo {
param([string]$CommandLine, [scriptblock]$Action)
if ($DryRun) {
Write-Step "DRY RUN: $CommandLine"
return
}
& $Action
if ($LASTEXITCODE -ne 0) {
throw "command failed: $CommandLine"
}
}
函数 `Get-GitHubRepoInfo`
作者: gaohongshun
时间: 2026-04-02
# 参数
无
# 返回
返回函数执行结果
#>
function Get-GitHubRepoInfo {
$remote = (& git remote get-url origin 2>$null) -join ""
if ([string]::IsNullOrWhiteSpace($remote)) {
throw "git remote origin not found"
}
if ($remote -match "github\.com[:/](?<owner>[^/]+)/(?<repo>[^/.]+?)(?:\.git)?$") {
return @{
owner = $matches.owner
repo = $matches.repo
}
}
throw "origin is not a GitHub repository: $remote"
}
函数 `Resolve-GitHubToken`
作者: gaohongshun
时间: 2026-04-02
# 参数
无
# 返回
返回函数执行结果
#>
function Resolve-GitHubToken {
if (-not [string]::IsNullOrWhiteSpace($GithubToken)) {
return $GithubToken.Trim()
}
foreach ($name in @("GITHUB_TOKEN", "GH_TOKEN")) {
$value = [Environment]::GetEnvironmentVariable($name)
if (-not [string]::IsNullOrWhiteSpace($value)) {
return $value.Trim()
}
}
throw "GitHub token required for -AllPlatforms. Pass -GithubToken or set GITHUB_TOKEN."
}
函数 `Invoke-GitHubApi`
作者: gaohongshun
时间: 2026-04-02
# 参数
无
# 返回
返回函数执行结果
#>
function Invoke-GitHubApi {
param(
[ValidateSet("GET", "POST")]
[string]$Method,
[string]$Uri,
[string]$Token,
[object]$Body
)
$headers = @{
Authorization = "Bearer $Token"
Accept = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
"User-Agent" = "codexmanager-rebuild-script"
}
if ($Method -eq "GET") {
return Invoke-RestMethod -Method Get -Uri $Uri -Headers $headers
}
$json = if ($null -eq $Body) { $null } else { $Body | ConvertTo-Json -Depth 10 -Compress }
return Invoke-RestMethod -Method Post -Uri $Uri -Headers $headers -ContentType "application/json" -Body $json
}
函数 `Resolve-WorkflowDefinition`
作者: gaohongshun
时间: 2026-04-02
# 参数
- Repo: 参数 Repo
- Token: 参数 Token
- WorkflowFile: 参数 WorkflowFile
# 返回
返回函数执行结果
#>
function Resolve-WorkflowDefinition {
param(
[hashtable]$Repo,
[string]$Token,
[string]$WorkflowFile
)
$workflowUri = "https://api.github.com/repos/$($Repo.owner)/$($Repo.repo)/actions/workflows/$WorkflowFile"
try {
return Invoke-GitHubApi -Method GET -Uri $workflowUri -Token $Token -Body $null
} catch {
$listUri = "https://api.github.com/repos/$($Repo.owner)/$($Repo.repo)/actions/workflows?per_page=100"
$workflowList = @()
try {
$resp = Invoke-GitHubApi -Method GET -Uri $listUri -Token $Token -Body $null
if ($null -ne $resp.workflows) {
$workflowList = $resp.workflows | ForEach-Object { $_.path } | Sort-Object
}
} catch {
}
if ($workflowList.Count -gt 0) {
throw "workflow '$WorkflowFile' not found. Available workflows: $($workflowList -join ', ')"
}
throw "workflow '$WorkflowFile' not found."
}
}
函数 `Invoke-LocalWindowsBuild`
作者: gaohongshun
时间: 2026-04-02
# 参数
无
# 返回
返回函数执行结果
#>
function Invoke-LocalWindowsBuild {
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
throw "cargo not found in PATH"
}
if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) {
Write-Warning "pnpm not found; tauri beforeBuildCommand may fail."
}
Push-Location $root
try {
Remove-Dir $targetDir
if ($CleanDist) {
Remove-Dir $distDir
}
Push-Location $tauriDir
try {
if ($NoBundle) {
Run-Cargo "cargo tauri build --no-bundle" { cargo tauri build --no-bundle }
} else {
Run-Cargo "cargo tauri build --bundles $Bundle" { cargo tauri build --bundles $Bundle }
}
} finally {
Pop-Location
}
if ($Portable) {
if ($DryRun) {
Write-Step "DRY RUN: stage portable -> $portableRoot"
Write-Step "DRY RUN: remove legacy portable exe -> $legacyPortableExe"
Write-Step "DRY RUN: remove legacy marker -> $legacyPortableMarker"
Write-Step "DRY RUN: copy $appExe -> $portableExe"
} else {
if (-not (Test-Path $portableRoot)) {
New-Item -ItemType Directory -Force $portableRoot | Out-Null
}
if (-not (Test-Path $appExe)) {
throw "missing app exe: $appExe"
}
if ((Test-Path $legacyPortableExe) -and ($legacyPortableExe -ne $portableExe)) {
Remove-Item -Force $legacyPortableExe
}
if (Test-Path $legacyPortableMarker) {
Remove-Item -Force $legacyPortableMarker
}
Copy-Item -Force $appExe $portableExe
}
}
} finally {
Pop-Location
}
}
函数 `Invoke-AllPlatformBuild`
作者: gaohongshun
时间: 2026-04-02
# 参数
无
# 返回
返回函数执行结果
#>
function Invoke-AllPlatformBuild {
$repo = Get-GitHubRepoInfo
$token = Resolve-GitHubToken
if ([string]::IsNullOrWhiteSpace($GitRef)) {
$GitRef = (& git branch --show-current 2>$null) -join ""
}
if ([string]::IsNullOrWhiteSpace($GitRef)) {
$GitRef = (& git describe --tags --exact-match 2>$null) -join ""
}
if ([string]::IsNullOrWhiteSpace($GitRef)) {
throw "cannot resolve git ref. Pass -GitRef explicitly."
}
if ([string]::IsNullOrWhiteSpace($ReleaseTag)) {
$ReleaseTag = (& git describe --tags --exact-match 2>$null) -join ""
}
if ([string]::IsNullOrWhiteSpace($ReleaseTag)) {
throw "release tag required for -AllPlatforms. Pass -ReleaseTag (e.g. v0.0.6)."
}
if ($ReleaseMode -eq "publish-artifacts" -and [string]::IsNullOrWhiteSpace($ArtifactsRunId)) {
throw "ArtifactsRunId is required when -ReleaseMode publish-artifacts is used."
}
$workflowAlias = @{
"release-windows.yml" = "release-all.yml"
"release-linux.yml" = "release-all.yml"
"release-macos-beta.yml" = "release-all.yml"
"release-service-windows.yml" = "release-all.yml"
"release-service-linux.yml" = "release-all.yml"
"release-service-macos.yml" = "release-all.yml"
"ci-verify.yml" = "release-all.yml"
}
if ($workflowAlias.ContainsKey($WorkflowFile)) {
Write-Step "workflow alias: $WorkflowFile -> $($workflowAlias[$WorkflowFile])"
$WorkflowFile = $workflowAlias[$WorkflowFile]
}
$resolvedSha = (& git rev-parse --verify "$GitRef^{commit}" 2>$null) -join ""
if ([string]::IsNullOrWhiteSpace($resolvedSha)) {
throw "cannot resolve git ref to commit: $GitRef"
}
$dispatchUri = "https://api.github.com/repos/$($repo.owner)/$($repo.repo)/actions/workflows/$WorkflowFile/dispatches"
$runsUri = "https://api.github.com/repos/$($repo.owner)/$($repo.repo)/actions/workflows/$WorkflowFile/runs?event=workflow_dispatch&per_page=50"
$prereleaseInput = $Prerelease.ToLowerInvariant()
$dispatchBody = @{
ref = $GitRef
inputs = @{
mode = $ReleaseMode
tag = $ReleaseTag
ref = $GitRef
prerelease = $prereleaseInput
artifacts_run_id = if ($ArtifactsRunId) { $ArtifactsRunId } else { "" }
publish_containers = if ($PublishContainers) { "true" } else { "false" }
}
}
if ($DryRun) {
Write-Step "DRY RUN: using workflow .github/workflows/$WorkflowFile"
Write-Step "DRY RUN: dispatch workflow $WorkflowFile on ref=$GitRef tag=$ReleaseTag mode=$ReleaseMode prerelease=$prereleaseInput artifacts_run_id=$ArtifactsRunId publish_containers=$PublishContainers"
Write-Step "DRY RUN: resolved target sha $resolvedSha"
Write-Step "DRY RUN: POST $dispatchUri"
Write-Step "DRY RUN: payload $($dispatchBody | ConvertTo-Json -Depth 10 -Compress)"
if ($DownloadArtifacts) {
Write-Step "DRY RUN: download artifacts -> $artifactsRoot"
}
return
}
$workflow = Resolve-WorkflowDefinition -Repo $repo -Token $token -WorkflowFile $WorkflowFile
Write-Step "using workflow: $($workflow.path)"
Write-Step "dispatching workflow: $WorkflowFile (ref=$GitRef tag=$ReleaseTag mode=$ReleaseMode prerelease=$prereleaseInput artifacts_run_id=$ArtifactsRunId publish_containers=$PublishContainers)"
Invoke-GitHubApi -Method POST -Uri $dispatchUri -Token $token -Body $dispatchBody | Out-Null
$deadline = (Get-Date).ToUniversalTime().AddMinutes($TimeoutMin)
$dispatchedAt = (Get-Date).ToUniversalTime().AddSeconds(-5)
$run = $null
while ((Get-Date).ToUniversalTime() -lt $deadline) {
Start-Sleep -Seconds $PollIntervalSec
$runs = Invoke-GitHubApi -Method GET -Uri $runsUri -Token $token -Body $null
if ($null -eq $runs.workflow_runs) {
continue
}
$run = $runs.workflow_runs |
Where-Object {
[DateTime]::Parse($_.created_at).ToUniversalTime() -ge $dispatchedAt -and
$_.head_sha -eq $resolvedSha
} |
Sort-Object { [DateTime]::Parse($_.created_at).ToUniversalTime() } -Descending |
Select-Object -First 1
if ($null -eq $run) {
Write-Step "waiting workflow run: ref=$GitRef sha=$resolvedSha"
continue
}
Write-Step ("workflow run: id={0} status={1} conclusion={2} head_sha={3}" -f $run.id, $run.status, $run.conclusion, $run.head_sha)
if ($run.status -eq "completed") {
break
}
}
if ($null -eq $run) {
throw "workflow run not found within timeout"
}
if ($run.status -ne "completed") {
throw "workflow did not complete within timeout"
}
if ($run.conclusion -ne "success") {
throw "workflow failed: conclusion=$($run.conclusion)"
}
if (-not $DownloadArtifacts) {
Write-Step "workflow succeeded"
return
}
if ($ReleaseMode -eq "publish-artifacts") {
Write-Step "workflow succeeded; publish-artifacts mode reused artifacts from run $ArtifactsRunId and does not upload new artifacts"
return
}
if (-not (Test-Path $artifactsRoot)) {
New-Item -ItemType Directory -Force $artifactsRoot | Out-Null
}
$artifactsUri = "https://api.github.com/repos/$($repo.owner)/$($repo.repo)/actions/runs/$($run.id)/artifacts?per_page=100"
$artifactsResp = Invoke-GitHubApi -Method GET -Uri $artifactsUri -Token $token -Body $null
if ($null -eq $artifactsResp.artifacts -or $artifactsResp.artifacts.Count -eq 0) {
throw "workflow succeeded but no artifacts were found"
}
$headers = @{
Authorization = "Bearer $token"
Accept = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
"User-Agent" = "codexmanager-rebuild-script"
}
foreach ($artifact in $artifactsResp.artifacts) {
if ($artifact.expired -eq $true) {
continue
}
$zipName = "{0}-{1}.zip" -f $artifact.name, $artifact.id
$zipPath = Join-Path $artifactsRoot $zipName
Write-Step "download artifact: $($artifact.name) -> $zipPath"
Invoke-WebRequest -Uri $artifact.archive_download_url -Headers $headers -OutFile $zipPath | Out-Null
}
}
Push-Location $root
try {
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw "git not found in PATH"
}
if ($AllPlatforms) {
Invoke-AllPlatformBuild
} else {
Invoke-LocalWindowsBuild
}
} finally {
Pop-Location
}
Write-Step "done"