@rightkit/release 0.2.105 → 0.2.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/deps.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawnSync } from "./background-process.mjs";
5
5
  import { existsSync } from "node:fs";
6
6
 
7
7
  const args = process.argv.slice(2);
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawnSync } from "./background-process.mjs";
5
5
  import { RIGHTRELEASE_MANIFEST_SIGNER, signReleaseManifestWithAzure } from "./sign-release-manifest.mjs";
6
6
  import { validateCycloneDxSbom, validateInTotoSlsaProvenance } from "./supply-chain-evidence.mjs";
7
7
 
@@ -222,11 +222,13 @@ export function renderPowerShellBootstrap({
222
222
  expectedStatus = "complete",
223
223
  healthAssertions = null,
224
224
  preflightArgs = [],
225
+ priorExecutableMinVersion = null,
225
226
  allowLocalReleaseRoot = false,
226
227
  }) {
227
228
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error("invalid GitHub repository");
228
229
  if (!VERSION.test(bootstrapVersion)) throw new Error("invalid bootstrap version");
229
230
  if (typeof allowLocalReleaseRoot !== "boolean") throw new Error("allowLocalReleaseRoot must be boolean");
231
+ if (priorExecutableMinVersion !== null && !VERSION.test(priorExecutableMinVersion)) throw new Error("priorExecutableMinVersion must be a semantic version");
230
232
  assertRelativeFile(executablePath, "executable path");
231
233
  if (!Array.isArray(acceptedManifestSigners) || !acceptedManifestSigners.length) throw new Error("accepted manifest signers are required");
232
234
  const signers = {};
@@ -265,6 +267,7 @@ export function renderPowerShellBootstrap({
265
267
  statusArgs,
266
268
  healthAssertions: assertions,
267
269
  preflightArgs,
270
+ ...(priorExecutableMinVersion ? { priorExecutableMinVersion } : {}),
268
271
  ...(allowLocalReleaseRoot ? { allowLocalReleaseRoot: true } : {}),
269
272
  };
270
273
  // Bind generated contract metadata so a caller cannot pass a marker plus an
@@ -293,6 +296,55 @@ export function renderPowerShellBootstrap({
293
296
  "function Remove-StableJunction([string]$Path) { $Item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue; if ($null -eq $Item) { return }; if ($Item.LinkType -ne 'Junction') { throw ('Stable pointer path is not a junction: ' + $Path) }; [IO.Directory]::Delete($Path) }",
294
297
  "function Resolve-HealthPath($Value, [string]$JsonPath) { $CurrentValue = $Value; foreach ($Segment in $JsonPath.Split('.')) { if ($Segment -match '^\\d+$') { $Index = [int]$Segment; if ($null -eq $CurrentValue -or $Index -ge @($CurrentValue).Count) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = @($CurrentValue)[$Index] } else { $Property = $CurrentValue.PSObject.Properties[$Segment]; if ($null -eq $Property) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = $Property.Value } }; return $CurrentValue }",
295
298
  "function Assert-Health($Health, [string]$ActiveVersion) { foreach ($Assertion in $Contract.healthAssertions) { $Actual = Resolve-HealthPath $Health ([string]$Assertion.path); if ($Assertion.PSObject.Properties.Name -contains 'equals') { $Expected = $Assertion.equals; if ($Expected -is [string]) { $Expected = $Expected.Replace('{version}', $ActiveVersion).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot) }; if ($Actual -ne $Expected) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif ($Assertion.nonempty -eq $true) { if ($null -eq $Actual -or ([string]$Actual).Trim().Length -eq 0) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif (@($Actual).Count -lt [int]$Assertion.minCount) { throw ('Health assertion failed: ' + $Assertion.path) } } }",
299
+ "function Resolve-PriorVersion($PriorExePath, $PriorLeaf) { if ($PriorExePath -and (Test-Path -LiteralPath $PriorExePath -PathType Leaf)) { $Info = (Get-Item -LiteralPath $PriorExePath).VersionInfo; $Candidate = [string]$Info.ProductVersion; if ([string]::IsNullOrWhiteSpace($Candidate)) { $Candidate = [string]$Info.FileVersion }; $Match = [regex]::Match($Candidate, '(\\d+\\.\\d+\\.\\d+)'); if ($Match.Success) { return $Match.Groups[1].Value } }; if ($PriorLeaf) { $LeafMatch = [regex]::Match([string]$PriorLeaf, '^(\\d+\\.\\d+\\.\\d+)(?:-|$)'); if ($LeafMatch.Success) { return $LeafMatch.Groups[1].Value } }; return $null }",
300
+ // Every executable the installer invokes (prior probes, activation,
301
+ // health) runs through one bounded launcher: a hung or TUI-launching
302
+ // legacy binary must never stall installation or rollback forever.
303
+ String.raw`function ConvertTo-NativeArgument([AllowEmptyString()][string]$Value) {
304
+ # Start-Process joins ArgumentList with spaces; quote each argv element for
305
+ # the Windows native command-line parser, including empty/trailing-slash args.
306
+ return ('"' + [regex]::Replace([regex]::Replace($Value, '(\\*)"', '$1$1\"'), '(\\+)$', '$1$1') + '"')
307
+ }
308
+ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
309
+ $Out = Join-Path ([IO.Path]::GetTempPath()) ('rkexe-' + [guid]::NewGuid() + '.out'); $Err = "$Out.err"
310
+ $Proc = $null; $ExitCode = $null; $Failure = $null; $TimedOut = $false; $Stdout = ''; $Stderr = ''
311
+ try {
312
+ $StartOptions = @{ FilePath=$Path; NoNewWindow=$true; PassThru=$true; RedirectStandardOutput=$Out; RedirectStandardError=$Err }
313
+ if ($Arguments.Count) { $StartOptions.ArgumentList = (($Arguments | ForEach-Object { ConvertTo-NativeArgument $_ }) -join ' ') }
314
+ $Proc = Start-Process @StartOptions
315
+ # Windows PowerShell 5.1 must acquire the handle before waiting; otherwise
316
+ # ExitCode can remain null even after a successful native process exits.
317
+ $NativeProcessHandle = $Proc.Handle
318
+ if (-not $Proc.WaitForExit($TimeoutSeconds * 1000)) {
319
+ $TimedOut = $true
320
+ try { Stop-Process -Id $Proc.Id -Force -ErrorAction Stop; $null = $Proc.WaitForExit(2000) } catch {}
321
+ $Failure = 'Timed out after ' + $TimeoutSeconds + 's: ' + $Path
322
+ } else {
323
+ $ExitCode = $Proc.ExitCode
324
+ if ($null -eq $ExitCode) { $Failure = 'Native exit code unavailable: ' + $Path }
325
+ elseif ($ExitCode -ne 0) { $Failure = 'Command failed (exit ' + $ExitCode + '): ' + $Path }
326
+ }
327
+ } catch { $Failure = $_.Exception.Message }
328
+ finally {
329
+ try {
330
+ if (Test-Path -LiteralPath $Out) { $Stdout = [IO.File]::ReadAllText($Out) }
331
+ if (Test-Path -LiteralPath $Err) { $Stderr = [IO.File]::ReadAllText($Err) }
332
+ } finally {
333
+ if ($null -ne $Proc) { $Proc.Dispose() }
334
+ Remove-Item -LiteralPath $Out,$Err -Force -ErrorAction SilentlyContinue
335
+ }
336
+ }
337
+ $Global:LASTEXITCODE = $ExitCode
338
+ if ($Failure) {
339
+ $Newline = [Environment]::NewLine
340
+ $Exception = New-Object System.Exception ($Failure + $Newline + 'stdout:' + $Newline + $Stdout + $Newline + 'stderr:' + $Newline + $Stderr)
341
+ $Exception.Data['Path'] = $Path; $Exception.Data['Arguments'] = $Arguments
342
+ $Exception.Data['ExitCode'] = $ExitCode; $Exception.Data['TimedOut'] = $TimedOut
343
+ $Exception.Data['Stdout'] = $Stdout; $Exception.Data['Stderr'] = $Stderr
344
+ throw $Exception
345
+ }
346
+ return $Stdout
347
+ }`,
296
348
  ...(allowLocalReleaseRoot ? [
297
349
  "New-Item -ItemType Directory -Force -Path $VersionsRoot | Out-Null",
298
350
  "$LocalReleaseRoot = $null; if ($ReleaseRoot) { $LocalReleaseRoot = [IO.Path]::GetFullPath($ReleaseRoot); if (-not (Test-Path -LiteralPath $LocalReleaseRoot -PathType Container)) { throw 'Local release root is missing' } }",
@@ -310,7 +362,7 @@ export function renderPowerShellBootstrap({
310
362
  "if (-not $Version) { $Version = ((Invoke-RestMethod " + psQuote("https://api.github.com/repos/" + repository + "/releases/latest") + ").tag_name -replace '^v','') }",
311
363
  "if ($Version -notmatch '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$') { throw 'Invalid stable release version' }",
312
364
  "$PriorTarget = $null; if (Test-Path $Current) { $PriorTarget = (Get-Item $Current).Target }",
313
- "$PriorVersion = if ($PriorTarget) { ((Split-Path ([string]$PriorTarget) -Leaf) -split '-')[0] } else { $null }",
365
+ "$PriorVersion = if ($PriorTarget) { Resolve-PriorVersion (Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + ") (Split-Path ([string]$PriorTarget) -Leaf) } else { $null }",
314
366
  "if ($PriorVersion -and -not $AllowDowngrade -and ([version]$Version -lt [version]$PriorVersion)) { throw 'Downgrade requires -AllowDowngrade' }",
315
367
  "$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
316
368
  "$Work = Join-Path ([IO.Path]::GetTempPath()) (" + psQuote(product + "-install-") + " + [guid]::NewGuid())",
@@ -332,7 +384,7 @@ export function renderPowerShellBootstrap({
332
384
  "if ($Version -notmatch '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$') { throw 'Invalid stable release version' }",
333
385
  "$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
334
386
  "$PriorTarget = $null; if (Test-Path $Current) { $PriorTarget = (Get-Item $Current).Target }",
335
- "$PriorVersion = if ($PriorTarget) { ((Split-Path ([string]$PriorTarget) -Leaf) -split '-')[0] } else { $null }",
387
+ "$PriorVersion = if ($PriorTarget) { Resolve-PriorVersion (Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + ") (Split-Path ([string]$PriorTarget) -Leaf) } else { $null }",
336
388
  "if ($PriorVersion -and -not $AllowDowngrade -and ([version]$Version -lt [version]$PriorVersion)) { throw 'Downgrade requires -AllowDowngrade' }",
337
389
  ] : []),
338
390
  "if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne 'authenticode-catalog-sha256' -or $Manifest.signatureProvider -ne 'windows-authenticode-catalog' -or $Manifest.signatureProviderVersion -ne 1) { throw 'Release manifest signer identity mismatch' }",
@@ -367,9 +419,14 @@ export function renderPowerShellBootstrap({
367
419
  "$StatusArgsTemplate = " + psArray(statusArgs),
368
420
  "$PreflightArgsTemplate = " + psArray(preflightArgs),
369
421
  "$PreflightArgs = @(Expand-BootstrapArgs $PreflightArgsTemplate $VersionRoot $Version)",
370
- "$PriorStatus = $null; if ($PriorTarget) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); try { $PriorStatus = (& $PriorExe @PriorStatusArgs | Out-String).Trim() } catch {} }",
422
+ // A prior executable predating the installer verb surface (statusArgs /
423
+ // activationArgs) must never be invoked: legacy binaries can fall through
424
+ // to an interactive UI whose startup side effects (services, daemons,
425
+ // resident workers) corrupt the new install's own probes.
426
+ "$PriorCliCapable = $true; if ($Contract.priorExecutableMinVersion) { $PriorCliCapable = ($null -ne $PriorVersion -and ([version]$PriorVersion -ge [version]$Contract.priorExecutableMinVersion)) }",
427
+ "$PriorStatus = $null; if ($PriorTarget -and $PriorCliCapable) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); try { $PriorStatus = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 60) | Out-String).Trim() } catch {} }",
371
428
  "@{ schemaVersion=1; priorTarget=$PriorTarget; priorVersion=$PriorVersion; targetVersion=$Version; priorStatus=$PriorStatus } | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $Journal",
372
- "if ($PreflightArgs.Count) { & $VersionExe @PreflightArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation preflight failed' } }",
429
+ "if ($PreflightArgs.Count) { Invoke-BoundedExe $VersionExe $PreflightArgs 120 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Activation preflight failed' } }",
373
430
  "Remove-StableJunction $NextLink; Remove-StableJunction $PreviousLink",
374
431
  "New-Item -ItemType Junction -Path $NextLink -Target $VersionRoot | Out-Null",
375
432
  "$Switched = $false; $PriorDetached = $false",
@@ -377,18 +434,32 @@ export function renderPowerShellBootstrap({
377
434
  " if (Test-Path $Current) { Rename-Item -LiteralPath $Current -NewName (Split-Path -Leaf $PreviousLink); $PriorDetached = $true }; Rename-Item -LiteralPath $NextLink -NewName (Split-Path -Leaf $Current); $Switched = $true",
378
435
  " $StableExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")),
379
436
  " $ActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate $VersionRoot $Version); $StatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate $VersionRoot $Version)",
380
- " & $StableExe @ActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Activation failed' }",
381
- " $HealthText = (& $StableExe @StatusArgs | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Health command failed' }; $Health = $HealthText | ConvertFrom-Json",
437
+ " Invoke-BoundedExe $StableExe $ActivationArgs 240 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Activation failed' }",
438
+ " $HealthText = ((Invoke-BoundedExe $StableExe $StatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Health command failed' }; $Health = $HealthText | ConvertFrom-Json",
382
439
  " Assert-Health $Health $Version",
383
- " $StableBin = Split-Path $StableExe -Parent; $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); if (-not (($UserPath -split ';') -contains $StableBin)) { [Environment]::SetEnvironmentVariable('Path', (($UserPath.TrimEnd(';') + ';' + $StableBin).Trim(';')), 'User') }",
440
+ " $StableBin = Split-Path $StableExe -Parent; $InstallRootResolved = [IO.Path]::GetFullPath($InstallRoot).TrimEnd([IO.Path]::DirectorySeparatorChar); $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); $Kept = @($UserPath -split ';' | Where-Object { $Entry = $_.Trim(); $Entry -ne '' -and -not ($Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).StartsWith(($InstallRootResolved + [IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase) -and -not $Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).Equals($StableBin.TrimEnd([IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) }); $NewPath = ((@($StableBin) + $Kept) -join ';'); if ($NewPath -ne $UserPath) { [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') }",
384
441
  // Journal cleanup must succeed before discarding the prior pointer. If
385
442
  // cleanup fails, catch still has a rollback anchor to restore.
386
443
  " Remove-Item -Force $Journal; Remove-StableJunction $PreviousLink; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
387
444
  "} catch {",
388
- " if ($Switched -or $PriorDetached) { Remove-StableJunction $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { Rename-Item -LiteralPath $PreviousLink -NewName (Split-Path -Leaf $Current) }",
389
- " if ($PriorTarget -and (Test-Path $Current)) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); & $PriorExe @PriorActivationArgs; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = (& $PriorExe @PriorStatusArgs | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw 'Prior health was not restored' } }",
390
- " if (Test-Path $VersionRoot) { Remove-Item -Recurse -Force $VersionRoot }; throw",
391
- "} finally { if (Test-Path $Work) { Remove-Item -Recurse -Force $Work } }",
445
+ " $InstallError = $_; $RollbackErrors = New-Object 'System.Collections.Generic.List[string]'",
446
+ " try { if ($Switched -or $PriorDetached) { Remove-StableJunction $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { Rename-Item -LiteralPath $PreviousLink -NewName (Split-Path -Leaf $Current) } } catch { $RollbackErrors.Add($_.Exception.Message) }",
447
+ " try { if ($PriorTarget -and (Test-Path $Current) -and $PriorCliCapable -and ([string](Get-Item -LiteralPath $Current).Target -eq [string]$PriorTarget)) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); Invoke-BoundedExe $PriorExe $PriorActivationArgs 240 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw ('Prior health was not restored: ' + $_.Exception.Message) } } } catch { $RollbackErrors.Add($_.Exception.Message) }",
448
+ String.raw` try {
449
+ $CandidatePath = [IO.Path]::GetFullPath($VersionRoot).TrimEnd([IO.Path]::DirectorySeparatorChar)
450
+ $VersionsPrefix = [IO.Path]::GetFullPath($VersionsRoot).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
451
+ $ActiveTarget = if (Test-Path -LiteralPath $Current) { [string](Get-Item -LiteralPath $Current).Target } else { $null }
452
+ if ($ActiveTarget -and -not [IO.Path]::IsPathRooted($ActiveTarget)) { $ActiveTarget = Join-Path (Split-Path -Parent $Current) $ActiveTarget }
453
+ if (-not $CandidatePath.StartsWith($VersionsPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Failed version cleanup escapes versions root' }
454
+ if ($ActiveTarget -and [IO.Path]::GetFullPath($ActiveTarget).TrimEnd([IO.Path]::DirectorySeparatorChar).Equals($CandidatePath, [StringComparison]::OrdinalIgnoreCase)) { throw 'Failed version is still active; preserved for recovery' }
455
+ if (Test-Path -LiteralPath $CandidatePath) { Remove-Item -LiteralPath $CandidatePath -Recurse -Force }
456
+ } catch { $RollbackErrors.Add($_.Exception.Message) }
457
+ if ($RollbackErrors.Count) {
458
+ $InstallError.Exception.Data['RollbackErrors'] = $RollbackErrors.ToArray()
459
+ Write-Warning ('Rollback/cleanup also failed: ' + ($RollbackErrors -join '; '))
460
+ }
461
+ throw $InstallError
462
+ } finally { try { if (Test-Path -LiteralPath $Work) { Remove-Item -LiteralPath $Work -Recurse -Force } } catch { Write-Warning ('Installer work cleanup failed: ' + $_.Exception.Message) } }`,
392
463
  ];
393
464
  const body = lines.join("\r\n") + "\r\n";
394
465
  const sourceSha256 = createHash("sha256").update(body).digest("hex");
@@ -434,6 +505,7 @@ export function validatePowerShellBootstrap(script, expected = {}) {
434
505
  statusArgs: contract.statusArgs,
435
506
  healthAssertions: contract.healthAssertions,
436
507
  preflightArgs: contract.preflightArgs,
508
+ priorExecutableMinVersion: contract.priorExecutableMinVersion ?? null,
437
509
  allowLocalReleaseRoot: contract.allowLocalReleaseRoot === true,
438
510
  });
439
511
  if (regenerated !== script) errors.push("bootstrap source does not match canonical generated output");
@@ -2,7 +2,7 @@
2
2
  import { createHash } from "node:crypto";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawnSync } from "./background-process.mjs";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { verifySealedRelease } from "./release-state.mjs";
8
8
  import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
package/heavy-command.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { spawn, spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "./background-process.mjs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { randomUUID } from "node:crypto";
8
8
 
@@ -0,0 +1,13 @@
1
+ import { spawnSync } from "./background-process.mjs";
2
+
3
+ export function resolveMacosDeveloperIdIdentity({ env = process.env, run = spawnSync } = {}) {
4
+ const configured = env.APPLE_DEVELOPER_ID?.trim();
5
+ if (configured) return configured;
6
+ const result = run("security", ["find-identity", "-v", "-p", "codesigning"], { encoding: "utf8" });
7
+ if (result.status !== 0) throw new Error("could not inspect macOS signing identities in Keychain");
8
+ const identities = [...String(result.stdout ?? "").matchAll(/"([^"\n]*Developer ID Application:[^"]+)"/g)]
9
+ .map((match) => match[1]);
10
+ if (identities.length === 1) return identities[0];
11
+ if (!identities.length) throw new Error("no Developer ID Application signing identity found in Keychain");
12
+ throw new Error(`multiple Developer ID Application signing identities found in Keychain; set APPLE_DEVELOPER_ID explicitly (${identities.join(", ")})`);
13
+ }
@@ -0,0 +1,29 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "./background-process.mjs";
4
+
5
+ export function managedRuntimeClient(env = process.env, platform = process.platform, exists = existsSync) {
6
+ const roots = [env.RIGHTKIT_MANAGED_ROOT, env.RIGHTKIT_EXTERNAL_ROOT,
7
+ platform === "win32" ? "D:\\.rightkit-managed" : platform === "darwin" ? "/Volumes/D/.rightkit-managed" : undefined]
8
+ .filter((root) => typeof root === "string" && root.length > 0);
9
+ for (const root of roots) {
10
+ const client = path.join(root, "runtime-current", "build-control-clients", "src", "rightkit.mjs");
11
+ if (exists(client)) return client;
12
+ }
13
+ return undefined;
14
+ }
15
+
16
+ function managedHost(env) {
17
+ return Boolean(env.RIGHTKIT_BUILD_BROKER_SOCKET)
18
+ || /(?:^|[;:])[^;:]*[/\\](?:\.rightkit-managed|rightkitmanagedagent)[/\\]agent-bin(?:[;:]|$)/i.test(String(env.PATH ?? ""));
19
+ }
20
+
21
+ export function runCargoSync(args, options = {}) {
22
+ const env = options.env ?? process.env;
23
+ const client = managedRuntimeClient(env);
24
+ if (client) return spawnSync(process.execPath, [client, "cargo", ...args], { ...options, cwd: process.cwd() });
25
+ if (managedHost(env) && process.platform === "win32") {
26
+ return { status: null, stdout: "", stderr: "", error: new Error("installed RightKit runtime client is unavailable for managed Cargo metadata") };
27
+ }
28
+ return spawnSync("cargo", args, options);
29
+ }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { copyFileSync, mkdirSync, realpathSync, renameSync, rmSync } from "node:fs";
3
- import { execFileSync } from "node:child_process";
3
+ import { execFileSync } from "./background-process.mjs";
4
4
  import path from "node:path";
5
5
 
6
6
  const args = parseArgs(process.argv.slice(2));
package/model-promote.mjs CHANGED
@@ -4,7 +4,7 @@ import { createReadStream } from "node:fs";
4
4
  import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
- import { spawn, spawnSync } from "node:child_process";
7
+ import { spawn, spawnSync } from "./background-process.mjs";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
 
10
10
  import {
@@ -2,7 +2,7 @@
2
2
  import { createHash } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
4
  import path from "node:path";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawnSync } from "./background-process.mjs";
6
6
  import { notarytoolAuthArgs } from "./notary-auth.mjs";
7
7
  import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
8
8
 
package/nsis-payload.mjs CHANGED
@@ -16,7 +16,7 @@ import { createHash } from "node:crypto";
16
16
  import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
- import { spawnSync } from "node:child_process";
19
+ import { spawnSync } from "./background-process.mjs";
20
20
 
21
21
  const SEVEN_ZIP_CANDIDATES = [
22
22
  "C:\\Program Files\\7-Zip\\7z.exe",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.105",
3
+ "version": "0.2.106",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -33,4 +33,4 @@
33
33
  "doctor:all": "node --test right-suite-contract.test.mjs",
34
34
  "verify:standalone": "node standalone-clone-verify.mjs"
35
35
  }
36
- }
36
+ }
package/preflight.mjs CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
22
22
  import path from "node:path";
23
- import { spawnSync } from "node:child_process";
23
+ import { spawnSync } from "./background-process.mjs";
24
24
  import { brokerManagedHost } from "./cargo-contract.mjs";
25
25
 
26
26
  export const PREFLIGHT_OK = "ok";
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawnSync } from "./background-process.mjs";
2
2
 
3
3
  /**
4
4
  * Total CPU seconds reported by `ps` for a process group, summed across every
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn } from "./background-process.mjs";
2
2
 
3
3
  const MANAGED_REQUEST_PATTERN = /\brightkit(?: managed-agent)?: request ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
4
4
  export const RELEASE_LIVENESS_OWNER_MARGIN_MS = 5 * 60 * 1000;
package/pub-upload.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { accessSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawnSync } from "./background-process.mjs";
5
5
 
6
6
  const ACCOUNT_ID = "03ae77ccd7a07bcbb2dcfde47fa7ba3a";
7
7
  const BUCKETS = new Map([
package/publish-cargo.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { spawnSync } from "node:child_process";
4
+ import { spawnSync } from "./background-process.mjs";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\.[^/]*)?|target(?:\/.*)?|node_modules(?:\/.*)?|[^/]+\.(?:p8|p12|pem|key))$/i;
package/publish-swift.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
  import { createHash } from "node:crypto";
12
- import { spawnSync } from "node:child_process";
12
+ import { spawnSync } from "./background-process.mjs";
13
13
  import { scanCrateForSecrets } from "./publish-cargo.mjs";
14
14
 
15
15
  const IDENTIFIER = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/i;
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { access, readFile, stat } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { spawn } from "node:child_process";
6
+ import { spawn } from "./background-process.mjs";
7
7
  import { pruneReleaseObjects } from "./prune-r2.mjs";
8
8
  import { loadReleaseToken } from "./release-token.mjs";
9
9
  import { registerRightAppsRelease } from "./rightapps-register.mjs";
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash } from 'node:crypto';
3
- import { execFileSync } from 'node:child_process';
3
+ import { execFileSync } from './background-process.mjs';
4
4
  import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { basename, join, relative } from 'node:path';
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawnSync } from "./background-process.mjs";
2
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
package/release-state.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { spawnSync } from "node:child_process";
2
+ import { spawnSync } from "./background-process.mjs";
3
3
  import { existsSync, readFileSync, readdirSync, watch } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { readCachePolicy, resolveCacheLayout } from "./cache-policy.mjs";
@@ -182,6 +182,7 @@ export async function runBuildStateMachine({
182
182
  await ops.build?.({ root, app, version, commit, platform, releaseId });
183
183
  await ops.sign?.({ root, app, version, commit, platform, releaseId });
184
184
  await ops.harden?.({ root, app, version, commit, platform, releaseId });
185
+ await ops.preSeal?.({ root, app, version, commit, platform, releaseId, sealedDir });
185
186
  await ops.seal?.({ root, app, version, commit, platform, releaseId, sealedDir });
186
187
  return { status: "sealed", resumed: false, releaseId, sealedDir };
187
188
  }
package/release-token.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile } from "./background-process.mjs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
package/release.mjs CHANGED
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
- import { spawn, spawnSync } from "node:child_process";
7
+ import { spawn, spawnSync } from "./background-process.mjs";
8
8
  import { validateRightKitCargoContract } from "./cargo-contract.mjs";
9
9
  import { assertQaBackdoorContract } from "./qa-contract.mjs";
10
10
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
@@ -24,6 +24,7 @@ import { terminateProcessTree } from "./heavy-command.mjs";
24
24
  import { materializeHardeningEvidence } from "./hardening-evidence.mjs";
25
25
  import { MACOS_PORTABLE_SIGNING_CONTRACT } from "./macos-signing-contract.mjs";
26
26
  import { runConfiguredChecks } from "./release-checks.mjs";
27
+ import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
27
28
 
28
29
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
29
30
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -56,6 +57,8 @@ const opts = {
56
57
  skipChecks: false,
57
58
  doctor: false,
58
59
  tier: null,
60
+ resume: false,
61
+ resumeFrom: null,
59
62
  };
60
63
 
61
64
  for (let i = 0; i < args.length; i++) {
@@ -70,6 +73,8 @@ for (let i = 0; i < args.length; i++) {
70
73
  else if (arg === "--skip-checks") opts.skipChecks = true;
71
74
  else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
72
75
  else if (arg === "--doctor") opts.doctor = true;
76
+ else if (arg === "--resume") opts.resume = true;
77
+ else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
73
78
  else if (arg === "--tier") opts.tier = args[++i];
74
79
  else if (arg.startsWith("--tier=")) opts.tier = arg.slice("--tier=".length);
75
80
  else if (arg === "--version") {
@@ -82,6 +87,8 @@ for (let i = 0; i < args.length; i++) {
82
87
 
83
88
  if (opts.upload) fail("combined build+upload was removed; run right-release build, then right-release upload --release <id> --tier patch|update");
84
89
  if (opts.tier && !TIERS.has(opts.tier)) usage(2, `invalid --tier: ${opts.tier} (expected patch|update)`);
90
+ const MAC_RESUME_PHASES = ["prepackage", "sign", "package", "post-sign", "harden"];
91
+ if (opts.resumeFrom && !MAC_RESUME_PHASES.includes(opts.resumeFrom)) usage(2, `invalid --resume-from: ${opts.resumeFrom}`);
85
92
  const sccacheVersion = assertSharedSccachePrerequisite();
86
93
 
87
94
  const requestedConfigPath = path.resolve(opts.config);
@@ -216,6 +223,8 @@ if (opts.upload && !target.publish && !target.upload) {
216
223
 
217
224
  const started = Date.now();
218
225
  console.log(`right-release ${VERSION}: ${config.app ?? path.basename(root)} ${opts.platform} ${mode} tier=${opts.tier ?? "neutral"}`);
226
+ const macResumeAt = opts.platform === "mac" && opts.resumeFrom ? MAC_RESUME_PHASES.indexOf(opts.resumeFrom) : 0;
227
+ const runMacPhase = (phase) => opts.platform !== "mac" || !opts.resumeFrom || MAC_RESUME_PHASES.indexOf(phase) >= macResumeAt;
219
228
 
220
229
  if (opts.install) await runInstall(config.packageManager, workdir);
221
230
  await runConfiguredChecks({
@@ -256,9 +265,9 @@ if (opts.platform === "win") {
256
265
  }
257
266
  } else if (opts.platform === "mac") {
258
267
  const signingFiles = target.sign.prePackageFiles.map((p) => path.resolve(root, p));
259
- await runCommand(target.prePackage, root);
268
+ if (runMacPhase("prepackage")) await runCommand(target.prePackage, root);
260
269
  for (const file of signingFiles) await mustExist(file, `missing macOS Developer ID signing artifact: ${file}`);
261
- await signMacos(signingFiles, root);
270
+ if (runMacPhase("sign")) await signMacos(signingFiles, root);
262
271
  } else if (nativeAssembly) {
263
272
  if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
264
273
  if (nativeFinalizer) {
@@ -272,7 +281,7 @@ if (opts.platform === "win") {
272
281
  }
273
282
  }
274
283
 
275
- await runCommand(command, root);
284
+ if (opts.platform !== "mac" || runMacPhase("package")) await runCommand(command, root);
276
285
 
277
286
  for (const rel of target.artifacts ?? []) {
278
287
  await mustExist(path.resolve(root, rel), `missing release artifact: ${rel}`);
@@ -294,7 +303,7 @@ if (opts.platform === "win" && target.signingContract === WINDOWS_NSIS_SIGNING_C
294
303
  await run("node", [SIGN_UPDATER, ...(opts.dryRun ? ["--dry-run"] : []), ...updaterFiles.flatMap((file) => ["--file", file])], root);
295
304
  }
296
305
 
297
- if (target.postSign && opts.platform !== "win") {
306
+ if (target.postSign && opts.platform !== "win" && (opts.platform !== "mac" || runMacPhase("post-sign"))) {
298
307
  await runCommand(target.postSign, root);
299
308
  }
300
309
 
@@ -304,7 +313,7 @@ if (opts.platform === "mac") {
304
313
  await notarizeMacos(archive, root);
305
314
  }
306
315
 
307
- {
316
+ if (opts.platform !== "mac" || runMacPhase("harden")) {
308
317
  const scanTargets = target.hardening ?? target.artifacts ?? [];
309
318
  if (scanTargets.length) {
310
319
  let hardeningEvidence = null;
@@ -726,10 +735,7 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
726
735
  // (Windows) and macos-sign (macOS), so publish is unreachable unless every
727
736
  // signing/qualification stage actually verified in this exact run.
728
737
  const legacyReleasePublishGate = /publish:\n needs: \[[^\n]*installed-qualification[^\n]*\]\n if: \$\{\{ startsWith\(github\.ref, 'refs\/tags\/v'\) && needs\.installed-qualification\.result == 'success'/.test(publishSource);
729
- // Template 1.9.0 adds a dry-run rehearsal: publish is additionally unreachable
730
- // when admission recorded `dry_run`. The clause only narrows the gate, so it
731
- // is accepted as an optional trailing conjunct.
732
- const admittedReleasePublishGate = /publish:\n needs: \[(?=[^\n]*\badmission\b)(?=[^\n]*\binstalled-qualification\b)(?=[^\n]*\bmacos-sign\b)[^\n]*\]\n if: \$\{\{ needs\.admission\.outputs\.publish == 'true' && needs\.installed-qualification\.result == 'success' && needs\.macos-sign\.result == 'success'(?: && needs\.admission\.outputs\.dry_run != 'true')? \}\}/.test(publishSource);
738
+ const admittedReleasePublishGate = /publish:\n needs: \[(?=[^\n]*\badmission\b)(?=[^\n]*\binstalled-qualification\b)(?=[^\n]*\bmacos-sign\b)[^\n]*\]\n if: \$\{\{ needs\.admission\.outputs\.publish == 'true' && needs\.installed-qualification\.result == 'success' && needs\.macos-sign\.result == 'success' \}\}/.test(publishSource);
733
739
  const protectedReleasePublish = releaseChain
734
740
  && (legacyReleasePublishGate || admittedReleasePublishGate)
735
741
  && /\n permissions:\n contents: write\n/.test(publishSource);
@@ -826,7 +832,8 @@ function verifyMacosSigningReceipt(receipt, files) {
826
832
  if (!existsSync(receipt)) fail(`missing macOS Developer ID signing receipt: ${receipt}`);
827
833
  let evidence;
828
834
  try { evidence = JSON.parse(readFileSync(receipt, "utf8")); } catch { fail(`invalid macOS Developer ID signing receipt: ${receipt}`); }
829
- const expectedIdentity = process.env.APPLE_DEVELOPER_ID?.trim();
835
+ let expectedIdentity;
836
+ try { expectedIdentity = resolveMacosDeveloperIdIdentity(); } catch (error) { fail(error.message); }
830
837
  const expected = files.map((file) => path.resolve(file)).sort();
831
838
  const actual = Array.isArray(evidence.files) ? evidence.files.map((item) => path.resolve(item.file)).sort() : [];
832
839
  const hashes = new Map(files.map((file) => [path.resolve(file), createHash("sha256").update(readFileSync(file)).digest("hex")]));