Merge pull request #5952 from deepseek-harness/build/windows-ci-presets-signing
build(windows): add machine presets and EV-signed Actions packaging
This commit is contained in:
commit
b35ef84914
21 files changed
+437
-177
No files matched your search
+2
-2
@@ -24,8 +24,8 @@
|
||||
en: e9f140cd22670dfa
|
||||
zh: bf85cfe9c12a0345
|
||||
/agent-note-package-and-update-the-electron-desktop-application/security-and-release-policy:
|
||||
en: 97ee608e96fc3602
|
||||
zh: 932b98e7d344cd87
|
||||
en: 36559f9179764da7
|
||||
zh: f1d39a6438ca520c
|
||||
/agent-note-package-and-update-the-electron-desktop-application/implementation:
|
||||
en: 041a6d35ed61b149
|
||||
zh: 617c6b90a6f4fa4d
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file.
|
||||
|
||||
Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles.
|
||||
|
||||
Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap.
|
||||
Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. Windows packaging can instead select a machine-owned public `presets/<preset>.env`, plus `secrets/windows-signing.env` for signed builds; unsigned and preparation-only modes omit the signing file. The selected files exclusively own release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap.
|
||||
|
||||
The [pinned osx-sign patch](../../../../patches/@[email protected]) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
|
||||
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
|
||||
|
||||
核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
|
||||
|
||||
Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
|
||||
Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。Windows 打包也可选择机器管理的公开 `presets/<preset>.env`,签名构建另读 `secrets/windows-signing.env`;未签名和仅准备模式不读取签名文件。所选文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
|
||||
|
||||
[固定版本的 osx-sign 补丁](../../../../patches/@[email protected])在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
|
||||
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
name: Windows installer (unsigned)
|
||||
name: Windows installer
|
||||
|
||||
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
|
||||
preset:
|
||||
description: 'Machine preset (configured on the packaging runner)'
|
||||
type: choice
|
||||
options: [test, production]
|
||||
default: test
|
||||
required: true
|
||||
policy_origin:
|
||||
description: 'Required update policy HTTPS origin (no path)'
|
||||
type: string
|
||||
signing:
|
||||
description: 'unsigned: test installer; signed: use the configured EV hardware token'
|
||||
type: choice
|
||||
options: [unsigned, signed]
|
||||
default: unsigned
|
||||
required: true
|
||||
login_origins:
|
||||
description: 'Required for test: comma-separated allowed login HTTPS origins'
|
||||
build_version:
|
||||
description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)'
|
||||
type: string
|
||||
required: false
|
||||
default: auto
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -39,7 +37,20 @@ jobs:
|
||||
shell: pwsh
|
||||
env:
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
|
||||
PACKAGE_SIGNING: ${{ inputs.signing }}
|
||||
steps:
|
||||
- name: Reset packaging checkout
|
||||
working-directory: ${{ runner.temp }}
|
||||
run: |
|
||||
$workspace = [IO.Path]::GetFullPath($env:GITHUB_WORKSPACE)
|
||||
$parent = [IO.Path]::GetFullPath($env:RUNNER_WORKSPACE)
|
||||
if ([IO.Path]::GetDirectoryName($workspace) -ne $parent) { throw 'Checkout must be a direct child of RUNNER_WORKSPACE' }
|
||||
for ($item = [IO.DirectoryInfo]::new($workspace); $null -ne $item; $item = $item.Parent) {
|
||||
if ($item.Exists -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Checkout ancestors cannot be links' }
|
||||
}
|
||||
node -e "const fs = require('node:fs'); fs.rmSync(process.argv[1], { recursive: true, force: true, maxRetries: 3 }); fs.mkdirSync(process.argv[1])" $workspace
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Checkout cleanup failed' }
|
||||
- uses: actions/[email protected]
|
||||
id: checkout
|
||||
with:
|
||||
@@ -55,25 +66,26 @@ jobs:
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
exit $LASTEXITCODE
|
||||
- name: Validate inputs and prepare public package settings
|
||||
- name: Validate preset and resolve build version
|
||||
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"
|
||||
$packageArgs = @('run', 'package:desktop:win:x64', '--check', '--build-version', $env:PACKAGE_VERSION)
|
||||
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
|
||||
pnpm @packageArgs
|
||||
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"
|
||||
$packageArgs = @('run', 'package:desktop:win:x64', '--build-version', $env:PACKAGE_VERSION)
|
||||
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
|
||||
pnpm @packageArgs
|
||||
exit $LASTEXITCODE
|
||||
- name: Retain packaging diagnostics
|
||||
if: always() && steps.checkout.outcome == 'success'
|
||||
@@ -84,7 +96,8 @@ jobs:
|
||||
path: apps/desktop/.desktop-build/packaging-runs/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
- name: Retain verified installer
|
||||
- name: Retain verified unsigned installer
|
||||
if: success() && inputs.signing == 'unsigned'
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
@@ -92,9 +105,12 @@ jobs:
|
||||
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.checkout.outcome == 'success'
|
||||
run: |
|
||||
if (Test-Path -LiteralPath apps/desktop/.env.windows) {
|
||||
Remove-Item -LiteralPath apps/desktop/.env.windows
|
||||
}
|
||||
- name: Retain verified signed installer
|
||||
if: success() && inputs.signing == 'signed'
|
||||
uses: actions/[email protected]
|
||||
with:
|
||||
name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
path: apps/desktop/.desktop-build/targets/win-x64/artifacts/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -42,8 +42,8 @@
|
||||
en: 4cd8a8da6977928d
|
||||
zh: 9f624855a2edac37
|
||||
/deepseek-harness-desktop/package/release-versions:
|
||||
en: d938302f92fc3a1a
|
||||
zh: 29df50bc8791baa7
|
||||
en: fdc1fadc506d1087
|
||||
zh: 46d5cb8a60ddf7cf
|
||||
/deepseek-harness-desktop/package/local-macos-dmg-without-release-signing:
|
||||
en: 57869a5e3885f843
|
||||
zh: 7cfc01b2dcbebd78
|
||||
@@ -57,14 +57,14 @@
|
||||
en: f48baa680e841b0d
|
||||
zh: 25ded1aeaac6dad1
|
||||
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
|
||||
en: fb6de542360dbae7
|
||||
zh: 0e17d7765fb10c48
|
||||
en: 2e30de3155c32344
|
||||
zh: be88a9d7f8682e75
|
||||
/deepseek-harness-desktop/package/windows-installer-interface:
|
||||
en: 1fc1db8f54475c8f
|
||||
zh: dd834ed206070396
|
||||
/deepseek-harness-desktop/package/windows-ev-signing:
|
||||
en: 64836513eb7eda6e
|
||||
zh: 6ebb9184a7aa51de
|
||||
en: 5c12ccf2f4482606
|
||||
zh: efea2cb823bda62f
|
||||
/deepseek-harness-desktop/package/desktop-runtime-preparation:
|
||||
en: 9a71990bee9867a7
|
||||
zh: 76c5c1fdff6e6908
|
||||
|
||||
+11
-5
@@ -217,9 +217,9 @@ A production release publishes the product version and takes no `--build-version
|
||||
|
||||
Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier.
|
||||
|
||||
Except for local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging.
|
||||
Except for Windows packaging with a selected machine preset and local unsigned macOS builds, packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the selected configuration files, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. Windows CI reads machine presets as described below.
|
||||
|
||||
Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. `--build-version auto` does contact the destination bucket, including under `--check`. Run the same checks separately:
|
||||
Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Without a Windows machine preset, `--build-version auto` contacts the destination bucket, including under `--check`. Run the same checks separately:
|
||||
|
||||
```sh
|
||||
pnpm --dir apps/desktop run check:package
|
||||
@@ -364,9 +364,15 @@ The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mand
|
||||
|
||||
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) selects a self-hosted Windows x64 runner with the dedicated `dsh-win-package-trial` label and calls the same unsigned command. Provision PowerShell 7 and the tools listed above before starting the runner. 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`, using the CI identifier described in [Release versions](#release-versions). An explicit version uses the existing product-version validator. The resolved version appears in the run summary before packaging; manifests remain unchanged.
|
||||
The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `unsigned` (default) or `signed`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
|
||||
|
||||
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. Run it once from the default branch and inspect the retained logs and installer before relying on Actions packaging. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run can remain pending, and a newer dispatch replaces that pending run. Diagnostics are uploaded only after a successful clean checkout, and generated settings are removed even when preparation fails after writing them.
|
||||
Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts.
|
||||
|
||||
The shared packaging runner assumes trusted internal-team code and operators. Under this assumption, credential and device isolation are deferred and the residual risk is accepted (tracked in #5973); signed/master-only access is not enforced. Unsigned mode skips signing-file loading but cannot prevent branch code running as the same Windows account from reading those files or accessing the token. SafeNet Single Logon may reuse authentication. Direct device calls can bypass the signing scripts and exhaust PIN retries; the persistent failure interlock only protects calls through those scripts. Cross-account token isolation and remaining retry counts are unverified. Reassess runner/account/device isolation before expanding access, executing untrusted contributions, changing runner sharing, or after suspicious activity.
|
||||
|
||||
Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/<preset>.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt.
|
||||
|
||||
Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root <absolute-machine-root>` to prepare private `packaging/{presets,secrets,certificates}` and `cache/windows-signatures` directories and copy templates without overwriting existing files. Add `-CheckOnly` to inspect without writes. Fill the [preset templates](config/windows-packaging/test.env.example) and [signing template](config/windows-packaging/windows-signing.env.example) on the machine. Run `start-windows-packaging-runner.ps1 -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging -CheckOnly` from the same scripts directory to inspect PowerShell 7.6.6 under the runner's `tools` directory, Node, Python, Git, tar and Visual C++ tools; packaging `--check` performs the remaining toolchain validation. Omit `-CheckOnly` to start the existing runner with an explicit config root and scrubbed inherited environment. Registration and tool installation are separate; these scripts never create tokens, alter system security settings or clear signing failure records. Use the signing cache maintenance command below to migrate existing entries before changing the preset's cache path.
|
||||
|
||||
<a id="windows-uninstall-rules"></a>
|
||||
|
||||
@@ -422,7 +428,7 @@ Insert and unlock the token before packaging. The electron-builder hook passes e
|
||||
|
||||
Timestamp completion retries only a normally exited timestamp command returning failure or warning, at most three times with one- and two-second delays. Each attempt starts from the same verified primary signature. Launch errors, uncertain termination and verification failures stop immediately. SignTool operates on short private paths; publication copies verified bytes to the target volume before atomic replacement. Final Windows trust, certificate, timestamp and normalized full-file equality are required. Exhaustion stops packaging and preserves evidence without another hardware call. See the [signature completion decision](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md).
|
||||
|
||||
The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` stores the PIN in plaintext and needs restricted file access; CI uses a temporary file and deletes it after the job. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
|
||||
The PIN cannot contain `]`, a quote, or a line break because those characters delimit the SafeNet `/kc` value or its CMD argument. The CMD disables delayed expansion so a PIN containing `!` reaches SafeNet unchanged. Packaging withholds every `DSH_DESKTOP_WINDOWS_*` field from build and runtime-preparation subprocesses, gives signing preflight, the dedicated runtime signing stages and electron-builder only the four configured inputs, gives the signing CMD only the validated signing fields in an otherwise scrubbed environment, clears those fields before SignTool starts, and redacts SignTool diagnostics. SafeNet still requires the PIN in the SignTool process command line. The local `.env.windows` or machine-owned `secrets/windows-signing.env` stores the PIN in plaintext and needs restricted file access. CI reads the machine-owned file outside the checkout and retains it across jobs. Do not commit or share its contents or print credentials in logs. Configuration checks consume no token PIN attempts; signing still stops the batch on its first failure.
|
||||
|
||||
Create a runnable application directory instead of an installer by using the matching `:dir` command, such as:
|
||||
|
||||
|
||||
@@ -219,9 +219,9 @@ production 发布使用产品版本本身,不传 `--build-version`。其上传
|
||||
|
||||
版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。
|
||||
|
||||
除本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。
|
||||
除选择机器 preset 的 Windows 打包和本地 macOS 未签名构建外,打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从选定的配置文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。Windows CI 读取下文说明的机器 preset。
|
||||
|
||||
每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查:
|
||||
每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。未选择 Windows 机器 preset 时,`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查:
|
||||
|
||||
```sh
|
||||
pnpm --dir apps/desktop run check:package
|
||||
@@ -366,9 +366,15 @@ pnpm run package:desktop:win:x64:unsigned
|
||||
|
||||
先运行 `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) 选择带有专用标签 `dsh-win-package-trial` 的自托管 Windows x64 runner,并调用同一个未签名命令。启动 runner 前需准备 PowerShell 7 和上述工具。必须填写策略部署环境和 HTTPS origin;测试部署还需填写逗号分隔的登录 origin。这些公开输入生成临时 `.env.windows`,不包含签名或上传凭据。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中说明的 CI 标识。显式版本复用现有产品版本校验器。解析后的版本在打包前显示于运行摘要,manifest 保持不变。
|
||||
[Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `unsigned`(默认)或 `signed`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
|
||||
|
||||
工作流进入默认分支后,在 Actions 中选择它,选择源码分支、填写输入并点击 Run workflow。它不监听 push 或 PR,不改变现有自动 CI。打包日志和成功生成的 EXE 产物保留 14 天;安装、签名和发布是独立操作。依赖 Actions 打包前,先从默认分支运行一次,并检查保留的日志和安装包。Checkout 会清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新的手动触发会替换该待运行任务。仅在成功完成干净检出后上传诊断日志;即使配置准备在写入后失败,也会清理生成的配置。
|
||||
在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。
|
||||
|
||||
共享打包 runner 以内部团队代码和操作人员可信为前提。在此前提下,凭据和设备隔离暂缓实施,剩余风险按已知风险接受(由 #5973 跟踪);尚未强制限制仅 signed/master 可访问。未签名模式跳过签名文件加载,但无法阻止同一 Windows 账户下的分支代码读取这些文件或访问 Token。SafeNet Single Logon 可能复用认证状态。直接调用设备可以绕过签名脚本并耗尽 PIN 重试次数;持久失败 interlock 只保护经过这些脚本的调用。跨账户 Token 隔离和剩余重试次数尚未验证。扩大访问范围、执行不可信贡献、改变 runner 共享方式前,或出现可疑活动后,重新评估 runner、账户和设备隔离。
|
||||
|
||||
本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/<preset>.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。
|
||||
|
||||
运行 `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Root <absolute-machine-root>` 创建私有 `packaging/{presets,secrets,certificates}` 和 `cache/windows-signatures` 目录并复制模板,不覆盖现有文件。添加 `-CheckOnly` 只检查不写入。在机器上填写 [preset 模板](config/windows-packaging/test.env.example)和[签名模板](config/windows-packaging/windows-signing.env.example)。从同一 scripts 目录运行 `start-windows-packaging-runner.ps1 -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging -CheckOnly`,检查 runner 的 `tools` 目录下 PowerShell 7.6.6、Node、Python、Git、tar 和 Visual C++ 工具;打包 `--check` 完成其余工具链检查。省略 `-CheckOnly`,以显式配置目录和清理后的继承环境启动现有 runner。注册和工具安装单独进行;这些脚本不创建 token、不改系统安全设置、不清除签名失败记录。修改 preset 缓存路径前,使用下文签名缓存维护命令迁移已有条目。
|
||||
|
||||
<a id="windows-uninstall-rules"></a>
|
||||
|
||||
@@ -424,7 +430,7 @@ pnpm run package:desktop:win:x64
|
||||
|
||||
时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。
|
||||
|
||||
PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 明文保存 PIN,应限制文件访问权限;CI 使用临时文件并在任务结束后删除。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。
|
||||
PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet `/kc` 值或对应的 CMD 参数。CMD 会禁用延迟展开,因此包含 `!` 的 PIN 可以原样到达 SafeNet。打包流程不会把任何 `DSH_DESKTOP_WINDOWS_*` 字段传给构建与 运行时准备子进程;它只向签名预检、独立的第一方运行时签名阶段与 electron-builder 提供四个配置输入,在其他字段已经清理的环境中只向签名 CMD 提供经过校验的签名字段,在 SignTool 启动前清除这些字段,并遮盖 SignTool 诊断。SafeNet 仍要求 PIN 出现在 SignTool 进程命令行中。本地 `.env.windows` 或机器管理的 `secrets/windows-signing.env` 明文保存 PIN,应限制文件访问权限。CI 读取 checkout 外的机器签名文件,并在任务结束后保留。不要提交或分享文件内容,也不要把凭据写入日志。配置检查不会消耗 Token 的 PIN 尝试次数;签名仍在首次失败后停止整批任务。
|
||||
|
||||
使用对应的 `:dir` 命令可以生成可直接运行的应用目录,而不是安装包,例如:
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy to production.env in the same machine configuration directory and fill in the values.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=production
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}'
|
||||
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
|
||||
@@ -0,0 +1,13 @@
|
||||
# Copy to test.env in the same machine configuration directory and fill in the values.
|
||||
# Public test configuration. No PIN or upload credentials belong here.
|
||||
DSH_DESKTOP_APP_ID=com.deepseek.harness
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=test
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=
|
||||
# Replace login.example.com with the real login origin before packaging.
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{"allowedAuthOrigins":["https://login.example.com"]}'
|
||||
# Required for signed test packages; retain the release ID for an upgrade test batch.
|
||||
DOWNLOAD_TEST_ORIGIN=
|
||||
DOWNLOAD_TEST_RELEASE_ID=
|
||||
# Optional absolute private cache directory. Omit to retain the account default cache.
|
||||
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
|
||||
@@ -0,0 +1,7 @@
|
||||
# Copy to windows-signing.env in the same machine configuration directory and fill in the values.
|
||||
# Restrict this file to the runner account, SYSTEM and administrators.
|
||||
# File paths resolve relative to this file; use the same account as local signing.
|
||||
DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/windows.cer
|
||||
DSH_DESKTOP_WINDOWS_SIGNTOOL=
|
||||
DSH_DESKTOP_WINDOWS_KEY_CONTAINER=
|
||||
DSH_DESKTOP_WINDOWS_TOKEN_PIN=
|
||||
@@ -8,16 +8,18 @@
|
||||
export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv
|
||||
|
||||
/**
|
||||
* Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
|
||||
* Read a Windows machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
|
||||
* @param platform Target platform.
|
||||
* @param environment Parent environment, retained only for unrelated build tools.
|
||||
* @param appRoot Desktop application directory; relative credential paths resolve here.
|
||||
* @param options Unsigned and preparation-only presets never read signing credentials.
|
||||
* @returns Isolated environment with file-owned release settings.
|
||||
*/
|
||||
export function loadDesktopPackageEnvironment(
|
||||
platform: 'win32' | 'darwin',
|
||||
environment?: NodeJS.ProcessEnv,
|
||||
appRoot?: string,
|
||||
options?: { unsigned?: boolean, prepareOnly?: boolean },
|
||||
): NodeJS.ProcessEnv
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Select local test defaults or platform release settings without changing the caller's environment. */
|
||||
|
||||
import { accessSync, constants, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseEnv } from 'node:util'
|
||||
import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs'
|
||||
@@ -33,14 +33,46 @@ export function localMacOSPackageEnvironment(environment = process.env) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the target's required UTF-8 dotenv file; release settings never fall back to ambient values.
|
||||
* Read a Windows machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
|
||||
* @param {'win32' | 'darwin'} platform Target platform.
|
||||
* @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools.
|
||||
* @param {string} appRoot Desktop application directory; relative credential paths resolve here.
|
||||
* @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Unsigned and preparation-only presets never read signing credentials.
|
||||
* @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings.
|
||||
*/
|
||||
export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT) {
|
||||
const path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
|
||||
export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) {
|
||||
const preset = environment.DSH_PACKAGING_PRESET
|
||||
let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
|
||||
if (preset !== undefined) {
|
||||
if (platform !== 'win32') throw new Error('desktop package: presets support Windows only')
|
||||
if (preset !== 'test' && preset !== 'production') throw new Error('desktop package: preset must be test or production')
|
||||
const root = environment.DSH_PACKAGING_CONFIG_DIR
|
||||
if (!root || !isAbsolute(root)) throw new Error('desktop package: DSH_PACKAGING_CONFIG_DIR must be an absolute directory')
|
||||
path = join(root, 'presets', `${preset}.env`)
|
||||
}
|
||||
const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
|
||||
const settings = readSettings(path, name => preset === undefined
|
||||
? SHARED_SETTING.test(name) || platformSetting.test(name)
|
||||
: /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN)|WINDOWS_SIGNATURE_CACHE_(?:DIR|CONCURRENCY))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name))
|
||||
if (preset !== undefined) {
|
||||
if (settings.DSH_DESKTOP_AUTO_UPDATE_ENV !== preset) throw new Error('desktop package: preset deployment must match its name')
|
||||
if (!options.unsigned && !options.prepareOnly) Object.assign(settings, readSettings(join(dirname(dirname(path)), 'secrets', 'windows-signing.env'),
|
||||
name => /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN)$/u.test(name)))
|
||||
}
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))),
|
||||
...settings,
|
||||
...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an explicitly selected dotenv without exposing credential-bearing parser errors.
|
||||
* @param {string} path Selected file; relative file settings resolve beside it.
|
||||
* @param {(name: string) => boolean} accepts Whether this file may declare a setting.
|
||||
* @returns {NodeJS.ProcessEnv} Validated file settings.
|
||||
*/
|
||||
function readSettings(path, accepts) {
|
||||
let contents
|
||||
try {
|
||||
contents = readFileSync(path, 'utf8')
|
||||
@@ -56,9 +88,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
|
||||
// Parser diagnostics can contain credential-bearing input.
|
||||
throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
|
||||
}
|
||||
const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
|
||||
for (const name of Object.keys(settings)) {
|
||||
if (!SHARED_SETTING.test(name) && !platformSetting.test(name)) {
|
||||
if (!accepts(name)) {
|
||||
throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
|
||||
}
|
||||
if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
|
||||
@@ -66,10 +97,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
|
||||
for (const name of FILE_SETTINGS) {
|
||||
if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
|
||||
}
|
||||
return {
|
||||
...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))),
|
||||
...settings,
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
function requireReadableFile(environment, name) {
|
||||
|
||||
@@ -11,7 +11,7 @@ const { values } = parseArgs({ options: {
|
||||
if ([values.from !== undefined, values.usage === true, values.clear === true].filter(Boolean).length !== 1) {
|
||||
throw new Error('Specify exactly one of --from <old-cache>, --usage or --clear')
|
||||
}
|
||||
const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32')
|
||||
const environment = values.directory === undefined ? loadDesktopPackageEnvironment('win32', process.env, undefined, { unsigned: true })
|
||||
: { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR: values.directory }
|
||||
const root = resolveWindowsSignatureCacheDirectory(environment)
|
||||
const record = event => process.stdout.write(`${JSON.stringify(event)}\n`)
|
||||
|
||||
@@ -322,7 +322,9 @@ async function resolveRequestedBuildVersion(
|
||||
if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion)
|
||||
const paths = desktopTargetBuildPaths(invocation.target.name)
|
||||
return suggestDesktopBuildVersion({
|
||||
productVersion, target: invocation.target.name, environment,
|
||||
productVersion, target: invocation.target.name,
|
||||
// Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts.
|
||||
environment: environment.DSH_PACKAGING_PRESET === undefined ? environment : {},
|
||||
// Unsigned builds land beside the signed output, so numbering has to read the directory this run writes.
|
||||
artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts,
|
||||
})
|
||||
@@ -333,9 +335,11 @@ async function main(): Promise<void> {
|
||||
const { target } = invocation
|
||||
const environment = target.platform === 'darwin' && invocation.unsigned
|
||||
? localMacOSPackageEnvironment()
|
||||
: loadDesktopPackageEnvironment(target.platform)
|
||||
: loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, {
|
||||
unsigned: invocation.unsigned, prepareOnly: invocation.prepareOnly,
|
||||
})
|
||||
const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
|
||||
// Release settings come from the target dotenv file alone, so the version this run publishes is an
|
||||
// Release settings come from the selected configuration files, so the version this run publishes is an
|
||||
// argument; the environment variable below only carries it to the child processes that build.
|
||||
const buildVersion = await resolveRequestedBuildVersion(invocation, productVersion, environment)
|
||||
environment[DESKTOP_BUILD_VERSION_ENV] = buildVersion
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
/** Public settings prepared for a manual Windows package run. */
|
||||
export interface WindowsCiSettings {
|
||||
version: string
|
||||
settings: string
|
||||
}
|
||||
/** Resolve the version used by the manual Windows packaging workflow. */
|
||||
|
||||
/**
|
||||
* Validate manual inputs without local credentials or release storage.
|
||||
* @param environment Manual inputs and GitHub run identity.
|
||||
* Resolve an explicit version or a UTC date/run/attempt identifier without release storage access.
|
||||
* @param environment Manual version 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.
|
||||
* @returns Validated build version.
|
||||
*/
|
||||
export function windowsCiSettings(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): WindowsCiSettings
|
||||
export function windowsCiVersion(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): string
|
||||
@@ -1,54 +1,52 @@
|
||||
/** Prepare public Windows packaging settings and a version for a manual GitHub run. */
|
||||
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
/** Resolve a manual Windows build version and validate the selected machine preset. */
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs'
|
||||
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
|
||||
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
|
||||
/**
|
||||
* Validate manual inputs without reading local credentials or querying release storage.
|
||||
* @param {NodeJS.ProcessEnv} environment Manual inputs and GitHub run identity.
|
||||
* Resolve an explicit version or a UTC date/run/attempt identifier without release storage access.
|
||||
* @param {NodeJS.ProcessEnv} environment Manual version 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.
|
||||
* @returns {string} Validated build version.
|
||||
*/
|
||||
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),
|
||||
})
|
||||
export function windowsCiVersion(environment, productVersion, now = new Date()) {
|
||||
const requested = environment.PACKAGE_BUILD_VERSION?.trim()
|
||||
if (!requested) throw new Error('Build version is required; use auto or a full version')
|
||||
const date = now.toISOString().slice(0, 10).replaceAll('-', '')
|
||||
const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1`
|
||||
if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`)
|
||||
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 }
|
||||
try {
|
||||
return validateDesktopBuildVersion(candidate, productVersion)
|
||||
} catch {
|
||||
throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const version = windowsCiVersion(process.env, product.version)
|
||||
const mode = process.env.PACKAGE_SIGNING
|
||||
if (mode !== 'unsigned' && mode !== 'signed') throw new Error('Select unsigned or signed packaging')
|
||||
if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset')
|
||||
const environment = loadDesktopPackageEnvironment('win32', process.env, appRoot, { unsigned: mode === 'unsigned' })
|
||||
// Machine-owned secrets are not automatically known to the Actions log masker.
|
||||
for (const [name, value] of Object.entries(environment)) {
|
||||
if (/TOKEN|PASSWORD|SECRET/iu.test(name) && value) {
|
||||
const escaped = value.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A')
|
||||
process.stdout.write(`::add-mask::${escaped}\n`)
|
||||
}
|
||||
}
|
||||
validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' })
|
||||
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`)
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`)
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# Prepare or inspect machine-owned Windows packaging directories without reading secret values.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$Root,
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($Root -notmatch '^[A-Za-z]:[\\/]') { throw 'Root must be an absolute local drive directory' }
|
||||
$rootPath = [IO.Path]::GetFullPath($Root)
|
||||
$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($rootPath))
|
||||
if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'Packaging root requires a fixed local drive' }
|
||||
for ($ancestor = [IO.DirectoryInfo]::new($rootPath); $null -ne $ancestor; $ancestor = $ancestor.Parent) {
|
||||
if ($ancestor.Exists -and ($ancestor.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Packaging root cannot contain linked directories' }
|
||||
}
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent().User
|
||||
$allowed = @($identity.Value, 'S-1-5-18', 'S-1-5-32-544')
|
||||
foreach ($relative in @('packaging', 'packaging/presets', 'packaging/secrets', 'packaging/certificates', 'cache', 'cache/windows-signatures')) {
|
||||
$path = Join-Path $rootPath $relative
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
if ($CheckOnly) { throw "Missing directory: $path" }
|
||||
$null = New-Item -ItemType Directory -Path $path
|
||||
$acl = [Security.AccessControl.DirectorySecurity]::new()
|
||||
$acl.SetOwner($identity)
|
||||
$acl.SetAccessRuleProtection($true, $false)
|
||||
foreach ($sid in $allowed) {
|
||||
$rule = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
|
||||
$null = $acl.AddAccessRule($rule)
|
||||
}
|
||||
Set-Acl -LiteralPath $path -AclObject $acl
|
||||
}
|
||||
$item = Get-Item -LiteralPath $path
|
||||
if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Not a real directory: $path" }
|
||||
$acl = Get-Acl -LiteralPath $path
|
||||
if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $identity.Value) { throw "Directory must belong to current account: $path" }
|
||||
foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) {
|
||||
if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin $allowed) { throw "Directory permits another account: $path" }
|
||||
}
|
||||
}
|
||||
if (-not $CheckOnly) {
|
||||
$templates = Join-Path $PSScriptRoot '../config/windows-packaging'
|
||||
foreach ($name in @('test', 'production')) {
|
||||
$destination = Join-Path $rootPath "packaging/presets/$name.env.example"
|
||||
if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates "$name.env.example") -Destination $destination }
|
||||
}
|
||||
$destination = Join-Path $rootPath 'packaging/secrets/windows-signing.env.example'
|
||||
if (-not (Test-Path -LiteralPath $destination)) { Copy-Item -LiteralPath (Join-Path $templates 'windows-signing.env.example') -Destination $destination }
|
||||
}
|
||||
Write-Output "Packaging directories verified. Configuration root: $(Join-Path $rootPath 'packaging')"
|
||||
Write-Output 'Existing configuration, caches, runner registration and signing failure records were not overwritten.'
|
||||
@@ -0,0 +1,38 @@
|
||||
# Start the existing registered runner with explicit machine configuration and a scrubbed environment.
|
||||
[CmdletBinding()]
|
||||
param([Parameter(Mandatory=$true)][string]$RunnerRoot, [Parameter(Mandatory=$true)][string]$ConfigRoot, [switch]$CheckOnly)
|
||||
$ErrorActionPreference='Stop'
|
||||
$pwshRoot=Join-Path $RunnerRoot 'tools/pwsh-7.6.6'
|
||||
$runnerPath = $pwshRoot + ';' + [Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')
|
||||
$keep=@('SystemRoot','windir','SystemDrive','ComSpec','TEMP','TMP','USERPROFILE','HOMEDRIVE','HOMEPATH','LOCALAPPDATA','APPDATA','ProgramData','ProgramFiles','ProgramFiles(x86)','CommonProgramFiles','CommonProgramFiles(x86)','CommonProgramW6432','ProgramW6432','PROCESSOR_ARCHITECTURE','NUMBER_OF_PROCESSORS','OS','PATHEXT','USERNAME','USERDOMAIN','COMPUTERNAME','PUBLIC','ALLUSERSPROFILE')
|
||||
foreach($entry in @(Get-ChildItem Env:)) {
|
||||
if($entry.Name -notin $keep) { [Environment]::SetEnvironmentVariable($entry.Name,$null,'Process') }
|
||||
}
|
||||
$env:PATH=$runnerPath
|
||||
if (-not [IO.Path]::IsPathRooted($ConfigRoot) -or -not (Test-Path -LiteralPath $ConfigRoot -PathType Container)) { throw 'ConfigRoot must identify an existing absolute directory' }
|
||||
if ($ConfigRoot -notmatch '^[A-Za-z]:[\\/]') { throw 'ConfigRoot requires an absolute local drive directory' }
|
||||
$drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot([IO.Path]::GetFullPath($ConfigRoot)))
|
||||
if ($drive.DriveType -ne [IO.DriveType]::Fixed) { throw 'ConfigRoot requires a fixed local drive' }
|
||||
$env:DSH_PACKAGING_CONFIG_DIR=[IO.Path]::GetFullPath($ConfigRoot)
|
||||
$env:DSH_TELEMETRY_DISABLED='1'
|
||||
$env:POWERSHELL_TELEMETRY_OPTOUT='1'
|
||||
foreach($tool in @('pwsh','node','git','tar','python')) {
|
||||
$resolved=Get-Command $tool -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
||||
Write-Output ('Tool: '+$tool+' -> '+$resolved.Source)
|
||||
}
|
||||
$env:PYTHON=(Get-Command python -CommandType Application | Select-Object -First 1).Source
|
||||
& pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
|
||||
if($LASTEXITCODE -ne 0){throw 'PowerShell probe failed'}
|
||||
& node --version
|
||||
if($LASTEXITCODE -ne 0){throw 'Node probe failed'}
|
||||
& python --version
|
||||
if($LASTEXITCODE -ne 0){throw 'Python probe failed'}
|
||||
$vswhere=Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe'
|
||||
$visualStudio=& $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
if($LASTEXITCODE -ne 0 -or -not $visualStudio){throw 'Visual C++ tools unavailable'}
|
||||
Write-Output 'Visual C++ tools located.'
|
||||
if($CheckOnly){Write-Output 'Runner environment check passed.'; exit 0}
|
||||
if(Get-Process -Name Runner.Listener -ErrorAction SilentlyContinue){throw 'A runner listener is already running; inspect before starting another.'}
|
||||
Set-Location $RunnerRoot
|
||||
& .\run.cmd
|
||||
exit $LASTEXITCODE
|
||||
@@ -1,56 +1,22 @@
|
||||
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'
|
||||
import { windowsCiVersion } from '../scripts/prepare-windows-ci.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')
|
||||
const inputs = { PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '42', GITHUB_RUN_ATTEMPT: '2' }
|
||||
const now = new Date('2026-10-09T01:02:03Z')
|
||||
|
||||
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())
|
||||
describe('manual Windows CI version', () => {
|
||||
it('uses the selected product prefix and UTC run identity', () => {
|
||||
expect(windowsCiVersion(inputs, '0.2.1-alpha.1', now)).toBe('0.2.1-alpha.1.20261009.42.2')
|
||||
expect(windowsCiVersion(inputs, '0.2.1', now)).toBe('0.2.1-test.20261009.42.2')
|
||||
})
|
||||
|
||||
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.each(['0.2.1-alpha.1', '0.2.1-alpha.1.20261009.7'])('accepts explicit version %s without run identity', (version) => {
|
||||
expect(windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now)).toBe(version)
|
||||
})
|
||||
|
||||
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(['', 'bad', '0.3.0'])('reports checkout-specific guidance for %s', (version) => {
|
||||
expect(() => windowsCiVersion({ PACKAGE_BUILD_VERSION: version }, '0.2.1-alpha.1', now))
|
||||
.toThrow(/auto, 0\.2\.1-alpha\.1, or e\.g\. 0\.2\.1-alpha\.1\.20261009\.1/u)
|
||||
})
|
||||
|
||||
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:[email protected]' },
|
||||
{ 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()
|
||||
it.each([{ GITHUB_RUN_NUMBER: '0' }, { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' }])('rejects invalid run identity %j', (override) => {
|
||||
expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
|
||||
async function withPreset(run: (root: string) => Promise<void>) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-windows-preset-'))
|
||||
try {
|
||||
await mkdir(join(root, 'presets'))
|
||||
await mkdir(join(root, 'secrets'))
|
||||
await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_APP_ID=com.example.desktop\n')
|
||||
await run(root)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
it('loads unsigned public settings without requiring the signing secret file or personal dotenv', async () => {
|
||||
await withPreset(async (root) => {
|
||||
const environment = loadDesktopPackageEnvironment('win32', {
|
||||
DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'ambient-secret',
|
||||
}, join(root, 'missing-checkout'), { unsigned: true })
|
||||
expect(environment.DSH_DESKTOP_APP_ID).toBe('com.example.desktop')
|
||||
expect(environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined()
|
||||
const prepared = loadDesktopPackageEnvironment('win32', {
|
||||
DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test',
|
||||
}, root, { prepareOnly: true })
|
||||
expect(prepared.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBeUndefined()
|
||||
expect(prepared.DSH_PACKAGING_PRESET).toBe('test')
|
||||
expect(() => loadDesktopPackageEnvironment('win32', { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }))
|
||||
.toThrow(/cannot read/u)
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves signing file paths relative to the secret file and rejects public overrides there', async () => {
|
||||
await withPreset(async (root) => {
|
||||
const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }
|
||||
const file = join(root, 'secrets/windows-signing.env')
|
||||
await writeFile(file, 'DSH_DESKTOP_WINDOWS_CER_FILE=../certificates/ev.cer\nDSH_DESKTOP_WINDOWS_TOKEN_PIN="private-value"\n')
|
||||
const result = loadDesktopPackageEnvironment('win32', env)
|
||||
expect(result.DSH_DESKTOP_WINDOWS_CER_FILE).toBe(join(root, 'certificates/ev.cer'))
|
||||
expect(result.DSH_DESKTOP_WINDOWS_TOKEN_PIN).toBe('private-value')
|
||||
await writeFile(file, 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n')
|
||||
expect(() => loadDesktopPackageEnvironment('win32', env)).toThrow(/unsupported setting/u)
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the selected preset when Windows environment enumeration uses lowercase keys', async () => {
|
||||
await withPreset(async (root) => {
|
||||
const values: NodeJS.ProcessEnv = { dsh_packaging_config_dir: root, dsh_packaging_preset: 'test' }
|
||||
const environment = new Proxy(values, {
|
||||
get: (target, name) => typeof name === 'string' ? target[name.toLowerCase()] : undefined,
|
||||
})
|
||||
const selected = loadDesktopPackageEnvironment('win32', environment, root, { unsigned: true })
|
||||
expect(selected.DSH_PACKAGING_PRESET).toBe('test')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing presets, traversal, deployment mismatches and secrets in public configuration', async () => {
|
||||
await withPreset(async (root) => {
|
||||
const env = { DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' }
|
||||
const load = (override = {}) => loadDesktopPackageEnvironment('win32', { ...env, ...override }, root, { unsigned: true })
|
||||
expect(() => load({ DSH_PACKAGING_CONFIG_DIR: 'relative' })).toThrow(/absolute/u)
|
||||
expect(() => load({ DSH_PACKAGING_CONFIG_DIR: undefined })).toThrow(/absolute/u)
|
||||
expect(() => loadDesktopPackageEnvironment('darwin', env)).toThrow(/Windows only/u)
|
||||
expect(() => load({ DSH_PACKAGING_PRESET: '../secrets' })).toThrow(/preset must/u)
|
||||
expect(() => load({ DSH_PACKAGING_PRESET: 'production' })).toThrow(/cannot read/u)
|
||||
await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_AUTO_UPDATE_ENV=production\n')
|
||||
expect(() => load()).toThrow(/must match/u)
|
||||
await writeFile(join(root, 'presets/test.env'), 'DSH_DESKTOP_WINDOWS_TOKEN_PIN=private-value\n')
|
||||
expect(() => load()).toThrow(/unsupported setting DSH_DESKTOP_WINDOWS_TOKEN_PIN/u)
|
||||
expect(() => load()).not.toThrow(/private-value/u)
|
||||
})
|
||||
})
|
||||
|
||||
it('cache maintenance reaches cache path validation without a signing file', async () => {
|
||||
await withPreset(async (root) => {
|
||||
await writeFile(join(root, 'presets/test.env'),
|
||||
'DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=relative-cache\n')
|
||||
const result = spawnSync(process.execPath,
|
||||
[join(import.meta.dirname, '../scripts/manage-windows-signature-cache.mjs'), '--usage'], {
|
||||
env: { ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test' },
|
||||
encoding: 'utf8', timeout: 10_000, windowsHide: true,
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.signal).toBeNull()
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('absolute local')
|
||||
expect(result.stderr).not.toContain('cannot read')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { expect, it } from 'vitest'
|
||||
@@ -6,11 +8,12 @@ import { desktopTargetBuildPaths } from '../apps/desktop/scripts/desktop-build-p
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows-package.yml'), 'utf8')) as {
|
||||
on: Record<string, { inputs: Record<string, { required?: boolean }> }>
|
||||
on: Record<string, { inputs: Record<string, { required?: boolean; default?: string; description?: string }> }>
|
||||
permissions: Record<string, string>
|
||||
concurrency: { group: string; 'cancel-in-progress': boolean }
|
||||
jobs: Record<string, {
|
||||
'runs-on': string
|
||||
env: Record<string, string>
|
||||
steps: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
@@ -24,47 +27,75 @@ const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows
|
||||
}
|
||||
const job = workflow.jobs.package!
|
||||
|
||||
it('requires explicit manual dispatch on the dedicated Windows runner', () => {
|
||||
it.skipIf(process.platform !== 'win32')('resets only the allocated checkout without following dependency junctions', () => {
|
||||
const directory = mkdtempSync(resolve(tmpdir(), 'windows-checkout-'))
|
||||
try {
|
||||
const parent = resolve(directory, 'runner-workspace')
|
||||
const workspace = resolve(parent, 'repository')
|
||||
const external = resolve(directory, 'external')
|
||||
mkdirSync(workspace, { recursive: true })
|
||||
mkdirSync(external)
|
||||
writeFileSync(resolve(external, 'keep.txt'), 'keep')
|
||||
symlinkSync(external, resolve(workspace, 'node_modules'), 'junction')
|
||||
const script = resolve(directory, 'reset.ps1')
|
||||
writeFileSync(script, job.steps.find(step => step.name === 'Reset packaging checkout')!.run!)
|
||||
const run = (target: string) => execFileSync('pwsh', ['-NoProfile', '-File', script], {
|
||||
cwd: directory,
|
||||
env: { ...process.env, RUNNER_WORKSPACE: parent, GITHUB_WORKSPACE: target },
|
||||
stdio: 'pipe',
|
||||
})
|
||||
run(workspace)
|
||||
expect(existsSync(workspace)).toBe(true)
|
||||
expect(existsSync(resolve(workspace, 'node_modules'))).toBe(false)
|
||||
expect(readFileSync(resolve(external, 'keep.txt'), 'utf8')).toBe('keep')
|
||||
expect(() => run(external)).toThrow()
|
||||
rmSync(workspace, { recursive: true })
|
||||
symlinkSync(external, workspace, 'junction')
|
||||
expect(() => run(workspace)).toThrow()
|
||||
expect(readFileSync(resolve(external, 'keep.txt'), 'utf8')).toBe('keep')
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 3 })
|
||||
}
|
||||
})
|
||||
|
||||
it('routes manual packaging to the dedicated runner with explicit preset and signing selection', () => {
|
||||
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
|
||||
expect(job['runs-on']).toBe('dsh-win-package-trial')
|
||||
expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false })
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
expect(workflow.on.workflow_dispatch!.inputs.policy_origin!.required).toBe(true)
|
||||
const checkout = job.steps.find(step => step.id === 'checkout')!
|
||||
expect(checkout.with).toMatchObject({ clean: true, 'persist-credentials': false })
|
||||
expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual(['build_version', 'preset', 'signing'])
|
||||
expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('unsigned')
|
||||
expect(workflow.on.workflow_dispatch!.inputs.build_version!.description).toContain('prefix must match checkout')
|
||||
expect(job.env.DSH_PACKAGING_PRESET).toBe('${{ inputs.preset }}')
|
||||
expect(job.env.PACKAGE_SIGNING).toBe('${{ inputs.signing }}')
|
||||
expect(job.steps.find(step => step.id === 'checkout')!.with).toMatchObject({ clean: true, 'persist-credentials': false })
|
||||
expect(job.steps.find(step => step.uses?.startsWith('pnpm/action-setup@'))?.with?.dest)
|
||||
.toBe('${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}')
|
||||
})
|
||||
|
||||
it('passes user input through environment variables and shares the validated version', () => {
|
||||
it('validates before building and passes the same mode and version to checks and packaging', () => {
|
||||
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(settings.env).toEqual({ PACKAGE_BUILD_VERSION: '${{ inputs.build_version }}' })
|
||||
const commands = job.steps.filter(step => step.run?.includes('package:desktop:win:x64'))
|
||||
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"')
|
||||
expect(commands[0]!.run).toContain("'--check'")
|
||||
for (const step of commands) {
|
||||
expect(step.env?.PACKAGE_VERSION).toBe('${{ steps.settings.outputs.version }}')
|
||||
expect(step.run).toContain("if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }")
|
||||
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]!))
|
||||
expect(job.steps.some(step => step.run?.includes('.env.windows'))).toBe(false)
|
||||
})
|
||||
|
||||
it('retains failure diagnostics and only uploads successful installers from the owned output directory', () => {
|
||||
it('retains failure diagnostics and uploads only the selected successful installer', () => {
|
||||
const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))
|
||||
const logs = uploads.find(step => step.if === "always() && steps.checkout.outcome == 'success'")!
|
||||
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?.includes('Remove-Item -LiteralPath apps/desktop/.env.windows'))!
|
||||
expect(cleanup.if).toBe("always() && steps.checkout.outcome == 'success'")
|
||||
expect(cleanup.run).toContain('Test-Path -LiteralPath apps/desktop/.env.windows')
|
||||
expect(uploads).toHaveLength(3)
|
||||
expect(uploads[0]!.if).toBe("always() && steps.checkout.outcome == 'success'")
|
||||
expect(uploads[0]!.with).toMatchObject({ path: 'apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true })
|
||||
for (const [mode, directory] of [['unsigned', desktopTargetBuildPaths('win-x64').unsignedArtifacts], ['signed', desktopTargetBuildPaths('win-x64').artifacts]]) {
|
||||
const upload = uploads.find(step => step.if === `success() && inputs.signing == '${mode}'`)!
|
||||
expect(upload.with).toMatchObject({ path: `${relative(root, directory!).replaceAll('\\', '/')}/*.exe`, 'if-no-files-found': 'error' })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user