Files
hermes-agent/scripts/build-bundle.ps1
ethernet 3d7f1090d4 fix(desktop): build-bundle.ps1 parses the claim timestamp and rejects a missing -Python
The tagger-line lookup indexed into a single string, so the epoch was the
character 't'. A -Python path that does not exist left LASTEXITCODE at 0, so the
script printed done and listed a previous build's package.
2026-10-08 18:02:26 -04:00

171 lines
7.7 KiB
PowerShell

<#
.SYNOPSIS
Build the desktop MSIX for the commit checked out here, on this machine.
.DESCRIPTION
scripts\build-bundle.ps1 Sideload MSIX (bundled variant) for this machine's architecture.
scripts\build-bundle.ps1 -Store Microsoft Store MSIX (Store identity).
The commit does not need to be pushed. The checkout must be clean: the build
packages HEAD. Output goes to apps\desktop\release\. The script removes the
AZURE_SIGN_* variables from the build, so the packages are unsigned even when
your shell has release signing set.
-Store needs a stable release tag. This script makes a local claim tag
(rc.<N>-vX.Y.Z) for the next patch version, builds with it, and deletes it
when the build ends. It never pushes the tag. Each architecture builds on its
own native host. The script prints the claim tag's timestamp as
HERMES_RELEASE_EPOCH. To combine the x64 and arm64 Store packages, copy both
Store-*.msix files into one apps\desktop\release, set the same
HERMES_RELEASE_EPOCH on that host, and run
node scripts\bundle-store-msixbundle.mjs --tag vX.Y.Z.
Both hosts must use the epoch from ONE claim, or the package versions differ.
Use a short checkout path such as C:\hsb. On Windows ARM64, a long path makes
the cryptography build fail with LNK1104.
.PARAMETER Store
Build the Store package instead of the sideload package.
.PARAMETER Python
Python 3.11+ to run the driver. Default: the first suitable python on PATH.
.PARAMETER Remote
Remote that lists published release tags and attempt refs. Default: origin.
#>
[CmdletBinding()]
param(
[switch]$Store,
[string]$Python = '',
[string]$Remote = 'origin'
)
# 'Stop' turns the native stderr progress lines of the build into terminating errors on Windows PowerShell 5.
$ErrorActionPreference = 'Continue'
$Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
# Attempt numbers from here up cannot collide with a real release attempt.
$LocalAttemptFloor = 900
function Fail([string]$Message) {
[Console]::Error.WriteLine("build-bundle: $Message")
exit 1
}
function Find-Python {
if ($Python) {
if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) { Fail "-Python '$Python' is not a file." }
return $Python
}
foreach ($name in 'python', 'python3') {
foreach ($command in @(Get-Command $name -CommandType Application -ErrorAction SilentlyContinue)) {
# The WindowsApps python.exe is a Store stub. Over ssh it fails with "Access is denied".
if ($command.Source -like '*\WindowsApps\*') { continue }
& $command.Source -c 'import sys; sys.exit(sys.version_info < (3, 11))' 2>$null
if ($LASTEXITCODE -eq 0) { return $command.Source }
}
}
Fail 'needs Python 3.11+ on PATH (or pass -Python).'
}
function Git-Lines {
$lines = & git @args
if ($LASTEXITCODE) { Fail "git $($args -join ' ') failed" }
return $lines | Where-Object { $_ }
}
# Next patch after the newest published stable tag (vX.Y.Z, not CalVer or canary).
function Get-ClaimVersion {
$latest = $null
foreach ($line in @(Git-Lines ls-remote --tags $Remote 'refs/tags/v*')) {
if ($line -notmatch 'refs/tags/v((?:0|[1-9]\d{0,2})\.\d+\.\d+)$') { continue }
$version = [version]$Matches[1]
if ($null -eq $latest -or $version -gt $latest) { $latest = $version }
}
if ($null -eq $latest) { Fail "no stable release tag found on remote '$Remote' ($(git remote get-url $Remote)). Pass -Remote with the remote that points at NousResearch/hermes-agent." }
return "$($latest.Major).$($latest.Minor).$($latest.Build + 1)"
}
# One past the highest attempt for this version, counting local and remote refs.
function Get-ClaimAttempt([string]$Version) {
$refs = @(Git-Lines tag --list "rc.*-v$Version") + @(Git-Lines ls-remote --tags $Remote "refs/tags/rc.*-v$Version")
$highest = $LocalAttemptFloor - 1
foreach ($ref in $refs) {
if ($ref -match "rc\.([1-9]\d*)-v$([regex]::Escape($Version))$" -and [int]$Matches[1] -gt $highest) {
$highest = [int]$Matches[1]
}
}
return $highest + 1
}
Set-Location $Repo
$py = Find-Python
if (git status --porcelain --untracked-files=all) {
[Console]::Error.WriteLine('build-bundle: the checkout has uncommitted changes. Commit them (the build packages HEAD) or stash them.')
git status --short --untracked-files=all
exit 1
}
$sha = @(Git-Lines rev-parse HEAD)[0]
if ($Repo.Length -gt 40) {
Write-Warning "checkout path is $($Repo.Length) characters. On Windows ARM64 a long path can fail the cryptography build (LNK1104). Prefer C:\hsb."
}
# Release settings and signing credentials exist only for this build. Save the caller's
# values and put them back, so a later direct build command sees an untouched session.
$buildVariables = @('PYTHONUTF8', 'RELEASE_CLAIM_TAG', 'RELEASE_CLAIM_OBJECT', 'HERMES_PAYLOAD_TAG',
'HERMES_PAYLOAD_VERSION', 'HERMES_DESKTOP_VARIANT') +
@(Get-ChildItem Env: | Where-Object { $_.Name -like 'AZURE_*' } | ForEach-Object { $_.Name })
$savedEnvironment = @{}
foreach ($name in $buildVariables) { $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') }
foreach ($name in $buildVariables | Where-Object { $_ -like 'AZURE_*' }) { Remove-Item "Env:$name" }
$env:PYTHONUTF8 = '1'
$claim = $null
try {
if ($Store) {
$version = Get-ClaimVersion
$claim = "rc.$(Get-ClaimAttempt $version)-v$version"
# Pass -c for identity and signing so a machine without a git identity, or with signed tags on, still works.
& git -c user.name=hermes-local-build -c user.email=local-build@invalid -c tag.gpgSign=false `
tag -a $claim -m 'local build claim' $sha
if ($LASTEXITCODE) { Fail "could not create claim tag $claim" }
$claimObject = @(Git-Lines rev-parse "refs/tags/$claim")[0]
# Every architecture's Store package derives its 4-part version from this one timestamp.
$taggerLine = @(@(Git-Lines cat-file -p $claimObject) | Where-Object { $_ -match '^tagger ' })[0]
if ($taggerLine -notmatch ' (\d+) [+-]\d{4}$') { Fail "claim tag $claim has no tagger timestamp" }
$claimEpoch = [int64]$Matches[1]
$env:RELEASE_CLAIM_TAG = $claim
$env:RELEASE_CLAIM_OBJECT = $claimObject
$env:HERMES_PAYLOAD_TAG = "v$version"
$env:HERMES_PAYLOAD_VERSION = $version
$env:HERMES_DESKTOP_VARIANT = 'bundled'
Write-Host "build-bundle: building $sha as Store v$version (local claim $claim)"
# Prepare as bundled, then package as store from that preparation, as CI does.
$work = Join-Path $Repo '.build\desktop-job'
& $py scripts/bundles/desktop.py --tag "v$version" --release-commit $sha --variant bundled --prepare-only `
--work $work --cache (Join-Path $Repo '.cache\desktop-inputs') --clean
if ($LASTEXITCODE) { Fail "preparation failed (exit $LASTEXITCODE)" }
& $py scripts/bundles/desktop.py --prepared (Join-Path $work 'prepared.json') --variant store
if ($LASTEXITCODE) { Fail "Store packaging failed (exit $LASTEXITCODE)" }
} else {
Write-Host "build-bundle: building $sha (sideload)"
& $py scripts/bundles/desktop.py --commit $sha --variant bundled --clean
if ($LASTEXITCODE) { Fail "build failed (exit $LASTEXITCODE)" }
}
} finally {
foreach ($name in $buildVariables) {
[Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process')
}
if ($claim) {
# Claim tags are local only. Delete this one even when the build fails.
& git tag -d $claim | Out-Null
}
}
Write-Host "build-bundle: done. Unsigned packages in $Repo\apps\desktop\release:"
if ($Store) {
Write-Host "build-bundle: claim timestamp for combining architectures: HERMES_RELEASE_EPOCH=$claimEpoch (version v$version)"
}
Get-ChildItem (Join-Path $Repo 'apps\desktop\release') -Filter '*.msix*' |
ForEach-Object { ' {0} {1:N0} MB' -f $_.Name, ($_.Length / 1MB) }