mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
build(windows): add manual installer CI and scope desktop runtime patches
This commit is contained in:
1 parent
647b2b5f3b
commit
2f4dbc0fa9
18 files changed
+625
-12
No files matched your search
@@ -0,0 +1,89 @@
|
||||
name: Windows installer (unsigned)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_version:
|
||||
description: 'Build version: auto generates a version; otherwise enter a full version'
|
||||
type: string
|
||||
default: auto
|
||||
required: true
|
||||
deployment:
|
||||
description: Update policy deployment
|
||||
type: choice
|
||||
options: [test, production]
|
||||
default: test
|
||||
required: true
|
||||
policy_origin:
|
||||
description: 'Required update policy HTTPS origin (no path)'
|
||||
type: string
|
||||
required: true
|
||||
login_origins:
|
||||
description: 'Test only: comma-separated allowed login HTTPS origins'
|
||||
type: string
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: windows-2025
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
env:
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Install locked dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
exit $LASTEXITCODE
|
||||
- name: Validate inputs and prepare public package settings
|
||||
id: settings
|
||||
env:
|
||||
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
|
||||
PACKAGE_DEPLOYMENT: ${{ inputs.deployment }}
|
||||
PACKAGE_POLICY_ORIGIN: ${{ inputs.policy_origin }}
|
||||
PACKAGE_LOGIN_ORIGINS: ${{ inputs.login_origins }}
|
||||
run: node apps/desktop/scripts/prepare-windows-ci.mjs
|
||||
- name: Check packaging configuration and toolchain
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
|
||||
run: |
|
||||
pnpm run package:desktop:win:x64:unsigned --check --build-version "$env:PACKAGE_VERSION"
|
||||
exit $LASTEXITCODE
|
||||
- name: Build installer and verify packaged runtime
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
|
||||
run: |
|
||||
pnpm run package:desktop:win:x64:unsigned --build-version "$env:PACKAGE_VERSION"
|
||||
exit $LASTEXITCODE
|
||||
- name: Retain packaging diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
path: apps/desktop/.desktop-build/packaging-runs/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
- name: Retain verified installer
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
- name: Remove generated package settings
|
||||
if: always() && steps.settings.outcome == 'success'
|
||||
run: Remove-Item -LiteralPath apps/desktop/.env.windows
|
||||
@@ -57,14 +57,14 @@
|
||||
en: f48baa680e841b0d
|
||||
zh: 25ded1aeaac6dad1
|
||||
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
|
||||
en: cfd5780dd26a8e46
|
||||
zh: f7a16b1ede793b7b
|
||||
en: 5607322f83cf7e1e
|
||||
zh: 960af2fc4a5aaeea
|
||||
/deepseek-harness-desktop/package/windows-installer-interface:
|
||||
en: 1fc1db8f54475c8f
|
||||
zh: dd834ed206070396
|
||||
/deepseek-harness-desktop/package/windows-ev-signing:
|
||||
en: be1379e4b4703e14
|
||||
zh: 244ccd2874bb155a
|
||||
en: 59dc085f6af4d374
|
||||
zh: a57bd63d9eef1031
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: 10d58aa5bbffbd67
|
||||
zh: d9c2476e744a6537
|
||||
|
||||
+11
-1
@@ -352,7 +352,13 @@ On Windows x64, use the complete unsigned packaging command for local installati
|
||||
pnpm run package:desktop:win:x64:unsigned
|
||||
```
|
||||
|
||||
The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements.
|
||||
The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](#mandatory-update-policy), including a real HTTPS service origin. It requires Node 24, the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements.
|
||||
|
||||
Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths.
|
||||
|
||||
The [manual Windows workflow](../../.github/workflows/windows-package.yml) prepares a GitHub-hosted `windows-2025` runner and calls the same unsigned command. It requires the policy deployment and HTTPS origin; test deployments also require comma-separated login origins. These public inputs generate a temporary `.env.windows` without signing or upload credentials. `build_version` defaults to `auto`: the selected checkout's product version is extended with UTC date, workflow run number and attempt, for example `0.2.1-alpha.1.20261008.42.2`. A stable product uses the `-test` prefix. An explicit version uses the existing product-version validator. The resolved version appears in the run summary before packaging; manifests remain unchanged. These numbers identify workflow builds, not a globally reserved release sequence.
|
||||
|
||||
After this workflow reaches the default branch, select it in Actions, choose the source branch, fill the inputs and click Run workflow. It has no push or PR trigger and leaves existing automatic CI unchanged. Packaging logs and successful EXE artifacts are retained for 14 days; installation, signing and publication are separate operations. The workflow requires a remote acceptance run before its hosted execution can be considered verified.
|
||||
|
||||
### Windows installer interface
|
||||
|
||||
@@ -423,6 +429,10 @@ pnpm run prepare:desktop
|
||||
|
||||
This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content.
|
||||
|
||||
Desktop [patch policy](scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures.
|
||||
|
||||
Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration.
|
||||
|
||||
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
|
||||
|
||||
Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload.
|
||||
|
||||
@@ -354,7 +354,13 @@ Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置
|
||||
pnpm run package:desktop:win:x64:unsigned
|
||||
```
|
||||
|
||||
该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。
|
||||
该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](#mandatory-update-policy),包括真实的 HTTPS 服务 origin。工具要求为 Node 24、根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。
|
||||
|
||||
先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。
|
||||
|
||||
[Windows 手动工作流](../../.github/workflows/windows-package.yml) 准备 GitHub 托管的 `windows-2025` runner,并调用同一个未签名命令。必须填写策略部署环境和 HTTPS origin;测试部署还需填写逗号分隔的登录 origin。这些公开输入生成临时 `.env.windows`,不包含签名或上传凭据。`build_version` 默认为 `auto`:在所选代码的产品版本后附加 UTC 日期、工作流运行编号和尝试次数,例如 `0.2.1-alpha.1.20261008.42.2`。稳定产品版本使用 `-test` 前缀。显式版本复用现有产品版本校验器。解析后的版本在打包前显示于运行摘要,manifest 保持不变。这些编号标识工作流构建,不是全局预留的发布序号。
|
||||
|
||||
工作流进入默认分支后,在 Actions 中选择它,选择源码分支、填写输入并点击 Run workflow。它不监听 push 或 PR,不改变现有自动 CI。打包日志和成功生成的 EXE 产物保留 14 天;安装、签名和发布是独立操作。必须完成远端验收运行后,才能认定该工作流的托管执行已经验证。
|
||||
|
||||
### Windows 安装界面
|
||||
|
||||
@@ -425,6 +431,10 @@ pnpm run prepare:desktop
|
||||
|
||||
这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
|
||||
|
||||
Desktop [补丁策略](scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。
|
||||
|
||||
Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。
|
||||
|
||||
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
|
||||
|
||||
macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
|
||||
import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts'
|
||||
import { prepareRuntimePatches, verifyRuntimePatches } from './prepare-runtime-patches.ts'
|
||||
import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
|
||||
import {
|
||||
resolveDesktopAppId,
|
||||
@@ -117,12 +118,14 @@ async function main(): Promise<void> {
|
||||
mkdirSync(STORE_ROOT, { recursive: true })
|
||||
})
|
||||
const release = desktopRelease()
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:stage-packages', async () => {
|
||||
const patches = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:stage-packages', async () => {
|
||||
copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE))
|
||||
cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
|
||||
createRuntimeProjectMetadata(BUILD_ROOT, release)
|
||||
return prepareRuntimePatches(BUILD_ROOT, resolve(APP_ROOT, '..', '..'))
|
||||
})
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only']))
|
||||
verifyRuntimePatches(BUILD_ROOT, patches)
|
||||
verifyDesktopCoreLockfile(
|
||||
readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
|
||||
readDesktopCorePackageSet(BUILD_ROOT, release.version),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/** Stage explicitly selected Desktop patches without modifying the source workspace. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { valid } from 'semver'
|
||||
import { DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES, DESKTOP_RUNTIME_PATCHES, type DesktopRuntimePatch } from './runtime-patch-policy.ts'
|
||||
|
||||
/** Expected patch bytes and package version for one independent installation. */
|
||||
export interface PreparedRuntimePatch {
|
||||
spec: string
|
||||
name: string
|
||||
version: string
|
||||
path: string
|
||||
hash: string
|
||||
}
|
||||
|
||||
/** Selected bytes and dependencies excluded from the Desktop runtime. */
|
||||
export interface PreparedRuntimePatches {
|
||||
patches: PreparedRuntimePatch[]
|
||||
workspaceOnly: string[]
|
||||
}
|
||||
|
||||
function patchIdentity(spec: string): { name: string; version: string } {
|
||||
const separator = spec.lastIndexOf('@')
|
||||
const name = spec.slice(0, separator)
|
||||
const version = spec.slice(separator + 1)
|
||||
if (separator <= 0 || valid(version) !== version) throw new Error(`desktop patches: expected an exact package version: ${spec}`)
|
||||
return { name, version }
|
||||
}
|
||||
|
||||
function patchHash(bytes: Buffer): string {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage selected patches and explicit overrides before Desktop resolves its temporary lockfile.
|
||||
* Every root patch must be classified; shared bytes must match the root lockfile's pnpm 11 hash.
|
||||
* @param projectRoot Disposable project containing pnpm-workspace.yaml, outside the repository root itself.
|
||||
* @param repositoryRoot Source repository; its files remain read-only.
|
||||
* @param policy Desktop patch decisions, including optional runtime-only files relative to the repository.
|
||||
* @param overrides Explicit Desktop dependency constraints; patch versions never generate overrides.
|
||||
* @returns Expected patch identities and hashes for lockfile and staged-file verification.
|
||||
*/
|
||||
export function prepareRuntimePatches(
|
||||
projectRoot: string, repositoryRoot: string,
|
||||
policy: Readonly<Record<string, DesktopRuntimePatch>> = DESKTOP_RUNTIME_PATCHES,
|
||||
overrides: Readonly<Record<string, string>> = DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES,
|
||||
): PreparedRuntimePatches {
|
||||
if (resolve(projectRoot) === resolve(repositoryRoot)) throw new Error('desktop patches: destination must not be the source workspace')
|
||||
const source = yaml.load(readFileSync(join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf8')) as {
|
||||
patchedDependencies: Record<string, string>
|
||||
}
|
||||
const sourceLock = yaml.load(readFileSync(join(repositoryRoot, 'pnpm-lock.yaml'), 'utf8')) as {
|
||||
patchedDependencies: Record<string, string>
|
||||
}
|
||||
for (const spec of Object.keys(source.patchedDependencies)) {
|
||||
if (!Object.hasOwn(policy, spec)) throw new Error(`desktop patches: classify the workspace patch ${spec}`)
|
||||
}
|
||||
const selected: Array<PreparedRuntimePatch & { bytes: Buffer }> = []
|
||||
for (const [spec, rule] of Object.entries(policy)) {
|
||||
const identity = patchIdentity(spec)
|
||||
if (rule.scope !== 'runtime-only' && !Object.hasOwn(source.patchedDependencies, spec)) {
|
||||
throw new Error(`desktop patches: stale workspace patch decision ${spec}`)
|
||||
}
|
||||
if (rule.scope === 'workspace-only') continue
|
||||
const sourcePath = rule.scope === 'shared' ? source.patchedDependencies[spec]! : rule.path
|
||||
const localPath = relative(resolve(repositoryRoot), resolve(repositoryRoot, sourcePath))
|
||||
if (isAbsolute(sourcePath) || localPath.startsWith('..') || isAbsolute(localPath)) {
|
||||
throw new Error(`desktop patches: patch must be inside the repository: ${sourcePath}`)
|
||||
}
|
||||
const bytes = readFileSync(join(repositoryRoot, sourcePath))
|
||||
const hash = patchHash(bytes)
|
||||
if (rule.scope === 'shared' && sourceLock.patchedDependencies[spec] !== hash) {
|
||||
throw new Error(`desktop patches: shared patch hash differs from the root lockfile: ${spec}`)
|
||||
}
|
||||
selected.push({ spec, ...identity, path: `patches/desktop-${selected.length}.patch`, hash, bytes })
|
||||
}
|
||||
const targetPath = join(projectRoot, 'pnpm-workspace.yaml')
|
||||
const target = yaml.load(readFileSync(targetPath, 'utf8')) as Record<string, unknown> & { overrides?: Record<string, string> }
|
||||
for (const [name, version] of Object.entries(overrides)) {
|
||||
if (target.overrides?.[name] !== undefined && target.overrides[name] !== version) {
|
||||
throw new Error(`desktop patches: conflicting dependency override for ${name}`)
|
||||
}
|
||||
}
|
||||
mkdirSync(join(projectRoot, 'patches'), { recursive: true })
|
||||
for (const entry of selected) writeFileSync(join(projectRoot, entry.path), entry.bytes)
|
||||
writeFileSync(targetPath, yaml.dump({ ...target, overrides: { ...target.overrides, ...overrides },
|
||||
patchedDependencies: Object.fromEntries(selected.map(entry => [entry.spec, entry.path])), allowUnusedPatches: true }))
|
||||
return { patches: selected.map(({ bytes: _bytes, ...entry }) => entry),
|
||||
workspaceOnly: Object.entries(policy).filter(([, rule]) => rule.scope === 'workspace-only').map(([spec]) => patchIdentity(spec).name) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject changed patch bytes, lock hashes or resolved versions before the frozen production install.
|
||||
* @param projectRoot Independent Desktop installation containing its resolved lockfile.
|
||||
* @param plan Expected identities and exclusions returned by patch preparation.
|
||||
*/
|
||||
export function verifyRuntimePatches(projectRoot: string, plan: PreparedRuntimePatches): void {
|
||||
const lock = yaml.load(readFileSync(join(projectRoot, 'pnpm-lock.yaml'), 'utf8')) as {
|
||||
patchedDependencies?: Record<string, string>
|
||||
packages?: Record<string, { version?: string }>
|
||||
}
|
||||
for (const patch of plan.patches) {
|
||||
if (patchHash(readFileSync(join(projectRoot, patch.path))) !== patch.hash) {
|
||||
throw new Error(`desktop patches: staged patch bytes changed: ${patch.spec}`)
|
||||
}
|
||||
if (lock.patchedDependencies?.[patch.spec] !== patch.hash) {
|
||||
throw new Error(`desktop patches: lockfile patch hash mismatch: ${patch.spec}`)
|
||||
}
|
||||
}
|
||||
for (const [spec, entry] of Object.entries(lock.packages ?? {})) {
|
||||
if (plan.workspaceOnly.some(name => spec.startsWith(`${name}@`))) {
|
||||
throw new Error(`desktop patches: workspace-only dependency entered the runtime: ${spec}; review its patch scope`)
|
||||
}
|
||||
const candidates = plan.patches.filter(patch => spec.startsWith(`${patch.name}@`))
|
||||
if (candidates.length === 0) continue
|
||||
if (!candidates.some(patch => (entry.version ?? spec.slice(patch.name.length + 1)) === patch.version)) {
|
||||
throw new Error(`desktop patches: ${spec} has no matching patch; review its version and Desktop patch policy`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Public settings prepared for a manual Windows package run. */
|
||||
export interface WindowsCiSettings {
|
||||
version: string
|
||||
settings: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate manual inputs without local credentials or release storage.
|
||||
* @param environment Manual inputs and GitHub run identity.
|
||||
* @param productVersion Version declared by the selected checkout.
|
||||
* @param now Time used for the UTC date segment.
|
||||
* @returns Validated version and public dotenv content.
|
||||
*/
|
||||
export function windowsCiSettings(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): WindowsCiSettings
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Prepare public Windows packaging settings and a version for a manual GitHub run. */
|
||||
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs'
|
||||
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
|
||||
|
||||
/**
|
||||
* Validate manual inputs without reading local credentials or querying release storage.
|
||||
* @param {NodeJS.ProcessEnv} environment Manual inputs and GitHub run identity.
|
||||
* @param {string} productVersion Version declared by the selected checkout.
|
||||
* @param {Date} now Time used for the UTC date segment.
|
||||
* @returns {{ version: string, settings: string }} Validated version and public dotenv content.
|
||||
*/
|
||||
export function windowsCiSettings(environment, productVersion, now = new Date()) {
|
||||
const deployment = environment.PACKAGE_DEPLOYMENT
|
||||
if (deployment !== 'test' && deployment !== 'production') throw new Error('Select test or production deployment')
|
||||
const loginOrigins = (environment.PACKAGE_LOGIN_ORIGINS ?? '').split(',').map(value => value.trim()).filter(Boolean)
|
||||
if (deployment === 'production' && loginOrigins.length > 0) throw new Error('Production must not supply login origins')
|
||||
const originKey = deployment === 'test' ? 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN' : 'DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN'
|
||||
const config = deployment === 'test' ? { allowedAuthOrigins: loginOrigins } : {}
|
||||
const policy = resolveDesktopPolicyEnvironment({
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: deployment,
|
||||
[originKey]: environment.PACKAGE_POLICY_ORIGIN,
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify(config),
|
||||
})
|
||||
const requested = environment.PACKAGE_BUILD_VERSION?.trim()
|
||||
if (!requested) throw new Error('Build version is required; use auto or a full version')
|
||||
let candidate = requested
|
||||
if (requested === 'auto') {
|
||||
for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) {
|
||||
if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`)
|
||||
}
|
||||
const date = now.toISOString().slice(0, 10).replaceAll('-', '')
|
||||
candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}`
|
||||
}
|
||||
const version = validateDesktopBuildVersion(candidate, productVersion)
|
||||
const settings = [
|
||||
'DSH_DESKTOP_APP_ID=com.deepseek.harness',
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV=${deployment}`,
|
||||
`${originKey}=${policy.origin}`,
|
||||
`DSH_DESKTOP_MANDATORY_UPDATE_CONFIG=${JSON.stringify(config)}`,
|
||||
].join('\n') + '\n'
|
||||
return { version, settings }
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
|
||||
const appRoot = resolve(import.meta.dirname, '..')
|
||||
const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'))
|
||||
const result = windowsCiSettings(process.env, product.version)
|
||||
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
|
||||
writeFileSync(resolve(appRoot, '.env.windows'), result.settings, { flag: 'wx' })
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.version}\n`)
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 unsigned installer\n\nVersion: ${result.version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nDeployment: ${process.env.PACKAGE_DEPLOYMENT}\n`)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Desktop-specific selection of workspace and runtime-only dependency patches. */
|
||||
|
||||
/** A patch shared with the workspace, excluded from runtime installation, or supplied only to Desktop. */
|
||||
export type DesktopRuntimePatch =
|
||||
| { scope: 'shared'; reason: string }
|
||||
| { scope: 'workspace-only'; reason: string }
|
||||
| { scope: 'runtime-only'; path: string; reason: string }
|
||||
|
||||
/** Every workspace patch requires an explicit Desktop decision; runtime-only entries may add or replace a patch. */
|
||||
export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatch>> = {
|
||||
'@earendil-works/pi-ai@1.0.2': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' },
|
||||
'@electron/osx-sign@1.3.3': { scope: 'workspace-only', reason: 'Signing executes on the build host.' },
|
||||
'@fortune-sheet/core@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' },
|
||||
'@fortune-sheet/react@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' },
|
||||
'@yao-pkg/pkg@6.21.0': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' },
|
||||
'exceljs@4.4.0': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
|
||||
'node-pty@1.2.0-beta.15': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' },
|
||||
}
|
||||
|
||||
/** Reviewed Desktop dependency constraints, independent of patch selection. */
|
||||
export const DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES: Readonly<Record<string, string>> = {
|
||||
// The provider declares ^1.0.2; its patch is qualified against 1.0.2.
|
||||
'@earendil-works/pi-ai@^1.0.2': '1.0.2',
|
||||
}
|
||||
@@ -31,7 +31,15 @@ export async function smokePreparedRuntime(
|
||||
const { stdout } = await promisify(execFile)(node, [
|
||||
'--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime,
|
||||
], { timeout: 120_000, windowsHide: true,
|
||||
env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) })
|
||||
env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) }).catch((error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
if ('stdout' in error && typeof error.stdout === 'string') process.stdout.write(error.stdout)
|
||||
if ('killed' in error && 'signal' in error) {
|
||||
process.stderr.write(`runtime payload: killed=${String(error.killed)}, signal=${String(error.signal)}\n`)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
})
|
||||
process.stdout.write(stdout)
|
||||
await smokeDesktopRuntime(root, node, descriptor, environment, resourcesRuntime)
|
||||
} finally {
|
||||
|
||||
@@ -161,12 +161,19 @@ function checkHtml() {
|
||||
try {
|
||||
const builtin = requireRuntime('node-addon-require-builtin')
|
||||
assert.equal(typeof builtin.requireBuiltin('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function')
|
||||
console.error('runtime payload: pnpm')
|
||||
checkPnpm()
|
||||
console.error('runtime payload: koffi')
|
||||
checkKoffi()
|
||||
console.error('runtime payload: sharp')
|
||||
await checkSharp()
|
||||
console.error('runtime payload: html')
|
||||
checkHtml()
|
||||
console.error('runtime payload: pty')
|
||||
await checkPty()
|
||||
console.error('runtime payload: search')
|
||||
await checkSearch()
|
||||
console.error('runtime payload: checks complete; awaiting process exit')
|
||||
} finally {
|
||||
// This private tree contains only fixture files; Windows may release handles after terminal exit.
|
||||
await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 })
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Exercise terminal cleanup in a child whose natural exit detects retained workers. */
|
||||
const assert = require('node:assert/strict')
|
||||
const { createRequire } = require('node:module')
|
||||
const { resolve } = require('node:path')
|
||||
const pty = createRequire(resolve(__dirname, '../../../../packages/subprocess/subprocess-local/package.json'))('node-pty')
|
||||
const killWhileRunning = process.argv[2] === 'kill'
|
||||
const terminal = pty.spawn(process.env.ComSpec, ['/d', '/c', killWhileRunning ? 'echo pty-ready & pause >nul' : 'echo pty-complete'], {
|
||||
cwd: __dirname, env: process.env, cols: 80, rows: 24, useConptyDll: true,
|
||||
})
|
||||
let output = ''
|
||||
let killed = false
|
||||
terminal.onData(data => {
|
||||
output += data
|
||||
if (killWhileRunning && !killed && output.includes('pty-ready')) {
|
||||
killed = true
|
||||
terminal.kill()
|
||||
}
|
||||
})
|
||||
terminal.onExit(event => {
|
||||
if (!killWhileRunning) {
|
||||
assert.equal(event.exitCode, 0)
|
||||
assert.match(output, /pty-complete/u)
|
||||
terminal.kill()
|
||||
} else {
|
||||
assert.equal(killed, true)
|
||||
}
|
||||
process.once('beforeExit', () => console.log('pty-cleanup-complete'))
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import yaml from 'js-yaml'
|
||||
import { pnpmCommand } from '../../../scripts/release/process.ts'
|
||||
import { prepareRuntimePatches, verifyRuntimePatches } from '../scripts/prepare-runtime-patches.ts'
|
||||
import type { DesktopRuntimePatch } from '../scripts/runtime-patch-policy.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const shared = { scope: 'shared', reason: 'Runtime fixture.' } as const
|
||||
const excluded = { scope: 'workspace-only', reason: 'Build fixture.' } as const
|
||||
const patch = 'diff --git a/value.txt b/value.txt\n--- a/value.txt\n+++ b/value.txt\n@@ -1 +1 @@\n-original\n+patched\n'
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 })
|
||||
})
|
||||
|
||||
function fixture(): { root: string; source: string; project: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'desktop-patch-policy-'))
|
||||
roots.push(root)
|
||||
const source = join(root, 'source')
|
||||
const project = join(root, 'project')
|
||||
mkdirSync(source)
|
||||
mkdirSync(project)
|
||||
writeFileSync(join(source, 'shared.patch'), patch)
|
||||
writeFileSync(join(source, 'private.patch'), patch.replace('+patched', '+private'))
|
||||
writeFileSync(join(source, 'pnpm-workspace.yaml'), yaml.dump({ patchedDependencies: { 'fixture@1.0.0': 'shared.patch' } }))
|
||||
writeFileSync(join(source, 'pnpm-lock.yaml'), yaml.dump({
|
||||
patchedDependencies: { 'fixture@1.0.0': createHash('sha256').update(patch).digest('hex') },
|
||||
}))
|
||||
writeFileSync(join(project, 'pnpm-workspace.yaml'), yaml.dump({
|
||||
packages: ['.'], nodeLinker: 'hoisted', overrides: { internal: 'file:./internal.tgz' }, allowBuilds: { fixture: false },
|
||||
}))
|
||||
return { root, source, project }
|
||||
}
|
||||
|
||||
it('classifies the current workspace and shares only runtime patch bytes', () => {
|
||||
const { project } = fixture()
|
||||
const repository = resolve(import.meta.dirname, '../../..')
|
||||
const before = readFileSync(join(repository, 'pnpm-lock.yaml'))
|
||||
const plan = prepareRuntimePatches(project, repository)
|
||||
expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.0.2', 'node-pty@1.2.0-beta.15'])
|
||||
expect(plan.workspaceOnly).toHaveLength(5)
|
||||
const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record<string, string> }
|
||||
expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined()
|
||||
expect(settings.overrides['@earendil-works/pi-ai@^1.0.2']).toBe('1.0.2')
|
||||
expect(readFileSync(join(repository, 'pnpm-lock.yaml'))).toEqual(before)
|
||||
})
|
||||
|
||||
it('does not infer version overrides from selected patches', () => {
|
||||
const { source, project } = fixture()
|
||||
const before = readFileSync(join(source, 'pnpm-workspace.yaml'))
|
||||
prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, {})
|
||||
expect(yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8'))).toMatchObject({
|
||||
overrides: { internal: 'file:./internal.tgz' }, allowBuilds: { fixture: false },
|
||||
})
|
||||
expect(readFileSync(join(source, 'pnpm-workspace.yaml'))).toEqual(before)
|
||||
expect(readFileSync(join(project, 'patches/desktop-0.patch'), 'utf8')).toBe(patch)
|
||||
})
|
||||
|
||||
it('supports an independent runtime patch for a package also patched in the workspace', () => {
|
||||
const { source, project } = fixture()
|
||||
const plan = prepareRuntimePatches(project, source, {
|
||||
'fixture@1.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Different runtime layout.' },
|
||||
'other@2.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Runtime-only dependency.' },
|
||||
}, {})
|
||||
expect(plan.patches).toHaveLength(2)
|
||||
expect(readFileSync(join(project, plan.patches[0]!.path), 'utf8')).toContain('+private')
|
||||
expect(readFileSync(join(source, 'shared.patch'), 'utf8')).toBe(patch)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ policy: {}, message: 'classify the workspace patch' },
|
||||
{ policy: { 'fixture@1.0.0': shared, 'absent@1.0.0': shared }, message: 'stale workspace patch decision' },
|
||||
{ policy: { 'fixture@1.0.0': { scope: 'runtime-only', path: '../outside.patch', reason: 'Invalid path.' } }, message: 'inside the repository' },
|
||||
{ policy: { 'fixture@1.0.0': { scope: 'runtime-only', path: 'missing.patch', reason: 'Missing file.' } }, message: 'ENOENT' },
|
||||
] satisfies Array<{ policy: Record<string, DesktopRuntimePatch>; message: string }>)('rejects invalid selection: $message', ({ policy, message }) => {
|
||||
const { source, project } = fixture()
|
||||
expect(() => { prepareRuntimePatches(project, source, policy, {}) }).toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects shared bytes that differ from the workspace lockfile', () => {
|
||||
const { source, project } = fixture()
|
||||
writeFileSync(join(source, 'shared.patch'), patch + '\n')
|
||||
expect(() => { prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, {}) }).toThrow('root lockfile')
|
||||
})
|
||||
|
||||
it('refuses to write to the workspace or replace an unrelated existing override', () => {
|
||||
const { source, project } = fixture()
|
||||
expect(() => { prepareRuntimePatches(source, source, { 'fixture@1.0.0': shared }, {}) }).toThrow('source workspace')
|
||||
expect(() => { prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, { internal: '2.0.0' }) }).toThrow('conflicting')
|
||||
})
|
||||
|
||||
it.each(['version', 'lock hash', 'file bytes', 'workspace-only'] as const)('rejects invalid resolved installation: %s', (failure) => {
|
||||
const { source, project } = fixture()
|
||||
const plan = prepareRuntimePatches(project, source, { 'fixture@1.0.0': failure === 'workspace-only' ? excluded : shared }, {})
|
||||
writeFileSync(join(project, 'pnpm-lock.yaml'), yaml.dump({
|
||||
patchedDependencies: Object.fromEntries(plan.patches.map(entry => [entry.spec, failure === 'lock hash' ? 'wrong' : entry.hash])),
|
||||
packages: { [failure === 'version' ? 'fixture@1.0.1' : 'fixture@1.0.0']: {} },
|
||||
}))
|
||||
if (failure === 'file bytes') writeFileSync(join(project, plan.patches[0]!.path), 'changed')
|
||||
expect(() => { verifyRuntimePatches(project, plan) }).toThrow(/desktop patches:/u)
|
||||
})
|
||||
|
||||
it.each([false, true])('pnpm applies runtime-only bytes and rejects an inapplicable patch (invalid=%s)', async (invalid) => {
|
||||
const { source, project, root } = fixture()
|
||||
const packageDir = join(root, 'package')
|
||||
mkdirSync(packageDir)
|
||||
writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0' }))
|
||||
writeFileSync(join(packageDir, 'value.txt'), 'original\n')
|
||||
const run = promisify(execFile)
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
|
||||
/^(?:path|systemroot|windir|comspec|temp|tmp)$/iu.test(name)
|
||||
)))
|
||||
await run('tar', ['-czf', join(project, 'fixture.tgz'), '-C', root, 'package'], { env, timeout: 15_000, windowsHide: true })
|
||||
writeFileSync(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { fixture: 'file:./fixture.tgz' } }))
|
||||
writeFileSync(join(project, 'npmrc'), '')
|
||||
if (invalid) writeFileSync(join(source, 'private.patch'), patch.replace('-original', '-does-not-exist'))
|
||||
const plan = prepareRuntimePatches(project, source, {
|
||||
'fixture@1.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Offline install fixture.' },
|
||||
}, {})
|
||||
const [command, ...args] = pnpmCommand()
|
||||
const install = run(command, [...args, 'install', '--offline', '--ignore-scripts',
|
||||
`--config.userconfig=${join(project, 'npmrc')}`, `--config.store-dir=${join(project, 'store')}`,
|
||||
'--config.manage-package-manager-versions=false'], { cwd: project, env: { ...env, CI: 'true' }, timeout: 30_000, windowsHide: true })
|
||||
if (invalid) {
|
||||
const failure: unknown = await install.catch((error: unknown) => error)
|
||||
if (!(failure instanceof Error) || !('stdout' in failure)) throw new Error('Expected a captured pnpm failure')
|
||||
expect(failure).toHaveProperty('code', 1)
|
||||
expect(failure.stdout).toMatch(/PATCH_FAILED/u)
|
||||
} else {
|
||||
await install
|
||||
verifyRuntimePatches(project, plan)
|
||||
expect(readFileSync(join(project, 'node_modules/fixture/value.txt'), 'utf8')).toBe('private\n')
|
||||
}
|
||||
expect(existsSync(join(source, 'node_modules'))).toBe(false)
|
||||
}, 45_000)
|
||||
@@ -0,0 +1,56 @@
|
||||
import { parseEnv } from 'node:util'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { windowsCiSettings } from '../scripts/prepare-windows-ci.mjs'
|
||||
import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
|
||||
const inputs = {
|
||||
PACKAGE_BUILD_VERSION: 'auto',
|
||||
PACKAGE_DEPLOYMENT: 'test',
|
||||
PACKAGE_POLICY_ORIGIN: 'https://policy.example.com',
|
||||
PACKAGE_LOGIN_ORIGINS: 'https://login.example.com, https://sso.example.com',
|
||||
GITHUB_RUN_NUMBER: '42',
|
||||
GITHUB_RUN_ATTEMPT: '2',
|
||||
}
|
||||
const productVersion = '0.2.1-alpha.1'
|
||||
const now = new Date('2026-10-08T23:59:59Z')
|
||||
|
||||
describe('manual Windows package inputs', () => {
|
||||
it('numbers fresh runners and reruns without storage credentials', () => {
|
||||
const result = windowsCiSettings(inputs, productVersion, now)
|
||||
expect(result.version).toBe('0.2.1-alpha.1.20261008.42.2')
|
||||
const settings = parseEnv(result.settings)
|
||||
expect(() => { validateDesktopPackageEnvironment(settings, { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow()
|
||||
expect(Object.keys(settings).sort()).toEqual([
|
||||
'DSH_DESKTOP_APP_ID', 'DSH_DESKTOP_AUTO_UPDATE_ENV',
|
||||
'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG',
|
||||
].sort())
|
||||
})
|
||||
|
||||
it('accepts an explicit version without a GitHub run identity', () => {
|
||||
const result = windowsCiSettings({ ...inputs, PACKAGE_BUILD_VERSION: '0.2.1-alpha.1.20261008.7',
|
||||
GITHUB_RUN_NUMBER: undefined, GITHUB_RUN_ATTEMPT: undefined }, productVersion, now)
|
||||
expect(result.version).toBe('0.2.1-alpha.1.20261008.7')
|
||||
})
|
||||
|
||||
it('uses the stable product test prefix and anonymous production policy', () => {
|
||||
const result = windowsCiSettings({ ...inputs, PACKAGE_DEPLOYMENT: 'production', PACKAGE_LOGIN_ORIGINS: '' }, '0.2.1', now)
|
||||
expect(result.version).toBe('0.2.1-test.20261008.42.2')
|
||||
expect(() => { validateDesktopPackageEnvironment(parseEnv(result.settings), { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ PACKAGE_BUILD_VERSION: '' },
|
||||
{ PACKAGE_BUILD_VERSION: '0.3.0' },
|
||||
{ PACKAGE_DEPLOYMENT: 'staging' },
|
||||
{ PACKAGE_POLICY_ORIGIN: '' },
|
||||
{ PACKAGE_POLICY_ORIGIN: 'http://policy.example.com' },
|
||||
{ PACKAGE_POLICY_ORIGIN: 'https://policy.example.com/path' },
|
||||
{ PACKAGE_LOGIN_ORIGINS: '' },
|
||||
{ PACKAGE_LOGIN_ORIGINS: 'https://user:password@login.example.com' },
|
||||
{ PACKAGE_DEPLOYMENT: 'production' },
|
||||
{ GITHUB_RUN_NUMBER: '0' },
|
||||
{ GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' },
|
||||
])('rejects invalid manual settings %j', (override) => {
|
||||
expect(() => windowsCiSettings({ ...inputs, ...override }, productVersion, now)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,26 @@ afterEach(() => {
|
||||
Object.defineProperty(process, 'arch', hostArch)
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('retains payload progress and termination details when the child fails', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'prepared-runtime-failure-'))
|
||||
roots.push(root)
|
||||
const fixture = runtimeFixture(root)
|
||||
const target = { platform: 'win32' as const, arch: 'x64' }
|
||||
writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name), target)
|
||||
const descriptor = await verifyDesktopRuntime(root, fixture.release.version, target)
|
||||
const failure = Object.assign(new Error('payload timed out'), {
|
||||
stdout: 'runtime payload: pty\n', killed: true, signal: 'SIGTERM',
|
||||
})
|
||||
payload.mockRejectedValueOnce(failure)
|
||||
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
await expect(smokePreparedRuntime(root, join(root, 'electron'), join(root, 'runtime'), descriptor)).rejects.toBe(failure)
|
||||
expect(stdout).toHaveBeenCalledWith('runtime payload: pty\n')
|
||||
expect(stderr).toHaveBeenCalledWith('runtime payload: killed=true, signal=SIGTERM\n')
|
||||
expect(smokeDesktopRuntime).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('smokes an x64 target verified on an arm64 build host without revalidating against the host', async () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
it.skipIf(process.platform !== 'win32').each(['natural', 'kill'])('releases the terminal worker after %s exit', async (mode) => {
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
|
||||
/^(?:path|systemroot|windir|comspec|temp|tmp)$/iu.test(name)
|
||||
)))
|
||||
const result = await promisify(execFile)(process.execPath, [resolve(import.meta.dirname, 'fixtures/windows-pty-exit.cjs'), mode], {
|
||||
env, timeout: 15_000, windowsHide: true,
|
||||
})
|
||||
expect(result.stderr).toBe('')
|
||||
expect(result.stdout.trim()).toBe('pty-cleanup-complete')
|
||||
}, 20_000)
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
|
||||
index 6966d24..18f1d25 100644
|
||||
index af2d24c39deb60e77ad16e6b864f37a7ef58881c..2ece2590445dc1ecce167b8ba4710c3176d32a21 100644
|
||||
--- a/lib/unixTerminal.js
|
||||
+++ b/lib/unixTerminal.js
|
||||
@@ -28,10 +28,23 @@ var terminal_1 = require("./terminal");
|
||||
@@ -30,3 +30,16 @@ index 6966d24..18f1d25 100644
|
||||
var DEFAULT_FILE = 'sh';
|
||||
var DEFAULT_NAME = 'xterm';
|
||||
var DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||
diff --git a/lib/windowsPtyAgent.js b/lib/windowsPtyAgent.js
|
||||
index 90ffd8fb69764052b0714cfb2b1213c073603c28..399861d7bbd0fb733fa032e923efa8b6aab8e62b 100644
|
||||
--- a/lib/windowsPtyAgent.js
|
||||
+++ b/lib/windowsPtyAgent.js
|
||||
@@ -207,6 +207,8 @@ var WindowsPtyAgent = /** @class */ (function () {
|
||||
// Close the input write handle to signal the end of session.
|
||||
this._inSocket.destroy();
|
||||
this._ptyNative.kill(this._pty, this._useConptyDll);
|
||||
+ // Start draining even when the exited process has no more output.
|
||||
+ this._conoutSocketWorker.dispose();
|
||||
this._outSocket.on('data', function () {
|
||||
_this._conoutSocketWorker.dispose();
|
||||
});
|
||||
Generated
+3
-3
@@ -16,7 +16,7 @@ patchedDependencies:
|
||||
'@fortune-sheet/react@1.0.4': 2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37
|
||||
'@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7
|
||||
exceljs@4.4.0: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48
|
||||
node-pty@1.2.0-beta.15: b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0
|
||||
node-pty@1.2.0-beta.15: 9d30053266d0f39d3debe894c675867f6782da6bcadbc94a693b3cfb9eff8ed0
|
||||
|
||||
importers:
|
||||
|
||||
@@ -13883,7 +13883,7 @@ importers:
|
||||
version: 3.1.1
|
||||
node-pty:
|
||||
specifier: 1.2.0-beta.15
|
||||
version: 1.2.0-beta.15(patch_hash=b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0)
|
||||
version: 1.2.0-beta.15(patch_hash=9d30053266d0f39d3debe894c675867f6782da6bcadbc94a693b3cfb9eff8ed0)
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:~
|
||||
@@ -29105,7 +29105,7 @@ snapshots:
|
||||
|
||||
node-int64@0.4.0: {}
|
||||
|
||||
node-pty@1.2.0-beta.15(patch_hash=b40ae545608897914bd25fb009c97eeac478c34e8a910298ddcb01b746534bb0):
|
||||
node-pty@1.2.0-beta.15(patch_hash=9d30053266d0f39d3debe894c675867f6782da6bcadbc94a693b3cfb9eff8ed0):
|
||||
dependencies:
|
||||
node-addon-api: 7.1.1
|
||||
|
||||
|
||||
Reference in new issue
Block a user