mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-02 03:24:52 +08:00
fix: migrate Windows updates to a monitored per-user installer
This commit is contained in:
@@ -18,8 +18,9 @@ PLATFORM_ASSETS = {
|
||||
"darwin-aarch64-app": "Codex-X.app.tar.gz",
|
||||
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
|
||||
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
|
||||
"windows-x86_64": "Codex-X.msi",
|
||||
"windows-x86_64-msi": "Codex-X.msi",
|
||||
"windows-x86_64": "Codex-X.exe",
|
||||
"windows-x86_64-nsis": "Codex-X.exe",
|
||||
"windows-x86_64-msi": "Codex-X.exe",
|
||||
"linux-x86_64": "Codex-X.AppImage",
|
||||
"linux-x86_64-deb": "Codex-X.deb",
|
||||
"linux-x86_64-rpm": "Codex-X.rpm",
|
||||
@@ -131,6 +132,60 @@ class ValidateUpdaterReleaseTests(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_migration_adds_legacy_msi_key_without_changing_other_platforms(self) -> None:
|
||||
manifest = json.loads(self.manifest_path.read_text())
|
||||
del manifest["platforms"]["windows-x86_64-msi"]
|
||||
original = manifest["platforms"]
|
||||
self.manifest_path.write_text(json.dumps(manifest))
|
||||
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
actual = json.loads(self.manifest_path.read_text())["platforms"]
|
||||
self.assertEqual(actual["windows-x86_64-msi"], actual["windows-x86_64-nsis"])
|
||||
self.assertEqual(actual["windows-x86_64"], actual["windows-x86_64-nsis"])
|
||||
for key, value in original.items():
|
||||
if not key.startswith("windows-"):
|
||||
self.assertEqual(actual[key]["signature"], value["signature"])
|
||||
self.assertEqual(actual[key]["url"].rsplit("/", 1)[1], value["url"].rsplit("/", 1)[1])
|
||||
|
||||
def test_migration_requires_an_actual_nsis_source_entry(self) -> None:
|
||||
manifest = json.loads(self.manifest_path.read_text())
|
||||
del manifest["platforms"]["windows-x86_64-nsis"]
|
||||
self.manifest_path.write_text(json.dumps(manifest))
|
||||
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("missing signed NSIS", result.stderr)
|
||||
|
||||
def test_rejects_windows_signature_divergence(self) -> None:
|
||||
manifest = json.loads(self.manifest_path.read_text())
|
||||
manifest["platforms"]["windows-x86_64-msi"]["signature"] = base64.b64encode(b"x" * 64).decode()
|
||||
self.manifest_path.write_text(json.dumps(manifest))
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("same signed NSIS installer", result.stderr)
|
||||
|
||||
def test_rejects_mixed_msi_release_assets(self) -> None:
|
||||
assets = json.loads(self.assets_path.read_text())
|
||||
assets.append({"name": "legacy.msi", "browser_download_url": draft_url("legacy.msi")})
|
||||
self.assets_path.write_text(json.dumps(assets))
|
||||
result = self.run_validator("--rewrite-download-urls")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("must not be mixed", result.stderr)
|
||||
|
||||
def test_rejects_msi_payload_even_when_nsis_key_exists(self) -> None:
|
||||
manifest = json.loads(self.manifest_path.read_text())
|
||||
assets = json.loads(self.assets_path.read_text())
|
||||
for item in manifest["platforms"].values():
|
||||
item["url"] = item["url"].replace("Codex-X.exe", "Codex-X.msi")
|
||||
for item in assets:
|
||||
if item["name"] == "Codex-X.exe":
|
||||
item["name"] = "Codex-X.msi"
|
||||
item["browser_download_url"] = draft_url("Codex-X.msi")
|
||||
self.manifest_path.write_text(json.dumps(manifest))
|
||||
self.assets_path.write_text(json.dumps(assets))
|
||||
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("expected a .exe updater", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -17,13 +17,30 @@ REQUIRED_PLATFORMS = {
|
||||
"darwin-aarch64-app": ".app.tar.gz",
|
||||
"darwin-x86_64": ".app.tar.gz",
|
||||
"darwin-x86_64-app": ".app.tar.gz",
|
||||
"windows-x86_64": ".msi",
|
||||
"windows-x86_64-msi": ".msi",
|
||||
"windows-x86_64": ".exe",
|
||||
"windows-x86_64-nsis": ".exe",
|
||||
# Older MSI clients select this key, then identify EXE/MSI by file content.
|
||||
"windows-x86_64-msi": ".exe",
|
||||
"linux-x86_64": ".AppImage",
|
||||
"linux-x86_64-deb": ".deb",
|
||||
"linux-x86_64-rpm": ".rpm",
|
||||
"linux-x86_64-appimage": ".AppImage",
|
||||
}
|
||||
WINDOWS_PLATFORMS = (
|
||||
"windows-x86_64", "windows-x86_64-nsis", "windows-x86_64-msi"
|
||||
)
|
||||
|
||||
|
||||
def migrate_windows_to_nsis(manifest: dict[str, Any]) -> None:
|
||||
"""Serve the same signed NSIS installer to old MSI and new NSIS clients."""
|
||||
platforms = manifest.get("platforms")
|
||||
if not isinstance(platforms, dict):
|
||||
fail("latest.json has no platforms object")
|
||||
source = platforms.get("windows-x86_64-nsis")
|
||||
if not isinstance(source, dict):
|
||||
fail("missing signed NSIS platform for Windows migration")
|
||||
for platform in WINDOWS_PLATFORMS:
|
||||
platforms[platform] = dict(source)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
@@ -103,6 +120,7 @@ def main() -> None:
|
||||
parser.add_argument("--repository", required=True)
|
||||
parser.add_argument("--release-tag", required=True)
|
||||
parser.add_argument("--rewrite-download-urls", action="store_true")
|
||||
parser.add_argument("--migrate-windows-to-nsis", action="store_true")
|
||||
parser.add_argument("--require-signature-assets", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -144,6 +162,11 @@ def main() -> None:
|
||||
if not isinstance(platforms, dict):
|
||||
fail("latest.json has no platforms object")
|
||||
|
||||
if args.migrate_windows_to_nsis:
|
||||
if not args.rewrite_download_urls:
|
||||
fail("--migrate-windows-to-nsis requires --rewrite-download-urls")
|
||||
migrate_windows_to_nsis(manifest)
|
||||
|
||||
if args.rewrite_download_urls:
|
||||
rewritten = rewrite_download_urls(
|
||||
args.manifest,
|
||||
@@ -185,6 +208,15 @@ def main() -> None:
|
||||
if args.require_signature_assets and f"{asset_name}.sig" not in asset_names:
|
||||
fail(f"signature asset is missing for {asset_name}")
|
||||
|
||||
windows_entries = {
|
||||
(platforms[key]["url"], platforms[key]["signature"])
|
||||
for key in WINDOWS_PLATFORMS
|
||||
}
|
||||
if len(windows_entries) != 1:
|
||||
fail("all Windows updater keys must use the same signed NSIS installer")
|
||||
if any(name.lower().endswith((".msi", ".msi.zip")) for name in asset_names):
|
||||
fail("legacy MSI installers must not be mixed into an NSIS release")
|
||||
|
||||
signature_status = (
|
||||
"signature assets required"
|
||||
if args.require_signature_assets
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Run only on an ephemeral GitHub Windows runner. Never run on a user's machine.
|
||||
param([Parameter(Mandatory = $true)][string]$Installer)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows') {
|
||||
throw 'The installation smoke test is restricted to an ephemeral Windows Actions runner.'
|
||||
}
|
||||
$Installer = (Resolve-Path $Installer).Path
|
||||
$UpgradeCode = '{1482A8E8-9217-517B-8528-93A6C90E0C2F}'
|
||||
$LegacyProductCode = '{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}'
|
||||
$LegacySha256 = 'e0f2f172a31f860806a9714bab2e67eb38b85d525d7e5f0f569667cbeadfb152'
|
||||
$InstallDir = Join-Path $env:LOCALAPPDATA 'Codex-X'
|
||||
$UninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Codex-X'
|
||||
$Work = Join-Path $env:RUNNER_TEMP 'codex-x-installer-smoke'
|
||||
New-Item -ItemType Directory -Force $Work | Out-Null
|
||||
Add-Type @'
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
public static class CodexXMsiSmoke {
|
||||
[DllImport("msi.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern uint MsiEnumRelatedProducts(string code, uint reserved, uint index, StringBuilder product);
|
||||
}
|
||||
'@
|
||||
function Related-Products {
|
||||
$results = @()
|
||||
for ($index = 0; $index -lt 64; $index++) {
|
||||
$product = [Text.StringBuilder]::new(39)
|
||||
$result = [CodexXMsiSmoke]::MsiEnumRelatedProducts($UpgradeCode, 0, $index, $product)
|
||||
if ($result -eq 259) { return $results }
|
||||
if ($result -ne 0) { throw "MsiEnumRelatedProducts failed: $result" }
|
||||
$results += $product.ToString()
|
||||
}
|
||||
throw 'Unexpected number of MSI registrations.'
|
||||
}
|
||||
function Invoke-Installer([string]$File, [string]$Arguments, [int]$ExpectedExit = 0) {
|
||||
$watch = [Diagnostics.Stopwatch]::StartNew()
|
||||
$process = Start-Process -FilePath $File -ArgumentList $Arguments -PassThru
|
||||
if (-not $process.WaitForExit(600000)) {
|
||||
# Do not kill msiexec or an installer transaction. Fail the disposable job.
|
||||
throw "Installer has not finished after ten minutes (PID $($process.Id)); no forced termination was attempted."
|
||||
}
|
||||
$process.Refresh()
|
||||
if ($process.ExitCode -ne $ExpectedExit) { throw "Installer returned $($process.ExitCode), expected $ExpectedExit" }
|
||||
Write-Host "Installer completed in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) seconds."
|
||||
}
|
||||
function Assert-Installed {
|
||||
if (-not (Test-Path (Join-Path $InstallDir 'codex-x.exe'))) { throw 'EXE not installed in original user LOCALAPPDATA.' }
|
||||
$registration = Get-ItemProperty $UninstallKey
|
||||
if ($registration.MainBinaryName -ne 'codex-x.exe') { throw 'Invalid NSIS registration.' }
|
||||
if (@(Related-Products).Count -ne 0) { throw 'Legacy MSI is still registered.' }
|
||||
foreach ($root in @('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')) {
|
||||
$found = @(Get-ChildItem $root -ErrorAction SilentlyContinue | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Codex-X' })
|
||||
if ($root.StartsWith('HKCU:')) {
|
||||
if ($found.Count -ne 1) { throw 'Expected exactly one current-user uninstall entry.' }
|
||||
} elseif ($found.Count -ne 0) { throw 'A machine-wide Codex-X uninstall entry remains.' }
|
||||
}
|
||||
foreach ($entry in $Sentinels.GetEnumerator()) {
|
||||
if (-not (Test-Path $entry.Key) -or (Get-FileHash $entry.Key -Algorithm SHA256).Hash -ne $entry.Value) {
|
||||
throw "User configuration changed during installation: $($entry.Key)"
|
||||
}
|
||||
}
|
||||
}
|
||||
function Remove-TestNsis {
|
||||
# _?= avoids NSIS's detached temporary uninstaller process so WaitForExit
|
||||
# actually covers registry/files removal. This is NSIS's documented syntax.
|
||||
$uninstaller = Join-Path $InstallDir 'uninstall.exe'
|
||||
if (Test-Path $uninstaller) { Invoke-Installer $uninstaller "/S _?=$InstallDir" }
|
||||
if (Test-Path $UninstallKey) { throw 'NSIS uninstall registration was not removed.' }
|
||||
}
|
||||
if ((Test-Path $UninstallKey) -or @(Related-Products).Count -ne 0) { throw 'Runner already has a Codex-X installation.' }
|
||||
$Sentinels = @{}
|
||||
foreach ($relative in @('.codex\config.toml', '.codex\auth.json', '.codexx\installer-smoke-data.txt')) {
|
||||
$file = Join-Path $env:USERPROFILE $relative
|
||||
if (Test-Path $file) { throw "Refusing to overwrite an existing file: $file" }
|
||||
New-Item -ItemType Directory -Force (Split-Path $file) | Out-Null
|
||||
[IO.File]::WriteAllText($file, "Codex-X installer sentinel: $relative")
|
||||
$Sentinels[$file] = (Get-FileHash $file -Algorithm SHA256).Hash
|
||||
}
|
||||
try {
|
||||
Write-Host 'Scenario 1: clean current-user install and subsequent NSIS update.'
|
||||
Invoke-Installer $Installer '/S'
|
||||
Assert-Installed
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
Assert-Installed
|
||||
# A 3010/incomplete migration barrier must survive immediate retries. Once
|
||||
# the boot identity differs, it must be cleared and allow the install.
|
||||
$pendingKey = 'HKCU:\Software\yynxxxxx\Codex-X\Installer'
|
||||
New-Item -Force $pendingKey | Out-Null
|
||||
$boot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToFileTimeUtc().ToString()
|
||||
Set-ItemProperty $pendingKey PendingBoot $boot
|
||||
Invoke-Installer $Installer '/S /UPDATE' 1
|
||||
Assert-Installed
|
||||
Set-ItemProperty $pendingKey PendingBoot 'previous-boot-smoke-fixture'
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
if ((Get-ItemProperty $pendingKey -ErrorAction SilentlyContinue).PendingBoot) { throw 'Previous-boot marker was not cleared.' }
|
||||
Assert-Installed
|
||||
Remove-TestNsis
|
||||
|
||||
Write-Host 'Scenario 2: released v0.3.20 machine MSI -> current-user NSIS -> NSIS update.'
|
||||
$legacyMsi = Join-Path $Work 'Codex-X-0.3.20.msi'
|
||||
Invoke-WebRequest 'https://github.com/yynxxxxx/Codex-X/releases/download/v0.3.20/Codex-X-0.3.20-windows-x64.msi' -OutFile $legacyMsi
|
||||
if ((Get-FileHash $legacyMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $LegacySha256) { throw 'Released MSI hash mismatch.' }
|
||||
Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$legacyMsi`" /qn /norestart /L*v `"$Work\legacy-install.log`""
|
||||
if ($LegacyProductCode -notin @(Related-Products)) { throw 'The genuine legacy MSI did not register.' }
|
||||
# Deliberately keep the real MSI's HKCU manufacturer path to verify that the
|
||||
# NSIS installer does not inherit a legacy Program Files directory.
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
Assert-Installed
|
||||
Invoke-Installer $Installer '/S /UPDATE'
|
||||
Assert-Installed
|
||||
Write-Host 'Windows installation smoke tests passed; configuration sentinels unchanged.'
|
||||
} finally {
|
||||
Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Codex-X-updates\logs') -Filter 'install-*.log' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Write-Host "--- $($_.Name) ---"
|
||||
Get-Content $_.FullName -Tail 100
|
||||
}
|
||||
Remove-TestNsis
|
||||
}
|
||||
Reference in New Issue
Block a user