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'") +})