diff --git a/.github/workflows/cli-installer-smoke.yml b/.github/workflows/cli-installer-smoke.yml index 8a820f43c..4508e390b 100644 --- a/.github/workflows/cli-installer-smoke.yml +++ b/.github/workflows/cli-installer-smoke.yml @@ -3,6 +3,14 @@ name: CLI Installer Smoke on: workflow_dispatch: inputs: + windows_network: + description: Only accept public stable-to-beta downloads on Windows PowerShell 5.1 + type: boolean + default: false + windows_published_installer: + description: Use the checksum-bound published installer instead of checkout (network mode) + type: boolean + default: false windows_preview: description: Build only the independent Windows x64/ARM64 preview type: boolean @@ -19,11 +27,18 @@ on: # below builds every native candidate and accepts the live preview channel. # Hosted checkout/Bun/SSH candidate work begins at the master boundary. pull_request: - branches: [master] + branches: [dev, master] paths: - ".bun-version" - ".github/workflows/cli-installer-smoke.yml" - ".github/workflows/release.yml" + - "install.ps1" + - "scripts/windows-cli-preview-smoke.ts" + - "scripts/windows-cli-preview.spec.ts" + - "scripts/cli-installer-workflow.spec.ts" + - "tests/integration/installer-powershell/**" + - ".github/workflows/windows-cli-preview.yml" + - "packages/update-lifecycle/**" - "install" - "package.json" - "packages/cli/**" @@ -78,15 +93,75 @@ concurrency: cancel-in-progress: true jobs: + windows-network-scope: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + relevant: ${{ steps.scope.outputs.relevant }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + # Keep unrelated UI/business PRs off the Windows network lane. + git diff --name-only "$BASE_SHA" "$HEAD_SHA" > "$RUNNER_TEMP/windows-network-paths" + if grep -Eq '^(install\.ps1|packages/cli/|packages/update-lifecycle/|scripts/windows-cli-preview|scripts/cli-installer-workflow|tests/integration/installer-powershell/|\.github/workflows/(cli-installer-smoke|windows-cli-preview)\.yml)' "$RUNNER_TEMP/windows-network-paths"; then + echo 'relevant=true' >> "$GITHUB_OUTPUT" + else + echo 'relevant=false' >> "$GITHUB_OUTPUT" + fi + + windows-network: + needs: windows-network-scope + if: >- + !cancelled() && + ((github.event_name == 'workflow_dispatch' && inputs.windows_network) || + (github.event_name == 'pull_request' && needs.windows-network-scope.outputs.relevant == 'true')) + name: PowerShell 5.1 public install and update + runs-on: windows-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: oven-sh/setup-bun@v2 + with: + bun-version-file: .bun-version + - name: Accept real public release lifecycle + env: + PUBLISHED_INSTALLER: ${{ inputs.windows_published_installer && '1' || '0' }} + shell: powershell + run: | + $extra = @() + if ($env:PUBLISHED_INSTALLER -eq '1') { $extra += '--published-installer' } + bun scripts/windows-cli-preview-smoke.ts --published @extra + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Preserve PowerShell and public release evidence + if: always() + uses: actions/upload-artifact@v6 + with: + name: windows-network-${{ github.event.pull_request.head.sha || github.sha }} + path: dist/windows-cli-preview/**/native-smoke.json + if-no-files-found: warn + retention-days: 30 + windows-preview: - if: github.event_name == 'workflow_dispatch' && inputs.windows_preview + if: github.event_name == 'workflow_dispatch' && inputs.windows_preview && !inputs.windows_network uses: ./.github/workflows/windows-cli-preview.yml with: candidate_run: ${{ inputs.windows_candidate_run || '' }} channel_build: ${{ inputs.windows_channel_build || false }} release-prep-scope: - if: github.event_name != 'push' && !inputs.windows_preview + if: github.event_name != 'push' && !inputs.windows_preview && !inputs.windows_network && (github.event_name != 'pull_request' || github.base_ref == 'master') name: release-prep scope runs-on: ubuntu-latest timeout-minutes: 5 @@ -118,7 +193,7 @@ jobs: needs: release-prep-scope if: >- !cancelled() && - !inputs.windows_preview && + !inputs.windows_preview && !inputs.windows_network && github.event_name != 'push' && (github.event_name != 'pull_request' || github.base_ref == 'master') && ( @@ -185,8 +260,9 @@ jobs: needs: release-prep-scope if: >- !cancelled() && - !inputs.windows_preview && + !inputs.windows_preview && !inputs.windows_network && github.event_name != 'push' && + (github.event_name != 'pull_request' || github.base_ref == 'master') && ( needs.release-prep-scope.result != 'success' || needs.release-prep-scope.outputs.beta_release_prep != 'true' @@ -209,7 +285,7 @@ jobs: needs: release-prep-scope if: >- !cancelled() && - !inputs.windows_preview && + !inputs.windows_preview && !inputs.windows_network && github.event_name != 'push' && (github.event_name != 'pull_request' || github.base_ref == 'master') && ( @@ -639,7 +715,7 @@ jobs: echo "activated=true" >> "$GITHUB_OUTPUT" dev-channel-install: - if: always() && !inputs.windows_preview && ((github.event_name == 'push' && needs.activate-dev-cli.result == 'success' && needs.activate-dev-cli.outputs.activated == 'true') || github.event_name == 'workflow_dispatch') + if: always() && !inputs.windows_preview && !inputs.windows_network && ((github.event_name == 'push' && needs.activate-dev-cli.result == 'success' && needs.activate-dev-cli.outputs.activated == 'true') || github.event_name == 'workflow_dispatch') name: Live raw dev channel install needs: activate-dev-cli runs-on: ubuntu-latest diff --git a/docs/cli-installer.md b/docs/cli-installer.md index 2548018c4..e53500d48 100644 --- a/docs/cli-installer.md +++ b/docs/cli-installer.md @@ -132,6 +132,51 @@ rebuilding. An installer or test-only fix may replay those bytes; executable changes require a new candidate. The earlier custom ZIPs remain historical diagnostic fixtures, not a second channel or an upgrade target. +### Windows PowerShell 5.1 public-network acceptance + +The existing Windows smoke has two deliberately separate modes. Candidate +acceptance uses local archives and a synthetic previous version; it cannot prove +public checksum transport or a released updater. The public mode uses the system +`WindowsPowerShell/v1.0/powershell.exe`, asserts Desktop edition and version 5.1, +and records the full host version in `native-smoke.json`: + +```bash +bun scripts/windows-cli-preview-smoke.ts --published +# Also accept the public manifest's checksum-bound installer snapshot: +bun scripts/windows-cli-preview-smoke.ts --published --published-installer + +gh workflow run cli-installer-smoke.yml --ref -f windows_network=true +# Published installer instead of checked-out installer: +gh workflow run cli-installer-smoke.yml --ref \ + -f windows_network=true -f windows_published_installer=true +``` + +This explicit system lane needs native Windows, system Git/Bash and tar, Bun +from `.bun-version`, and public GitHub/CDN access; it needs no release credentials +or workspace dependency install. It resolves the live stable and beta manifests, +requires different releases, and fails if the target changes during acceptance. +It probes the live binary SHA256 sidecar with the checked-out `Download-Text` +function (or selected published snapshot), requiring `System.Byte[]` input and +`System.String` output. Ordinary installer invocations then download and verify +the real archives without `-Archive` or `-Sha256` overrides. + +The shared lifecycle checks fresh beta install/version/start/stop, then a fresh +stable install followed by the **released binary's** real `update --channel beta` +while its Runtime is running, pending activation, rollback in both directions, +updated Runtime readiness, and deferred data-preserving uninstall. This does not +execute unshipped updater code: source updater coverage remains in its existing +integration tests and candidate acceptance. Receipts distinguish checkout versus +published installer, actual release identities, and synthetic versus real updates. + +The installer workflow selects this Windows job for installer/CLI/update-lifecycle +PR changes to dev or master; unrelated UI/business changes do not run it. Manual +network mode runs only this lane and does not publish or activate any channel. +Tests allocate temporary install/home/profile paths, bind loopback, use lite +mode without provider credentials, avoid persistent PATH changes, and clean only +their own files and Runtime. An unavailable release/network is a failure, never a +skipped pass. The `runtime-cli-acceptance` suite owns both modes of the existing +`windows-cli-preview` runner. + ## Artifact contract Every accepted archive is named: diff --git a/scripts/beta-release-prep-workflow.spec.ts b/scripts/beta-release-prep-workflow.spec.ts index 6fdb85de6..acc1aa692 100644 --- a/scripts/beta-release-prep-workflow.spec.ts +++ b/scripts/beta-release-prep-workflow.spec.ts @@ -100,10 +100,10 @@ describe('exact beta release-preparation workflow lane', () => { const cli = workflow('cli-installer-smoke.yml') const jobs = cli.jobs const scope = jobs['release-prep-scope'] - expect(cli.on?.pull_request?.branches).toEqual(['master']) + expect(cli.on?.pull_request?.branches).toEqual(['dev', 'master']) expectTrustedClassifier(scope) expectOutcomeGatedOutput(scope) - expect(scope.if).toBe("github.event_name != 'push' && !inputs.windows_preview") + expect(scope.if).toBe("github.event_name != 'push' && !inputs.windows_preview && !inputs.windows_network && (github.event_name != 'pull_request' || github.base_ref == 'master')") for (const name of ['bun-cli-feasibility', 'checkout-install', 'checkout-remote']) { expect(jobs[name].needs).toBe('release-prep-scope') expect(jobs[name].if).toContain('!cancelled()') @@ -112,7 +112,7 @@ describe('exact beta release-preparation workflow lane', () => { } expect(jobs['bun-cli-feasibility'].if).toContain("github.base_ref == 'master'") expect(jobs['checkout-remote'].if).toContain("github.base_ref == 'master'") - expect(jobs['checkout-install'].if).not.toContain("github.base_ref == 'master'") + expect(jobs['checkout-install'].if).toContain("github.base_ref == 'master'") expect(jobs['build-dev-cli-neutral'].if).toBe("github.event_name == 'push'") expect(jobs['build-dev-cli'].if).toBe("github.event_name == 'push'") expect(jobs['build-dev-cli'].needs).toEqual(['build-dev-cli-neutral', 'build-dev-broker-packs']) diff --git a/scripts/cli-installer-workflow.spec.ts b/scripts/cli-installer-workflow.spec.ts index d359d8be2..fee89f17f 100644 --- a/scripts/cli-installer-workflow.spec.ts +++ b/scripts/cli-installer-workflow.spec.ts @@ -45,7 +45,7 @@ function step(job: WorkflowJob, name: string): WorkflowStep { describe('CLI installer dev publication workflow', () => { it('owns every dev push while reserving hosted PR acceptance for master', () => { expect(workflow.on?.push?.branches).toEqual(['dev']) - expect(workflow.on?.pull_request?.branches).toEqual(['master']) + expect(workflow.on?.pull_request?.branches).toEqual(['dev', 'master']) }) it('keeps master and manual acceptance behind the trusted preflight', () => { @@ -58,7 +58,7 @@ describe('CLI installer dev publication workflow', () => { expect(job.if).toContain("needs.release-prep-scope.result != 'success'") expect(job.if).toContain("beta_release_prep != 'true'") } - expect(checkoutInstall.if).not.toContain("github.base_ref == 'master'") + expect(checkoutInstall.if).toContain("github.base_ref == 'master'") expect(checkoutInstall.if).toContain("needs.release-prep-scope.result != 'success'") expect(checkoutInstall.if).toContain("beta_release_prep != 'true'") }) diff --git a/scripts/windows-cli-preview-smoke.ts b/scripts/windows-cli-preview-smoke.ts index 29bb96d8b..9a9d48e61 100644 --- a/scripts/windows-cli-preview-smoke.ts +++ b/scripts/windows-cli-preview-smoke.ts @@ -1,18 +1,20 @@ -import { cp, mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { basename, dirname, join, resolve } from 'node:path' -import { rewriteExpandedCliRelease, syntheticPreviousVersion } from './cli-release-fixture.mjs' if (process.platform !== 'win32') throw new Error('Run this smoke on native Windows') const outputRoot = resolve(`dist/windows-cli-preview/${process.arch}`) -const candidates = process.argv[2] ? [resolve(process.argv[2])] : await findCandidates(outputRoot) -if (candidates.length !== 1) throw new Error(`Expected one candidate for this architecture, found ${candidates.length}`) -const candidateFile = candidates[0]! -const candidate = JSON.parse(await readFile(candidateFile, 'utf8')) -// Actions downloads have a new filesystem root. The candidate's ZIP and -// checksum travel together; never execute paths recorded on the old runner. -const archive = join(dirname(candidateFile), basename(candidate.archive)) +// Published mode exercises real release bytes and public HTTPS downloads. The +// default candidate mode retains its separate synthetic/local-archive contract. +const published = process.argv.includes('--published') +const publishedInstaller = process.argv.includes('--published-installer') +if (publishedInstaller && !published) throw new Error('--published-installer requires --published') +const candidates = published ? [] : process.argv[2] ? [resolve(process.argv[2])] : await findCandidates(outputRoot) +if (!published && candidates.length !== 1) throw new Error(`Expected one candidate for this architecture, found ${candidates.length}`) +const candidateFile = candidates[0] +const candidate = published ? { channelBuild: true, arch: process.arch } : JSON.parse(await readFile(candidateFile!, 'utf8')) +const archive = published ? '' : join(dirname(candidateFile!), basename(candidate.archive)) if (candidate.arch !== process.arch) throw new Error('Native smoke architecture mismatch') const scratch = await mkdtemp(join(tmpdir(), 'openalice-preview-smoke-')) const installDir = join(scratch, 'installed preview') @@ -23,116 +25,252 @@ const powershell = join(process.env.SystemRoot!, 'System32/WindowsPowerShell/v1. // module (Get-FileHash, ConvertFrom-Json), just as on an ordinary user host. const powershellEnv = { ...process.env } delete powershellEnv.PSModulePath -await command(powershell, ['-NoProfile', '-File', resolve(candidate.channelBuild ? 'install.ps1' : 'install-preview.ps1'), - '-Archive', archive, '-Sha256', candidate.sha256, '-InstallDir', installDir, - ...(candidate.channelBuild ? ['-Channel', candidate.version.includes('-beta') ? 'beta' : 'stable', '-NoModifyPath'] : []), '-Yes'], powershellEnv) -const releaseName = candidate.channelBuild ? (await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() : null -const releaseDir = releaseName ? join(installDir, 'cli/releases', releaseName) : installDir -const executable = join(releaseDir, 'bin/openalice.exe') -const portProbe = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: { data() {} } }) -const port = portProbe.port -portProbe.stop(true) -const environment = { - // Keep Windows account/system discovery real while isolating Alice state - // and excluding every host development tool from PATH. - ...Object.fromEntries(['APPDATA', 'LOCALAPPDATA', 'ProgramData', 'SystemDrive', - 'USERNAME', 'USERDOMAIN', 'COMPUTERNAME', 'HOMEDRIVE', 'HOMEPATH', 'ComSpec', - 'ProgramFiles', 'ProgramFiles(x86)'].flatMap(key => process.env[key] ? [[key, process.env[key]!]] : [])), - SystemRoot: process.env.SystemRoot!, WINDIR: process.env.WINDIR!, - OS: 'Windows_NT', PROCESSOR_ARCHITECTURE: process.arch === 'arm64' ? 'ARM64' : 'AMD64', - PSExecutionPolicyPreference: 'Restricted', - TEMP: scratch, TMP: scratch, HOME: scratch, USERPROFILE: process.env.USERPROFILE!, - PATH: join(process.env.SystemRoot!, 'System32'), - OPENALICE_HOME: home, OPENALICE_TRADING_MODE: 'lite', - OPENALICE_DISABLE_AUTH: '1', OPENALICE_BIND_HOST: '127.0.0.1', - ...(releaseName ? { - OPENALICE_INSTALL_ROOT: installDir, OPENALICE_RELEASE_DIR: releaseDir, - OPENALICE_INSTALL_SOURCE: join(installDir, 'cli/provenance', `${releaseName}.json`), - } : {}), -} -let uninstalled = false +const host = JSON.parse(await command(powershell, ['-NoProfile', '-Command', ` + $ErrorActionPreference = 'Stop' + if ($PSVersionTable.PSEdition -ne 'Desktop' -or $PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -ne 1) { throw 'Windows PowerShell 5.1 is required' } + @{ version = $PSVersionTable.PSVersion.ToString(); edition = $PSVersionTable.PSEdition; os = [Environment]::OSVersion.VersionString; executable = (Get-Process -Id $PID).Path } | ConvertTo-Json -Compress +`], powershellEnv)) +console.log('[windows-smoke] host', JSON.stringify(host)) +await mkdir(outputRoot, { recursive: true }) +let installer = resolve(candidate.channelBuild ? 'install.ps1' : 'install-preview.ps1') +let stableVersion = '' +let sidecar: unknown +let uninstallReceipt: unknown +let report: Record = { status: 'failed', host, mode: published ? 'public-network' : 'local-candidate' } +let cleanupSafe = true +const sourceCommit = (await command('git', ['-C', resolve('.'), 'rev-parse', 'HEAD'])).trim() +const installNetwork = (version: string, channel: string) => command(powershell, [ + '-NoProfile', '-File', installer, '-Version', version, '-Channel', channel, + '-InstallDir', installDir, '-NoModifyPath', '-Yes', +], powershellEnv) +// Enclose installation too: a failed download must still clean its owned root. try { - const setup = JSON.parse(await command(executable, ['setup', '--check', '--json'], environment)) - if (setup.status !== 'ready') throw new Error('Windows acceptance requires system Git/Bash; finish openalice setup first') - const git = setup.checks.find((check: { id: string }) => check.id === 'git')?.executable - if (!git || git.startsWith(releaseDir)) throw new Error('CLI must use system-owned Git') - await command(executable, ['up', '--home', home, '--port', String(port), '--wait', '90', '--no-update-check'], environment) - const status = JSON.parse(await command(executable, ['status', '--home', home, '--json'], environment)).result.status - if (status.class !== 'running' || status.provider?.kind !== 'bun' || - status.provider.contentIdentity !== candidate.contentIdentity || - !status.owner?.pid || !status.componentDetail?.alice?.pid || - status.owner.pid === status.componentDetail.alice.pid) throw new Error('Runtime status/identity mismatch') - const html = await (await fetch(`http://127.0.0.1:${port}/`)).text() - if (!html.includes('
')) throw new Error('Real Web UI was not served') - await command(git, ['--version'], environment) - await command(executable, ['down', '--home', home, '--wait', '30'], environment) - const stopped = JSON.parse(await command(executable, ['status', '--home', home, '--json'], environment)).result.status - if (stopped.class === 'running') throw new Error('Runtime did not stop') - if (candidate.channelBuild) { - const channel = candidate.version.includes('-beta') ? 'beta' : 'stable' - const previousVersion = syntheticPreviousVersion(candidate.version) - const previousName = `openalice-cli-${previousVersion}-win32-${process.arch}` - const previousTree = join(scratch, previousName) - await cp(releaseDir, previousTree, { recursive: true }) - const previous = rewriteExpandedCliRelease({ releaseRoot: previousTree, fromVersion: candidate.version, toVersion: previousVersion }) - const previousArchive = join(scratch, `${previousName}.tar.gz`) - await command(join(process.env.SystemRoot!, 'System32/tar.exe'), ['-czf', previousArchive, '-C', scratch, previousName]) - const previousSha256 = createHash('sha256').update(await readFile(previousArchive)).digest('hex') - const install = async (path: string, hash: string) => command(powershell, [ - '-NoProfile', '-File', resolve('install.ps1'), '-Archive', path, '-Sha256', hash, - '-InstallDir', installDir, '-Channel', channel, '-NoModifyPath', '-Yes', - ], powershellEnv) - await install(previousArchive, previousSha256) - const previousReleaseName = (await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() - const previousRelease = join(installDir, 'cli/releases', previousReleaseName) - const previousExe = join(previousRelease, 'bin/openalice.exe') - const previousEnv = { ...environment, OPENALICE_RELEASE_DIR: previousRelease, - OPENALICE_INSTALL_SOURCE: join(installDir, 'cli/provenance', `${previousReleaseName}.json`) } - await command(previousExe, ['up', '--home', home, '--port', String(port), '--wait', '90', '--no-update-check'], previousEnv) - await install(archive, candidate.sha256) - const pending = JSON.parse(await command(executable, ['status', '--home', home, '--json'], environment)).result.status - if (!pending.pendingActivation || pending.provider.contentIdentity !== previous.contentIdentity) throw new Error('Update did not preserve the mapped previous Runtime') - await command(executable, ['down', '--home', home, '--wait', '30'], environment) - await command(executable, ['rollback', '--yes'], environment) - if ((await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() !== previousReleaseName) throw new Error('Rollback did not restore the previous release') - await command(previousExe, ['rollback', '--yes'], previousEnv) - if ((await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() !== releaseName) throw new Error('Inverse rollback did not restore the candidate') - const marker = join(installDir, 'user-data-marker.txt') - await writeFile(marker, 'preserve user data') - try { console.log(await command(executable, ['uninstall', '--yes'], environment)) } - catch (error) { - console.log(await readFile(join(installDir, '.cli-uninstall.log'), 'utf8').catch(() => 'No helper startup log')) - throw error + if (published) { + const stable = await publicManifest('https://download.openalice.ai/manifest.json', 'stable') + const beta = await publicManifest('https://download.openalice.ai/beta/manifest.json', 'beta') + stableVersion = stable.version + candidate.version = beta.version + if (stableVersion === candidate.version) throw new Error('Published upgrade requires distinct releases') + if (publishedInstaller) { + const response = await fetch(beta.windowsInstaller.versionedUrl) + if (!response.ok) throw new Error(`Published installer HTTP ${response.status}`) + const bytes = Buffer.from(await response.arrayBuffer()) + if (createHash('sha256').update(bytes).digest('hex') !== beta.windowsInstaller.sha256) throw new Error('Published installer checksum mismatch') + installer = join(scratch, 'install.ps1') + await writeFile(installer, bytes) } - const receipt = join(installDir, '.cli-uninstall-result.json') - const deadline = Date.now() + 90_000 - let removed: { status?: string } | undefined - while (Date.now() < deadline) { - try { - removed = JSON.parse((await readFile(receipt, 'utf8')).replace(/^\uFEFF/, '')) - if (removed?.status === 'removed' || removed?.status === 'failed') break + // Load the actual Download-Text function via the AST, without executing the + // installer or replacing Invoke-WebRequest. Both requests hit the live sidecar. + const probe = join(scratch, 'probe.ps1') + await writeFile(probe, `param([string]$Installer, [string]$Url) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 2 +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$ast = [System.Management.Automation.Language.Parser]::ParseFile($Installer, [ref]$null, [ref]$null) +$fn = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Download-Text' }, $true) +if (-not $fn) { throw 'Download-Text not found' } +. ([scriptblock]::Create($fn.Extent.Text)) +$response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 30 +if ($response.Content -isnot [byte[]]) { throw 'Live binary sidecar did not exercise byte[] decoding' } +$text = Download-Text $Url +if ($text -isnot [string] -or $text.Trim() -notmatch '^[a-f0-9]{64}\\s') { throw 'Invalid decoded sidecar' } +@{ url = $Url; contentType = $response.Headers['Content-Type']; rawType = $response.Content.GetType().FullName; decodedType = $text.GetType().FullName; sha256 = $text.Trim().Split(' ')[0] } | ConvertTo-Json -Compress +`) + sidecar = JSON.parse(await command(powershell, ['-NoProfile', '-File', probe, '-Installer', installer, + '-Url', `https://github.com/TraderAlice/OpenAlice/releases/download/v${candidate.version}/openalice-cli-${candidate.version}-win32-${process.arch}.tar.gz.sha256`], powershellEnv)) + console.log('[windows-smoke] live sidecar', JSON.stringify(sidecar)) + console.log(await installNetwork(candidate.version, 'beta')) + } else { + await command(powershell, ['-NoProfile', '-File', installer, + '-Archive', archive, '-Sha256', candidate.sha256, '-InstallDir', installDir, + ...(candidate.channelBuild ? ['-Channel', candidate.version.includes('-beta') ? 'beta' : 'stable', '-NoModifyPath'] : []), '-Yes'], powershellEnv) + } + const releaseName = candidate.channelBuild ? (await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() : null + const releaseDir = releaseName ? join(installDir, 'cli/releases', releaseName) : installDir + const executable = join(releaseDir, 'bin/openalice.exe') + if (published) { + const metadata = JSON.parse(await readFile(join(releaseDir, 'release.json'), 'utf8')) + if (metadata.version !== candidate.version) throw new Error('Installed version mismatch') + candidate.contentIdentity = metadata.contentIdentity + candidate.sourceCommit = metadata.sourceCommit ?? null + candidate.sha256 = (sidecar as { sha256: string }).sha256 + } + if ((await command(executable, ['--version'])).trim() !== candidate.version) throw new Error('Executable version mismatch') + const portProbe = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: { data() {} } }) + const port = portProbe.port + portProbe.stop(true) + const environment = { + // Keep Windows account/system discovery real while isolating Alice state + // and excluding every host development tool from PATH. + ...Object.fromEntries(['APPDATA', 'LOCALAPPDATA', 'ProgramData', 'SystemDrive', + 'USERNAME', 'USERDOMAIN', 'COMPUTERNAME', 'HOMEDRIVE', 'HOMEPATH', 'ComSpec', + 'ProgramFiles', 'ProgramFiles(x86)', 'PATHEXT'].flatMap(key => process.env[key] ? [[key, process.env[key]!]] : [])), + SystemRoot: process.env.SystemRoot!, WINDIR: process.env.WINDIR!, + OS: 'Windows_NT', PROCESSOR_ARCHITECTURE: process.arch === 'arm64' ? 'ARM64' : 'AMD64', + PSExecutionPolicyPreference: 'Restricted', + TEMP: scratch, TMP: scratch, HOME: scratch, USERPROFILE: scratch, + APPDATA: join(scratch, 'AppData/Roaming'), LOCALAPPDATA: join(scratch, 'AppData/Local'), + PATH: join(process.env.SystemRoot!, 'System32'), + OPENALICE_HOME: home, OPENALICE_TRADING_MODE: 'lite', + OPENALICE_DISABLE_AUTH: '1', OPENALICE_BIND_HOST: '127.0.0.1', + ...(releaseName ? { + OPENALICE_INSTALL_ROOT: installDir, OPENALICE_RELEASE_DIR: releaseDir, + OPENALICE_INSTALL_SOURCE: join(installDir, 'cli/provenance', `${releaseName}.json`), + } : {}), + } + // PATHEXT is part of the Windows process contract, not a development tool. + // Verify native command exit-code propagation in the updater's exact env. + const nativeCommand = JSON.parse(await command(powershell, ['-NoProfile', '-Command', ` + $ErrorActionPreference = 'Stop' + Set-StrictMode -Version 2 + $text = & (Join-Path $env:SystemRoot 'System32/tar.exe') --version + if ($LASTEXITCODE -ne 0 -or -not $text) { throw 'Native tar execution failed in updater environment' } + @{ pathExt = $env:PATHEXT; tarVersion = [string]$text; exitCode = $LASTEXITCODE } | ConvertTo-Json -Compress + `], environment)) + console.log('[windows-smoke] updater native command', JSON.stringify(nativeCommand)) + let uninstalled = false + let cleanupExecutable = executable + let cleanupEnvironment = environment + try { + const setup = JSON.parse(await command(executable, ['setup', '--check', '--json'], environment)) + if (setup.status !== 'ready') throw new Error('Windows acceptance requires system Git/Bash; finish openalice setup first') + const git = setup.checks.find((check: { id: string }) => check.id === 'git')?.executable + if (!git || git.startsWith(releaseDir)) throw new Error('CLI must use system-owned Git') + await command(executable, ['up', '--home', home, '--port', String(port), '--wait', '90', '--no-update-check'], environment) + const status = JSON.parse(await command(executable, ['status', '--home', home, '--json'], environment)).result.status + if (status.class !== 'running' || status.provider?.kind !== 'bun' || + status.provider.contentIdentity !== candidate.contentIdentity || + !status.owner?.pid || !status.componentDetail?.alice?.pid || + status.owner.pid === status.componentDetail.alice.pid) throw new Error('Runtime status/identity mismatch') + const html = await (await fetch(`http://127.0.0.1:${port}/`)).text() + if (!html.includes('
')) throw new Error('Real Web UI was not served') + await command(git, ['--version'], environment) + await command(executable, ['down', '--home', home, '--wait', '30'], environment) + const stopped = JSON.parse(await command(executable, ['status', '--home', home, '--json'], environment)).result.status + if (stopped.class === 'running') throw new Error('Runtime did not stop') + if (candidate.channelBuild) { + const channel = candidate.version.includes('-beta') ? 'beta' : 'stable' + const install = async (path: string, hash: string) => command(powershell, [ + '-NoProfile', '-File', installer, '-Archive', path, '-Sha256', hash, + '-InstallDir', installDir, '-Channel', channel, '-NoModifyPath', '-Yes', + ], powershellEnv) + if (published) { + // Start the real upgrade from a fresh stable install, not a retained beta + // archive or a home already migrated by the newer Runtime. + await rm(installDir, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) + console.log(await installNetwork(stableVersion, 'stable')) + } else { + const { rewriteExpandedCliRelease, syntheticPreviousVersion } = await import('./cli-release-fixture.mjs') + const previousVersion = syntheticPreviousVersion(candidate.version) + const previousName = `openalice-cli-${previousVersion}-win32-${process.arch}` + const previousTree = join(scratch, previousName) + await cp(releaseDir, previousTree, { recursive: true }) + rewriteExpandedCliRelease({ releaseRoot: previousTree, fromVersion: candidate.version, toVersion: previousVersion }) + const previousArchive = join(scratch, `${previousName}.tar.gz`) + await command(join(process.env.SystemRoot!, 'System32/tar.exe'), ['-czf', previousArchive, '-C', scratch, previousName]) + await install(previousArchive, createHash('sha256').update(await readFile(previousArchive)).digest('hex')) + } + const previousReleaseName = (await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() + const previousRelease = join(installDir, 'cli/releases', previousReleaseName) + const previous = JSON.parse(await readFile(join(previousRelease, 'release.json'), 'utf8')) + const previousExe = join(previousRelease, 'bin/openalice.exe') + const previousEnv = { ...environment, OPENALICE_RELEASE_DIR: previousRelease, + OPENALICE_INSTALL_SOURCE: join(installDir, 'cli/provenance', `${previousReleaseName}.json`) } + cleanupExecutable = previousExe + cleanupEnvironment = previousEnv + await command(previousExe, ['up', '--home', home, '--port', String(port), '--wait', '90', '--no-update-check'], previousEnv) + if (published) { + const check = JSON.parse(await command(previousExe, ['update', '--check', '--channel', 'beta', '--json'], previousEnv)) + console.log('[windows-smoke] public update discovery', JSON.stringify(check)) + if (check.status !== 'available' || check.latestVersion !== candidate.version) throw new Error('Expected public beta update is unavailable') + console.log(await command(previousExe, ['update', '--channel', 'beta', '--yes'], previousEnv)) + if ((await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() !== releaseName) throw new Error('Live update did not activate the expected beta; manifest may have changed') + } else { + await install(archive, candidate.sha256) } - catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error } - await Bun.sleep(250) + const pending = JSON.parse(await command(executable, ['status', '--home', home, '--json'], environment)).result.status + if (!pending.pendingActivation || pending.provider.contentIdentity !== previous.contentIdentity) throw new Error('Update did not preserve the mapped previous Runtime') + await command(executable, ['down', '--home', home, '--wait', '30'], environment) + await command(executable, ['rollback', '--yes'], environment) + if ((await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() !== previousReleaseName) throw new Error('Rollback did not restore the previous release') + await command(previousExe, ['rollback', '--yes'], previousEnv) + if ((await readFile(join(installDir, 'cli/current.txt'), 'utf8')).trim() !== releaseName) throw new Error('Inverse rollback did not restore the candidate') + await command(executable, ['up', '--home', home, '--port', String(port), '--wait', '90', '--no-update-check'], environment) + const restarted = JSON.parse(await command(executable, ['status', '--home', home, '--json'], environment)).result.status + if (restarted.class !== 'running' || restarted.provider?.contentIdentity !== candidate.contentIdentity) throw new Error('Updated Runtime failed readiness') + await command(executable, ['down', '--home', home, '--wait', '30'], environment) + const marker = join(installDir, 'user-data-marker.txt') + await writeFile(marker, 'preserve user data') + try { console.log(await command(executable, ['uninstall', '--yes'], environment)) } + catch (error) { + console.log(await readFile(join(installDir, '.cli-uninstall.log'), 'utf8').catch(() => 'No helper startup log')) + throw error + } + const receipt = join(installDir, '.cli-uninstall-result.json') + const deadline = Date.now() + 90_000 + let removed: { status?: string } | undefined + while (Date.now() < deadline) { + try { + removed = JSON.parse((await readFile(receipt, 'utf8')).replace(/^\uFEFF/, '')) + if (removed?.status === 'removed' || removed?.status === 'failed') break + } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error } + await Bun.sleep(250) + } + if (!removed) { + console.log(await readFile(join(installDir, '.cli-uninstall.log'), 'utf8').catch(() => 'No helper startup log')) + // Diagnose the retained helper with the exact minimal environment. Plan + // mode cannot remove files and exposes errors before receipt creation. + console.log(await command(powershell, ['-NoProfile', '-ExecutionPolicy', 'RemoteSigned', '-File', join(installDir, '.cli-uninstall.ps1'), '-InstallDir', installDir, '-Uninstall', '-Plan'], environment)) + } + if (removed?.status !== 'removed' || await readFile(marker, 'utf8') !== 'preserve user data') throw new Error(`Data-preserving removal failed: ${JSON.stringify(removed)}`) + uninstallReceipt = removed + uninstalled = true + } + report = { + status: 'pass', host, testedSourceCommit: sourceCommit, mode: published ? 'public-network' : 'local-candidate', + installerSource: publishedInstaller ? 'published' : 'checkout', installerSha256: createHash('sha256').update(await readFile(installer)).digest('hex'), + sidecar, nativeCommand, productVersion: candidate.version, previousPublishedVersion: published ? stableVersion : null, uninstallReceipt, + statusScope: published ? 'published binary updater and published update installer; checkout installer for initial installs unless selected otherwise' : 'synthetic previous version; local archives', + arch: process.arch, archiveSha256: candidate.sha256, + contentIdentity: candidate.contentIdentity, sourceCommit: candidate.sourceCommit, + accepted: [candidate.channelBuild ? 'PowerShell managed install' : 'PowerShell ZIP install', 'detached Guardian/Alice', 'real Web UI', 'Git', 'stop', + ...(candidate.channelBuild ? [published ? 'real stable-to-beta network update' : 'synthetic mapped-runtime update', 'bidirectional rollback', 'deferred CLI data-preserving removal'] : [])], + remaining: ['interactive agent/provider acceptance', ...(candidate.channelBuild ? [] : ['manual upgrade/removal'])], } - if (!removed) { - console.log(await readFile(join(installDir, '.cli-uninstall.log'), 'utf8').catch(() => 'No helper startup log')) - // Diagnose the retained helper with the exact minimal environment. Plan - // mode cannot remove files and exposes errors before receipt creation. - console.log(await command(powershell, ['-NoProfile', '-ExecutionPolicy', 'RemoteSigned', '-File', join(installDir, '.cli-uninstall.ps1'), '-InstallDir', installDir, '-Uninstall', '-Plan'], environment)) + } finally { + if (!uninstalled) { + try { await command(cleanupExecutable, ['down', '--home', home, '--wait', '30'], cleanupEnvironment) } + catch (error) { cleanupSafe = false; throw error } } - if (removed?.status !== 'removed' || await readFile(marker, 'utf8') !== 'preserve user data') throw new Error(`Data-preserving removal failed: ${JSON.stringify(removed)}`) - uninstalled = true } - await writeFile(join(outputRoot, 'native-smoke.json'), JSON.stringify({ - status: 'pass', arch: process.arch, archiveSha256: candidate.sha256, - contentIdentity: candidate.contentIdentity, sourceCommit: candidate.sourceCommit, - accepted: [candidate.channelBuild ? 'PowerShell managed install' : 'PowerShell ZIP install', 'detached Guardian/Alice', 'real Web UI', 'Git', 'stop', - ...(candidate.channelBuild ? ['mapped-runtime update', 'bidirectional rollback', 'deferred CLI data-preserving removal'] : [])], - remaining: ['interactive agent/provider acceptance', ...(candidate.channelBuild ? [] : ['manual upgrade/removal'])], - }, null, 2) + '\n') +} catch (error) { + report = { ...report, status: 'failed', error: error instanceof Error ? error.message : String(error) } + throw error } finally { - if (!uninstalled) await command(executable, ['down', '--home', home, '--wait', '30'], environment).catch(console.error) + // The inner finally stops the test-owned Runtime before removal. Never remove + // an arbitrary caller path: scratch was allocated by this invocation. + try { + if (!cleanupSafe) throw new Error(`Runtime stop failed; retaining owned scratch for diagnosis: ${scratch}`) + await rm(scratch, { recursive: true, force: true }) + report.cleanupComplete = true + } catch (error) { + report.status = 'failed' + report.cleanupComplete = false + throw error + } finally { + await writeFile(join(outputRoot, 'native-smoke.json'), JSON.stringify(report, null, 2) + '\n') + } +} + +async function publicManifest(url: string, channel: string) { + const response = await fetch(url) + if (!response.ok) throw new Error(`Manifest HTTP ${response.status}`) + const manifest = await response.json() as { channel: string; version: string; windowsInstaller: { versionedUrl: string; sha256: string } } + if (manifest.channel !== channel || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-beta(?:\.[1-9][0-9]*)?)?$/.test(manifest.version)) throw new Error('Invalid public release manifest') + if (!manifest.windowsInstaller?.versionedUrl.startsWith('https://download.openalice.ai/') || !/^[a-f0-9]{64}$/.test(manifest.windowsInstaller.sha256)) throw new Error('Invalid public Windows installer') + return manifest } async function findCandidates(directory: string): Promise { @@ -146,15 +284,20 @@ async function findCandidates(directory: string): Promise { } async function command(exe: string, args: string[], env = process.env) { - console.log(`[windows-smoke] ${basename(exe)} ${args[0]}`) + console.log(`[windows-smoke] ${basename(exe)} ${args.includes('-Command') ? '-NoProfile -Command ' : args.join(' ')}`) const child = Bun.spawn([exe, ...args], { env, cwd: scratch, stdout: 'pipe', stderr: 'pipe' }) const stdout = new Response(child.stdout).text() const stderr = new Response(child.stderr).text() - const timeout = setTimeout(() => child.kill(), 120_000) + // Public PowerShell downloads plus extraction are much slower than local + // candidates (about 100s per archive on the hosted runner). Keep a bounded + // network budget distinct from the ordinary local command deadline. + const timeoutMs = published && (args.includes('-Version') || args[0] === 'update') ? 300_000 : 120_000 + let timedOut = false + const timeout = setTimeout(() => { timedOut = true; child.kill() }, timeoutMs) try { const code = await child.exited const [out, err] = await Promise.all([stdout, stderr]) - if (code !== 0) throw new Error(`${exe} ${args[0]} exited ${code}: ${err}\n${out}`) + if (code !== 0 || timedOut) throw new Error(`${exe} ${args[0]} ${timedOut ? `timed out after ${timeoutMs}ms` : `exited ${code}`}: ${err}\n${out}`) return out } finally { clearTimeout(timeout) } } diff --git a/scripts/windows-cli-preview.spec.ts b/scripts/windows-cli-preview.spec.ts index 013025a51..aa5221fc2 100644 --- a/scripts/windows-cli-preview.spec.ts +++ b/scripts/windows-cli-preview.spec.ts @@ -81,3 +81,34 @@ describe('Windows preview delivery boundary', () => { expect(read('install.ps1')).toContain('[IO.File]::Delete($backup)') }) }) + +describe('Windows public installer acceptance boundary', () => { + it('adds a read-only path-scoped PR lane and an isolated manual entry', () => { + const workflow = YAML.parse(read('.github/workflows/cli-installer-smoke.yml')) + expect(workflow.on.pull_request.branches).toEqual(['dev', 'master']) + expect(workflow.on.pull_request.paths).toContain('install.ps1') + const network = workflow.jobs['windows-network'] + expect(network.permissions).toEqual({ contents: 'read' }) + expect(network['runs-on']).toBe('windows-latest') + expect(network.if).toContain("needs.windows-network-scope.outputs.relevant == 'true'") + expect(network.if).toContain('inputs.windows_network') + expect(network.steps.find((s: { run?: string }) => s.run?.includes('--published')).shell).toBe('powershell') + for (const job of ['release-prep-scope', 'bun-cli-feasibility', 'checkout-install', 'checkout-remote', 'dev-channel-install']) { + expect(workflow.jobs[job].if).toContain('!inputs.windows_network') + } + expect(JSON.stringify(network)).not.toContain('secrets.') + }) + + it('filters unrelated changes but keeps installer and update ownership', () => { + const workflow = YAML.parse(read('.github/workflows/cli-installer-smoke.yml')) + const scope = workflow.jobs['windows-network-scope'].steps.find((s: { id?: string }) => s.id === 'scope').run + const expression = scope.match(/grep -Eq '([^']+)'/)[1] + const relevant = new RegExp(expression) + for (const path of ['install.ps1', 'packages/cli/src/update.mjs', 'packages/cli/src/uninstall.mjs', 'packages/update-lifecycle/src/release-policy.ts', 'scripts/windows-cli-preview-smoke.ts']) { + expect(relevant.test(path), path).toBe(true) + } + for (const path of ['ui/src/App.tsx', 'src/domain/trading.ts', 'README.md']) { + expect(relevant.test(path), path).toBe(false) + } + }) +})