diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml
index 6bff66ab06..cb4edbc37a 100644
--- a/.github/workflows/windows-package.yml
+++ b/.github/workflows/windows-package.yml
@@ -19,7 +19,7 @@ on:
type: string
required: true
login_origins:
- description: 'Test only: comma-separated allowed login HTTPS origins'
+ description: 'Required for test: comma-separated allowed login HTTPS origins'
type: string
required: false
@@ -39,8 +39,8 @@ jobs:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- - uses: pnpm/action-setup@v4
- - uses: actions/setup-node@v6
+ - uses: pnpm/action-setup@v5.0.0
+ - uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Install locked dependencies
@@ -69,7 +69,7 @@ jobs:
exit $LASTEXITCODE
- name: Retain packaging diagnostics
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7.0.1
with:
name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
@@ -77,7 +77,7 @@ jobs:
if-no-files-found: warn
retention-days: 14
- name: Retain verified installer
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7.0.1
with:
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml
index 9e6e3f505d..522afa2b16 100644
--- a/apps/desktop/README.i18n.yaml
+++ b/apps/desktop/README.i18n.yaml
@@ -42,8 +42,8 @@
en: 4cd8a8da6977928d
zh: 9f624855a2edac37
/deepseek-harness-desktop/package/release-versions:
- en: 99607dc36badb7f1
- zh: 5df47873fe8cdea8
+ en: d938302f92fc3a1a
+ zh: 29df50bc8791baa7
/deepseek-harness-desktop/package/local-macos-dmg-without-release-signing:
en: 57869a5e3885f843
zh: 7cfc01b2dcbebd78
@@ -57,14 +57,17 @@
en: f48baa680e841b0d
zh: 25ded1aeaac6dad1
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
- en: 13020cb369942ae9
- zh: 1c9bdb97917087c7
+ en: c753286dca564319
+ zh: a3111c9dd82243f6
/deepseek-harness-desktop/package/windows-installer-interface:
en: 1fc1db8f54475c8f
zh: dd834ed206070396
/deepseek-harness-desktop/package/windows-ev-signing:
- en: 59dc085f6af4d374
- zh: a57bd63d9eef1031
+ en: 64836513eb7eda6e
+ zh: 6ebb9184a7aa51de
+/deepseek-harness-desktop/package/desktop-runtime-preparation:
+ en: 32ef30fc38462109
+ zh: 093b1b57d084f5bb
/deepseek-harness-desktop/updates:
en: 41d0f50eb7173342
zh: ce940ef3cbcc6fa5
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index d6a49c6dfa..08bf5c530d 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -199,6 +199,8 @@ Record the current dsh version as the base. A production Desktop release uses th
Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base.
+Manual Windows CI uses a separate unpublished build identifier: `auto` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers.
+
Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert:
```sh
@@ -352,7 +354,7 @@ On Windows x64, use the complete unsigned packaging command for local installati
pnpm run package:desktop:win:x64:unsigned
```
-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.
+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. The local run and CI use Node 24; the remaining tools are 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.
@@ -431,6 +433,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 runtime preparation
+
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.
diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md
index 8ed1971e61..836edca19e 100644
--- a/apps/desktop/README.zh.md
+++ b/apps/desktop/README.zh.md
@@ -201,6 +201,8 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否
日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。
+Windows 手动 CI 使用独立的未发布构建标识:`auto` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。
+
把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动:
```sh
@@ -354,7 +356,7 @@ Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置
pnpm run package:desktop:win:x64:unsigned
```
-该命令读取 `.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 或上传凭据。签名打包和上传仍遵循正式发布要求。
+该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 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 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。
@@ -433,6 +435,10 @@ pnpm run prepare:desktop
这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
+
+
+### Desktop 运行时准备
+
Desktop [补丁策略](scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。
Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。
diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs
index e2152ce0d3..06a212b041 100644
--- a/apps/desktop/scripts/desktop-build-version.mjs
+++ b/apps/desktop/scripts/desktop-build-version.mjs
@@ -2,6 +2,9 @@
* Resolve the version one build publishes, which is not always the version the
* repository declares.
*
+ * Manual Windows CI uses UTC date, workflow run number and attempt as
+ * `.YYYYMMDD.run.attempt`; these identify unpublished builds, not release sequences.
+ *
* A production release publishes the version in the manifests, aligned with the
* `dsh` npm package. A test build publishes a version that appends a date and a
* sequence number, so one test feed can carry several builds of a single
diff --git a/apps/desktop/scripts/prepare-runtime-patches.ts b/apps/desktop/scripts/prepare-runtime-patches.ts
index 67e73fb2f3..83ebbbe457 100644
--- a/apps/desktop/scripts/prepare-runtime-patches.ts
+++ b/apps/desktop/scripts/prepare-runtime-patches.ts
@@ -86,7 +86,7 @@ export function prepareRuntimePatches(
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 }))
+ patchedDependencies: Object.fromEntries(selected.map(entry => [entry.spec, entry.path])), allowUnusedPatches: false }))
return { patches: selected.map(({ bytes: _bytes, ...entry }) => entry),
workspaceOnly: Object.entries(policy).filter(([, rule]) => rule.scope === 'workspace-only').map(([spec]) => patchIdentity(spec).name) }
}
@@ -102,6 +102,9 @@ export function verifyRuntimePatches(projectRoot: string, plan: PreparedRuntimeP
packages?: Record
}
for (const patch of plan.patches) {
+ if (!Object.keys(lock.packages ?? {}).some(spec => spec.startsWith(`${patch.name}@`))) {
+ throw new Error(`desktop patches: selected patch has no runtime dependency: ${patch.spec}`)
+ }
if (patchHash(readFileSync(join(projectRoot, patch.path))) !== patch.hash) {
throw new Error(`desktop patches: staged patch bytes changed: ${patch.spec}`)
}
diff --git a/apps/desktop/scripts/smoke-prepared-runtime.ts b/apps/desktop/scripts/smoke-prepared-runtime.ts
index 86bccad6d9..e6afd574da 100644
--- a/apps/desktop/scripts/smoke-prepared-runtime.ts
+++ b/apps/desktop/scripts/smoke-prepared-runtime.ts
@@ -34,6 +34,7 @@ export async function smokePreparedRuntime(
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 ('stderr' in error && typeof error.stderr === 'string') process.stderr.write(error.stderr)
if ('killed' in error && 'signal' in error) {
process.stderr.write(`runtime payload: killed=${String(error.killed)}, signal=${String(error.signal)}\n`)
}
diff --git a/apps/desktop/tests/prepare-runtime-patches.spec.ts b/apps/desktop/tests/prepare-runtime-patches.spec.ts
index 864203c24d..951724e114 100644
--- a/apps/desktop/tests/prepare-runtime-patches.spec.ts
+++ b/apps/desktop/tests/prepare-runtime-patches.spec.ts
@@ -94,12 +94,12 @@ it('refuses to write to the workspace or replace an unrelated existing override'
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) => {
+it.each(['version', 'lock hash', 'file bytes', 'workspace-only', 'unused'] 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']: {} },
+ packages: failure === 'unused' ? {} : { [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)
@@ -115,7 +115,7 @@ it.each([false, true])('pnpm applies runtime-only bytes and rejects an inapplica
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 })
+ await run('tar', ['-czf', 'fixture.tgz', '-C', root, 'package'], { cwd: project, 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'))
diff --git a/apps/desktop/tests/prepared-runtime-smoke.spec.ts b/apps/desktop/tests/prepared-runtime-smoke.spec.ts
index 6fc68c2789..c4c840c009 100644
--- a/apps/desktop/tests/prepared-runtime-smoke.spec.ts
+++ b/apps/desktop/tests/prepared-runtime-smoke.spec.ts
@@ -32,13 +32,14 @@ it('retains payload progress and termination details when the child fails', asyn
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',
+ stdout: 'payload output\n', stderr: '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(stdout).toHaveBeenCalledWith('payload output\n')
+ expect(stderr).toHaveBeenCalledWith('runtime payload: pty\n')
expect(stderr).toHaveBeenCalledWith('runtime payload: killed=true, signal=SIGTERM\n')
expect(smokeDesktopRuntime).not.toHaveBeenCalled()
})
diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts
new file mode 100644
index 0000000000..35cb4810fd
--- /dev/null
+++ b/scripts/windows-package-workflow.spec.ts
@@ -0,0 +1,63 @@
+import { readFileSync } from 'node:fs'
+import { relative, resolve } from 'node:path'
+import yaml from 'js-yaml'
+import { expect, it } from 'vitest'
+import { desktopTargetBuildPaths } from '../apps/desktop/scripts/desktop-build-paths.mjs'
+
+const root = resolve(import.meta.dirname, '..')
+const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows-package.yml'), 'utf8')) as {
+ on: Record }>
+ permissions: Record
+ jobs: Record
+ with?: Record
+ }>
+ }>
+}
+const job = workflow.jobs.package!
+
+it('requires explicit manual dispatch on a hosted Windows runner', () => {
+ expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
+ expect(job['runs-on']).toBe('windows-2025')
+ expect(workflow.permissions).toEqual({ contents: 'read' })
+ expect(workflow.on.workflow_dispatch!.inputs.policy_origin!.required).toBe(true)
+})
+
+it('passes user input through environment variables and shares the validated version', () => {
+ const settings = job.steps.find(step => step.id === 'settings')!
+ expect(settings.env).toEqual({
+ PACKAGE_BUILD_VERSION: '${{ inputs.build_version }}', PACKAGE_DEPLOYMENT: '${{ inputs.deployment }}',
+ PACKAGE_POLICY_ORIGIN: '${{ inputs.policy_origin }}', PACKAGE_LOGIN_ORIGINS: '${{ inputs.login_origins }}',
+ })
+ const commands = job.steps.filter(step => step.run?.includes('pnpm run package:desktop:win:x64:unsigned'))
+ expect(commands).toHaveLength(2)
+ expect(commands[0]!.run).toContain('--check --build-version "$env:PACKAGE_VERSION"')
+ expect(commands[1]!.run).toContain('unsigned --build-version "$env:PACKAGE_VERSION"')
+ for (const step of commands) {
+ expect(step.env?.PACKAGE_VERSION).toBe('${{ steps.settings.outputs.version }}')
+ expect(step.run).toContain('exit $LASTEXITCODE')
+ }
+ for (const step of job.steps) expect(step.run ?? '').not.toContain('${{ inputs.')
+ expect(job.steps.indexOf(settings)).toBeLessThan(job.steps.indexOf(commands[0]!))
+})
+
+it('retains failure diagnostics and only uploads successful installers from the owned output directory', () => {
+ const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))
+ const logs = uploads.find(step => step.if === 'always()')!
+ expect(logs.with).toMatchObject({ path: 'apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true })
+ const installer = uploads.find(step => step !== logs)!
+ expect(installer.if).toBeUndefined()
+ expect(installer.with).toMatchObject({
+ path: `${relative(root, desktopTargetBuildPaths('win-x64').unsignedArtifacts).replaceAll('\\', '/')}/*.exe`,
+ 'include-hidden-files': true, 'if-no-files-found': 'error',
+ })
+ const cleanup = job.steps.find(step => step.run === 'Remove-Item -LiteralPath apps/desktop/.env.windows')!
+ expect(cleanup.if).toBe("always() && steps.settings.outcome == 'success'")
+})