mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge pull request #6675 from deepseek-harness/perf/desktop-packaging-cache
perf(desktop): speed up signed packaging and align compression defaults
This commit is contained in:
commit
efe44ee17f
72 files changed
+3036
-179
No files matched your search
@@ -85,6 +85,9 @@ jobs:
|
||||
- name: Verify windowless Windows runner launcher
|
||||
shell: pwsh
|
||||
run: ./apps/desktop/tests/windows-runner-launcher.ps1
|
||||
- name: Verify Windows packaging priority inheritance
|
||||
shell: pwsh
|
||||
run: ./apps/desktop/tests/windows-packaging-priority.ps1
|
||||
- name: Verify Windows runner network supervision
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -9,6 +9,12 @@ on:
|
||||
options: [test, production]
|
||||
default: test
|
||||
required: true
|
||||
zip_compression_level:
|
||||
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
|
||||
type: choice
|
||||
options: ['3', '7']
|
||||
default: '3'
|
||||
required: true
|
||||
skip_notarization:
|
||||
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
|
||||
type: boolean
|
||||
@@ -57,6 +63,7 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }}
|
||||
PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }}
|
||||
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
|
||||
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
|
||||
@@ -95,6 +102,12 @@ jobs:
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Configure persistent package caches
|
||||
env:
|
||||
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
|
||||
run: |
|
||||
echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV"
|
||||
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
|
||||
- name: Install locked dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Validate preset and resolve build version
|
||||
@@ -138,5 +151,6 @@ jobs:
|
||||
name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
path: ${{ steps.retained.outputs.directory }}/*
|
||||
compression-level: 0
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -42,6 +42,22 @@ on:
|
||||
type: string
|
||||
default: ''
|
||||
required: false
|
||||
cache_namespace:
|
||||
description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存'
|
||||
type: string
|
||||
default: ''
|
||||
required: false
|
||||
build_root:
|
||||
description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区'
|
||||
type: string
|
||||
default: ''
|
||||
required: false
|
||||
compression_level:
|
||||
description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置'
|
||||
type: choice
|
||||
options: ['3', '7']
|
||||
default: '3'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -65,6 +81,9 @@ jobs:
|
||||
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
|
||||
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
|
||||
PACKAGE_SIGNING: ${{ inputs.signing }}
|
||||
PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }}
|
||||
PACKAGE_BUILD_ROOT: ${{ inputs.build_root }}
|
||||
PACKAGE_WINDOWS_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
|
||||
steps:
|
||||
- name: Reset packaging checkout
|
||||
working-directory: ${{ runner.temp }}
|
||||
@@ -77,6 +96,20 @@ jobs:
|
||||
}
|
||||
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' }
|
||||
- name: Checkout workflow helpers
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.workflow_sha }}
|
||||
sparse-checkout: apps/desktop/scripts
|
||||
- name: Stage workflow helpers
|
||||
run: |
|
||||
$helpers = Join-Path $env:RUNNER_TEMP "packaging-helpers-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
|
||||
New-Item -ItemType Directory -Path $helpers -ErrorAction Stop | Out-Null
|
||||
foreach ($name in @('windows-packaging-priority.ps1', 'windows-build-workspace.mjs')) {
|
||||
Copy-Item -LiteralPath (Join-Path 'apps/desktop/scripts' $name) -Destination $helpers -ErrorAction Stop
|
||||
}
|
||||
"PACKAGE_CI_HELPERS=$helpers" >> $env:GITHUB_ENV
|
||||
- uses: actions/checkout@v7.0.1
|
||||
id: checkout
|
||||
with:
|
||||
@@ -107,16 +140,50 @@ jobs:
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Install locked dependencies
|
||||
- name: Select packaging workspace
|
||||
id: build_workspace
|
||||
env:
|
||||
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
|
||||
run: |
|
||||
if (!$env:PACKAGE_BUILD_ROOT) {
|
||||
"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT
|
||||
} else {
|
||||
node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs"
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
- name: Configure persistent pnpm store
|
||||
# PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory.
|
||||
env:
|
||||
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
|
||||
run: |
|
||||
$storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store'
|
||||
$namespace = $env:PACKAGE_CACHE_NAMESPACE
|
||||
if ($namespace) {
|
||||
if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' }
|
||||
if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' }
|
||||
$storeRoot = Join-Path $storeRoot $namespace
|
||||
$env:DSH_DESKTOP_PACKAGE_CACHE_DIR = Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace
|
||||
}
|
||||
"DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV
|
||||
"PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
|
||||
- name: Install locked dependencies
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
run: |
|
||||
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
|
||||
node -e "const os=require('node:os'); console.log(JSON.stringify({node:process.version,availableParallelism:os.availableParallelism(),logicalCpus:os.cpus().length,totalMemoryBytes:os.totalmem(),freeMemoryBytes:os.freemem()}))"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
pnpm store path
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
pnpm install --frozen-lockfile
|
||||
exit $LASTEXITCODE
|
||||
- name: Validate preset and resolve build version
|
||||
id: settings
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
env:
|
||||
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
|
||||
run: node apps/desktop/scripts/prepare-windows-ci.mjs
|
||||
- name: Check packaging configuration and toolchain
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
|
||||
run: |
|
||||
@@ -125,15 +192,23 @@ jobs:
|
||||
pnpm @packageArgs
|
||||
exit $LASTEXITCODE
|
||||
- name: Build installer and verify packaged runtime
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
|
||||
PACKAGE_BUILD_TEMP: ${{ steps.build_workspace.outputs.temporaryDirectory }}
|
||||
run: |
|
||||
& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort
|
||||
if ($env:PACKAGE_BUILD_TEMP) {
|
||||
$env:TEMP = $env:PACKAGE_BUILD_TEMP
|
||||
$env:TMP = $env:PACKAGE_BUILD_TEMP
|
||||
}
|
||||
$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 complete package outside the checkout
|
||||
id: retained
|
||||
working-directory: ${{ steps.build_workspace.outputs.directory }}
|
||||
env:
|
||||
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
|
||||
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
|
||||
@@ -144,7 +219,7 @@ jobs:
|
||||
with:
|
||||
name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
path: apps/desktop/.desktop-build/packaging-runs/
|
||||
path: ${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
- name: Retain verified unsigned installer
|
||||
@@ -152,6 +227,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
compression-level: 0
|
||||
include-hidden-files: true
|
||||
path: ${{ steps.retained.outputs.directory }}/*
|
||||
if-no-files-found: error
|
||||
@@ -161,7 +237,13 @@ jobs:
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
compression-level: 0
|
||||
include-hidden-files: true
|
||||
path: ${{ steps.retained.outputs.directory }}/*
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
- name: Remove isolated packaging workspace
|
||||
if: success() && inputs.build_root != ''
|
||||
env:
|
||||
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
|
||||
run: node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove
|
||||
@@ -14,6 +14,7 @@ DOWNLOAD_TEST_RELEASE_ID=
|
||||
|
||||
# Workspace tarball workers; omit to use 4. Must be a positive integer.
|
||||
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
|
||||
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
|
||||
# Optional independent proxy origins; empty values preserve inherited networking.
|
||||
# Downloads: HTTP/HTTPS proxy for Electron, runtime assets, pnpm and builder downloads.
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
|
||||
|
||||
@@ -31,6 +31,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN=
|
||||
# Cache-hit restore and verification workers (1-8). Hardware signing remains serial.
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
|
||||
|
||||
# Installer archive compression (0-9); lower values trade size for packaging time.
|
||||
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
|
||||
|
||||
# Optional upload configuration; packaging does not require these credentials.
|
||||
# DOWNLOAD_TEST_COS_BUCKET=
|
||||
# DOWNLOAD_TEST_COS_SECRET_ID=
|
||||
|
||||
@@ -3,6 +3,7 @@ DSH_DESKTOP_APP_ID=com.deepseek.dsh
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV=production
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
|
||||
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY=
|
||||
# Optional absolute private directory; setup fills this in for machine presets. Omit to use checkout-local storage.
|
||||
|
||||
@@ -7,6 +7,7 @@ DOWNLOAD_TEST_ORIGIN=
|
||||
# Generate a 32-character lowercase hexadecimal release ID; retain it for upgrade testing.
|
||||
DOWNLOAD_TEST_RELEASE_ID=
|
||||
DSH_DESKTOP_MACOS_PACK_CONCURRENCY=4
|
||||
DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=3
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY=
|
||||
# Temporarily changes the active network service's system HTTP/HTTPS proxies.
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY=
|
||||
|
||||
@@ -5,3 +5,5 @@ DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN=
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG='{}'
|
||||
# DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=
|
||||
DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4
|
||||
# Installer archive compression (0-9).
|
||||
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
|
||||
@@ -11,3 +11,5 @@ 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
|
||||
# Installer archive compression (0-9).
|
||||
DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
|
||||
@@ -15,8 +15,8 @@
|
||||
en: a6203dc2e690c7e1
|
||||
zh: 07b516549b8e3328
|
||||
/desktop-packaging/choose-a-build-path:
|
||||
en: 17c6bcdefa05a8f8
|
||||
zh: 4ee18f832276144d
|
||||
en: 0b35c7471c09b27a
|
||||
zh: cc4a382b99fc3210
|
||||
/desktop-packaging/release-versions:
|
||||
en: 3f38ab26ee195b39
|
||||
zh: f00eddafa8f3b9ca
|
||||
@@ -24,20 +24,20 @@
|
||||
en: 5b4f0f80a117f202
|
||||
zh: ff05be196c181087
|
||||
/desktop-packaging/windows-actions-packaging:
|
||||
en: e1b101593f6f755e
|
||||
zh: 121f225de399de1e
|
||||
en: 364223725374d9ac
|
||||
zh: c4d4cc58d7cbafbd
|
||||
/desktop-packaging/macos-self-hosted-packaging:
|
||||
en: a3af9523fdc60036
|
||||
zh: 5e18d4740e9b7f57
|
||||
en: bb1dcdfef3f3a113
|
||||
zh: 9c151800a61e2505
|
||||
/desktop-packaging/unsigned-windows-test-installer:
|
||||
en: 6843a0fdc03ead58
|
||||
zh: 957f92fc8126e23e
|
||||
en: f429bc0c280e2dfa
|
||||
zh: 4adbcc9e906bdf80
|
||||
/desktop-packaging/local-macos-dmg-without-release-signing:
|
||||
en: 2feaa0412247d81f
|
||||
zh: 461dde8e13fd4734
|
||||
/desktop-packaging/macos-release-signing-and-diagnostics:
|
||||
en: f4a2b14da34ba883
|
||||
zh: 6292f3a7b4038213
|
||||
en: 29fa6bd8919f3ec0
|
||||
zh: 9e31e037fa3d9378
|
||||
/desktop-packaging/directory-builds-and-preparation:
|
||||
en: 5860c306375ddd63
|
||||
zh: a76e3cdbbe25c79e
|
||||
@@ -45,14 +45,14 @@
|
||||
en: b42f103358d0dfd7
|
||||
zh: a534164ad9eb00da
|
||||
/desktop-packaging/runtime-file-selection-and-installation-checks:
|
||||
en: bfd89e1d139c5729
|
||||
zh: 5d6cced5462ba13e
|
||||
en: 83e1075417039410
|
||||
zh: 3088a7ae0a2cc3c5
|
||||
/desktop-packaging/desktop-runtime-preparation:
|
||||
en: a7aa3066e649ffe8
|
||||
zh: 4334637910058c09
|
||||
en: ab73b67d899e611d
|
||||
zh: d77b0525c6b81e91
|
||||
/desktop-packaging/installation-and-release-verification:
|
||||
en: eb35eb973b796643
|
||||
zh: c1c52311632b7296
|
||||
/desktop-packaging/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
en: 691194744993dfaa
|
||||
zh: e5798faf99546ff8
|
||||
@@ -32,6 +32,8 @@ Build Desktop installers locally or through Windows or macOS Actions, choose dep
|
||||
|
||||
Run local commands from the repository root after installing locked dependencies. Use platform-specific commands for a local build, or platform Actions for the registered self-hosted runner. Choose test or production deployment independently from signed or unsigned mode. Read [Windows signing and runner operations](../windows-signing/README.md#trust-and-isolation) before any Windows signed build or shared-runner use. Installation is separate from packaging; [publication](../publishing/README.md) is a further explicit operation.
|
||||
|
||||
Set `DSH_DESKTOP_PACKAGE_CACHE_DIR` to an absolute build-cache directory for reuse across checkouts; see [cache configuration and lifecycle](#desktop-runtime-preparation).
|
||||
|
||||
<a id="release-versions"></a>
|
||||
|
||||
## Release versions
|
||||
@@ -113,18 +115,26 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a
|
||||
|
||||
Open the completed run in Actions and download its Artifacts. `windows-x64-signed-<version>-<run_id>-<run_attempt>` and `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
|
||||
|
||||
For path-sensitive Windows build measurements, `build_root` optionally selects an absolute directory outside existing Git checkouts. Each run clones the validated source commit into a new `windows-package-<run-id>-<attempt>/source` directory; ignored files and previous build outputs are not copied. Only the packaging process uses a sibling run-owned `temp` directory through `TEMP` and `TMP`; Actions steps and post-job hooks retain their original temporary directories. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads, retrying transient filesystem failures up to three times with 100 ms increasing delays; persistent cleanup errors fail the Job and may leave a partially removed directory. Failed builds retain their checkout for inspection. Leave the input empty to build in the original Actions checkout. Include checkout creation and cleanup in the complete Job timing.
|
||||
|
||||
For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory.
|
||||
|
||||
The dependency-install and packaging steps normalize their own shell to normal CPU, I/O and memory priority before launching children. Priority and isolated-workspace helpers are copied from the exact workflow commit into a run-specific temporary directory, independently of the selected product source. Missing helpers stop CI setup; unavailable priority adjustment emits a warning and marks scheduling unsuitable for performance comparison, but does not stop packaging. This does not modify the runner task or other processes. Version, preset and retained-artifact adapters remain tied to product source and its formats.
|
||||
|
||||
Installer uploads store the already-compressed release files without ZIP recompression; this preserves installer bytes and signatures. Diagnostic uploads retain their ordinary compression.
|
||||
|
||||
<a id="macos-actions"></a>
|
||||
|
||||
## macOS self-hosted packaging
|
||||
|
||||
The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it.
|
||||
The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG.
|
||||
|
||||
Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior.
|
||||
|
||||
Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing).
|
||||
|
||||
|
||||
The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets/<target>/unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds.
|
||||
ZIP compression uses the same configured level with or without notarization; the App is assembled once, then DMG and ZIP are built separately so the ZIP setting does not change DMG compression. The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets/<target>/unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds.
|
||||
|
||||
Install the user service from the checkout as the same account that registered the runner, without `sudo`:
|
||||
|
||||
@@ -146,7 +156,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](../README.md#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.
|
||||
The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](../README.md#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 7 (`pwsh`), 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.
|
||||
|
||||
@@ -179,11 +189,12 @@ Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory un
|
||||
|
||||
App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log.
|
||||
|
||||
Mac packaging reads three tuning fields from `.env.macos`:
|
||||
Mac packaging reads these tuning fields from `.env.macos`:
|
||||
|
||||
| Setting | Default | Scope |
|
||||
|---|---|---|
|
||||
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | Workers for first-party and vendor workspace tarballs; requires a positive integer. |
|
||||
| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | Final update ZIP compression, integer `0`–`9`; lower levels trade larger downloads for shorter builds. App notarization archives and DMG compression are unchanged. |
|
||||
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | Empty | HTTP/HTTPS proxy origin for Electron, runtime assets, pnpm installation and builder downloads. |
|
||||
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | Empty | HTTP proxy origin for Apple tools through temporary system proxy settings. |
|
||||
|
||||
@@ -254,18 +265,26 @@ When extraction fails, the installer writes a report with the 7-Zip result and i
|
||||
|
||||
Windows packaging fixes the 7-Zip filter to `BCJ` for compatibility with the bundled NSIS decoder. This preserves ARM64 binaries carried by dependencies in x64 installers; automatic ARM64 filtering produces entries that this decoder cannot extract.
|
||||
|
||||
`DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` in the Windows dotenv or machine preset selects installer archive compression from `0` to `9`. If omitted, the existing `ELECTRON_BUILDER_COMPRESSION_LEVEL` environment override remains effective, otherwise the default is `3` for both test and production. The manual Windows workflow's `compression_level` dropdown defaults to `3` and also offers `7`, matching the Mac ZIP choices; it overrides the file setting for that run. Direct local builds retain explicit machine settings. Mac ZIP workflow overrides must be `3` or `7`; an empty value is invalid even on macOS. Any defined Mac ZIP workflow override, including an empty value, rejects Windows; nonempty Windows workflow overrides reject macOS. Lower levels trade installer size for packaging time; qualify the complete signed workflow and artifact size before changing a release preset.
|
||||
|
||||
NSIS removes its temporary extraction tree during installation, before the completion page or an automatic launch. The installed production packages remain ordinary files; startup does not extract them again. Installation still writes the complete application tree.
|
||||
|
||||
<a id="desktop-runtime-preparation"></a>
|
||||
|
||||
## Desktop runtime preparation
|
||||
|
||||
Windows prepares Electron and primary-runtime concurrently; macOS prepares them serially. CLI preparation and `versions.json` publication require both payloads to succeed. If either Windows payload fails, preparation waits for the other to settle before reporting failure and publishes neither CLI resources nor versions. Nested preparation timings overlap and must not be added together.
|
||||
|
||||
`DSH_DESKTOP_PACKAGE_CACHE_DIR` in the packaging process environment selects a nonempty absolute directory for shared `downloads` and `pnpm-store` caches; omission uses `.desktop-build`. Temporary installation projects, lockfiles and configuration remain separate per run. Runtime installation verifies store integrity and copies package files so later signing cannot modify cached bytes; cleanup retains these caches. Both packaging workflows use `runner.tool_cache/dsh-desktop-package-cache`; Windows repository dependency installation uses a separate `.pnpm-store` at the workspace drive root. Both installation logs identify their store and report package reuse. A fresh runtime lockfile still resolves dependencies on every run. Share writable caches only among trusted build jobs; integrity checks do not isolate untrusted writers. Clean caches only when no job is using them. Operators manage cache disk usage; no automatic eviction runs.
|
||||
|
||||
Desktop [patch policy](../scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](../scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures.
|
||||
|
||||
Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration.
|
||||
|
||||
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution plus primary-runtime with its shared pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
|
||||
|
||||
Windows runtime sealing reads at most eight files concurrently, before and after native signing. A read failure drains outstanding reads without replacing the descriptor; successful sealing still requires the subsequent complete integrity and smoke checks.
|
||||
|
||||
Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload.
|
||||
|
||||
An unpacked artifact contains Electron, the materialized dsh production tree, pnpm, and the shell application. Installer size and filesystem size differ; release qualification measures both, plus the profile’s plugin storage and first-launch latency. The runtime trades more application files for eliminating core package installation on the user’s machine.
|
||||
@@ -280,4 +299,4 @@ Keep the generated installer, its version and packaging records together. Review
|
||||
|
||||
## Dev Note
|
||||
|
||||
None.
|
||||
XXX: Evaluate disk-space monitoring, a maintenance entry point and automatic eviction if disk shortages, sustained cache growth or frequent manual cleanup occur. Any automated cleanup would need coordination with every cache user.
|
||||
@@ -32,6 +32,8 @@ description: "在本地或 Windows/macOS Actions 中生成 Desktop 安装包
|
||||
|
||||
安装锁定依赖后,从仓库根目录运行本地命令。本地构建使用平台命令,已注册的自托管 runner 使用对应平台的 Actions。test/production 部署与 signed/unsigned 模式分别选择。任何 Windows 签名构建或共享 runner 使用前,先阅读 [Windows 签名与 runner 运维](../windows-signing/README.zh.md#trust-and-isolation)。安装与打包分开进行;[发布](../publishing/README.zh.md)还需要单独的显式操作。
|
||||
|
||||
将 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 设为绝对构建缓存目录以跨 checkout 复用;详见[缓存配置与生命周期](#desktop-runtime-preparation)。
|
||||
|
||||
<a id="release-versions"></a>
|
||||
|
||||
## 发布版本
|
||||
@@ -113,18 +115,26 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
|
||||
|
||||
在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed-<version>-<run_id>-<run_attempt>` 与 `windows-x64-unsigned-<version>-<run_id>-<run_attempt>` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
|
||||
|
||||
测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package-<run-id>-<attempt>/source` 目录;不会复制忽略文件或此前的构建输出。仅打包进程通过 `TEMP` 和 `TMP` 使用同轮所属的同级 `temp` 目录;Actions 步骤及 Job 后置钩子保留原临时目录。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout,临时文件系统错误最多重试三次,等待时间按 100 ms 递增;持续清理失败会使 Job 失败,并可能留下部分删除的目录。构建失败时保留 checkout 供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。
|
||||
|
||||
测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。
|
||||
|
||||
依赖安装和打包步骤在启动子进程前,将自身 shell 的 CPU、I/O 和内存优先级调整为普通值。优先级及独立构建目录辅助脚本从 workflow 的精确提交复制到本轮临时目录,与选中的产品源码分离。缺失辅助脚本会终止 CI 准备;无法调整优先级时明确告警并标记调度条件不适合性能对比,但继续打包。这不会修改 runner 任务或其他进程。版本、preset 和产物留存适配器仍跟随产品源码及其格式。
|
||||
|
||||
安装器上传直接存储已经压缩的发布文件,不再进行 ZIP 二次压缩;安装器字节和签名保持不变。诊断日志上传保留通常的压缩方式。
|
||||
|
||||
<a id="macos-actions"></a>
|
||||
|
||||
## macOS 自托管打包
|
||||
|
||||
`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。
|
||||
`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
|
||||
|
||||
运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。
|
||||
|
||||
运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" <absolute-node-24-bin-directory> --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。
|
||||
|
||||
|
||||
工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets/<target>/unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。
|
||||
无论是否公证,ZIP 都使用同一压缩配置;App 只组装一次,再分别生成 DMG 和 ZIP,确保 ZIP 设置不改变 DMG 压缩。工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets/<target>/unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。
|
||||
|
||||
在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`:
|
||||
|
||||
@@ -146,7 +156,7 @@ bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner"
|
||||
pnpm run package:desktop:win:x64:unsigned
|
||||
```
|
||||
|
||||
该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#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 或上传凭据。签名打包和上传仍遵循正式发布要求。
|
||||
该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](../README.zh.md#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell 7(`pwsh`)、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 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。
|
||||
|
||||
@@ -179,11 +189,12 @@ Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-
|
||||
|
||||
App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。
|
||||
|
||||
Mac 打包从 `.env.macos` 读取三个调优字段:
|
||||
Mac 打包从 `.env.macos` 读取以下调优字段:
|
||||
|
||||
| 设置 | 默认值 | 作用范围 |
|
||||
|---|---|---|
|
||||
| `DSH_DESKTOP_MACOS_PACK_CONCURRENCY` | `4` | 第一方与 vendor workspace tarball 的打包 worker 数;必须是正整数。 |
|
||||
| `DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL` | `3` | 最终更新 ZIP 的压缩等级,整数 `0`–`9`;较低等级以更大的下载体积换取更短的构建时间。App 公证归档和 DMG 压缩不变。 |
|
||||
| `DSH_DESKTOP_MACOS_DOWNLOAD_PROXY` | 空 | Electron、运行时资源、pnpm 安装及 builder 下载使用的 HTTP/HTTPS 代理 origin。 |
|
||||
| `DSH_DESKTOP_MACOS_NOTARIZATION_PROXY` | 空 | 通过临时系统代理设置供 Apple 工具使用的 HTTP 代理 origin。 |
|
||||
|
||||
@@ -254,18 +265,26 @@ Windows 安装器在启动时和选定目标目录后检查应用是否正在运
|
||||
|
||||
Windows 打包将 7-Zip 过滤器固定为 `BCJ`,以兼容内置的 NSIS 解码器。这样可以保留 x64 安装包中由依赖携带的 ARM64 二进制文件;自动 ARM64 过滤会生成该解码器无法解压的条目。
|
||||
|
||||
Windows dotenv 或机器 preset 中的 `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL` 选择 `0` 至 `9` 的安装包归档压缩等级。省略时,现有 `ELECTRON_BUILDER_COMPRESSION_LEVEL` 环境覆盖仍然生效,否则 test 和 production 均默认为 `3`。手动 Windows workflow 的 `compression_level` 下拉默认选 `3`,也提供 `7`,与 Mac ZIP 选项一致;它对本轮覆盖文件中的设置。直接本地打包保留显式机器配置。Mac ZIP workflow 覆盖值必须为 `3` 或 `7`;空值在 macOS 上也非法。任何已定义的 Mac ZIP workflow 覆盖值(包括空值)均拒绝用于 Windows;非空的 Windows workflow 覆盖值拒绝用于 macOS。较低等级以安装包体积换取打包时间;修改发布 preset 前应验证完整签名 workflow 和产物体积。
|
||||
|
||||
NSIS 在安装阶段清理临时解压目录,完成后才显示完成页或自动启动应用。已安装的生产依赖保持为普通文件;启动时不会再次解压。安装仍会写入完整的应用目录树。
|
||||
|
||||
<a id="desktop-runtime-preparation"></a>
|
||||
|
||||
## Desktop 运行时准备
|
||||
|
||||
Windows 并行准备 Electron 和 primary-runtime;macOS 串行准备二者。只有两份运行时资源均成功,才会准备 CLI 并写入 `versions.json`。任一 Windows 运行时准备失败时,流程会等待另一路结束后再报告失败,且不写入 CLI 资源或版本记录。嵌套准备阶段的计时存在重叠,不能直接相加。
|
||||
|
||||
打包进程环境中的 `DSH_DESKTOP_PACKAGE_CACHE_DIR` 为共享的 `downloads` 和 `pnpm-store` 缓存指定非空绝对目录;省略时使用 `.desktop-build`。临时安装项目、lockfile 和配置仍逐次独立创建。运行时安装校验 store 完整性并复制包文件,防止后续签名修改缓存字节;清理时保留这些缓存。两个打包工作流使用 `runner.tool_cache/dsh-desktop-package-cache`;Windows 仓库依赖安装使用工作区盘根目录下独立的 `.pnpm-store`。两次安装日志均标明各自的 store 并报告包复用情况。每次运行仍通过新的运行时 lockfile 解析依赖。可写缓存仅供可信构建任务共享;完整性校验不能隔离不可信的写入者。仅在没有任务使用缓存时清理。缓存磁盘用量由操作者管理,不自动淘汰。
|
||||
|
||||
Desktop [补丁策略](../scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](../scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。
|
||||
|
||||
Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。
|
||||
|
||||
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包和包含共享 pnpm CLI 的 primary-runtime。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
|
||||
|
||||
Windows 运行时在原生签名前后封存文件清单,每次最多并发读取八个文件。读取失败时会等待未完成的读取结束,不替换描述符;封存成功后仍须完成后续的完整性与 smoke 检查。
|
||||
|
||||
macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
|
||||
|
||||
未压缩产物包含 Electron、物化后的 dsh 生产依赖树、pnpm,以及壳应用。安装包大小与文件系统占用不同;发布验收需要测量两者,以及 profile 插件存储和首次启动耗时。此布局用更多应用内文件换取消除用户机器上的核心包安装过程。
|
||||
@@ -280,4 +299,4 @@ Windows 以不带版本后缀的产品名称显示已安装应用,版本保留
|
||||
|
||||
## 开发备注
|
||||
|
||||
无。
|
||||
XXX:出现磁盘空间不足、缓存持续增长或频繁人工清理时,评估空间监测、维护入口和自动淘汰。任何自动清理方案都需要与所有使用缓存的任务协调。
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DesktopAutoUpdateTarget } from './desktop-auto-update-environment.mjs'
|
||||
|
||||
/** Mutable target directories plus the shared immutable download cache. */
|
||||
/** Mutable target directories plus shared download and package caches. */
|
||||
export interface DesktopTargetBuildPaths {
|
||||
readonly root: string
|
||||
readonly artifacts: string
|
||||
@@ -15,6 +15,7 @@ export interface DesktopTargetBuildPaths {
|
||||
readonly packedVendor: string
|
||||
readonly packedLandlock: string
|
||||
readonly downloads: string
|
||||
readonly pnpmStore: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,9 +34,10 @@ export function resolveDesktopBuildTarget(
|
||||
/**
|
||||
* Return the mutable preparation and artifact directories owned by one release target.
|
||||
* @param target - Supported Desktop target name.
|
||||
* @returns Target paths plus the shared immutable download cache.
|
||||
* @param environment - Packaging environment with an optional absolute cache directory.
|
||||
* @returns Target paths plus shared download and package caches.
|
||||
*/
|
||||
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget): DesktopTargetBuildPaths
|
||||
export function desktopTargetBuildPaths(target: DesktopAutoUpdateTarget, environment?: NodeJS.ProcessEnv): DesktopTargetBuildPaths
|
||||
|
||||
/**
|
||||
* Return the platform and architecture of the payload one release target prepares.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Resolve build-owned Desktop paths without sharing mutable state across release targets. */
|
||||
/** Resolve target-owned Desktop outputs and shared package caches. */
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
const BUILD_ROOT = join(APP_ROOT, '.desktop-build')
|
||||
@@ -38,10 +38,15 @@ function assertSupportedTarget(target) {
|
||||
/**
|
||||
* Return the mutable preparation and artifact directories owned by one release target.
|
||||
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name.
|
||||
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache.
|
||||
* @param {NodeJS.ProcessEnv} environment - Packaging environment with an optional absolute cache directory.
|
||||
* @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string, pnpmStore: string }} Target paths plus shared download and package caches.
|
||||
*/
|
||||
export function desktopTargetBuildPaths(target) {
|
||||
export function desktopTargetBuildPaths(target, environment = process.env) {
|
||||
assertSupportedTarget(target)
|
||||
const cacheRoot = environment.DSH_DESKTOP_PACKAGE_CACHE_DIR ?? BUILD_ROOT
|
||||
if (!cacheRoot.trim() || !isAbsolute(cacheRoot) || cacheRoot.includes('\0')) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_PACKAGE_CACHE_DIR must be a nonempty absolute directory')
|
||||
}
|
||||
const root = join(BUILD_ROOT, 'targets', target)
|
||||
const packed = join(root, 'packed')
|
||||
return {
|
||||
@@ -57,7 +62,8 @@ export function desktopTargetBuildPaths(target) {
|
||||
packedDsh: join(packed, 'dsh'),
|
||||
packedVendor: join(packed, 'vendor'),
|
||||
packedLandlock: join(packed, 'landlock'),
|
||||
downloads: join(BUILD_ROOT, 'downloads'),
|
||||
downloads: join(cacheRoot, 'downloads'),
|
||||
pnpmStore: join(cacheRoot, 'pnpm-store'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +93,7 @@ export function resolveDesktopTargetBuildPaths(
|
||||
hostPlatform = process.platform,
|
||||
hostArch = process.arch,
|
||||
) {
|
||||
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
|
||||
return desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,9 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N
|
||||
/**
|
||||
* Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
|
||||
* PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID.
|
||||
* PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid.
|
||||
* Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS.
|
||||
* PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting.
|
||||
* @param platform Target platform.
|
||||
* @param environment Parent environment, retained only for unrelated build tools.
|
||||
* @param appRoot Desktop application directory; relative credential paths resolve here.
|
||||
|
||||
@@ -12,11 +12,12 @@ import { validateMacOSSignatureCacheDirectory } from './macos-signature-cache-di
|
||||
import { resolveMacOSPackageSettings } from './macos-package-settings.mjs'
|
||||
import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs'
|
||||
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
|
||||
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY)$/u
|
||||
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
|
||||
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
|
||||
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
|
||||
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
|
||||
|
||||
@@ -35,7 +36,10 @@ export function localMacOSPackageEnvironment(environment = process.env) {
|
||||
|
||||
/**
|
||||
* Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
|
||||
* PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL overrides the Mac file setting with 3 or 7; an empty value is invalid.
|
||||
* Any defined Mac ZIP override rejects Windows, including an empty value; nonempty Windows overrides reject macOS.
|
||||
* PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID.
|
||||
* PACKAGE_WINDOWS_COMPRESSION_LEVEL selects 3 or 7 for Windows workflow builds; blank retains the file setting.
|
||||
* @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.
|
||||
@@ -43,6 +47,17 @@ export function localMacOSPackageEnvironment(environment = process.env) {
|
||||
* @returns {NodeJS.ProcessEnv} Isolated environment with file-owned release settings.
|
||||
*/
|
||||
export function loadDesktopPackageEnvironment(platform, environment = process.env, appRoot = APP_ROOT, options = {}) {
|
||||
const compression = environment.PACKAGE_WINDOWS_COMPRESSION_LEVEL
|
||||
if (compression !== undefined && compression !== '') {
|
||||
if (platform !== 'win32' || !['3', '7'].includes(compression)) {
|
||||
throw new Error('desktop package: PACKAGE_WINDOWS_COMPRESSION_LEVEL requires Windows and a choice of 3 or 7')
|
||||
}
|
||||
}
|
||||
const zipCompression = environment.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL
|
||||
if (zipCompression !== undefined) {
|
||||
if (platform !== 'darwin') throw new Error('desktop package: PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS')
|
||||
if (!['3', '7'].includes(zipCompression)) throw new Error('desktop package: workflow ZIP compression must be 3 or 7')
|
||||
}
|
||||
const preset = environment.DSH_PACKAGING_PRESET
|
||||
let path = join(appRoot, platform === 'win32' ? '.env.windows' : '.env.macos')
|
||||
if (preset !== undefined) {
|
||||
@@ -53,8 +68,8 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
|
||||
}
|
||||
const platformSetting = platform === 'win32' ? WINDOWS_SETTING : MACOS_SETTING
|
||||
const publicPlatformSetting = platform === 'win32'
|
||||
? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY)$/u
|
||||
: /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u
|
||||
? /^DSH_DESKTOP_WINDOWS_(?:SIGNATURE_CACHE_(?:DIR|CONCURRENCY)|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
|
||||
: /^DSH_DESKTOP_MACOS_(?:SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)$/u
|
||||
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))|DOWNLOAD_TEST_(?:ORIGIN|RELEASE_ID))$/u.test(name) || publicPlatformSetting.test(name))
|
||||
@@ -68,6 +83,10 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
|
||||
: MACOS_SETTING.test(name) && !publicPlatformSetting.test(name)))
|
||||
}
|
||||
}
|
||||
if (compression !== undefined && compression !== '') settings.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL = compression
|
||||
if (zipCompression !== undefined) {
|
||||
settings.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL = zipCompression
|
||||
}
|
||||
const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim()
|
||||
if (releaseId) {
|
||||
if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset')
|
||||
@@ -134,6 +153,7 @@ function requireReadableFile(environment, name) {
|
||||
* @returns {void}
|
||||
*/
|
||||
export function validateDesktopPackageEnvironment(environment, target, options = {}) {
|
||||
resolveDesktopTargetBuildPaths(environment, target.platform, target.arch)
|
||||
resolveDesktopAppId(environment)
|
||||
resolveNpmRegistry(environment)
|
||||
if (target.platform !== 'darwin' || !options.unsigned) resolveDesktopPolicyEnvironment(environment)
|
||||
|
||||
@@ -70,7 +70,7 @@ export function createElectronBuilderConfig(
|
||||
if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller()
|
||||
const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined
|
||||
if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env)
|
||||
const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch))
|
||||
const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch), env)
|
||||
const output = desktopArtifactOutput(buildPaths, { unsigned, skipNotarization })
|
||||
let primaryRuntimeDestination
|
||||
let dshDestination
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
/** Validated settings shared by preflight and stage execution. */
|
||||
export interface MacOSPackageSettings {
|
||||
readonly packConcurrency: number
|
||||
readonly zipCompressionLevel: number
|
||||
readonly downloadProxy?: string
|
||||
readonly notarizationProxy?: string
|
||||
}
|
||||
|
||||
@@ -15,14 +15,19 @@ function proxy(environment, name, protocols) {
|
||||
/**
|
||||
* Validate tuning before any build or network operation.
|
||||
* @param {NodeJS.ProcessEnv} environment File-owned packaging settings.
|
||||
* @returns {{packConcurrency: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking.
|
||||
* @returns {{packConcurrency: number, zipCompressionLevel: number, downloadProxy?: string, notarizationProxy?: string}} Resolved settings; empty proxy fields preserve inherited networking.
|
||||
*/
|
||||
export function resolveMacOSPackageSettings(environment) {
|
||||
const concurrency = environment.DSH_DESKTOP_MACOS_PACK_CONCURRENCY ?? '4'
|
||||
if (!/^[1-9]\d*$/u.test(concurrency) || !Number.isSafeInteger(Number(concurrency))) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_MACOS_PACK_CONCURRENCY must be a positive integer')
|
||||
}
|
||||
const compression = environment.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL ?? '3'
|
||||
if (!/^[0-9]$/u.test(compression)) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL must be an integer from 0 to 9')
|
||||
}
|
||||
return {
|
||||
zipCompressionLevel: Number(compression),
|
||||
packConcurrency: Number(concurrency),
|
||||
downloadProxy: proxy(environment, 'DSH_DESKTOP_MACOS_DOWNLOAD_PROXY', ['http:', 'https:']),
|
||||
notarizationProxy: proxy(environment, 'DSH_DESKTOP_MACOS_NOTARIZATION_PROXY', ['http:']),
|
||||
|
||||
@@ -328,7 +328,7 @@ async function resolveRequestedBuildVersion(
|
||||
const requested = invocation.requestedBuildVersion
|
||||
if (requested === undefined) return productVersion
|
||||
if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion)
|
||||
const paths = desktopTargetBuildPaths(invocation.target.name)
|
||||
const paths = desktopTargetBuildPaths(invocation.target.name, environment)
|
||||
return suggestDesktopBuildVersion({
|
||||
productVersion, target: invocation.target.name,
|
||||
// Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts.
|
||||
@@ -370,7 +370,7 @@ async function main(): Promise<void> {
|
||||
version: buildVersion, productVersion, node: process.version,
|
||||
commit: packaged.commit,
|
||||
dirty: packaged.dirty,
|
||||
}, { parallel: target.platform === 'darwin', secrets })
|
||||
}, { parallel: true, secrets })
|
||||
console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
|
||||
const previousDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
|
||||
process.env.DSH_DESKTOP_PACKAGING_RUN_DIR = run.directory
|
||||
@@ -380,7 +380,7 @@ async function main(): Promise<void> {
|
||||
await packagingStep(run.directory, 'toolchain', () => requireDesktopToolchain(target.platform, environment), secrets)
|
||||
if (target.platform === 'darwin') {
|
||||
const settings = resolveMacOSPackageSettings(environment)
|
||||
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency,
|
||||
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency, zipCompressionLevel: settings.zipCompressionLevel,
|
||||
downloadProxyConfigured: settings.downloadProxy !== undefined,
|
||||
notarizationProxyConfigured: settings.notarizationProxy !== undefined })
|
||||
await packagingStep(run.directory, 'macos-package', () => invocation.unsigned
|
||||
@@ -400,7 +400,7 @@ async function main(): Promise<void> {
|
||||
run.finish(success)
|
||||
}
|
||||
if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) {
|
||||
const paths = desktopTargetBuildPaths(target.name)
|
||||
const paths = desktopTargetBuildPaths(target.name, environment)
|
||||
const output = desktopArtifactOutput(paths, invocation)
|
||||
const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${output.suffix}.dmg`
|
||||
console.log(`DMG: ${join(output.directory, filename)}`)
|
||||
@@ -426,7 +426,7 @@ export async function packageTarget(
|
||||
const mac = target.platform === 'darwin' ? resolveMacOSPackageSettings(environment) : undefined
|
||||
const packConcurrency = mac?.packConcurrency ?? resolveWindowsPackageSettings(environment).packConcurrency
|
||||
const packArguments = ['--concurrency', String(packConcurrency)]
|
||||
const buildPaths = desktopTargetBuildPaths(target.name)
|
||||
const buildPaths = desktopTargetBuildPaths(target.name, environment)
|
||||
const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name))
|
||||
if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) {
|
||||
rmSync(releaseRecordPath, { force: true })
|
||||
@@ -443,6 +443,9 @@ export async function packageTarget(
|
||||
}
|
||||
const downloadEnv = macOSDownloadEnvironment(targetEnv, mac?.downloadProxy)
|
||||
const electronBuilderEnv = desktopElectronBuilderEnvironment(downloadEnv, invocation.unsigned)
|
||||
if (target.platform === 'win32') {
|
||||
electronBuilderEnv.ELECTRON_BUILDER_COMPRESSION_LEVEL = String(resolveWindowsPackageSettings(environment).compressionLevel)
|
||||
}
|
||||
for (const name of WINDOWS_SIGNING_ENV_NAMES) {
|
||||
if (!invocation.unsigned && environment[name] !== undefined) electronBuilderEnv[name] = environment[name]
|
||||
}
|
||||
@@ -476,36 +479,56 @@ export async function packageTarget(
|
||||
})
|
||||
}
|
||||
await execute(invocation.unsigned ? ['run', 'build', '--profile', 'local'] : ['run', 'build:official'], buildEnv, REPOSITORY_ROOT)
|
||||
await execute([
|
||||
...(invocation.unsigned
|
||||
? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts']
|
||||
: ['run', 'release:pack', '--family', 'dsh']),
|
||||
'--out', buildPaths.packedDsh, ...packArguments,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await execute([
|
||||
'--dir',
|
||||
'apps/desktop-host',
|
||||
'pack',
|
||||
'--pack-destination',
|
||||
buildPaths.packedDsh,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT)
|
||||
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
|
||||
mkdirSync(buildPaths.packedLandlock, { recursive: true })
|
||||
await execute(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
|
||||
await execute([
|
||||
'--dir',
|
||||
'native/system/packages/entry',
|
||||
'pack',
|
||||
'--pack-destination',
|
||||
buildPaths.packedLandlock,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await execute(['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv)
|
||||
let preparationFailure: { error: unknown } | undefined
|
||||
const executePreparation = async (args: readonly string[], env: NodeJS.ProcessEnv, cwd?: string): Promise<void> => {
|
||||
if (preparationFailure !== undefined) throw preparationFailure.error
|
||||
try { await execute(args, env, cwd) }
|
||||
catch (error) { preparationFailure ??= { error }; throw error }
|
||||
}
|
||||
const pack = async (): Promise<void> => {
|
||||
await executePreparation([
|
||||
...(invocation.unsigned
|
||||
? ['exec', 'tsx', 'apps/desktop/scripts/pack-local-packages.ts']
|
||||
: ['run', 'release:pack', '--family', 'dsh']),
|
||||
'--out', buildPaths.packedDsh, ...packArguments,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await executePreparation([
|
||||
'--dir',
|
||||
'apps/desktop-host',
|
||||
'pack',
|
||||
'--pack-destination',
|
||||
buildPaths.packedDsh,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
await executePreparation(['run', 'release:pack', '--family', 'vendor', '--out', buildPaths.packedVendor, ...packArguments], buildEnv, REPOSITORY_ROOT)
|
||||
rmSync(buildPaths.packedLandlock, { recursive: true, force: true })
|
||||
mkdirSync(buildPaths.packedLandlock, { recursive: true })
|
||||
await executePreparation(['--dir', 'native/system', 'run', 'build:ts'], buildEnv, REPOSITORY_ROOT)
|
||||
await executePreparation([
|
||||
'--dir',
|
||||
'native/system/packages/entry',
|
||||
'pack',
|
||||
'--pack-destination',
|
||||
buildPaths.packedLandlock,
|
||||
], buildEnv, REPOSITORY_ROOT)
|
||||
}
|
||||
const prepareRuntime = () => executePreparation(
|
||||
['run', 'prepare:runtime', ...(signPrimaryRuntime ? ['--defer-primary-runtime-smoke'] : [])], downloadEnv)
|
||||
if (target.platform === 'win32') {
|
||||
const results = await Promise.allSettled([pack(), prepareRuntime()])
|
||||
for (const result of results) if (result.status === 'rejected') throw result.reason
|
||||
} else {
|
||||
await pack()
|
||||
await prepareRuntime()
|
||||
}
|
||||
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime'], electronBuilderEnv)
|
||||
await execute(['run', 'prepare:packages'], targetEnv)
|
||||
await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv)
|
||||
if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv)
|
||||
if (invocation.prepareOnly) return
|
||||
const buildMacArtifact = (artifact: DesktopPrepackagedArtifact) => execute(
|
||||
desktopElectronBuilderArguments(target, false, artifact), artifact.format === 'zip'
|
||||
? { ...electronBuilderEnv, ELECTRON_BUILDER_COMPRESSION_LEVEL: String(mac?.zipCompressionLevel) }
|
||||
: electronBuilderEnv)
|
||||
if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization && !invocation.directory) {
|
||||
await execute([
|
||||
...desktopElectronBuilderArguments(target, true),
|
||||
@@ -518,7 +541,7 @@ export async function packageTarget(
|
||||
version: resolveDesktopBuildVersion(environment, packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')),
|
||||
artifactsRoot: buildPaths.artifacts,
|
||||
environment: electronBuilderEnv,
|
||||
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent)
|
||||
}, buildMacArtifact), undefined, undefined, proxyEvent)
|
||||
} else if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization) {
|
||||
await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv)
|
||||
await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv)
|
||||
@@ -526,7 +549,15 @@ export async function packageTarget(
|
||||
await withMacOSNotarizationProxy(mac?.notarizationProxy,
|
||||
() => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent)
|
||||
} else {
|
||||
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
|
||||
if (target.platform === 'darwin' && invocation.skipNotarization && !invocation.directory) {
|
||||
await execute(desktopElectronBuilderArguments(target, true), electronBuilderEnv)
|
||||
const appPath = join(artifactsRoot, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app')
|
||||
for (const format of ['dmg', 'zip'] as const) {
|
||||
await buildMacArtifact({ format, appPath, output: artifactsRoot })
|
||||
}
|
||||
} else {
|
||||
await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv))
|
||||
}
|
||||
await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts',
|
||||
...(invocation.unsigned ? ['--unsigned'] : []), ...(invocation.skipNotarization ? ['--skip-notarization'] : [])], targetEnv)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { resolveMacOSSignatureCacheDirectory } from './macos-signature-cache-directory.mjs'
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join, relative, resolve } from 'node:path'
|
||||
@@ -22,7 +23,7 @@ import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
|
||||
import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts'
|
||||
import { prepareRuntimePatches, verifyRuntimePatches } from './prepare-runtime-patches.ts'
|
||||
import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
|
||||
import { writeDesktopRuntime, writeDesktopRuntimeAsync, verifyDesktopRuntime } from '../src/runtime-tree.ts'
|
||||
import {
|
||||
resolveDesktopAppId,
|
||||
resolveMacOSSigningEnvironment,
|
||||
@@ -39,7 +40,7 @@ const APP_ROOT = resolve(import.meta.dirname, '..')
|
||||
const BUILD_PATHS = resolveDesktopTargetBuildPaths()
|
||||
const DSH_OUTPUT_ROOT = BUILD_PATHS.dsh
|
||||
const BUILD_ROOT = mkdtempSync(join(tmpdir(), 'dsh-desktop-runtime-'))
|
||||
const STORE_ROOT = join(BUILD_ROOT, 'store')
|
||||
const STORE_ROOT = BUILD_PATHS.pnpmStore
|
||||
const RUNTIME_ROOT = BUILD_PATHS.runtime
|
||||
const PNPM_BUILD_STATE = BUILD_PATHS.dshPnpm
|
||||
const PACKAGE_SET_ROOT = BUILD_PATHS.packageSet
|
||||
@@ -82,6 +83,8 @@ function runPnpm(args: readonly string[]): Promise<void> {
|
||||
PNPM,
|
||||
`--config.registry=${registry}`,
|
||||
`--config.store-dir=${STORE_ROOT}`,
|
||||
'--config.verify-store-integrity=true',
|
||||
'--config.package-import-method=copy',
|
||||
'--config.enable-global-virtual-store=false',
|
||||
`--config.userconfig=${userConfig}`,
|
||||
command,
|
||||
@@ -113,6 +116,7 @@ function runPnpm(args: readonly string[]): Promise<void> {
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
console.log(`desktop runtime: pnpm store ${STORE_ROOT}`)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:reset', async () => {
|
||||
rmSync(DSH_OUTPUT_ROOT, { recursive: true, force: true })
|
||||
rmSync(PNPM_BUILD_STATE, { recursive: true, force: true })
|
||||
@@ -127,8 +131,10 @@ async function main(): Promise<void> {
|
||||
})
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only']))
|
||||
verifyRuntimePatches(BUILD_ROOT, patches)
|
||||
const lockfile = readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8')
|
||||
console.log(`desktop runtime: lockfile sha256 ${createHash('sha256').update(lockfile).digest('hex')}`)
|
||||
verifyDesktopCoreLockfile(
|
||||
readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
|
||||
lockfile,
|
||||
readDesktopCorePackageSet(BUILD_ROOT, release.version),
|
||||
)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']))
|
||||
@@ -164,7 +170,10 @@ async function main(): Promise<void> {
|
||||
}
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => writeDesktopRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target))
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:write-descriptor', async () => {
|
||||
const writeRuntime = target.platform === 'win32' ? writeDesktopRuntimeAsync : writeDesktopRuntime
|
||||
return writeRuntime(DSH_OUTPUT_ROOT, release, packageSet.packages.map(entry => entry.name), target)
|
||||
})
|
||||
const descriptor = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:verify-before-smoke', () => verifyDesktopRuntime(DSH_OUTPUT_ROOT, release.version, target))
|
||||
if (!process.argv.includes('--defer-runtime-smoke')) {
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:smoke', () => smokePreparedRuntime(DSH_OUTPUT_ROOT, NODE, RUNTIME_ROOT, descriptor))
|
||||
|
||||
@@ -53,9 +53,18 @@ async function main(): Promise<void> {
|
||||
const target = desktopTargetPlatform(resolveDesktopBuildTarget())
|
||||
rmSync(RUNTIME_ROOT, { recursive: true, force: true })
|
||||
mkdirSync(RUNTIME_ROOT, { recursive: true })
|
||||
const nodeVersion = await prepareElectron(target)
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
|
||||
const preparePrimary = () => packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:primary-runtime',
|
||||
() => preparePrimaryRuntime({ deferSmoke: values['defer-primary-runtime-smoke'] }))
|
||||
let nodeVersion: string
|
||||
if (target.platform === 'win32') {
|
||||
const [electron, primary] = await Promise.allSettled([prepareElectron(target), preparePrimary()])
|
||||
if (electron.status === 'rejected') throw electron.reason
|
||||
if (primary.status === 'rejected') throw primary.reason
|
||||
nodeVersion = electron.value
|
||||
} else {
|
||||
nodeVersion = await prepareElectron(target)
|
||||
await preparePrimary()
|
||||
}
|
||||
const { pnpm } = await readPrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime'))
|
||||
if (pnpm === undefined) throw new Error('desktop runtime: primary-runtime manifest has no pnpm version')
|
||||
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'prepare:cli', async () => { prepareCli(target) })
|
||||
|
||||
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs'
|
||||
export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs'
|
||||
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
|
||||
const appRoot = resolve(import.meta.dirname, '..')
|
||||
@@ -22,6 +23,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
|
||||
}
|
||||
}
|
||||
validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' })
|
||||
console.log(`Windows installer compression level: ${resolveWindowsPackageSettings(environment).compressionLevel}`)
|
||||
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`)
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { withWindowsSigningStage } from './windows-signing-stage.mjs'
|
||||
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
|
||||
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
|
||||
import { verifyDesktopRuntime, writeDesktopRuntime, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
|
||||
import { verifyDesktopRuntime, writeDesktopRuntimeAsync, type DesktopRuntimeDescriptor } from '../src/runtime-tree.ts'
|
||||
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
|
||||
|
||||
/**
|
||||
@@ -39,7 +39,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string,
|
||||
options: WindowsCodeSigningOptions & { smoke: (descriptor: DesktopRuntimeDescriptor) => Promise<void> }): Promise<void> {
|
||||
const descriptor = await verifyDesktopRuntime(root, version)
|
||||
await signWindowsCode(root, options)
|
||||
writeDesktopRuntime(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name))
|
||||
await writeDesktopRuntimeAsync(root, descriptor.release, descriptor.sharedPackages.map(entry => entry.name))
|
||||
const signed = await verifyDesktopRuntime(root, version)
|
||||
await options.smoke(signed)
|
||||
await verifyDesktopRuntime(root, version)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Identity of a Windows packaging run and its independent build root. */
|
||||
export interface WindowsBuildWorkspaceOptions {
|
||||
sourceDirectory: string
|
||||
buildRoot: string
|
||||
runId: string
|
||||
runAttempt: string
|
||||
sourceSha: string
|
||||
}
|
||||
/**
|
||||
* Clone the clean selected commit into a new run-owned directory.
|
||||
* @param options - Original checkout and Actions run identity.
|
||||
* @returns Checkout and temporary directory owned by this run.
|
||||
*/
|
||||
export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: string; temporaryDirectory: string }
|
||||
/**
|
||||
* Remove the directory only when its ownership record matches the run.
|
||||
* @param options - Original checkout and Actions run identity.
|
||||
*/
|
||||
export function removeWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): void
|
||||
@@ -0,0 +1,96 @@
|
||||
/** Create and remove a run-owned checkout for Windows packaging. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, join, parse, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
function assertUnlinkedAncestors(directory) {
|
||||
for (let current = directory; ; current = dirname(current)) {
|
||||
if (existsSync(current) && lstatSync(current).isSymbolicLink()) {
|
||||
throw new Error('Packaging workspace ancestors must not be links')
|
||||
}
|
||||
if (dirname(current) === current) break
|
||||
}
|
||||
}
|
||||
|
||||
function locations(options) {
|
||||
const { buildRoot, runId, runAttempt, sourceSha } = options
|
||||
if (!buildRoot || !isAbsolute(buildRoot) || /[\r\n\0]/.test(buildRoot)) {
|
||||
throw new Error('Packaging build root must be a nonempty absolute directory')
|
||||
}
|
||||
const root = resolve(buildRoot)
|
||||
if (root === parse(root).root) throw new Error('Packaging build root must not be a volume root')
|
||||
if (!/^[1-9]\d*$/.test(runId) || !/^[1-9]\d*$/.test(runAttempt) || !/^[a-f0-9]{40}$/.test(sourceSha)) {
|
||||
throw new Error('Packaging workspace requires a run identity and exact commit')
|
||||
}
|
||||
assertUnlinkedAncestors(root)
|
||||
for (let current = root; ; current = dirname(current)) {
|
||||
if (existsSync(join(current, '.git'))) throw new Error('Packaging build root must be outside existing Git checkouts')
|
||||
if (dirname(current) === current) break
|
||||
}
|
||||
const owner = join(root, `windows-package-${runId}-${runAttempt}`)
|
||||
assertUnlinkedAncestors(owner)
|
||||
return { root, owner, directory: join(owner, 'source'), temporaryDirectory: join(owner, 'temp') }
|
||||
}
|
||||
|
||||
function git(directory, args) {
|
||||
return execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
||||
}
|
||||
|
||||
function ownership(options, paths) {
|
||||
return JSON.stringify({ version: 1, root: paths.root, source: resolve(options.sourceDirectory), runId: options.runId, runAttempt: options.runAttempt, sourceSha: options.sourceSha })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the validated commit into a new run-owned checkout; failures retain diagnostics.
|
||||
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Source and run identity.
|
||||
* @returns {{directory: string, temporaryDirectory: string}} Checkout and temporary directory owned by this run.
|
||||
*/
|
||||
export function prepareWindowsBuildWorkspace(options) {
|
||||
const paths = locations(options)
|
||||
if (git(options.sourceDirectory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(options.sourceDirectory, ['status', '--porcelain'])) {
|
||||
throw new Error('Packaging source must be clean and match the selected commit')
|
||||
}
|
||||
mkdirSync(paths.root, { recursive: true })
|
||||
mkdirSync(paths.owner)
|
||||
writeFileSync(join(paths.owner, 'owner.json'), ownership(options, paths), { flag: 'wx' })
|
||||
mkdirSync(paths.temporaryDirectory)
|
||||
git(paths.owner, ['clone', '--no-hardlinks', '--no-checkout', resolve(options.sourceDirectory), paths.directory])
|
||||
git(paths.directory, ['checkout', '--detach', options.sourceSha])
|
||||
if (git(paths.directory, ['rev-parse', 'HEAD']) !== options.sourceSha || git(paths.directory, ['status', '--porcelain'])) {
|
||||
throw new Error('Isolated packaging checkout does not match the selected commit')
|
||||
}
|
||||
return { directory: paths.directory, temporaryDirectory: paths.temporaryDirectory }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove only the matching run-owned directory after successful artifact upload.
|
||||
* Transient filesystem failures retry up to three times; persistent failures propagate.
|
||||
* @param {{sourceDirectory: string, buildRoot: string, runId: string, runAttempt: string, sourceSha: string}} options - Original source and run identity.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function removeWindowsBuildWorkspace(options) {
|
||||
const paths = locations(options)
|
||||
const marker = join(paths.owner, 'owner.json')
|
||||
const stat = lstatSync(marker)
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192 || readFileSync(marker, 'utf8') !== ownership(options, paths)) {
|
||||
throw new Error('Packaging workspace ownership does not match this run')
|
||||
}
|
||||
rmSync(paths.owner, { recursive: true, maxRetries: 3, retryDelay: 100 })
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
const options = {
|
||||
sourceDirectory: process.env.GITHUB_WORKSPACE ?? '',
|
||||
buildRoot: process.env.PACKAGE_BUILD_ROOT ?? '',
|
||||
runId: process.env.GITHUB_RUN_ID ?? '',
|
||||
runAttempt: process.env.GITHUB_RUN_ATTEMPT ?? '',
|
||||
sourceSha: process.env.PACKAGE_SOURCE_SHA ?? '',
|
||||
}
|
||||
if (process.argv[2] === '--remove') removeWindowsBuildWorkspace(options)
|
||||
else {
|
||||
if (process.argv.length !== 2 || !process.env.GITHUB_OUTPUT) throw new Error('Expected Actions output file and no arguments')
|
||||
const result = prepareWindowsBuildWorkspace(options)
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `directory=${result.directory}\ntemporaryDirectory=${result.temporaryDirectory}\n`)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Validate file-owned Windows packaging and cache worker limits before packaging starts.
|
||||
* @param environment Windows packaging settings loaded from .env.windows.
|
||||
* @returns Cache workers from one to eight and a positive integer pack limit; both default to four. Invalid values throw.
|
||||
* Validate worker limits and compression before packaging starts.
|
||||
* File-owned compression takes precedence over the existing electron-builder environment override.
|
||||
* @param environment Loaded packaging settings and inherited build environment.
|
||||
* @returns Cache workers from one to eight and a positive integer pack limit, both defaulting to four; archive compression from zero to nine, defaulting to three. Invalid values throw.
|
||||
*/
|
||||
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number }
|
||||
export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number }
|
||||
@@ -1,9 +1,10 @@
|
||||
/** Resolve file-owned Windows packaging and signature-cache concurrency. */
|
||||
/** Resolve Windows packaging concurrency and archive compression. */
|
||||
|
||||
/**
|
||||
* Validate packaging and cache worker limits without accessing signing hardware.
|
||||
* @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings.
|
||||
* @returns {{signatureCacheConcurrency: number, packConcurrency: number}} Worker limits, each defaulting to four when omitted.
|
||||
* Validate worker limits and compression without accessing signing hardware.
|
||||
* File-owned compression takes precedence over the existing electron-builder environment override.
|
||||
* @param {NodeJS.ProcessEnv} environment Loaded packaging settings and inherited build environment.
|
||||
* @returns {{signatureCacheConcurrency: number, packConcurrency: number, compressionLevel: number}} Worker limits defaulting to four and archive compression defaulting to three.
|
||||
*/
|
||||
export function resolveWindowsPackageSettings(environment) {
|
||||
const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4'
|
||||
@@ -14,5 +15,9 @@ export function resolveWindowsPackageSettings(environment) {
|
||||
if (!/^[1-9]\d*$/u.test(pack) || !Number.isSafeInteger(Number(pack))) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY must be a positive integer')
|
||||
}
|
||||
return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack) }
|
||||
const compression = environment.DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL ?? environment.ELECTRON_BUILDER_COMPRESSION_LEVEL ?? '3'
|
||||
if (!/^[0-9]$/u.test(compression)) {
|
||||
throw new Error('desktop package: DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL must be an integer from 0 to 9')
|
||||
}
|
||||
return { signatureCacheConcurrency: Number(value), packConcurrency: Number(pack), compressionLevel: Number(compression) }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Normalize only this workflow shell and the children it subsequently launches.
|
||||
[CmdletBinding()]
|
||||
param([switch]$InspectOnly, [switch]$BestEffort)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows) { throw 'Packaging process priority requires Windows.' }
|
||||
|
||||
if (-not ('DshPackagingPriority' -as [type])) {
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class DshPackagingPriority {
|
||||
[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
|
||||
[DllImport("ntdll.dll")] static extern int NtSetInformationProcess(IntPtr process, int kind, ref int value, int size);
|
||||
[DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr process, int kind, out int value, int size, out int length);
|
||||
public static int Read(int kind) {
|
||||
int value, length;
|
||||
int status = NtQueryInformationProcess(GetCurrentProcess(), kind, out value, 4, out length);
|
||||
if (status != 0 || length != 4) throw new InvalidOperationException("Cannot read packaging priority: " + status);
|
||||
return value;
|
||||
}
|
||||
public static void Set(int kind, int value) {
|
||||
int status = NtSetInformationProcess(GetCurrentProcess(), kind, ref value, 4);
|
||||
if (status != 0) throw new InvalidOperationException("Cannot set packaging priority: " + status);
|
||||
}
|
||||
}
|
||||
'@
|
||||
}
|
||||
|
||||
$process = [Diagnostics.Process]::GetCurrentProcess()
|
||||
try {
|
||||
$before = [ordered]@{
|
||||
cpu = $process.PriorityClass.ToString()
|
||||
io = [DshPackagingPriority]::Read(33)
|
||||
memory = [DshPackagingPriority]::Read(39)
|
||||
}
|
||||
if (-not $InspectOnly) {
|
||||
$process.PriorityClass = [Diagnostics.ProcessPriorityClass]::Normal
|
||||
# PROCESSINFOCLASS: ProcessIoPriority (33), ProcessPagePriority (39).
|
||||
[DshPackagingPriority]::Set(33, 2)
|
||||
[DshPackagingPriority]::Set(39, 5)
|
||||
}
|
||||
$process.Refresh()
|
||||
$after = [ordered]@{
|
||||
cpu = $process.PriorityClass.ToString()
|
||||
io = [DshPackagingPriority]::Read(33)
|
||||
memory = [DshPackagingPriority]::Read(39)
|
||||
}
|
||||
[ordered]@{ event = 'packaging-process-priority'; before = $before; after = $after } | ConvertTo-Json -Compress
|
||||
if (-not $InspectOnly -and ($after.cpu -ne 'Normal' -or $after.io -ne 2 -or $after.memory -ne 5)) {
|
||||
throw 'Packaging process did not reach normal CPU, I/O and memory priority.'
|
||||
}
|
||||
} catch {
|
||||
if (-not $BestEffort) { throw }
|
||||
Write-Warning "Packaging priority adjustment unavailable; performance comparison is invalid: $($_.Exception.Message)"
|
||||
[ordered]@{ event = 'packaging-process-priority'; comparable = $false } | ConvertTo-Json -Compress
|
||||
} finally {
|
||||
$process.Dispose()
|
||||
}
|
||||
@@ -64,7 +64,7 @@ if ($Action -eq 'install') {
|
||||
(New-ScheduledTaskTrigger -AtLogOn -User $sid),
|
||||
(New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes($RestartMinutes) -RepetitionInterval (New-TimeSpan -Minutes $RestartMinutes))
|
||||
)
|
||||
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$settings = New-ScheduledTaskSettingsSet -Priority 4 -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
$settings.Enabled = $false
|
||||
$null = Register-ScheduledTask -TaskName $taskName -TaskPath '\' -Action $execution -Principal $principal -Trigger $trigger -Settings $settings -Description "DSH packaging runner: $RunnerRoot; owner: $sid"
|
||||
Write-Output "Installed disabled task $taskName. Run start while logged in; reboot requires desktop login. Logs: $serviceDir"
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface WindowsCodeSigningOptions {
|
||||
/**
|
||||
* Enumerate PE files by content without following links.
|
||||
* @param root Materialized directory to scan.
|
||||
* @returns Sorted PE paths; rejects malformed Windows executables and links.
|
||||
* @returns Sorted PE paths; eight bounded readers reject links or malformed executables and drain open handles before failure.
|
||||
*/
|
||||
export function windowsRuntimeCode(root: string): Promise<string[]>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise<string[]>
|
||||
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
|
||||
* @param root Materialized directory to sign.
|
||||
* @param options Signer, certificate identity and audit sink.
|
||||
* @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries.
|
||||
* @returns Resolves after parallel cache restores, batched target verification and sequential signing; failures drain active restores or inspections without retries.
|
||||
*/
|
||||
export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise<void>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
|
||||
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
|
||||
import { recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { inspectSignaturesBatched } from './windows-signature-batch.mjs'
|
||||
import { packagingStep } from './packaging-step.mjs'
|
||||
|
||||
/**
|
||||
* Read Windows trust, timestamp and signer identity using the engine's bundled modules, without accessing the private key.
|
||||
@@ -52,17 +53,32 @@ export async function preserveWindowsRuntimeSignature(path, options) {
|
||||
/**
|
||||
* Enumerate Windows code without following links or treating foreign .node files as PE binaries.
|
||||
* @param {string} root - Owned, materialized runtime directory.
|
||||
* @returns {Promise<string[]>} Sorted real PE files; rejects links and malformed Windows executable files.
|
||||
* @returns {Promise<string[]>} Sorted real PE files; eight bounded readers reject links or malformed executables and drain open handles before failure.
|
||||
*/
|
||||
export async function windowsRuntimeCode(root) {
|
||||
const rootStat = await lstat(root)
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory')
|
||||
return packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'windows:enumerate-code', async () => {
|
||||
const candidates = []
|
||||
async function collect(directory) {
|
||||
const rootStat = await lstat(directory)
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('Windows code: expected a real directory')
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`)
|
||||
if (entry.isDirectory()) await collect(path)
|
||||
else if (entry.isFile()) candidates.push(path)
|
||||
}
|
||||
}
|
||||
await collect(root)
|
||||
return inspectPortableExecutables(candidates)
|
||||
})
|
||||
}
|
||||
|
||||
/** Close every active file before returning a sorted inventory or the first inspection failure. */
|
||||
async function inspectPortableExecutables(candidates) {
|
||||
const files = []
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name)
|
||||
if (entry.isSymbolicLink()) throw new Error(`Windows code: directory links are not signable: ${path}`)
|
||||
if (entry.isDirectory()) { files.push(...await windowsRuntimeCode(path)); continue }
|
||||
if (!entry.isFile()) continue
|
||||
let next = 0
|
||||
let failure
|
||||
async function inspect(path) {
|
||||
const file = await open(path, 'r')
|
||||
let portableExecutable = false
|
||||
let windowsCandidate = false
|
||||
@@ -80,11 +96,26 @@ export async function windowsRuntimeCode(root) {
|
||||
if (portableExecutable) files.push(path)
|
||||
else if (windowsCandidate || ['.exe', '.dll', '.pyd'].includes(extname(path).toLowerCase())) throw new Error(`Windows code: invalid PE file: ${path}`)
|
||||
}
|
||||
async function worker() {
|
||||
while (failure === undefined && next < candidates.length) {
|
||||
const path = candidates[next++]
|
||||
try { await inspect(path) }
|
||||
catch (error) { failure ??= { error } }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, worker))
|
||||
if (failure !== undefined) throw failure.error
|
||||
return files.sort()
|
||||
}
|
||||
|
||||
/** Drain each public-key verification batch before signing or reporting a failure. */
|
||||
async function inspectWindowsCode(files, inspect) {
|
||||
return packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'windows:inspect-signatures',
|
||||
() => inspectWindowsCodeBatch(files, inspect))
|
||||
}
|
||||
|
||||
/** Complete bounded inspections without allowing callers to proceed past an active failed batch. */
|
||||
async function inspectWindowsCodeBatch(files, inspect) {
|
||||
if (inspect === inspectWindowsRuntimeSignature) return inspectSignaturesBatched(files)
|
||||
const signatures = []
|
||||
for (let offset = 0; offset < files.length; offset += 4) {
|
||||
@@ -101,7 +132,7 @@ async function inspectWindowsCode(files, inspect) {
|
||||
* Preserve valid signatures and sign unsigned PE files with a supervised signer.
|
||||
* @param {string} root - Owned final runtime directory.
|
||||
* @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink.
|
||||
* @returns {Promise<void>} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting.
|
||||
* @returns {Promise<void>} Resolves after bounded cache restores, batched target verification and sequential hardware signing; failures drain active work before rejecting.
|
||||
*/
|
||||
export async function signWindowsCode(root, options) {
|
||||
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
|
||||
@@ -116,12 +147,15 @@ export async function signWindowsCode(root, options) {
|
||||
else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`)
|
||||
}
|
||||
options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length })
|
||||
async function verifySigned(path) {
|
||||
const signature = await inspect(path)
|
||||
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
|
||||
throw new Error(`Windows code: signing verification failed: ${path}`)
|
||||
async function verifySigned(paths) {
|
||||
const verified = await inspectWindowsCode(paths, inspect)
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const signature = verified[index]
|
||||
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) {
|
||||
throw new Error(`Windows code: signing verification failed: ${path}`)
|
||||
}
|
||||
options.record({ type: 'windows-code-signature-verified', path, ...signature })
|
||||
}
|
||||
options.record({ type: 'windows-code-signature-verified', path, ...signature })
|
||||
}
|
||||
const restored = new Set()
|
||||
if (options.cache) {
|
||||
@@ -132,7 +166,6 @@ export async function signWindowsCode(root, options) {
|
||||
const path = unsigned[next++]
|
||||
try {
|
||||
if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) {
|
||||
await verifySigned(path)
|
||||
restored.add(path)
|
||||
}
|
||||
} catch (error) { failure ??= { error } }
|
||||
@@ -140,11 +173,12 @@ export async function signWindowsCode(root, options) {
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker))
|
||||
if (failure !== undefined) throw failure.error
|
||||
await verifySigned(unsigned.filter(path => restored.has(path)))
|
||||
}
|
||||
for (const path of unsigned) {
|
||||
if (restored.has(path)) continue
|
||||
await options.sign({ path, hash: 'sha256', isNest: false })
|
||||
await verifySigned(path)
|
||||
await verifySigned([path])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +135,9 @@ export function createRedactedWindowsSigningError(error, path, secrets) {
|
||||
const stderr = record !== undefined && 'stderr' in record
|
||||
? redactedSigningOutput(record.stderr, secrets).trim()
|
||||
: ''
|
||||
return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}`)
|
||||
return new Error(`Windows release signing failed for ${path}${code}${stderr === '' ? '' : `: ${stderr}`}. `
|
||||
+ 'Signing stopped. After replacing the UKey, check the certificate, container, SafeNet provider and user PIN. '
|
||||
+ 'This error does not establish device lock status or whether PIN retries were consumed; inspect the retained interlock before operator recovery.')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import { promisify } from 'node:util'
|
||||
import { createWindowsTokenSigner, scrubWindowsSigningEnvironment } from './windows-sign.mjs'
|
||||
import { inspectWindowsRuntimeSignature, type WindowsRuntimeSignature } from './windows-runtime-signature.mjs'
|
||||
import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs'
|
||||
import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from './windows-token-identity.ts'
|
||||
|
||||
const PROBE_SOURCE = 'internal static class SigningProbe { private static int Main() { return 0; } }\n'
|
||||
|
||||
@@ -18,6 +19,7 @@ interface SigningPreflightOptions {
|
||||
compile?: (compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv) => Promise<void>
|
||||
sign?: ReturnType<typeof createWindowsTokenSigner>
|
||||
inspect?: (path: string) => Promise<WindowsRuntimeSignature>
|
||||
inspectIdentity?: typeof inspectWindowsTokenIdentity
|
||||
}
|
||||
|
||||
async function compileProbe(compiler: string, source: string, output: string, environment: NodeJS.ProcessEnv): Promise<void> {
|
||||
@@ -28,7 +30,7 @@ async function compileProbe(compiler: string, source: string, output: string, en
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign one newly compiled, never-executed probe and verify its timestamp and configured certificate.
|
||||
* Match public container keys before signing one never-executed probe and verifying its certificate and timestamp.
|
||||
* @param options Supervised evidence directory, file-owned settings and isolated test adapters.
|
||||
* @returns Resolves after one signature and verification; failures retain evidence; timestamp attempts never repeat hardware signing.
|
||||
*/
|
||||
@@ -71,6 +73,12 @@ export async function preflightWindowsSigning(options: SigningPreflightOptions):
|
||||
compilerSha256: createHash('sha256').update(await readFile(compiler)).digest('hex'),
|
||||
signToolSha256: createHash('sha256').update(await readFile(environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!)).digest('hex'),
|
||||
certificateSha256: createHash('sha256').update(certificate).digest('hex') })
|
||||
const identity = await (options.inspectIdentity ?? inspectWindowsTokenIdentity)({
|
||||
certificate, container: environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim(),
|
||||
environment: scrubWindowsSigningEnvironment(environment),
|
||||
})
|
||||
record({ type: 'signing-preflight-public-identity', result: identity, pinSupplied: false })
|
||||
if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity))
|
||||
await (options.compile ?? compileProbe)(compiler, source, output, environment)
|
||||
const inspect = options.inspect ?? inspectWindowsRuntimeSignature
|
||||
if ((await inspect(output)).status !== 'NotSigned') throw new Error('Windows signing preflight requires a new unsigned probe')
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Compare CSP public material without supplying a PIN or invoking a signing operation. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createPublicKey, X509Certificate } from 'node:crypto'
|
||||
import { promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { scrubWindowsSigningEnvironment } from './windows-sign.mjs'
|
||||
|
||||
/** Public-data results do not attest physical device presence, authentication or remaining PIN attempts. */
|
||||
export type WindowsTokenIdentity = 'matched' | 'certificate-mismatch' | 'provider-unavailable' | 'container-unavailable' | 'unknown'
|
||||
|
||||
interface PublicKeyQuery {
|
||||
container: string
|
||||
environment: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
async function queryPublicKeys({ container, environment }: PublicKeyQuery): Promise<string> {
|
||||
const { NODE_OPTIONS: _options, NODE_PATH: _path, ...safeEnvironment } = scrubWindowsSigningEnvironment(environment)
|
||||
const { stdout, stderr } = await promisify(execFile)('pwsh', ['-NoProfile', '-NonInteractive', '-File',
|
||||
fileURLToPath(new URL('./windows-token-public-key.ps1', import.meta.url))], {
|
||||
env: { ...safeEnvironment, DSH_TOKEN_PUBLIC_CONTAINER: container },
|
||||
encoding: 'utf8', windowsHide: true, timeout: 15_000, maxBuffer: 64 * 1024,
|
||||
})
|
||||
if (stderr) throw new Error('Public-key query returned diagnostics')
|
||||
return stdout
|
||||
}
|
||||
|
||||
function publicBlobKey(value: string): Buffer {
|
||||
const bytes = Buffer.from(value, 'base64')
|
||||
if (bytes.toString('base64') !== value || bytes.length < 20 || bytes[0] !== 6 || bytes[1] !== 2
|
||||
|| bytes.readUInt16LE(2) !== 0 || ![0x2400, 0xa400].includes(bytes.readUInt32LE(4))
|
||||
|| bytes.readUInt32LE(8) !== 0x31415352) throw new Error('Invalid RSA public blob')
|
||||
const bits = bytes.readUInt32LE(12)
|
||||
const exponent = bytes.readUInt32LE(16)
|
||||
if (bits < 512 || bits % 8 !== 0 || bytes.length !== 20 + bits / 8 || exponent < 3 || exponent % 2 === 0) {
|
||||
throw new Error('Invalid RSA public parameters')
|
||||
}
|
||||
const exponentBytes = Buffer.alloc(4)
|
||||
exponentBytes.writeUInt32BE(exponent)
|
||||
const first = exponentBytes.findIndex(byte => byte !== 0)
|
||||
return createPublicKey({ format: 'jwk', key: { kty: 'RSA',
|
||||
n: Buffer.from(bytes.subarray(20)).reverse().toString('base64url'),
|
||||
e: exponentBytes.subarray(first).toString('base64url') } }).export({ format: 'der', type: 'spki' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read both CSP public key slots and compare them with the configured certificate; never sign or retry.
|
||||
* @param options Certificate bytes, container, process environment and optional isolated public-query adapter.
|
||||
* @returns Matched public information, a specific unavailable/mismatch result, or unknown on incomplete/invalid output.
|
||||
*/
|
||||
export async function inspectWindowsTokenIdentity(options: PublicKeyQuery & {
|
||||
certificate: Uint8Array
|
||||
query?: (input: PublicKeyQuery) => Promise<string>
|
||||
}): Promise<WindowsTokenIdentity> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(await (options.query ?? queryPublicKeys)({
|
||||
container: options.container, environment: options.environment,
|
||||
}))
|
||||
if (!value || typeof value !== 'object' || !('status' in value)) return 'unknown'
|
||||
if (value.status === 'provider-unavailable' || value.status === 'container-unavailable') return value.status
|
||||
if (value.status !== 'public-keys' || !('publicKeys' in value) || !Array.isArray(value.publicKeys)
|
||||
|| value.publicKeys.length < 1 || value.publicKeys.length > 2) return 'unknown'
|
||||
const keys = value.publicKeys.map((key: unknown) => {
|
||||
if (typeof key !== 'string') throw new Error('Invalid public key')
|
||||
return publicBlobKey(key)
|
||||
})
|
||||
const expected = new X509Certificate(options.certificate).publicKey.export({ format: 'der', type: 'spki' })
|
||||
return keys.some(key => key.equals(expected)) ? 'matched' : 'certificate-mismatch'
|
||||
} catch {
|
||||
// Native failures may include container names or inherited diagnostics; expose only the incomplete result.
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain why public-data validation stopped signing without asserting device lock or authentication status.
|
||||
* @param result Non-matching public-data result.
|
||||
* @returns A credential-free operator diagnostic.
|
||||
*/
|
||||
export function windowsTokenIdentityFailure(result: Exclude<WindowsTokenIdentity, 'matched'>): string {
|
||||
const reasons = {
|
||||
'provider-unavailable': 'The configured eToken provider is unavailable; check the SafeNet driver.',
|
||||
'container-unavailable': 'The configured container could not be opened; after replacing the UKey, check its container name and visibility.',
|
||||
'certificate-mismatch': 'The returned container public keys do not match the configured certificate; check the certificate and container after replacing the UKey.',
|
||||
unknown: 'The container public identity could not be confirmed; check that PowerShell 7 (pwsh) can start, then check device visibility and SafeNet public-information support.',
|
||||
}
|
||||
return `${reasons[result]} No PIN was supplied and no signing was attempted by this check. Signing stopped; device lock and PIN retry counts are unknown.`
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Read only public material through the existing SafeNet CSP. No authentication or key mutation APIs.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class DshTokenPublicKey
|
||||
{
|
||||
public const string Provider = "eToken Base Cryptographic Provider";
|
||||
public const uint PublicContextFlags = 0xF0000040; // CRYPT_VERIFYCONTEXT | CRYPT_SILENT
|
||||
public const int NoKey = unchecked((int)0x8009000D);
|
||||
|
||||
public interface IApi
|
||||
{
|
||||
int Acquire(string container, out IntPtr context);
|
||||
int GetKey(IntPtr context, uint spec, out IntPtr key);
|
||||
int Export(IntPtr key, byte[] bytes, ref uint length);
|
||||
bool Destroy(IntPtr key);
|
||||
bool Release(IntPtr context);
|
||||
}
|
||||
|
||||
public sealed class NativeApi : IApi
|
||||
{
|
||||
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
|
||||
static extern bool CryptAcquireContextW(out IntPtr context, string container, string provider, uint type, uint flags);
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CryptGetUserKey(IntPtr context, uint spec, out IntPtr key);
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CryptExportKey(IntPtr key, IntPtr exportKey, uint type, uint flags, byte[] bytes, ref uint length);
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CryptDestroyKey(IntPtr key);
|
||||
[DllImport("advapi32.dll", SetLastError = true)]
|
||||
static extern bool CryptReleaseContext(IntPtr context, uint flags);
|
||||
public int Acquire(string container, out IntPtr context) {
|
||||
return CryptAcquireContextW(out context, container, Provider, 1, PublicContextFlags) ? 0 : Marshal.GetLastWin32Error();
|
||||
}
|
||||
public int GetKey(IntPtr context, uint spec, out IntPtr key) {
|
||||
return CryptGetUserKey(context, spec, out key) ? 0 : Marshal.GetLastWin32Error();
|
||||
}
|
||||
public int Export(IntPtr key, byte[] bytes, ref uint length) {
|
||||
return CryptExportKey(key, IntPtr.Zero, 6, 0, bytes, ref length) ? 0 : Marshal.GetLastWin32Error(); // PUBLICKEYBLOB
|
||||
}
|
||||
public bool Destroy(IntPtr key) { return CryptDestroyKey(key); }
|
||||
public bool Release(IntPtr context) { return CryptReleaseContext(context, 0); }
|
||||
}
|
||||
|
||||
public sealed class Result
|
||||
{
|
||||
public string status = "unknown";
|
||||
public string[] publicKeys = new string[0];
|
||||
}
|
||||
|
||||
// The adapter seam allows all failure paths to be exercised without loading a hardware provider.
|
||||
public static Result Read(IApi api, string container)
|
||||
{
|
||||
var result = new Result();
|
||||
IntPtr context;
|
||||
int error = api.Acquire(container, out context);
|
||||
if (error != 0) {
|
||||
if (error == unchecked((int)0x80090013) || error == unchecked((int)0x80090017)
|
||||
|| error == unchecked((int)0x8009001E)) result.status = "provider-unavailable";
|
||||
else if (error == unchecked((int)0x80090016)) result.status = "container-unavailable";
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
var keys = new List<string>();
|
||||
foreach (uint spec in new uint[] { 2, 1 }) {
|
||||
IntPtr key;
|
||||
error = api.GetKey(context, spec, out key);
|
||||
if (error == NoKey) continue;
|
||||
if (error != 0) return result;
|
||||
bool destroyed;
|
||||
try {
|
||||
uint length = 0;
|
||||
if (api.Export(key, null, ref length) != 0 || length < 20 || length > 16384) return result;
|
||||
var bytes = new byte[length];
|
||||
uint capacity = length;
|
||||
if (api.Export(key, bytes, ref length) != 0 || length < 20 || length > capacity) return result;
|
||||
keys.Add(Convert.ToBase64String(bytes, 0, (int)length));
|
||||
} finally { destroyed = api.Destroy(key); }
|
||||
if (!destroyed) return result;
|
||||
}
|
||||
if (keys.Count > 0) {
|
||||
result.publicKeys = keys.ToArray();
|
||||
result.status = "public-keys";
|
||||
}
|
||||
} finally {
|
||||
if (!api.Release(context)) {
|
||||
result.status = "unknown";
|
||||
result.publicKeys = new string[0];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Only the container name is needed; the caller removes inherited signing credentials.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -Path (Join-Path $PSScriptRoot 'windows-token-public-key.cs')
|
||||
[DshTokenPublicKey]::Read([DshTokenPublicKey+NativeApi]::new(), $env:DSH_TOKEN_PUBLIC_CONTAINER) | ConvertTo-Json -Compress
|
||||
@@ -89,7 +89,7 @@ export function inventoryDesktopRuntime(root: string): DesktopRuntimeFile[] {
|
||||
.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)
|
||||
}
|
||||
|
||||
async function inventoryRuntimeForVerification(root: string): Promise<DesktopRuntimeFile[]> {
|
||||
async function inventoryRuntimeConcurrently(root: string): Promise<DesktopRuntimeFile[]> {
|
||||
const entries = runtimeFiles(root)
|
||||
const files: DesktopRuntimeFile[] = []
|
||||
const remaining = entries.values()
|
||||
@@ -125,6 +125,31 @@ export function writeDesktopRuntime(
|
||||
root: string, release: DesktopRelease, sharedNames: readonly string[],
|
||||
target: { platform: NodeJS.Platform; arch: string } = process,
|
||||
): DesktopRuntimeDescriptor {
|
||||
return writeRuntimeDescriptor(root, {
|
||||
...runtimeMetadata(root, release, sharedNames, target), files: inventoryDesktopRuntime(root),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal a materialized runtime with bounded concurrent reads, draining readers before failure returns.
|
||||
* @param root - Runtime output directory.
|
||||
* @param release - Matching shell, dsh, Host, and executable versions.
|
||||
* @param sharedNames - Release-owned packages supplied to plugins.
|
||||
* @param target - Platform and architecture selected by runtime preparation.
|
||||
* @returns Descriptor written after every final file has been inventoried.
|
||||
*/
|
||||
export async function writeDesktopRuntimeAsync(
|
||||
root: string, release: DesktopRelease, sharedNames: readonly string[],
|
||||
target: { platform: NodeJS.Platform; arch: string } = process,
|
||||
): Promise<DesktopRuntimeDescriptor> {
|
||||
const metadata = runtimeMetadata(root, release, sharedNames, target)
|
||||
return writeRuntimeDescriptor(root, { ...metadata, files: await inventoryRuntimeConcurrently(root) })
|
||||
}
|
||||
|
||||
function runtimeMetadata(
|
||||
root: string, release: DesktopRelease, sharedNames: readonly string[],
|
||||
target: { platform: NodeJS.Platform; arch: string },
|
||||
): Omit<DesktopRuntimeDescriptor, 'files'> {
|
||||
const sharedPackages = [...new Set(sharedNames)].sort().map((name) => {
|
||||
if (!PACKAGE_NAME.test(name)) throw new Error(`desktop runtime: invalid shared package ${name}`)
|
||||
const path = `node_modules/${name}`
|
||||
@@ -134,10 +159,10 @@ export function writeDesktopRuntime(
|
||||
}
|
||||
return { name, version: manifest.version, path }
|
||||
})
|
||||
const descriptor: DesktopRuntimeDescriptor = {
|
||||
schemaVersion: 1, release, platform: target.platform, arch: target.arch,
|
||||
sharedPackages, files: inventoryDesktopRuntime(root),
|
||||
}
|
||||
return { schemaVersion: 1, release, platform: target.platform, arch: target.arch, sharedPackages }
|
||||
}
|
||||
|
||||
function writeRuntimeDescriptor(root: string, descriptor: DesktopRuntimeDescriptor): DesktopRuntimeDescriptor {
|
||||
writeFileSync(join(root, DESKTOP_RUNTIME_FILE), `${JSON.stringify(descriptor, undefined, 2)}\n`)
|
||||
return descriptor
|
||||
}
|
||||
@@ -203,7 +228,7 @@ export async function verifyDesktopRuntime(
|
||||
throw new Error(`desktop runtime: shared package metadata mismatch for ${entry.name}`)
|
||||
}
|
||||
}
|
||||
const actual = await inventoryRuntimeForVerification(root)
|
||||
const actual = await inventoryRuntimeConcurrently(root)
|
||||
// Windows has no portable Unix executable permission bits.
|
||||
const comparable = (items: readonly DesktopRuntimeFile[]): unknown => process.platform === 'win32'
|
||||
? items.map(({ executable: _executable, ...item }) => item) : items
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { join, sep } from 'node:path'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
desktopTargetBuildPaths,
|
||||
desktopTargetPlatform,
|
||||
developmentRuntimeDirectory,
|
||||
resolveDesktopBuildTarget,
|
||||
resolveDesktopTargetBuildPaths,
|
||||
} from '../scripts/desktop-build-paths.mjs'
|
||||
|
||||
describe('desktop build paths', () => {
|
||||
@@ -35,11 +36,34 @@ describe('desktop build paths', () => {
|
||||
expect(windows.runtime).toContain(join('targets', 'win-x64', 'runtime'))
|
||||
})
|
||||
|
||||
it('shares only the immutable upstream download cache', () => {
|
||||
it('shares download and package caches outside target output directories', () => {
|
||||
const arm64 = desktopTargetBuildPaths('mac-arm64')
|
||||
const x64 = desktopTargetBuildPaths('mac-x64')
|
||||
expect(arm64.downloads).toBe(x64.downloads)
|
||||
expect(arm64.downloads).not.toContain(`${sep}targets${sep}`)
|
||||
expect(arm64.pnpmStore).toBe(x64.pnpmStore)
|
||||
expect(arm64.pnpmStore).not.toContain(`${sep}targets${sep}`)
|
||||
})
|
||||
|
||||
it('retains target outputs while relocating caches through the supplied environment', () => {
|
||||
const cache = resolve('external-package-cache')
|
||||
const environment = { DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }
|
||||
const defaults = desktopTargetBuildPaths('win-x64', {})
|
||||
const paths = resolveDesktopTargetBuildPaths(environment, 'win32', 'x64')
|
||||
expect(paths).toEqual({ ...defaults, downloads: join(cache, 'downloads'), pnpmStore: join(cache, 'pnpm-store') })
|
||||
expect(desktopTargetBuildPaths('mac-arm64', environment).pnpmStore).toBe(paths.pnpmStore)
|
||||
for (const target of ['mac-arm64', 'mac-x64', 'win-x64'] as const) {
|
||||
const configured = desktopTargetBuildPaths(target, environment)
|
||||
expect(configured.downloads).toBe(join(cache, 'downloads'))
|
||||
expect(configured.pnpmStore).toBe(join(cache, 'pnpm-store'))
|
||||
expect(configured.root).not.toContain(cache)
|
||||
}
|
||||
expect(defaults.pnpmStore).toBe(resolve(defaults.root, '../..', 'pnpm-store'))
|
||||
})
|
||||
|
||||
it.each(['', ' ', 'relative/cache', './cache', 'cache\0invalid'])('rejects invalid cache directory %j', (directory) => {
|
||||
expect(() => desktopTargetBuildPaths('win-x64', { DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }))
|
||||
.toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR')
|
||||
})
|
||||
|
||||
it('resolves the development primary runtime from the build target rather than the host architecture', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -23,6 +23,85 @@ async function withDirectory(action: (directory: string) => Promise<void>): Prom
|
||||
}
|
||||
|
||||
describe('Desktop local packaging configuration', () => {
|
||||
it('loads file-owned compression and defaults to fast Windows archive compression', async () => {
|
||||
expect(resolveWindowsPackageSettings({}).compressionLevel).toBe(3)
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3\n')
|
||||
const selected = loadDesktopPackageEnvironment('win32', { DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: '0' }, directory)
|
||||
expect(resolveWindowsPackageSettings(selected).compressionLevel).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['3', '7'])('applies an explicit Windows workflow compression override %s', async (value) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=5\n')
|
||||
const selected = loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: value }, directory)
|
||||
expect(resolveWindowsPackageSettings(selected).compressionLevel).toBe(Number(value))
|
||||
const unchanged = loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: '' }, directory)
|
||||
expect(resolveWindowsPackageSettings(unchanged).compressionLevel).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['0', '5', '7', '9'])('retains explicit Windows file compression %s outside the workflow', async (value) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), `DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=${value}\n`)
|
||||
expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', {}, directory)).compressionLevel).toBe(Number(value))
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the legacy builder override below the file-owned Windows setting', () => {
|
||||
expect(resolveWindowsPackageSettings({ ELECTRON_BUILDER_COMPRESSION_LEVEL: '7' }).compressionLevel).toBe(7)
|
||||
expect(resolveWindowsPackageSettings({ ELECTRON_BUILDER_COMPRESSION_LEVEL: '7', DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: '3' }).compressionLevel).toBe(3)
|
||||
})
|
||||
|
||||
it.each(['test', 'production'])('uses the same Windows compression defaults and overrides for %s', async (preset) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await mkdir(join(directory, 'presets'))
|
||||
const path = join(directory, 'presets', `${preset}.env`)
|
||||
const parent = { DSH_PACKAGING_CONFIG_DIR: directory, DSH_PACKAGING_PRESET: preset }
|
||||
await writeFile(path, `DSH_DESKTOP_AUTO_UPDATE_ENV=${preset}\n`)
|
||||
const load = (environment: NodeJS.ProcessEnv) => resolveWindowsPackageSettings(
|
||||
loadDesktopPackageEnvironment('win32', environment, directory, { unsigned: true }),
|
||||
).compressionLevel
|
||||
expect(load(parent)).toBe(3)
|
||||
await writeFile(path, `DSH_DESKTOP_AUTO_UPDATE_ENV=${preset}\nDSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=9\n`)
|
||||
expect(load(parent)).toBe(9)
|
||||
expect(load({ ...parent, PACKAGE_WINDOWS_COMPRESSION_LEVEL: '3' })).toBe(3)
|
||||
expect(load({ ...parent, PACKAGE_WINDOWS_COMPRESSION_LEVEL: '7' })).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
it.each([' ', '0', '5', '9', '-1', '10', '3.5', '03', 'fast'])('rejects invalid workflow compression %j before reading settings', (value) => {
|
||||
expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: value }, 'missing-settings'))
|
||||
.toThrow('PACKAGE_WINDOWS_COMPRESSION_LEVEL')
|
||||
})
|
||||
|
||||
it('rejects a Windows compression override for macOS before reading settings', () => {
|
||||
expect(() => loadDesktopPackageEnvironment('darwin', { PACKAGE_WINDOWS_COMPRESSION_LEVEL: '3' }, 'missing-settings'))
|
||||
.toThrow('PACKAGE_WINDOWS_COMPRESSION_LEVEL')
|
||||
})
|
||||
it.each(['', '-1', '10', '3.5', '03', 'fast'])('rejects invalid compression %j before preparation', (value) => {
|
||||
const selected = { ...RELEASE, DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: value }
|
||||
expect(() => { validateDesktopPackageEnvironment(selected, WINDOWS, { unsigned: true }) })
|
||||
.toThrow('COMPRESSION_LEVEL')
|
||||
})
|
||||
|
||||
it.each(['', ' ', 'relative/cache'])('rejects invalid package caches before any signing mode %j', (directory) => {
|
||||
for (const target of [WINDOWS, MACOS]) {
|
||||
for (const options of [{ unsigned: true }, { prepareOnly: true }, {}]) {
|
||||
expect(() => { validateDesktopPackageEnvironment({ DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }, target, options) })
|
||||
.toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves the machine package cache when loading a release file', async () => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.windows'), '')
|
||||
expect(loadDesktopPackageEnvironment('win32', { DSH_DESKTOP_PACKAGE_CACHE_DIR: directory }, directory))
|
||||
.toEqual({ DSH_DESKTOP_PACKAGE_CACHE_DIR: directory })
|
||||
})
|
||||
})
|
||||
it('selects macOS local defaults while excluding inherited release settings and retaining build networking', () => {
|
||||
const parent = {
|
||||
PATH: 'build-tools', HTTPS_PROXY: 'http://proxy.example:8080', DSH_DESKTOP_NPM_REGISTRY: 'https://registry.npmmirror.com',
|
||||
@@ -272,3 +351,40 @@ it('skips Apple credentials but still requires signing credentials for unnotariz
|
||||
}).toThrow(/CSC_KEY_PASSWORD/u)
|
||||
})
|
||||
})
|
||||
|
||||
it('takes ZIP compression from the Mac file instead of ambient release settings', async () => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=5\n')
|
||||
const loaded = loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: '9' }, directory)
|
||||
expect(loaded.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL).toBe('5')
|
||||
await writeFile(join(directory, '.env.macos'), '')
|
||||
expect(loadDesktopPackageEnvironment('darwin', { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: '9' }, directory))
|
||||
.not.toHaveProperty('DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL')
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['', 'relative-cache'])('rejects invalid build cache paths during preflight (%s)', (cache) => {
|
||||
expect(() => { validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache }, MACOS, { unsigned: true }) })
|
||||
.toThrow('DSH_DESKTOP_PACKAGE_CACHE_DIR must be a nonempty absolute directory')
|
||||
})
|
||||
|
||||
it.each(['3', '7'])('applies the workflow ZIP choice %s over the Mac file setting', async (level) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.macos'), 'DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL=5\n')
|
||||
const loaded = loadDesktopPackageEnvironment('darwin', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, directory)
|
||||
expect(loaded.DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL).toBe(level)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['', '3', '7'])('rejects Mac workflow ZIP choice %j on Windows before reading settings', (level) => {
|
||||
expect(() => loadDesktopPackageEnvironment('win32', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, 'missing-settings'))
|
||||
.toThrow('PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL requires macOS')
|
||||
})
|
||||
|
||||
it.each(['', '0', '9', 'invalid'])('rejects unsupported workflow ZIP choice %j', async (level) => {
|
||||
await withDirectory(async (directory) => {
|
||||
await writeFile(join(directory, '.env.macos'), '')
|
||||
expect(() => loadDesktopPackageEnvironment('darwin', { PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: level }, directory))
|
||||
.toThrow('workflow ZIP compression must be 3 or 7')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
public sealed class FakeTokenPublicKey : DshTokenPublicKey.IApi
|
||||
{
|
||||
string scenario;
|
||||
int releases, destroys, exports, keys;
|
||||
public FakeTokenPublicKey(string value) { scenario = value; }
|
||||
public int Acquire(string container, out System.IntPtr context) {
|
||||
if (container != "fake-container") throw new System.Exception("unexpected container");
|
||||
context = new System.IntPtr(1);
|
||||
if (scenario == "provider") return unchecked((int)0x80090013);
|
||||
if (scenario == "container") return unchecked((int)0x80090016);
|
||||
if (scenario == "auth") return unchecked((int)0x80090022);
|
||||
return 0;
|
||||
}
|
||||
public int GetKey(System.IntPtr context, uint spec, out System.IntPtr key) {
|
||||
keys++;
|
||||
key = new System.IntPtr(2);
|
||||
if (scenario == "no-key" || spec == 1) return DshTokenPublicKey.NoKey;
|
||||
if (scenario == "get-key") return 5;
|
||||
return 0;
|
||||
}
|
||||
public int Export(System.IntPtr key, byte[] bytes, ref uint length) {
|
||||
exports++;
|
||||
if (scenario == "export") return 5;
|
||||
if (scenario == "size") { length = 1000000; return 0; }
|
||||
length = 24;
|
||||
return 0;
|
||||
}
|
||||
public bool Destroy(System.IntPtr key) { destroys++; return scenario != "destroy"; }
|
||||
public bool Release(System.IntPtr context) { releases++; return scenario != "release"; }
|
||||
public static string Test() {
|
||||
if (DshTokenPublicKey.PublicContextFlags != 0xF0000040) throw new System.Exception("unsafe flags");
|
||||
foreach (string scenario in new [] { "ok", "provider", "container", "auth", "no-key", "get-key", "export", "size", "destroy", "release" }) {
|
||||
var fake = new FakeTokenPublicKey(scenario);
|
||||
var result = DshTokenPublicKey.Read(fake, "fake-container");
|
||||
string expected = scenario == "ok" ? "public-keys" : scenario == "provider" ? "provider-unavailable"
|
||||
: scenario == "container" ? "container-unavailable" : "unknown";
|
||||
if (result.status != expected) throw new System.Exception("wrong outcome: " + scenario);
|
||||
bool acquired = scenario != "provider" && scenario != "container" && scenario != "auth";
|
||||
if (fake.releases != (acquired ? 1 : 0)) throw new System.Exception("context leak: " + scenario);
|
||||
bool hasKey = acquired && scenario != "no-key" && scenario != "get-key";
|
||||
if (fake.destroys != (hasKey ? 1 : 0)) throw new System.Exception("key leak: " + scenario);
|
||||
if (!acquired && fake.keys != 0) throw new System.Exception("read after failed acquire");
|
||||
if (scenario == "ok" && (fake.exports != 2 || fake.keys != 2)) throw new System.Exception("incomplete read");
|
||||
if (result.status != "public-keys" && result.publicKeys.Length != 0) throw new System.Exception("partial result escaped");
|
||||
}
|
||||
return "fake CSP cases passed";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Snapshot operator-selected release files into unsigned, hash-pinned experiment inputs. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { copyFile, lstat, mkdir, readFile } from 'node:fs/promises'
|
||||
import { extname, isAbsolute, join } from 'node:path'
|
||||
import { inspectWindowsRuntimeSignature } from '../../scripts/windows-runtime-signature.mjs'
|
||||
|
||||
/** One immutable unsigned input; its source is never modified or executed. */
|
||||
export interface SigningCorpusFile {
|
||||
readonly path: string
|
||||
readonly sha256: string
|
||||
readonly bytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a nonempty JSON array of absolute paths and SHA-256 digests, then strip signatures only on copies.
|
||||
* @param manifestPath Operator-owned JSON manifest; every source must be a regular file with the pinned digest.
|
||||
* @param directory New private directory for retained unsigned templates.
|
||||
* @param removeSignature Public SignTool removal operation; never receives an original source path.
|
||||
* @param signal Stops preparation before the next file when aborted.
|
||||
* @returns Unsigned template digests and sizes in manifest order; rejects invalid signatures or changed inputs.
|
||||
*/
|
||||
export async function prepareSigningCorpus(
|
||||
manifestPath: string, directory: string, removeSignature: (path: string) => Promise<void>, signal: AbortSignal,
|
||||
): Promise<SigningCorpusFile[]> {
|
||||
const manifest: unknown = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
if (!Array.isArray(manifest) || manifest.length === 0) throw new Error('Signing corpus must be a nonempty JSON array')
|
||||
const inputs = manifest.map((entry: unknown) => {
|
||||
if (typeof entry !== 'object' || entry === null || !('path' in entry) || typeof entry.path !== 'string'
|
||||
|| !isAbsolute(entry.path) || !('sha256' in entry) || typeof entry.sha256 !== 'string'
|
||||
|| !/^[a-f0-9]{64}$/u.test(entry.sha256)) throw new Error('Signing corpus requires absolute paths and lowercase SHA-256 digests')
|
||||
return { path: entry.path, sha256: entry.sha256 }
|
||||
})
|
||||
await mkdir(directory)
|
||||
const result: SigningCorpusFile[] = []
|
||||
for (const [index, input] of inputs.entries()) {
|
||||
signal.throwIfAborted()
|
||||
const stat = await lstat(input.path)
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Signing corpus source must be a regular file')
|
||||
const path = join(directory, `${index}${extname(input.path) || '.exe'}`)
|
||||
await copyFile(input.path, path)
|
||||
if (createHash('sha256').update(await readFile(path)).digest('hex') !== input.sha256) {
|
||||
throw new Error('Signing corpus source differs from its pinned digest')
|
||||
}
|
||||
const before = await inspectWindowsRuntimeSignature(path)
|
||||
if (before.status === 'Valid') await removeSignature(path)
|
||||
else if (before.status !== 'NotSigned') throw new Error('Signing corpus source has an invalid signature')
|
||||
if ((await inspectWindowsRuntimeSignature(path)).status !== 'NotSigned') throw new Error('Signing corpus copy must be unsigned')
|
||||
const content = await readFile(path)
|
||||
result.push({ path, sha256: createHash('sha256').update(content).digest('hex'), bytes: content.length })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Bounded scheduling for the opt-in signing experiment; production signing does not use this helper. */
|
||||
|
||||
/** Operations owned by one experiment, with independently bounded completion and hardware work. */
|
||||
export interface SigningExperimentOptions<T> {
|
||||
readonly jobs: 1 | 2 | 4
|
||||
readonly hardwareConcurrency: 1 | 2 | 4
|
||||
readonly signal?: AbortSignal
|
||||
/** Complete one item, awaiting the supplied signing operation and all subsequent work before returning. */
|
||||
readonly complete: (item: T, sign: (path: string) => Promise<void>) => Promise<void>
|
||||
/** Perform one hardware request without retrying it. */
|
||||
readonly sign: (item: T, path: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule independent experiment items and stop dispatch on the first failure or cancellation.
|
||||
* @param items Ordered inputs owned by this batch.
|
||||
* @param options Completion workers, hardware limit, cancellation and experiment operations.
|
||||
* @returns Resolves after every item completes; otherwise drains issued work before rejecting with the first reason.
|
||||
* Cancellation prevents new items and hardware requests but does not terminate an issued native operation.
|
||||
*/
|
||||
export async function runSigningExperimentBatch<T>(items: readonly T[], options: SigningExperimentOptions<T>): Promise<void> {
|
||||
let failure: { reason: unknown } | undefined
|
||||
let next = 0
|
||||
let signing = 0
|
||||
const requests = new Set<Promise<void>>()
|
||||
const waiting = new Set<() => void>()
|
||||
const wake = (): void => {
|
||||
for (const resume of waiting) resume()
|
||||
waiting.clear()
|
||||
}
|
||||
const stop = (reason: unknown): void => {
|
||||
failure ??= { reason }
|
||||
wake()
|
||||
}
|
||||
const aborted = (): void => { stop(options.signal?.reason) }
|
||||
const signItem = async (item: T, path: string): Promise<void> => {
|
||||
while (failure === undefined && signing >= options.hardwareConcurrency) {
|
||||
await new Promise<void>((resume) => { waiting.add(resume) })
|
||||
}
|
||||
if (failure !== undefined) throw failure.reason
|
||||
signing++
|
||||
try { await options.sign(item, path) }
|
||||
catch (error) { stop(error); throw error }
|
||||
finally { signing--; wake() }
|
||||
}
|
||||
const worker = async (): Promise<void> => {
|
||||
while (failure === undefined && next < items.length) {
|
||||
const item = items[next++]!
|
||||
try {
|
||||
await options.complete(item, (path) => {
|
||||
const request = signItem(item, path)
|
||||
requests.add(request)
|
||||
void request.then(() => requests.delete(request), () => requests.delete(request))
|
||||
return request
|
||||
})
|
||||
}
|
||||
catch (error) { stop(error) }
|
||||
}
|
||||
}
|
||||
options.signal?.addEventListener('abort', aborted, { once: true })
|
||||
try {
|
||||
if (options.signal?.aborted) aborted()
|
||||
await Promise.all(Array.from({ length: Math.min(options.jobs, items.length) }, worker))
|
||||
// A completion failure can leave a previously issued request awaiting its native result.
|
||||
await Promise.allSettled(requests)
|
||||
if (failure !== undefined) throw failure.reason
|
||||
} finally {
|
||||
options.signal?.removeEventListener('abort', aborted)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { expect, it } from 'vitest'
|
||||
import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from '../scripts/macos-package-settings.mjs'
|
||||
|
||||
it('defaults to four workers and keeps download and Apple proxies independent', () => {
|
||||
expect(resolveMacOSPackageSettings({})).toEqual({ packConcurrency: 4 })
|
||||
expect(resolveMacOSPackageSettings({})).toEqual({ packConcurrency: 4, zipCompressionLevel: 3 })
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_PACK_CONCURRENCY: '2',
|
||||
DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: 'https://download.example:8080',
|
||||
DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: 'http://apple.example:8081',
|
||||
})).toEqual({ packConcurrency: 2, downloadProxy: 'https://download.example:8080', notarizationProxy: 'http://apple.example:8081' })
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: ' ', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: '' })).toEqual({ packConcurrency: 4 })
|
||||
})).toEqual({ packConcurrency: 2, zipCompressionLevel: 3, downloadProxy: 'https://download.example:8080', notarizationProxy: 'http://apple.example:8081' })
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_DOWNLOAD_PROXY: ' ', DSH_DESKTOP_MACOS_NOTARIZATION_PROXY: '' })).toEqual({ packConcurrency: 4, zipCompressionLevel: 3 })
|
||||
})
|
||||
|
||||
it.each(['', '0', '-1', '1.5', '1e2', 'Infinity', '9007199254740992'])('rejects invalid concurrency %j', (value) => {
|
||||
@@ -32,3 +32,11 @@ it('overrides conflicting child proxy variables while preserving the parent and
|
||||
expect(parent.HTTPS_PROXY).toBe('http://old')
|
||||
expect(parent.NO_PROXY).toBe('*')
|
||||
})
|
||||
|
||||
it.each(['', '-1', '10', '3.5', '03', 'NaN'])('rejects invalid ZIP compression %j', (value) => {
|
||||
expect(() => resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: value })).toThrow('ZIP_COMPRESSION_LEVEL')
|
||||
})
|
||||
|
||||
it.each(['0', '3', '7', '9'])('accepts ZIP compression %s', (value) => {
|
||||
expect(resolveMacOSPackageSettings({ DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: value }).zipCompressionLevel).toBe(Number(value))
|
||||
})
|
||||
@@ -1,4 +1,6 @@
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { packageTarget, parseDesktopPackageInvocation } from '../scripts/package-target.ts'
|
||||
import { withMacOSNotarizationProxy } from '../scripts/macos-notarization-proxy.ts'
|
||||
@@ -43,6 +45,97 @@ function supervisor(failure?: string) {
|
||||
return { run, stages }
|
||||
}
|
||||
|
||||
it.each(['pack', 'runtime'] as const)('overlaps Windows preparation and waits for both before signing when %s finishes first', async (first) => {
|
||||
const { run, stages } = supervisor()
|
||||
const pack = Promise.withResolvers<undefined>()
|
||||
const runtime = Promise.withResolvers<undefined>()
|
||||
const finished = new Set<string>()
|
||||
run.run.mockImplementation(async (stage) => {
|
||||
stages.push(stage)
|
||||
if (stage.startsWith('run release:pack --family dsh ')) await pack.promise
|
||||
if (stage === 'run prepare:runtime --defer-primary-runtime-smoke') {
|
||||
await runtime.promise
|
||||
finished.add('runtime')
|
||||
}
|
||||
if (stage.startsWith('--dir native/system/packages/entry pack ')) finished.add('pack')
|
||||
if (stage === 'run sign:primary-runtime') expect([...finished].sort()).toEqual(['pack', 'runtime'])
|
||||
})
|
||||
const operation = packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run)
|
||||
const outcome = operation.then(() => undefined, (error: unknown) => error)
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(stages.some(stage => stage.startsWith('run release:pack --family dsh '))).toBe(true)
|
||||
expect(stages).toContain('run prepare:runtime --defer-primary-runtime-smoke')
|
||||
})
|
||||
expect(stages).not.toContain('run sign:primary-runtime')
|
||||
;(first === 'pack' ? pack : runtime).resolve(undefined)
|
||||
await vi.waitFor(() => { expect(finished.has(first)).toBe(true) })
|
||||
expect(stages).not.toContain('run sign:primary-runtime')
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
;(first === 'pack' ? runtime : pack).resolve(undefined)
|
||||
expect(await outcome).toBeUndefined()
|
||||
expect(stages).toContain('run sign:primary-runtime')
|
||||
expect(writeFileSync).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
pack.resolve(undefined)
|
||||
runtime.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['pack', 'runtime'] as const)('drains the other Windows preparation after %s fails without starting later packing or signing', async (failed) => {
|
||||
const { run, stages } = supervisor()
|
||||
const pack = Promise.withResolvers<undefined>()
|
||||
const runtime = Promise.withResolvers<undefined>()
|
||||
const failure = new Error(`${failed} preparation failed`)
|
||||
let settled = false
|
||||
run.run.mockImplementation(async (stage) => {
|
||||
stages.push(stage)
|
||||
if (stage.startsWith('run release:pack --family dsh ')) await pack.promise
|
||||
if (stage === 'run prepare:runtime --defer-primary-runtime-smoke') await runtime.promise
|
||||
})
|
||||
const outcome = packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), environment, run).then(
|
||||
() => { settled = true; return undefined },
|
||||
(error: unknown) => { settled = true; return error },
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
expect(stages.some(stage => stage.startsWith('run release:pack --family dsh '))).toBe(true)
|
||||
expect(stages).toContain('run prepare:runtime --defer-primary-runtime-smoke')
|
||||
})
|
||||
;(failed === 'pack' ? pack : runtime).reject(failure)
|
||||
await setImmediate()
|
||||
expect(settled).toBe(false)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
;(failed === 'pack' ? runtime : pack).resolve(undefined)
|
||||
expect(await outcome).toBe(failure)
|
||||
expect(stages.filter(stage => stage.startsWith('run release:pack'))).toHaveLength(1)
|
||||
expect(stages.some(stage => stage.startsWith('--dir apps/desktop-host pack'))).toBe(false)
|
||||
expect(stages.some(stage => stage.startsWith('--dir native/system'))).toBe(false)
|
||||
expect(stages.some(stage => stage.startsWith('run sign:primary-runtime'))).toBe(false)
|
||||
expect(stages.some(stage => stage.startsWith('run prepare:packages') || stage.startsWith('run prepare:dsh'))).toBe(false)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
pack.resolve(undefined)
|
||||
runtime.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
|
||||
it.each([undefined, '3'])('passes the package cache and selected compression to their subprocesses (%s)', async (level) => {
|
||||
const { run } = supervisor()
|
||||
const cache = resolve('fixture-package-cache')
|
||||
await packageTarget(parseDesktopPackageInvocation(['win-x64'], 'win32', 'x64'), {
|
||||
...environment, DSH_DESKTOP_PACKAGE_CACHE_DIR: cache,
|
||||
...(level === undefined ? {} : { DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL: level }),
|
||||
ELECTRON_BUILDER_COMPRESSION_LEVEL: '0',
|
||||
}, run)
|
||||
const prepare = run.run.mock.calls.find(([stage]) => stage.startsWith('run prepare:dsh'))!
|
||||
expect(prepare[3].env.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe(cache)
|
||||
const builder = run.run.mock.calls.find(([stage]) => stage.startsWith('exec electron-builder'))!
|
||||
expect(builder[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '0')
|
||||
})
|
||||
|
||||
it.each([undefined, '2'])('uses bounded Windows packing while retaining signing and smoke (workers=%s)', async (workers) => {
|
||||
const { run, stages } = supervisor()
|
||||
const settings = workers === undefined ? environment : { ...environment, DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY: workers }
|
||||
@@ -147,7 +240,10 @@ it.each([
|
||||
const invocation = parseDesktopPackageInvocation([target, '--unsigned'],
|
||||
target === 'win-x64' ? 'win32' : 'darwin', target === 'win-x64' ? 'x64' : 'arm64')
|
||||
await expect(packageTarget(invocation, environment, run)).rejects.toThrow('stage refused')
|
||||
expect(stages.at(-1)?.startsWith(failure)).toBe(true)
|
||||
const failedAt = stages.findIndex(stage => stage.startsWith(failure))
|
||||
expect(failedAt).toBeGreaterThanOrEqual(0)
|
||||
expect(stages.slice(failedAt + 1)).toEqual(
|
||||
target === 'win-x64' && failure.startsWith('exec tsx') ? ['run prepare:runtime'] : [])
|
||||
expect(stages.some(stage => stage.startsWith('--dir apps/desktop-host pack'))).toBe(false)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -253,7 +349,8 @@ it.each([false, true])('keeps signing and runtime verification when notarization
|
||||
await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), {
|
||||
...environment, CSC_LINK: 'signing.p12', CSC_KEY_PASSWORD: 'fixture-password',
|
||||
}, run)
|
||||
expect(stages.at(-2)).toBe(`exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never${directory ? ' --dir' : ''}`)
|
||||
expect(stages).toContain('exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never --dir')
|
||||
if (!directory) expect(stages.at(-2)).toContain('--mac zip')
|
||||
expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization')
|
||||
expect(run.run.mock.calls.at(-2)![3].env).toMatchObject({
|
||||
DSH_DESKTOP_UNSIGNED: '0', DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', CSC_LINK: 'signing.p12',
|
||||
@@ -263,6 +360,34 @@ it.each([false, true])('keeps signing and runtime verification when notarization
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['mac-arm64', 'mac-x64'] as const)('applies ZIP compression without changing DMG settings when skipping notarization for %s', async (target) => {
|
||||
for (const level of [undefined, '3', '7']) {
|
||||
const { run } = supervisor()
|
||||
await packageTarget(parseDesktopPackageInvocation([target, '--skip-notarization'], 'darwin', 'arm64'), {
|
||||
...environment, ELECTRON_BUILDER_COMPRESSION_LEVEL: '9',
|
||||
...(level === undefined ? {} : { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: level }),
|
||||
}, run)
|
||||
const builders = run.run.mock.calls.filter(call => call[0].startsWith('exec electron-builder'))
|
||||
expect(builders).toHaveLength(3)
|
||||
const [app, dmg, zip] = builders
|
||||
expect(app![0]).toContain('--dir')
|
||||
expect(dmg![0]).toContain('--mac dmg')
|
||||
expect(zip![0]).toContain('--mac zip')
|
||||
expect(app![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9')
|
||||
expect(dmg![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9')
|
||||
expect(zip![3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '3')
|
||||
for (const call of [dmg!, zip!]) {
|
||||
expect(call[0]).toContain('--prepackaged')
|
||||
expect(call[0]).toContain('unnotarized-artifacts')
|
||||
expect(call[3].env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION).toBe('1')
|
||||
expect(call[3].env.DSH_DESKTOP_UNSIGNED).toBe('0')
|
||||
}
|
||||
}
|
||||
expect(packageMacOSArtifacts).not.toHaveBeenCalled()
|
||||
expect(withMacOSNotarizationProxy).not.toHaveBeenCalled()
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails debugging packages when runtime verification fails', async () => {
|
||||
const failure = 'exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization'
|
||||
const { run, stages } = supervisor(failure)
|
||||
@@ -271,3 +396,29 @@ it('fails debugging packages when runtime verification fails', async () => {
|
||||
expect(stages.at(-1)).toBe(failure)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops unnotarized ZIP packaging when the DMG stage fails', async () => {
|
||||
const failure = 'exec electron-builder --config electron-builder.config.mjs --mac dmg'
|
||||
const { run, stages } = supervisor(failure)
|
||||
await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64'), environment, run))
|
||||
.rejects.toThrow('stage refused')
|
||||
expect(stages.some(stage => stage.includes('--mac zip'))).toBe(false)
|
||||
expect(stages.some(stage => stage.includes('smoke-packaged-runtime'))).toBe(false)
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([undefined, '7'])('applies file-owned compression only to the final notarized ZIP (%s)', async (level) => {
|
||||
const { run } = supervisor()
|
||||
vi.mocked(packageMacOSArtifacts).mockImplementationOnce(async (_request, build) => {
|
||||
await build({ format: 'dmg', appPath: '/fixture/app', output: '/fixture/dmg' })
|
||||
await build({ format: 'zip', appPath: '/fixture/app', output: '/fixture/zip' })
|
||||
})
|
||||
const settings = { ...environment, ELECTRON_BUILDER_COMPRESSION_LEVEL: '9',
|
||||
...(level === undefined ? {} : { DSH_DESKTOP_MACOS_ZIP_COMPRESSION_LEVEL: level }) }
|
||||
await packageTarget(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64'), settings, run)
|
||||
const zip = run.run.mock.calls.find(call => call[0].includes('--mac zip'))
|
||||
const dmg = run.run.mock.calls.find(call => call[0].includes('--mac dmg'))
|
||||
expect(zip?.[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe(level ?? '3')
|
||||
expect(dmg?.[3].env.ELECTRON_BUILDER_COMPRESSION_LEVEL).toBe('9')
|
||||
expect(writeFileSync).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -65,32 +65,84 @@ it('bounds overlapping cache restores and verifies all hits before serial hardwa
|
||||
expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name)))
|
||||
})
|
||||
|
||||
it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => {
|
||||
it('drains active cache restores after failure without dispatching more work', async () => {
|
||||
const root = await fixture(['a.exe', 'b.exe', 'c.exe'])
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const failed = Promise.withResolvers<undefined>()
|
||||
const restored = new Set<string>()
|
||||
const calls: string[] = []
|
||||
const sign = vi.fn(async () => {})
|
||||
let settled = false
|
||||
const operation = signWindowsCode(root, { thumbprint, sign,
|
||||
record: (event) => {
|
||||
if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') {
|
||||
failed.resolve(undefined)
|
||||
throw new Error('audit failed')
|
||||
}
|
||||
},
|
||||
inspect: async (path) => {
|
||||
if (!restored.has(path)) return unsigned
|
||||
if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') }
|
||||
return valid
|
||||
},
|
||||
record: () => {},
|
||||
inspect: async () => unsigned,
|
||||
cache: { concurrency: 2, restore: async ({ path }) => {
|
||||
calls.push(path)
|
||||
if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false }
|
||||
await entered.promise
|
||||
if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') }
|
||||
failed.resolve(undefined)
|
||||
throw new Error('restore failed')
|
||||
} },
|
||||
}).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error })
|
||||
try {
|
||||
await Promise.race([failed.promise, operation])
|
||||
await new Promise<void>(resolve => setImmediate(resolve))
|
||||
expect(settled).toBe(false)
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
} finally { release.resolve(undefined); await operation }
|
||||
expect(await operation).toEqual(new Error('restore failed'))
|
||||
expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name)))
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['trust', 'timestamp', 'certificate', 'audit'])('rejects restored target %s failures before hardware signing', async (failure) => {
|
||||
const root = await fixture(['a.exe', 'b.exe', 'miss.exe'])
|
||||
const restored = new Set<string>()
|
||||
let restores = 0
|
||||
const sign = vi.fn(async () => {})
|
||||
await expect(signWindowsCode(root, { thumbprint, sign,
|
||||
record: (event) => {
|
||||
if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') throw new Error('audit failed')
|
||||
},
|
||||
inspect: async (path) => {
|
||||
if (!restored.has(path)) return unsigned
|
||||
expect(restores).toBe(3)
|
||||
return {
|
||||
...valid,
|
||||
...(failure === 'trust' ? { status: 'HashMismatch' } : {}),
|
||||
...(failure === 'timestamp' ? { timestamped: false } : {}),
|
||||
...(failure === 'certificate' ? { thumbprint: 'B'.repeat(40) } : {}),
|
||||
}
|
||||
},
|
||||
cache: { concurrency: 2, restore: async ({ path }) => {
|
||||
restores++
|
||||
if (path.endsWith('miss.exe')) return false
|
||||
restored.add(path)
|
||||
return true
|
||||
} },
|
||||
})).rejects.toThrow(failure === 'audit' ? 'audit failed' : 'signing verification failed')
|
||||
expect(restores).toBe(3)
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drains failed restored-target inspection before returning and never signs misses', async () => {
|
||||
const root = await fixture(['a.exe', 'b.exe', 'miss.exe'])
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const failed = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const restored = new Set<string>()
|
||||
const sign = vi.fn(async () => {})
|
||||
let settled = false
|
||||
const operation = signWindowsCode(root, { thumbprint, sign, record: () => {},
|
||||
inspect: async (path) => {
|
||||
if (!restored.has(path)) return unsigned
|
||||
if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return valid }
|
||||
await entered.promise
|
||||
failed.resolve(undefined)
|
||||
throw new Error('verification failed')
|
||||
},
|
||||
cache: { concurrency: 2, restore: async ({ path }) => {
|
||||
if (path.endsWith('miss.exe')) return false
|
||||
restored.add(path)
|
||||
return true
|
||||
} },
|
||||
@@ -101,8 +153,7 @@ it.each(['restore', 'verification', 'audit'])('drains active cache restores afte
|
||||
expect(settled).toBe(false)
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
} finally { release.resolve(undefined); await operation }
|
||||
expect(await operation).toEqual(new Error(`${failure} failed`))
|
||||
expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name)))
|
||||
expect(await operation).toEqual(new Error('verification failed'))
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const state = vi.hoisted(() => ({
|
||||
pnpm: '11.7.0' as string | undefined,
|
||||
failure: undefined as 'electron' | 'primary' | 'cli' | undefined,
|
||||
preparePrimary: vi.fn(),
|
||||
extractElectron: vi.fn(),
|
||||
commandLink: vi.fn(),
|
||||
}))
|
||||
vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({
|
||||
@@ -22,9 +23,7 @@ vi.mock('../scripts/desktop-build-paths.mjs', async importOriginal => ({
|
||||
}),
|
||||
}))
|
||||
vi.mock('@electron/get', () => ({ downloadArtifact: async () => 'electron.zip' }))
|
||||
vi.mock('extract-zip', () => ({ default: async () => {
|
||||
if (state.failure === 'electron') throw new Error('Electron extraction failed')
|
||||
} }))
|
||||
vi.mock('extract-zip', () => ({ default: state.extractElectron }))
|
||||
vi.mock('node:child_process', async importOriginal => ({
|
||||
...await importOriginal<typeof import('node:child_process')>(),
|
||||
execFileSync: (file: string) => file === '/usr/libexec/PlistBuddy' ? '12.0\n' : '24.18.1\n',
|
||||
@@ -54,6 +53,9 @@ beforeEach(() => {
|
||||
state.failure = undefined
|
||||
process.argv = [process.execPath, 'prepare-runtime.ts']
|
||||
vi.stubEnv('DSH_DESKTOP_PACKAGING_RUN_DIR', undefined)
|
||||
state.extractElectron.mockImplementation(async () => {
|
||||
if (state.failure === 'electron') throw new Error('Electron extraction failed')
|
||||
})
|
||||
state.preparePrimary.mockImplementation(async () => {
|
||||
if (state.failure === 'primary') throw new Error('primary preparation failed')
|
||||
const root = join(state.root, 'runtime', 'primary-runtime')
|
||||
@@ -118,3 +120,94 @@ it.each([
|
||||
await expect(import('../scripts/prepare-runtime.ts')).rejects.toThrow(error)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['electron', 'primary'] as const)('overlaps Windows payloads and waits for %s to finish last before publishing CLI and versions', async (last) => {
|
||||
state.target = 'win-x64'
|
||||
const electron = Promise.withResolvers<undefined>()
|
||||
const primary = Promise.withResolvers<undefined>()
|
||||
const started = new Set<string>()
|
||||
const finished = new Set<string>()
|
||||
const preparePrimary = state.preparePrimary.getMockImplementation()!
|
||||
state.extractElectron.mockImplementation(async () => {
|
||||
started.add('electron')
|
||||
await electron.promise
|
||||
finished.add('electron')
|
||||
})
|
||||
state.preparePrimary.mockImplementation(async () => {
|
||||
started.add('primary')
|
||||
await primary.promise
|
||||
await preparePrimary()
|
||||
finished.add('primary')
|
||||
})
|
||||
const outcome = import('../scripts/prepare-runtime.ts').then(() => undefined, (error: unknown) => error)
|
||||
try {
|
||||
await vi.waitFor(() => { expect([...started].sort()).toEqual(['electron', 'primary']) }, { timeout: 5000 })
|
||||
;(last === 'electron' ? primary : electron).resolve(undefined)
|
||||
await vi.waitFor(() => { expect(finished.size).toBe(1) }, { timeout: 5000 })
|
||||
expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false)
|
||||
;(last === 'electron' ? electron : primary).resolve(undefined)
|
||||
expect(await outcome).toBeUndefined()
|
||||
expect(existsSync(join(state.root, 'runtime', 'cli', 'command-manager.js'))).toBe(true)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(true)
|
||||
} finally {
|
||||
electron.resolve(undefined)
|
||||
primary.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['electron', 'primary'] as const)('drains the sibling after Windows %s failure without publishing CLI or versions', async (failed) => {
|
||||
state.target = 'win-x64'
|
||||
const electron = Promise.withResolvers<undefined>()
|
||||
const primary = Promise.withResolvers<undefined>()
|
||||
const started = new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
const failure = new Error(`${failed} payload failed`)
|
||||
const prepare = async (name: 'electron' | 'primary', barrier: Promise<undefined>) => {
|
||||
started.add(name)
|
||||
try { await barrier } finally { completed.add(name) }
|
||||
}
|
||||
state.extractElectron.mockImplementation(() => prepare('electron', electron.promise))
|
||||
state.preparePrimary.mockImplementation(() => prepare('primary', primary.promise))
|
||||
const outcome = import('../scripts/prepare-runtime.ts').then(
|
||||
() => ({ error: undefined, completed: [...completed].sort() }),
|
||||
(error: unknown) => ({ error, completed: [...completed].sort() }),
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => { expect([...started].sort()).toEqual(['electron', 'primary']) }, { timeout: 5000 })
|
||||
;(failed === 'electron' ? electron : primary).reject(failure)
|
||||
await vi.waitFor(() => { expect(completed.has(failed)).toBe(true) }, { timeout: 5000 })
|
||||
expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false)
|
||||
;(failed === 'electron' ? primary : electron).resolve(undefined)
|
||||
expect(await outcome).toEqual({ error: failure, completed: ['electron', 'primary'] })
|
||||
expect(existsSync(join(state.root, 'runtime', 'cli'))).toBe(false)
|
||||
expect(existsSync(join(state.root, 'runtime', 'versions.json'))).toBe(false)
|
||||
} finally {
|
||||
electron.resolve(undefined)
|
||||
primary.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['mac-arm64', 'mac-x64'] as const)('keeps %s primary preparation after Electron extraction', async (target) => {
|
||||
state.target = target
|
||||
const electron = Promise.withResolvers<undefined>()
|
||||
let extracting = false
|
||||
state.extractElectron.mockImplementation(async () => {
|
||||
extracting = true
|
||||
await electron.promise
|
||||
})
|
||||
const outcome = import('../scripts/prepare-runtime.ts').then(() => undefined, (error: unknown) => error)
|
||||
try {
|
||||
await vi.waitFor(() => { expect(extracting).toBe(true) }, { timeout: 5000 })
|
||||
expect(state.preparePrimary).not.toHaveBeenCalled()
|
||||
electron.resolve(undefined)
|
||||
expect(await outcome).toBeUndefined()
|
||||
expect(state.preparePrimary).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
electron.resolve(undefined)
|
||||
await outcome
|
||||
}
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { DESKTOP_HOST_PACKAGE, DESKTOP_HOST_RUNTIME_FILES } from '../src/core-package-set.ts'
|
||||
import { DESKTOP_RUNTIME_FILE, readDesktopRuntime, runtimePath, verifyDesktopRuntime } from '../src/runtime-tree.ts'
|
||||
import { DESKTOP_RUNTIME_FILE, readDesktopRuntime, runtimePath, verifyDesktopRuntime, writeDesktopRuntime, writeDesktopRuntimeAsync } from '../src/runtime-tree.ts'
|
||||
import { runtimeFixture } from './runtime-fixture.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -15,6 +15,29 @@ function fixture(): string {
|
||||
}
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
it('seals final file bytes identically with concurrent and synchronous inventory', async () => {
|
||||
const dsh = join(fixture(), 'dsh')
|
||||
const original = readDesktopRuntime(dsh)
|
||||
const names = original.sharedPackages.map(entry => entry.name)
|
||||
writeFileSync(join(dsh, 'extra.bin'), Buffer.from([0, 1, 255]))
|
||||
writeFileSync(join(dsh, 'package.json'), '{"type":"module","private":true}\n')
|
||||
const expected = writeDesktopRuntime(dsh, original.release, names)
|
||||
const expectedBytes = readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))
|
||||
expect(await writeDesktopRuntimeAsync(dsh, original.release, names)).toEqual(expected)
|
||||
expect(readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))).toEqual(expectedBytes)
|
||||
expect(await verifyDesktopRuntime(dsh, original.release.version)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(['manifest', 'link'])('leaves the descriptor unchanged when concurrent sealing rejects a %s', async (failure) => {
|
||||
const dsh = join(fixture(), 'dsh')
|
||||
const original = readDesktopRuntime(dsh)
|
||||
const before = readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))
|
||||
if (failure === 'manifest') writeFileSync(join(dsh, 'node_modules', DESKTOP_HOST_PACKAGE, 'package.json'), '{}')
|
||||
else symlinkSync(join(dsh, 'node_modules'), join(dsh, 'outside'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
await expect(writeDesktopRuntimeAsync(dsh, original.release, original.sharedPackages.map(entry => entry.name))).rejects.toThrow()
|
||||
expect(readFileSync(join(dsh, DESKTOP_RUNTIME_FILE))).toEqual(before)
|
||||
})
|
||||
|
||||
it('verifies a runtime after relocation without depending on build paths', async () => {
|
||||
const root = fixture()
|
||||
const before = await verifyDesktopRuntime(join(root, 'dsh'), '1.0.0')
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/tests/signing-concurrency/README.md
|
||||
/:
|
||||
en: cde5fda2a2f4b53f
|
||||
zh: 19dbeb4af20f26fa
|
||||
/windows-signing-concurrency-qualification:
|
||||
en: bd245a9d93c44d46
|
||||
zh: eb74f3edc9bb1f97
|
||||
/windows-signing-concurrency-qualification/summary:
|
||||
en: 0d910efcaf9434da
|
||||
zh: 0e6e179620e82713
|
||||
/windows-signing-concurrency-qualification/table-of-contents:
|
||||
en: 498f69e40952088d
|
||||
zh: 506c81b0f6879e9a
|
||||
/windows-signing-concurrency-qualification/retest-on-another-machine:
|
||||
en: b90878b9b5756cc9
|
||||
zh: e69d017153c10660
|
||||
/windows-signing-concurrency-qualification/measured-reference:
|
||||
en: c2d5b47db6ce1e20
|
||||
zh: ca1182b064d63081
|
||||
/windows-signing-concurrency-qualification/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: "Windows signing concurrency measurements and retesting on replacement runners."
|
||||
---
|
||||
|
||||
# Windows signing concurrency qualification
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use this record when choosing signing concurrency on a replacement Windows runner. Results apply to the measured host, token and software combination; repeat qualification after changing them. Production signing remains serial.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Retest on another machine](#retest-on-another-machine)
|
||||
- [Measured reference](#measured-reference)
|
||||
|
||||
<a id="retest-on-another-machine"></a>
|
||||
|
||||
## Retest on another machine
|
||||
|
||||
1. Follow the [operator-approved experiment procedure](../../windows-signing/README.md#verification), using a hash-pinned corpus of real runtime, application and installer files. Compare the same inputs across modes; preserve originals and use isolated copies without signature-cache reuse.
|
||||
2. Run `--corpus` with `--rounds 2` to compare 1/1, 2/2 and 4/4 completion workers/hardware requests, reversing the second round. Run the same corpus with `--pipeline-control --rounds 2` for the 4/1 control. Retain the procedure's first-failure stop and interlock handling.
|
||||
3. Record the date, test and artifact versions, corpus count/bytes/largest file, CPU/RAM/disk, token/client/provider/SignTool versions, timestamp service, competing jobs and resource pressure. Keep both batch wall times and failure/retry counts; retain detailed output outside Git without credentials. Select a candidate only from completely verified runs, then validate it in the complete signed release workflow. Similar results favor fewer hardware requests; unexpected variation needs more rounds.
|
||||
|
||||
<a id="measured-reference"></a>
|
||||
|
||||
## Measured reference
|
||||
|
||||
On 2026-10-22, the experiment in PR #6675 used retained files from release run 39725804188, version `0.2.1-rc.2.20261022.50.2`. Each batch contained 148 runtime and 8 application/installer targets: 156 files, 849.2 MiB unsigned, largest 277.5 MiB. Deleted transient targets and repeated intermediate signing passes were excluded.
|
||||
|
||||
The host had a Ryzen AI 9 HX 470 (12 cores/24 threads), 32 GiB RAM and a 1 TB UMIS RPJYJ1T24RLS1QWY disk. The operator identified the token as SafeNet 5110; the precise variant/firmware was not verified. Signing used SafeNet Authentication Client 10.9.4482.0, `eToken Base Cryptographic Provider` and the unchanged machine test preset's SignTool/timestamp service. Public signature removal alone used SDK 10.0.26100.0 SignTool. Mode-average host CPU was 7.6–8.9%, minimum sampled free RAM was 9.9 GiB, and no Runner.Worker was sampled; other Node processes shared the host.
|
||||
|
||||
Times below are batch wall seconds, including signing, timestamp completion and all file verification. They exclude preparation, lock waiting, build, cache lookup, package assembly and upload. File-system caches were uncontrolled; these are not cold/hot full-release measurements.
|
||||
|
||||
| Completion workers / hardware requests | Round 1 (s) | Round 2 (s) | Mean (s) | Reduction vs serial |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 1 / 1 | 732.3 | 718.5 | 725.4 | — |
|
||||
| 2 / 2 | 398.3 | 394.6 | 396.5 | 45.3% |
|
||||
| 4 / 4 | 459.4 | 465.0 | 462.2 | 36.3% |
|
||||
| 4 / 1, separate control | 427.7 | 428.3 | 428.0 | 41.0% |
|
||||
|
||||
All 1,248 signatures, timestamps and final validations passed, with zero hardware failures/timeouts or timestamp retries. Original files and signature-cache payloads remained unchanged. The 2/2 mode was fastest; 4/4 was slower, while pipeline overlap alone delivered most of the observed benefit. This selects candidates for further release testing, not a production default or evidence of parallel operations inside the token. It does not establish a ten-minute complete release.
|
||||
|
||||
## Dev Note
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
description: "Windows 签名并发实测结果与更换 runner 后的复测方法。"
|
||||
---
|
||||
|
||||
# Windows 签名并发验收
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 摘要
|
||||
|
||||
更换 Windows runner 后,使用本记录选择签名并发参数。结果仅适用于实测的主机、UKey 和软件组合;更换后需重新验收。生产签名仍为串行。
|
||||
|
||||
## 目录
|
||||
|
||||
- [在另一台机器上复测](#retest-on-another-machine)
|
||||
- [实测参考](#measured-reference)
|
||||
|
||||
<a id="retest-on-another-machine"></a>
|
||||
|
||||
## 在另一台机器上复测
|
||||
|
||||
1. 按[经操作人员授权的实验流程](../../windows-signing/README.zh.md#verification),使用哈希固定的真实 runtime、应用和安装器文件集。各模式使用相同输入,保留原件,使用隔离副本且不复用签名缓存。
|
||||
2. 使用 `--corpus` 和 `--rounds 2` 比较完成任务数/硬件请求数为 1/1、2/2、4/4 的模式,第二轮反转顺序。对相同文件集使用 `--pipeline-control --rounds 2` 测量 4/1 对照组。保留流程中的首错停止和 interlock 处理。
|
||||
3. 记录日期、测试及产物版本、文件数/总大小/最大文件、CPU/内存/磁盘、UKey/客户端/provider/SignTool 版本、时间戳服务、并行任务和资源压力。保留两轮整批墙钟时间及失败/重试次数;详细输出保存在 Git 外且不含凭据。仅从全部验证通过的结果中选择候选参数,再用完整签名发布流程验收。结果接近时优先减少硬件请求;波动异常时增加轮次。
|
||||
|
||||
<a id="measured-reference"></a>
|
||||
|
||||
## 实测参考
|
||||
|
||||
2026-10-22,PR #6675 中的实验使用发布 run 39725804188 保留的文件,版本为 `0.2.1-rc.2.20261022.50.2`。每批包含 148 个 runtime 和 8 个应用/安装器签名目标:共 156 个文件,无签名总大小 849.2 MiB,最大文件 277.5 MiB。不含已删除的临时目标及中间产物的重复签名调用。
|
||||
|
||||
主机使用 Ryzen AI 9 HX 470(12 核/24 线程)、32 GiB 内存和 1 TB UMIS RPJYJ1T24RLS1QWY 磁盘。操作人员确认 UKey 为 SafeNet 5110,未核实具体变体/固件。签名使用 SafeNet Authentication Client 10.9.4482.0、`eToken Base Cryptographic Provider`,以及未变更的机器 test preset 中的 SignTool/时间戳服务。仅公开移除签名使用 SDK 10.0.26100.0 SignTool。各模式主机平均 CPU 为 7.6–8.9%,采样可用内存最低 9.9 GiB,未采样到 Runner.Worker;另有 Node 进程共享主机。
|
||||
|
||||
下表为整批墙钟秒数,包含签名、时间戳完成及全部文件验证。不含准备、等锁、构建、缓存查询、包组装和上传。文件系统缓存未受控,因此不是完整发布的冷/热缓存测量。
|
||||
|
||||
| 完成任务数 / 硬件请求数 | 第 1 轮(秒) | 第 2 轮(秒) | 平均(秒) | 相对串行减少 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 1 / 1 | 732.3 | 718.5 | 725.4 | — |
|
||||
| 2 / 2 | 398.3 | 394.6 | 396.5 | 45.3% |
|
||||
| 4 / 4 | 459.4 | 465.0 | 462.2 | 36.3% |
|
||||
| 4 / 1,单独对照组 | 427.7 | 428.3 | 428.0 | 41.0% |
|
||||
|
||||
共 1,248 次签名、时间戳及最终验证全部通过,硬件失败/超时及时间戳重试均为零。原文件和签名缓存内容保持不变。2/2 模式最快,4/4 更慢;仅流水线重叠已获得大部分实测收益。这些结果用于选择后续发布测试的候选参数,不是生产默认值,也不能证明 UKey 内部并行执行。它们不能证明完整发布达到十分钟。
|
||||
|
||||
## 开发备注
|
||||
|
||||
无。
|
||||
@@ -0,0 +1,66 @@
|
||||
# Exercise priority inheritance in disposable children without changing the test host or runner.
|
||||
[CmdletBinding()]
|
||||
param([switch]$Child, [switch]$Failure)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if (-not $IsWindows) { throw 'Requires PowerShell 7 on Windows.' }
|
||||
$helper = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../scripts/windows-packaging-priority.ps1'))
|
||||
if ($Failure) {
|
||||
Add-Type 'public static class DshPackagingPriority { public static int Read(int kind) { throw new System.InvalidOperationException("fixture priority access denied"); } }'
|
||||
$warnings = @()
|
||||
$report = & $helper -BestEffort -WarningVariable warnings 3>$null | ConvertFrom-Json
|
||||
if ($report.comparable -ne $false -or $warnings.Count -ne 1) { throw 'Missing degraded scheduling report.' }
|
||||
$rejected = $false
|
||||
try { & $helper } catch { $rejected = $true }
|
||||
if (-not $rejected) { throw 'Strict priority inspection swallowed failure.' }
|
||||
exit 0
|
||||
}
|
||||
if ($Child) {
|
||||
$null = & $helper -InspectOnly
|
||||
$self = [Diagnostics.Process]::GetCurrentProcess()
|
||||
try {
|
||||
$self.PriorityClass = [Diagnostics.ProcessPriorityClass]::BelowNormal
|
||||
[DshPackagingPriority]::Set(33, 1)
|
||||
[DshPackagingPriority]::Set(39, 2)
|
||||
$report = (& $helper | ConvertFrom-Json)
|
||||
if ($report.before.cpu -ne 'BelowNormal' -or $report.before.io -ne 1 -or $report.before.memory -ne 2) {
|
||||
throw 'Fixture did not establish background priority.'
|
||||
}
|
||||
if ($report.after.cpu -ne 'Normal' -or $report.after.io -ne 2 -or $report.after.memory -ne 5) {
|
||||
throw 'Packaging shell retained background priority.'
|
||||
}
|
||||
$inherited = (& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $helper -InspectOnly | ConvertFrom-Json)
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Priority inspection child failed.' }
|
||||
if ($inherited.before.cpu -ne 'Normal' -or $inherited.before.io -ne 2 -or $inherited.before.memory -ne 5) {
|
||||
throw 'Packaging child did not inherit normal priority.'
|
||||
}
|
||||
} finally {
|
||||
$self.Dispose()
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
$process = [Diagnostics.Process]::new()
|
||||
$process.StartInfo.FileName = Join-Path $PSHOME 'pwsh.exe'
|
||||
$process.StartInfo.UseShellExecute = $false
|
||||
$process.StartInfo.CreateNoWindow = $true
|
||||
$process.StartInfo.RedirectStandardError = $true
|
||||
foreach ($argument in @('-NoProfile', '-File', $PSCommandPath, '-Child')) {
|
||||
$process.StartInfo.ArgumentList.Add($argument)
|
||||
}
|
||||
$started = $false
|
||||
try {
|
||||
$started = $process.Start()
|
||||
if (-not $started) { throw 'Could not start priority test.' }
|
||||
$errorOutput = $process.StandardError.ReadToEndAsync()
|
||||
if (-not $process.WaitForExit(30000)) { throw 'Priority test exceeded 30 seconds.' }
|
||||
if (-not $errorOutput.Wait(5000)) { throw 'Priority test error stream did not close.' }
|
||||
if ($process.ExitCode -ne 0) { throw "Priority test failed: $($process.ExitCode): $($errorOutput.Result)" }
|
||||
} finally {
|
||||
if ($started -and -not $process.HasExited) {
|
||||
$process.Kill($true)
|
||||
$process.WaitForExit()
|
||||
}
|
||||
$process.Dispose()
|
||||
}
|
||||
& (Join-Path $PSHOME 'pwsh.exe') -NoProfile -File $PSCommandPath -Failure
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Priority failure policy test failed.' }
|
||||
Write-Output 'Packaging priority normalization, child inheritance and failure policy passed.'
|
||||
@@ -14,6 +14,11 @@ param([string]$RunnerRoot, [string]$ConfigRoot, [switch]$CheckOnly)
|
||||
if ($CheckOnly) { exit 0 }
|
||||
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class ConsoleProbe { [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); }'
|
||||
Add-Content -LiteralPath (Join-Path $RunnerRoot 'consoles.txt') -Value ([ConsoleProbe]::GetConsoleWindow().ToInt64())
|
||||
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class SchedulingProbe { [DllImport("ntdll.dll")] static extern int NtQueryInformationProcess(IntPtr handle, int kind, out int value, int size, out int length); public static int Read(IntPtr handle, int kind) { int value, length; int status = NtQueryInformationProcess(handle, kind, out value, 4, out length); if (status != 0) throw new InvalidOperationException("Priority query failed: " + status); return value; } }'
|
||||
$process = [Diagnostics.Process]::GetCurrentProcess()
|
||||
try {
|
||||
@{ Cpu = $process.PriorityClass.ToString(); Io = [SchedulingProbe]::Read($process.Handle, 33); Memory = [SchedulingProbe]::Read($process.Handle, 39) } | ConvertTo-Json -Compress | Add-Content -LiteralPath (Join-Path $RunnerRoot 'priorities.jsonl')
|
||||
} finally { $process.Dispose() }
|
||||
Add-Content -LiteralPath (Join-Path $RunnerRoot 'starts.txt') -Value $PID
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $RunnerRoot 'failed-once'))) {
|
||||
Set-Content -LiteralPath (Join-Path $RunnerRoot 'failed-once') -Value 'yes'
|
||||
@@ -50,6 +55,7 @@ try {
|
||||
$peOffset = [BitConverter]::ToInt32($image, 0x3c)
|
||||
if ([BitConverter]::ToUInt16($image, $peOffset + 24 + 68) -ne 2) { throw 'Launcher must use the Windows GUI subsystem.' }
|
||||
if ($task.State -ne 'Disabled' -or $task.Settings.ExecutionTimeLimit -ne 'PT0S' -or $task.Settings.MultipleInstances -ne 'IgnoreNew') { throw 'Unexpected task scheduling settings.' }
|
||||
if ($task.Settings.Priority -ne 4) { throw 'Runner task must use normal CPU, I/O and memory priority.' }
|
||||
Invoke-Manager install -ExpectFailure
|
||||
$runScript = Join-Path $runner 'dsh-task/run.ps1'
|
||||
$originalRun = Get-Content -LiteralPath $runScript -Raw
|
||||
@@ -75,6 +81,8 @@ if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot 'inherited-writer-star
|
||||
if ([int](Get-Content -LiteralPath (Join-Path $runner 'dsh-task/attempts.txt') -Raw) -ne 2) { throw 'Drain recovery did not consume the normal restart budget.' }
|
||||
$consoles = @(Get-Content -LiteralPath (Join-Path $runner 'consoles.txt'))
|
||||
if ($consoles.Count -ne 2 -or @($consoles | Where-Object { $_ -ne '0' }).Count) { throw 'Initial or recovered runner allocated a console.' }
|
||||
$priorities = @(Get-Content -LiteralPath (Join-Path $runner 'priorities.jsonl') | ForEach-Object { $_ | ConvertFrom-Json })
|
||||
if ($priorities.Count -ne 2 -or @($priorities | Where-Object { $_.Cpu -ne 'Normal' -or $_.Io -ne 2 -or $_.Memory -ne 5 }).Count) { throw 'Initial or recovered runner inherited background scheduling priority.' }
|
||||
$childId = [int]@(Get-Content -LiteralPath $starts)[-1]
|
||||
$child = Get-Process -Id $childId
|
||||
Invoke-Manager start
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/** PE discovery keeps content checks and drains bounded file reads before failure. */
|
||||
import { mkdir, mkdtemp, rm, writeFile, type FileHandle } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { windowsRuntimeCode } from '../scripts/windows-runtime-signature.mjs'
|
||||
|
||||
const hooks = vi.hoisted(() => ({
|
||||
opened: undefined as ((handle: FileHandle) => void) | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
open: async (...args: Parameters<typeof actual.open>) => {
|
||||
const handle = await actual.open(...args)
|
||||
hooks.opened?.(handle)
|
||||
return handle
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function fixture(entries: Record<string, string | Buffer>): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'windows-runtime-code-'))
|
||||
roots.push(root)
|
||||
for (const [name, contents] of Object.entries(entries)) {
|
||||
const path = join(root, name)
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, contents)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function executable(): Buffer {
|
||||
const bytes = Buffer.alloc(128)
|
||||
bytes.writeUInt16LE(0x5a4d, 0)
|
||||
bytes.writeUInt32LE(64, 0x3c)
|
||||
bytes.writeUInt32LE(0x4550, 64)
|
||||
return bytes
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
hooks.opened = undefined
|
||||
vi.restoreAllMocks()
|
||||
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers extensionless PE files in nested directories and excludes foreign native modules', async () => {
|
||||
const root = await fixture({
|
||||
'nested/deeper/extensionless': executable(),
|
||||
'nested/runtime.node': executable(),
|
||||
'a.exe': executable(),
|
||||
'nested/deeper/foreign.node': Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
|
||||
'readme.txt': 'ordinary data',
|
||||
})
|
||||
expect(await windowsRuntimeCode(root)).toEqual([
|
||||
'nested/deeper/extensionless', 'nested/runtime.node', 'a.exe',
|
||||
].map(name => join(root, name)).sort())
|
||||
})
|
||||
|
||||
it.each([
|
||||
['broken.exe', Buffer.from('not PE')],
|
||||
['nested/extensionless', Buffer.from('MZ')],
|
||||
['nested/broken.node', Buffer.from('MZ')],
|
||||
['nested/deeper/broken.dll', Buffer.alloc(64)],
|
||||
])('rejects malformed Windows code at %s', async (name, contents) => {
|
||||
const root = await fixture({ [name]: contents })
|
||||
await expect(windowsRuntimeCode(root)).rejects.toThrow(`Windows code: invalid PE file: ${join(root, name)}`)
|
||||
})
|
||||
|
||||
it('limits open files to eight and drains pending closes before rejecting a read failure', async () => {
|
||||
const root = await fixture(Object.fromEntries(Array.from({ length: 24 }, (_, index) => [
|
||||
`${String(index).padStart(2, '0')}.txt`, 'ordinary data',
|
||||
])))
|
||||
const releaseReads = Promise.withResolvers<undefined>()
|
||||
const releaseCloses = Promise.withResolvers<undefined>()
|
||||
const failedClosed = Promise.withResolvers<undefined>()
|
||||
const failure = new Error('controlled PE header read failure')
|
||||
let opened = 0
|
||||
let active = 0
|
||||
let peak = 0
|
||||
let settled = false
|
||||
hooks.opened = (handle) => {
|
||||
const index = opened++
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
const close = handle.close.bind(handle)
|
||||
vi.spyOn(handle, 'read').mockImplementationOnce(async () => {
|
||||
await releaseReads.promise
|
||||
if (index === 0) throw failure
|
||||
return { bytesRead: 0, buffer: Buffer.alloc(64) }
|
||||
})
|
||||
vi.spyOn(handle, 'close').mockImplementationOnce(async () => {
|
||||
if (index !== 0) await releaseCloses.promise
|
||||
await close()
|
||||
active--
|
||||
if (index === 0) failedClosed.resolve(undefined)
|
||||
})
|
||||
}
|
||||
const operation = windowsRuntimeCode(root).then(
|
||||
(result) => { settled = true; return result },
|
||||
(error: unknown) => { settled = true; return error },
|
||||
)
|
||||
try {
|
||||
await vi.waitFor(() => { expect(opened).toBe(8) })
|
||||
expect(active).toBe(8)
|
||||
releaseReads.resolve(undefined)
|
||||
await failedClosed.promise
|
||||
await setImmediate()
|
||||
expect(settled).toBe(false)
|
||||
expect(opened).toBe(8)
|
||||
expect(active).toBe(7)
|
||||
releaseCloses.resolve(undefined)
|
||||
expect(await operation).toBe(failure)
|
||||
expect(opened).toBe(8)
|
||||
expect(peak).toBe(8)
|
||||
expect(active).toBe(0)
|
||||
} finally {
|
||||
hooks.opened = undefined
|
||||
releaseReads.resolve(undefined)
|
||||
releaseCloses.resolve(undefined)
|
||||
await operation
|
||||
}
|
||||
})
|
||||
@@ -317,7 +317,9 @@ describe('Windows token signing', () => {
|
||||
'C:\\release\\setup.exe',
|
||||
['token-secret'],
|
||||
)
|
||||
expect(failure.message).toBe('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected <redacted>')
|
||||
expect(failure.message).toContain('Windows release signing failed for C:\\release\\setup.exe (exit 1): provider rejected <redacted>')
|
||||
expect(failure.message).toContain('After replacing the UKey')
|
||||
expect(failure.message).toContain('does not establish device lock status')
|
||||
expect(failure.message).not.toContain('token-secret')
|
||||
expect(failure).not.toHaveProperty('cause')
|
||||
expect(failure).not.toHaveProperty('cmd')
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/** Operator-only comparison of serial signing and bounded concurrent signature completion. */
|
||||
import { execFile } from 'node:child_process'
|
||||
import { createHash, X509Certificate } from 'node:crypto'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir, cpus, totalmem, freemem } from 'node:os'
|
||||
import { extname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { parseArgs, promisify } from 'node:util'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
import { createPackagingRun, recordPackagingEvent } from '../scripts/packaging-run.mjs'
|
||||
import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs'
|
||||
import { withWindowsSigningStage } from '../scripts/windows-signing-stage.mjs'
|
||||
import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from '../scripts/windows-token-identity.ts'
|
||||
import { buildWindowsSigningEnvironment, createWindowsTokenSigner, scrubWindowsSigningEnvironment } from '../scripts/windows-sign.mjs'
|
||||
import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
|
||||
import { completeWindowsSignature, normalizeWindowsSignature } from '../scripts/windows-timestamp.mjs'
|
||||
import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
|
||||
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||
const MODES = [
|
||||
{ name: 'serial', jobs: 1, hardwareConcurrency: 1 },
|
||||
{ name: 'pipeline', jobs: 2, hardwareConcurrency: 1 },
|
||||
{ name: 'hardware2', jobs: 2, hardwareConcurrency: 2 },
|
||||
] as const
|
||||
const CORPUS_MODES = [
|
||||
{ name: 'serial', jobs: 1, hardwareConcurrency: 1 },
|
||||
{ name: 'hardware2', jobs: 2, hardwareConcurrency: 2 },
|
||||
{ name: 'hardware4', jobs: 4, hardwareConcurrency: 4 },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Run an explicitly opted-in hardware experiment, retaining unsigned inputs, signed copies and audit records.
|
||||
* @param args CLI arguments; omission uses the current process arguments.
|
||||
* @returns Resolves after all requested batches verify; a failure during a batch retains its interlock and stops later modes.
|
||||
*/
|
||||
export async function runWindowsSigningConcurrencyExperiment(args = process.argv.slice(2)): Promise<void> {
|
||||
const { values } = parseArgs({ args, options: {
|
||||
execute: { type: 'boolean' }, 'config-root': { type: 'string' }, rounds: { type: 'string', default: '2' },
|
||||
corpus: { type: 'string' }, 'pipeline-control': { type: 'boolean' }, 'strip-tool': { type: 'string' },
|
||||
} })
|
||||
if (!values.execute) {
|
||||
console.log('Operator hardware experiment: node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <absolute-machine-packaging-directory> [--rounds 1|2] [--corpus <absolute-manifest.json>] [--strip-tool <absolute-signtool.exe>] [--pipeline-control]')
|
||||
return
|
||||
}
|
||||
if (process.platform !== 'win32' || !values['config-root'] || !isAbsolute(values['config-root'])
|
||||
|| !['1', '2'].includes(values.rounds)) throw new Error('Requires Windows, an absolute config-root and rounds 1 or 2')
|
||||
if (values.corpus !== undefined && !isAbsolute(values.corpus)) throw new Error('Signing corpus manifest must be an absolute path')
|
||||
if (values['pipeline-control'] && !values.corpus) throw new Error('Pipeline control requires a real-file corpus')
|
||||
if (values['strip-tool'] !== undefined && (!values.corpus || !isAbsolute(values['strip-tool']))) throw new Error('Signature stripping override requires a corpus and an absolute SignTool path')
|
||||
const modes = values['pipeline-control'] ? [{ name: 'pipeline4', jobs: 4, hardwareConcurrency: 1 } as const]
|
||||
: values.corpus ? CORPUS_MODES : MODES
|
||||
const environment = loadDesktopPackageEnvironment('win32', {
|
||||
...process.env, DSH_PACKAGING_PRESET: 'test', DSH_PACKAGING_CONFIG_DIR: values['config-root'],
|
||||
})
|
||||
const certificateFile = environment.DSH_DESKTOP_WINDOWS_CER_FILE!
|
||||
const signTool = environment.DSH_DESKTOP_WINDOWS_SIGNTOOL!
|
||||
const stripTool = values['strip-tool'] ?? signTool
|
||||
const tokenPin = environment.DSH_DESKTOP_WINDOWS_TOKEN_PIN!
|
||||
const keyContainer = environment.DSH_DESKTOP_WINDOWS_KEY_CONTAINER!.trim()
|
||||
// Construction validates the same signing inputs; the production serial hook is never invoked here.
|
||||
createWindowsTokenSigner({ certificateFile, signTool, tokenPin, keyContainer })
|
||||
const certificate = await readFile(certificateFile)
|
||||
const leaf = new X509Certificate(certificate)
|
||||
if (Date.now() < Date.parse(leaf.validFrom) || Date.now() > Date.parse(leaf.validTo)) {
|
||||
throw new Error('Signing experiment requires a currently valid public certificate')
|
||||
}
|
||||
const thumbprint = leaf.fingerprint.replaceAll(':', '')
|
||||
const safeEnvironment = scrubWindowsSigningEnvironment(environment)
|
||||
delete safeEnvironment.NODE_OPTIONS
|
||||
delete safeEnvironment.NODE_PATH
|
||||
const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'qualification', 'signing-concurrency'), {
|
||||
kind: 'operator-signing-concurrency', certificateSha256: createHash('sha256').update(certificate).digest('hex'),
|
||||
node: process.version, cpu: cpus()[0]?.model, logicalCpus: cpus().length, memoryBytes: totalmem(),
|
||||
rounds: Number(values.rounds), corpus: values.corpus ?? null, stripTool, modes,
|
||||
cache: 'disabled', probeExecuted: false,
|
||||
}, { parallel: true, secrets: [tokenPin, keyContainer] })
|
||||
console.log(`SIGNING_EXPERIMENT_RECORD ${run.directory}`)
|
||||
const controller = new AbortController()
|
||||
const abort = (): void => { controller.abort(new Error('Operator interrupted the signing experiment')) }
|
||||
process.once('SIGINT', abort)
|
||||
process.once('SIGTERM', abort)
|
||||
const record = (event: object): void => { recordPackagingEvent(run.directory, event) }
|
||||
let success = false
|
||||
try {
|
||||
await withWindowsSigningStage({ stage: 'operator-signing-concurrency', signal: controller.signal, record }, async () => {
|
||||
if (existsSync(join(homedir(), '.dsh-desktop-signing', 'attempt.json'))) {
|
||||
throw new Error('Retained signing interlock requires operator inspection; experiment refused')
|
||||
}
|
||||
const identity = await inspectWindowsTokenIdentity({ certificate, container: keyContainer, environment: safeEnvironment })
|
||||
record({ type: 'experiment-public-identity', result: identity, pinSupplied: false })
|
||||
if (identity !== 'matched') throw new Error(windowsTokenIdentityFailure(identity))
|
||||
let templates: string[] = []
|
||||
if (values.corpus) {
|
||||
let removalIndex = 0
|
||||
const corpus = await prepareSigningCorpus(values.corpus, join(run.directory, 'unsigned'), async (path) => {
|
||||
await run.run(`remove-signature-${removalIndex++}`, stripTool, ['remove', '/s', path],
|
||||
{ cwd: run.directory, env: safeEnvironment, timeoutMs: 60_000 })
|
||||
}, controller.signal)
|
||||
await writeFile(join(run.directory, 'corpus.json'), `${JSON.stringify(corpus, null, 2)}\n`, { flag: 'wx' })
|
||||
templates = corpus.map(file => file.path)
|
||||
record({ type: 'experiment-corpus', files: corpus.length, bytes: corpus.reduce((sum, file) => sum + file.bytes, 0) })
|
||||
} else {
|
||||
const source = join(run.directory, 'probe.cs')
|
||||
await writeFile(source, 'internal static class SigningProbe { private static int Main() { return 0; } }\n', { flag: 'wx' })
|
||||
for (const size of [0, 1024 * 1024, 16 * 1024 * 1024]) {
|
||||
controller.signal.throwIfAborted()
|
||||
const resource = join(run.directory, `resource-${size}.bin`)
|
||||
if (size) await writeFile(resource, Buffer.alloc(size, 0x5a), { flag: 'wx' })
|
||||
const output = join(run.directory, `unsigned-${size}.exe`)
|
||||
await run.run(`compile-probe-${size}`, join(environment.SystemRoot ?? 'C:\\Windows', 'Microsoft.NET/Framework64/v4.0.30319/csc.exe'),
|
||||
['/nologo', '/target:exe', '/platform:x64', `/out:${output}`, ...(size ? [`/resource:${resource}`] : []), source],
|
||||
{ cwd: run.directory, env: safeEnvironment, timeoutMs: 30_000 })
|
||||
if ((await inspectWindowsRuntimeSignature(output)).status !== 'NotSigned') throw new Error('Probe must be unsigned')
|
||||
templates.push(output, output)
|
||||
}
|
||||
}
|
||||
const results: { round: number; mode: string; elapsedMs: number; files: number }[] = []
|
||||
for (let round = 1; round <= Number(values.rounds); round++) {
|
||||
const order = round % 2 ? modes : [...modes].reverse()
|
||||
for (const mode of order) {
|
||||
controller.signal.throwIfAborted()
|
||||
const directory = join(run.directory, `round-${round}-${mode.name}`)
|
||||
await mkdir(directory)
|
||||
const files: string[] = []
|
||||
for (const [index, template] of templates.entries()) {
|
||||
const file = join(directory, `probe-${index}${extname(template)}`)
|
||||
await copyFile(template, file)
|
||||
if (Buffer.compare(await readFile(template), await readFile(file)) !== 0) throw new Error('Signing experiment batch copy differs from its template')
|
||||
files.push(file)
|
||||
}
|
||||
record({ type: 'experiment-batch-plan', round, ...mode, files })
|
||||
const attempt = beginWindowsSigningAttempt({ runDirectory: run.directory, target: directory })
|
||||
let commandIndex = 0
|
||||
const started = performance.now()
|
||||
try {
|
||||
record({ type: 'experiment-resources', round, mode: mode.name, point: 'start', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() })
|
||||
await runSigningExperimentBatch(files, {
|
||||
...mode, signal: controller.signal,
|
||||
sign: async (_file, path) => {
|
||||
controller.signal.throwIfAborted()
|
||||
const stage = `experiment-sign-${round}-${mode.name}-${commandIndex++}`
|
||||
record({ type: 'experiment-hardware-start', stage, path })
|
||||
const interpreter = environment.ComSpec ?? join(environment.SystemRoot ?? 'C:\\Windows', 'System32', 'cmd.exe')
|
||||
await run.run(stage, interpreter,
|
||||
['/d', '/v:off', '/c', 'windows-sign.cmd'], {
|
||||
cwd: join(APP_ROOT, 'scripts'), timeoutMs: 60_000,
|
||||
env: buildWindowsSigningEnvironment(safeEnvironment, {
|
||||
certificateFile, signTool, tokenPin, keyContainer, path, isNest: false,
|
||||
}),
|
||||
})
|
||||
record({ type: 'experiment-hardware-exited', stage, path })
|
||||
const signature = await inspectWindowsRuntimeSignature(path)
|
||||
if (signature.status !== 'Valid' || signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) {
|
||||
throw new Error('Primary signature verification failed')
|
||||
}
|
||||
record({ type: 'experiment-hardware-verified', stage, path })
|
||||
},
|
||||
complete: async (path, sign) => {
|
||||
const fileStarted = performance.now()
|
||||
await completeWindowsSignature(path, {
|
||||
thumbprint, sign, inspect: inspectWindowsRuntimeSignature, evidenceDirectory: run.directory, record,
|
||||
normalize: file => normalizeWindowsSignature(file, signTool, safeEnvironment),
|
||||
timestamp: async (file) => {
|
||||
await promisify(execFile)(signTool, ['timestamp', '/v', '/tr', 'http://timestamp.digicert.com', '/td', 'sha256', file], {
|
||||
env: safeEnvironment, windowsHide: true, timeout: 60_000,
|
||||
})
|
||||
},
|
||||
})
|
||||
const signature = await inspectWindowsRuntimeSignature(path)
|
||||
if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== thumbprint.toUpperCase()) {
|
||||
throw new Error('Published probe signature verification failed')
|
||||
}
|
||||
record({ type: 'experiment-file-verified', path, ...signature, elapsedMs: performance.now() - fileStarted,
|
||||
freeMemoryBytes: freemem(), processMemory: process.memoryUsage(),
|
||||
sha256: createHash('sha256').update(await readFile(path)).digest('hex') })
|
||||
},
|
||||
})
|
||||
controller.signal.throwIfAborted()
|
||||
const result = { round, mode: mode.name, elapsedMs: performance.now() - started, files: files.length }
|
||||
record({ type: 'experiment-resources', round, mode: mode.name, point: 'end', freeMemoryBytes: freemem(), cpuTimes: cpus().map(cpu => cpu.times), processMemory: process.memoryUsage() })
|
||||
results.push(result)
|
||||
record({ type: 'experiment-batch-complete', ...result })
|
||||
await writeFile(join(run.directory, 'measurements.json'), `${JSON.stringify(results, null, 2)}\n`, { flush: true })
|
||||
attempt.success()
|
||||
console.log(`SIGNING_EXPERIMENT_SAMPLE ${JSON.stringify(result)}`)
|
||||
} catch (error) {
|
||||
attempt.failure(null, 'Signing concurrency experiment failed; inspect retained records before recovery')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
success = true
|
||||
} finally {
|
||||
process.removeListener('SIGINT', abort)
|
||||
process.removeListener('SIGTERM', abort)
|
||||
run.finish(success)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
runWindowsSigningConcurrencyExperiment().catch(() => {
|
||||
// Child-process errors can embed arguments; the retained supervisor output is already redacted.
|
||||
console.error('Signing experiment stopped; inspect its retained record. No automatic hardware retry or interlock recovery.')
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/** Driver ownership tests replace every native signing and device boundary. */
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { runWindowsSigningConcurrencyExperiment } from './windows-signing-concurrency.perf.ts'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
import { createPackagingRun } from '../scripts/packaging-run.mjs'
|
||||
import { beginWindowsSigningAttempt } from '../scripts/windows-signing-state.mjs'
|
||||
import { inspectWindowsTokenIdentity } from '../scripts/windows-token-identity.ts'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
root: '', environment: {} as NodeJS.ProcessEnv,
|
||||
identity: 'matched', failure: '' as '' | 'primary' | 'final' | 'audit',
|
||||
signed: new Set<string>(), completed: new Set<string>(), stages: [] as string[],
|
||||
events: [] as object[], successes: 0, failures: 0,
|
||||
removals: [] as { executable: string; args: readonly string[]; environment: NodeJS.ProcessEnv }[],
|
||||
}))
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFile: vi.fn(() => { throw new Error('Native subprocesses are forbidden in driver tests') }),
|
||||
}))
|
||||
vi.mock('node:os', async original => ({ ...await original<typeof import('node:os')>(), homedir: () => state.root }))
|
||||
vi.mock('node:crypto', async original => ({
|
||||
...await original<typeof import('node:crypto')>(),
|
||||
X509Certificate: class {
|
||||
fingerprint = 'A'.repeat(40)
|
||||
validFrom = 'Jan 1 2000 GMT'
|
||||
validTo = 'Jan 1 2100 GMT'
|
||||
},
|
||||
}))
|
||||
vi.mock('../scripts/desktop-package-environment.mjs', () => ({
|
||||
loadDesktopPackageEnvironment: vi.fn(() => state.environment),
|
||||
}))
|
||||
vi.mock('../scripts/windows-sign.mjs', async original => ({
|
||||
...await original<typeof import('../scripts/windows-sign.mjs')>(),
|
||||
createWindowsTokenSigner: vi.fn(() => { throw new Error('unconfigured constructor') }),
|
||||
}))
|
||||
vi.mock('../scripts/windows-token-identity.ts', () => ({
|
||||
inspectWindowsTokenIdentity: vi.fn(async () => state.identity),
|
||||
windowsTokenIdentityFailure: (result: string) => `Identity refused: ${result}`,
|
||||
}))
|
||||
vi.mock('./helpers/windows-signing-corpus.ts', () => ({
|
||||
prepareSigningCorpus: vi.fn(async () => {
|
||||
const files = []
|
||||
for (let index = 0; index < 6; index++) {
|
||||
const path = join(state.root, `real-${index}.dll`)
|
||||
await writeFile(path, 'inert real file')
|
||||
files.push({ path, bytes: 15, sha256: 'a'.repeat(64) })
|
||||
}
|
||||
return files
|
||||
}),
|
||||
}))
|
||||
vi.mock('../scripts/windows-signing-stage.mjs', () => ({
|
||||
withWindowsSigningStage: vi.fn(async (_options: object, operation: () => Promise<void>) => operation()),
|
||||
}))
|
||||
vi.mock('../scripts/packaging-run.mjs', () => ({
|
||||
createPackagingRun: vi.fn(() => ({
|
||||
directory: join(state.root, 'run'),
|
||||
finish: vi.fn(),
|
||||
run: vi.fn(async (stage: string, executable: string, args: readonly string[], options: { env: NodeJS.ProcessEnv }) => {
|
||||
state.stages.push(stage)
|
||||
if (stage.startsWith('compile-')) {
|
||||
await writeFile(args.find(arg => arg.startsWith('/out:'))!.slice(5), 'inert probe')
|
||||
} else if (stage.startsWith('remove-signature-')) {
|
||||
state.removals.push({ executable, args, environment: options.env })
|
||||
} else {
|
||||
state.signed.add(options.env.DSH_DESKTOP_WINDOWS_SIGN_TARGET!)
|
||||
}
|
||||
}),
|
||||
})),
|
||||
recordPackagingEvent: (_directory: string, event: object) => {
|
||||
if (state.failure === 'audit' && 'type' in event && event.type === 'experiment-file-verified') throw new Error('Audit failed')
|
||||
state.events.push(event)
|
||||
},
|
||||
}))
|
||||
vi.mock('../scripts/windows-signing-state.mjs', () => ({
|
||||
beginWindowsSigningAttempt: vi.fn((options: { runDirectory: string; target: string; stateDirectory?: string }) => {
|
||||
expect(options.stateDirectory).toBeUndefined()
|
||||
const lock = join(state.root, '.dsh-desktop-signing', 'attempt.json')
|
||||
mkdirSync(dirname(lock), { recursive: true })
|
||||
writeFileSync(lock, 'batch attempt', { flag: 'wx' })
|
||||
return {
|
||||
success: () => {
|
||||
const verified = state.events.filter(event => 'type' in event && event.type === 'experiment-file-verified'
|
||||
&& 'path' in event && typeof event.path === 'string' && dirname(event.path) === options.target)
|
||||
expect(verified).toHaveLength(6)
|
||||
expect(readFileSync(join(options.runDirectory, 'measurements.json'), 'utf8')).toContain('"files": 6')
|
||||
unlinkSync(lock)
|
||||
state.successes++
|
||||
},
|
||||
failure: () => { state.failures++ },
|
||||
}
|
||||
}),
|
||||
}))
|
||||
vi.mock('../scripts/windows-runtime-signature.mjs', () => ({
|
||||
inspectWindowsRuntimeSignature: vi.fn(async (path: string) => {
|
||||
if (!state.signed.has(path)) return { status: 'NotSigned', timestamped: false, thumbprint: null }
|
||||
const timestamped = state.completed.has(path)
|
||||
const failed = state.failure === (timestamped ? 'final' : 'primary')
|
||||
return { status: failed ? 'HashMismatch' : 'Valid', timestamped, thumbprint: 'A'.repeat(40) }
|
||||
}),
|
||||
}))
|
||||
vi.mock('../scripts/windows-timestamp.mjs', () => ({
|
||||
completeWindowsSignature: vi.fn(async (path: string, options: { sign: (path: string) => Promise<void> }) => {
|
||||
await options.sign(path)
|
||||
state.completed.add(path)
|
||||
}),
|
||||
normalizeWindowsSignature: vi.fn(() => { throw new Error('Native normalization is forbidden in driver tests') }),
|
||||
}))
|
||||
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
state.root = await mkdtemp(join(tmpdir(), 'signing-driver-'))
|
||||
mkdirSync(join(state.root, 'run'))
|
||||
state.identity = 'matched'; state.failure = ''; state.successes = 0; state.failures = 0
|
||||
state.signed.clear(); state.completed.clear(); state.stages = []; state.events = []; state.removals = []
|
||||
state.environment = {
|
||||
DSH_DESKTOP_WINDOWS_CER_FILE: join(state.root, 'certificate.cer'),
|
||||
DSH_DESKTOP_WINDOWS_SIGNTOOL: 'forbidden-signer', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin',
|
||||
DSH_DESKTOP_WINDOWS_KEY_CONTAINER: ' fixture-container ', API_KEY: 'fixture-secret', NODE_OPTIONS: 'forbidden-option',
|
||||
}
|
||||
await writeFile(state.environment.DSH_DESKTOP_WINDOWS_CER_FILE!, 'inert certificate')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
|
||||
const { createWindowsTokenSigner } = await import('../scripts/windows-sign.mjs')
|
||||
vi.mocked(createWindowsTokenSigner).mockReturnValue(vi.fn(async () => { throw new Error('Production signer is forbidden') }))
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
afterEach(async () => {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
await rm(state.root, { recursive: true, force: true, maxRetries: 3 })
|
||||
})
|
||||
|
||||
const execute = () => runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1'])
|
||||
const lock = () => join(state.root, '.dsh-desktop-signing', 'attempt.json')
|
||||
|
||||
it('requires opt-in before reading configuration or querying a device', async () => {
|
||||
await runWindowsSigningConcurrencyExperiment([])
|
||||
expect(loadDesktopPackageEnvironment).not.toHaveBeenCalled()
|
||||
expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled()
|
||||
expect(createPackagingRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['1999-01-01', '2101-01-01'])('rejects certificate validity at %s before querying or signing', async (date) => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(date))
|
||||
await expect(execute()).rejects.toThrow('currently valid')
|
||||
expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled()
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
expect(state.stages).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['certificate-mismatch', 'provider-unavailable', 'container-unavailable', 'unknown'])('refuses public identity %s before any signing', async (identity) => {
|
||||
state.identity = identity
|
||||
await expect(execute()).rejects.toThrow('Identity refused')
|
||||
expect(state.stages).toEqual([])
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains an existing default-account interlock without accessing the device', async () => {
|
||||
mkdirSync(dirname(lock()), { recursive: true }); writeFileSync(lock(), 'prior failure')
|
||||
await expect(execute()).rejects.toThrow('Retained signing interlock')
|
||||
expect(inspectWindowsTokenIdentity).not.toHaveBeenCalled()
|
||||
expect(readFileSync(lock(), 'utf8')).toBe('prior failure')
|
||||
})
|
||||
|
||||
it('completes all three modes and releases each default-account batch only after recording every verification', async () => {
|
||||
await execute()
|
||||
expect(state.successes).toBe(3)
|
||||
expect(state.failures).toBe(0)
|
||||
expect(state.signed.size).toBe(18)
|
||||
expect(existsSync(lock())).toBe(false)
|
||||
expect(beginWindowsSigningAttempt).toHaveBeenCalledTimes(3)
|
||||
expect(inspectWindowsTokenIdentity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
container: 'fixture-container', environment: {},
|
||||
}))
|
||||
expect(JSON.stringify(state.events)).not.toMatch(/fixture-pin|fixture-secret|fixture-container/u)
|
||||
})
|
||||
|
||||
it('uses fresh real-file copies for 1, 2, 4 workers and reverses the second round', async () => {
|
||||
await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')])
|
||||
expect(state.successes).toBe(6)
|
||||
expect(state.signed.size).toBe(36)
|
||||
expect(state.stages.some(stage => stage.startsWith('compile-'))).toBe(false)
|
||||
expect(prepareSigningCorpus).toHaveBeenCalledOnce()
|
||||
expect(state.events.filter(event => 'type' in event && event.type === 'experiment-batch-plan')).toEqual([
|
||||
expect.objectContaining({ round: 1, jobs: 1, hardwareConcurrency: 1 }),
|
||||
expect.objectContaining({ round: 1, jobs: 2, hardwareConcurrency: 2 }),
|
||||
expect.objectContaining({ round: 1, jobs: 4, hardwareConcurrency: 4 }),
|
||||
expect.objectContaining({ round: 2, jobs: 4, hardwareConcurrency: 4 }),
|
||||
expect.objectContaining({ round: 2, jobs: 2, hardwareConcurrency: 2 }),
|
||||
expect.objectContaining({ round: 2, jobs: 1, hardwareConcurrency: 1 }),
|
||||
])
|
||||
expect(existsSync(lock())).toBe(false)
|
||||
})
|
||||
|
||||
it('offers a separate four-job control with only one hardware request', async () => {
|
||||
await runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--rounds', '1', '--corpus', join(state.root, 'manifest.json'), '--pipeline-control'])
|
||||
expect(state.successes).toBe(1)
|
||||
expect(state.events).toContainEqual(expect.objectContaining({ type: 'experiment-batch-plan', jobs: 4, hardwareConcurrency: 1 }))
|
||||
})
|
||||
|
||||
it('refuses corpus preparation failure before beginning a hardware attempt', async () => {
|
||||
vi.mocked(prepareSigningCorpus).mockRejectedValueOnce(new Error('changed input'))
|
||||
await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json')])).rejects.toThrow('changed input')
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
expect(state.signed.size).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the stripping override only for public removal on a corpus copy', async () => {
|
||||
const copy = join(state.root, 'copy.exe')
|
||||
const stripTool = join(state.root, 'sdk-signtool.exe')
|
||||
vi.mocked(prepareSigningCorpus).mockImplementationOnce(async (_manifest, _directory, remove) => {
|
||||
await remove(copy)
|
||||
throw new Error('stop before hardware')
|
||||
})
|
||||
await expect(runWindowsSigningConcurrencyExperiment(['--execute', '--config-root', state.root, '--corpus', join(state.root, 'manifest.json'), '--strip-tool', stripTool])).rejects.toThrow('stop before hardware')
|
||||
expect(state.removals).toEqual([{ executable: stripTool, args: ['remove', '/s', copy], environment: {} }])
|
||||
expect(beginWindowsSigningAttempt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['primary', 'final', 'audit'] as const)('retains the batch interlock and stops later modes after %s failure', async (failure) => {
|
||||
state.failure = failure
|
||||
await expect(execute()).rejects.toThrow(failure === 'audit' ? 'Audit failed' : 'signature verification failed')
|
||||
expect(state.successes).toBe(0)
|
||||
expect(state.failures).toBe(1)
|
||||
expect(beginWindowsSigningAttempt).toHaveBeenCalledOnce()
|
||||
expect(state.signed.size).toBe(1)
|
||||
expect(existsSync(lock())).toBe(true)
|
||||
expect(state.stages.some(stage => stage.includes('pipeline') || stage.includes('hardware2'))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Corpus preparation touches only private copies; public signature inspection is simulated. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { prepareSigningCorpus } from './helpers/windows-signing-corpus.ts'
|
||||
import { inspectWindowsRuntimeSignature } from '../scripts/windows-runtime-signature.mjs'
|
||||
|
||||
vi.mock('../scripts/windows-runtime-signature.mjs', () => ({ inspectWindowsRuntimeSignature: vi.fn() }))
|
||||
let root: string
|
||||
let source: string
|
||||
let manifest: string
|
||||
const digest = (data: Buffer | string) => createHash('sha256').update(data).digest('hex')
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks()
|
||||
root = await mkdtemp(join(tmpdir(), 'signing-corpus-'))
|
||||
source = join(root, 'original.dll')
|
||||
manifest = join(root, 'manifest.json')
|
||||
await writeFile(source, 'signed original')
|
||||
await writeFile(manifest, JSON.stringify([{ path: source, sha256: digest('signed original') }]))
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'NotSigned', timestamped: false, thumbprint: null })
|
||||
})
|
||||
afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 3 }) })
|
||||
|
||||
it('strips only a pinned private copy and preserves the original bytes', async () => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValueOnce({ status: 'Valid', timestamped: true, thumbprint: 'A' })
|
||||
const remove = vi.fn(async (path: string) => { expect(path).not.toBe(source); await writeFile(path, 'unsigned copy') })
|
||||
const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)
|
||||
expect(remove).toHaveBeenCalledOnce()
|
||||
expect(files).toEqual([{ path: join(root, 'copies', '0.dll'), sha256: digest('unsigned copy'), bytes: 13 }])
|
||||
expect(await readFile(source, 'utf8')).toBe('signed original')
|
||||
})
|
||||
|
||||
it('accepts already unsigned input without invoking removal', async () => {
|
||||
const remove = vi.fn()
|
||||
const files = await prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(files[0]?.sha256).toBe(digest('signed original'))
|
||||
})
|
||||
|
||||
it.each([[], {}, [{ path: 'relative', sha256: 'a'.repeat(64) }], [{ path: 'ignored', sha256: 'invalid' }]])('rejects malformed manifest %j', async (value) => {
|
||||
await writeFile(manifest, JSON.stringify(value))
|
||||
const remove = vi.fn()
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('Signing corpus')
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects source content changed since the manifest was created', async () => {
|
||||
await writeFile(source, 'changed source')
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('pinned digest')
|
||||
expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['HashMismatch', 'NotTrusted'])('rejects invalid source signature %s without removal', async (status) => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status, timestamped: false, thumbprint: null })
|
||||
const remove = vi.fn()
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), remove, new AbortController().signal)).rejects.toThrow('invalid signature')
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a copy whose signature removal did not finish', async () => {
|
||||
vi.mocked(inspectWindowsRuntimeSignature).mockResolvedValue({ status: 'Valid', timestamped: true, thumbprint: 'A' })
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), new AbortController().signal)).rejects.toThrow('must be unsigned')
|
||||
})
|
||||
|
||||
it('stops before copying after cancellation', async () => {
|
||||
const controller = new AbortController(); controller.abort(new Error('cancelled'))
|
||||
await expect(prepareSigningCorpus(manifest, join(root, 'copies'), vi.fn(), controller.signal)).rejects.toThrow('cancelled')
|
||||
expect(inspectWindowsRuntimeSignature).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,288 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { runSigningExperimentBatch } from './helpers/windows-signing-experiment.ts'
|
||||
|
||||
function barrier() {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
return { promise: pending.promise, resolve: () => { pending.resolve(undefined) } }
|
||||
}
|
||||
const observe = (operation: Promise<void>) => operation.then(
|
||||
() => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }),
|
||||
)
|
||||
|
||||
it('drains four issued hardware calls after failure without dispatching a fifth', async () => {
|
||||
const allStarted = barrier()
|
||||
const release = barrier()
|
||||
const failed = barrier()
|
||||
const started: number[] = []
|
||||
const finished: number[] = []
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3, 4, 5, 6], {
|
||||
jobs: 4, hardwareConcurrency: 4,
|
||||
sign: async (item) => {
|
||||
started.push(item)
|
||||
if (started.length === 4) allStarted.resolve()
|
||||
await allStarted.promise
|
||||
if (item === 1) { failed.resolve(); throw new Error('hardware failure') }
|
||||
await release.promise
|
||||
finished.push(item)
|
||||
},
|
||||
complete: async (item, sign) => { await sign(`staged-${item}`) },
|
||||
}))
|
||||
let settled = false
|
||||
void outcome.then(() => { settled = true })
|
||||
try {
|
||||
await failed.promise
|
||||
expect(started).toEqual([1, 2, 3, 4])
|
||||
expect(settled).toBe(false)
|
||||
} finally { release.resolve() }
|
||||
expect(await outcome).toEqual({ ok: false, error: new Error('hardware failure') })
|
||||
expect(finished.sort()).toEqual([2, 3, 4])
|
||||
expect(started).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
|
||||
it('finishes each item before beginning the next in serial mode', async () => {
|
||||
const events: string[] = []
|
||||
await runSigningExperimentBatch([1, 2], {
|
||||
jobs: 1, hardwareConcurrency: 1,
|
||||
sign: async (item, path) => { expect(path).toBe(`staged-${item}`); events.push(`sign ${item}`) },
|
||||
complete: async (item, sign) => {
|
||||
events.push(`start ${item}`)
|
||||
await sign(`staged-${item}`)
|
||||
events.push(`complete ${item}`)
|
||||
},
|
||||
})
|
||||
expect(events).toEqual(['start 1', 'sign 1', 'complete 1', 'start 2', 'sign 2', 'complete 2'])
|
||||
})
|
||||
|
||||
it('overlaps completion with the next signature while keeping hardware concurrency at one', async () => {
|
||||
const firstSigning = barrier()
|
||||
const releaseFirstSignature = barrier()
|
||||
const secondStarted = barrier()
|
||||
const firstPost = barrier()
|
||||
const secondSigned = barrier()
|
||||
const releaseCompletion = barrier()
|
||||
const started: number[] = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 1,
|
||||
sign: async (item) => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
try {
|
||||
if (item === 1) { firstSigning.resolve(); await releaseFirstSignature.promise }
|
||||
if (item === 2) secondSigned.resolve()
|
||||
} finally { active-- }
|
||||
},
|
||||
complete: async (item, sign) => {
|
||||
started.push(item)
|
||||
if (item === 2) secondStarted.resolve()
|
||||
await sign(`staged-${item}`)
|
||||
if (item === 1) firstPost.resolve()
|
||||
await releaseCompletion.promise
|
||||
},
|
||||
}))
|
||||
try {
|
||||
await Promise.all([firstSigning.promise, secondStarted.promise])
|
||||
expect(active).toBe(1)
|
||||
releaseFirstSignature.resolve()
|
||||
await Promise.all([firstPost.promise, secondSigned.promise])
|
||||
expect(started).toEqual([1, 2])
|
||||
expect(peak).toBe(1)
|
||||
} finally {
|
||||
releaseFirstSignature.resolve()
|
||||
releaseCompletion.resolve()
|
||||
expect(await outcome).toEqual({ ok: true })
|
||||
}
|
||||
expect(started).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it.each([2, 4] as const)('allows %i hardware requests but keeps later items queued until a job completes', async (limit) => {
|
||||
const bothSigning = barrier()
|
||||
const release = barrier()
|
||||
const started: number[] = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const outcome = observe(runSigningExperimentBatch(Array.from({ length: limit + 1 }, (_, index) => index + 1), {
|
||||
jobs: limit, hardwareConcurrency: limit,
|
||||
sign: async () => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
if (active === limit) bothSigning.resolve()
|
||||
try { await release.promise } finally { active-- }
|
||||
},
|
||||
complete: async (item, sign) => { started.push(item); await sign(`staged-${item}`) },
|
||||
}))
|
||||
try {
|
||||
await bothSigning.promise
|
||||
expect(started).toEqual(Array.from({ length: limit }, (_, index) => index + 1))
|
||||
expect(active).toBe(limit)
|
||||
} finally { release.resolve(); expect(await outcome).toEqual({ ok: true }) }
|
||||
expect(peak).toBe(limit)
|
||||
expect(active).toBe(0)
|
||||
})
|
||||
|
||||
it.each([new Error('hardware failed'), undefined])('stops queued signatures and new items after a hardware rejection (%s)', async (failure) => {
|
||||
const secondStarted = barrier()
|
||||
const sign = vi.fn(async () => { await secondStarted.promise; throw failure })
|
||||
const started: number[] = []
|
||||
const result = await observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 1, sign,
|
||||
complete: async (item, request) => {
|
||||
started.push(item)
|
||||
if (item === 2) secondStarted.resolve()
|
||||
await request(`staged-${item}`)
|
||||
},
|
||||
}))
|
||||
expect(result).toEqual({ ok: false, error: failure })
|
||||
expect(sign).toHaveBeenCalledTimes(1)
|
||||
expect(started).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('drains concurrent hardware after the first failure and retains that failure', async () => {
|
||||
const secondSigning = barrier()
|
||||
const firstFailed = barrier()
|
||||
const releaseSecond = barrier()
|
||||
const failure = new Error('first hardware failure')
|
||||
const calls: number[] = []
|
||||
let active = 0
|
||||
let settled = false
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 2,
|
||||
sign: async (item) => {
|
||||
calls.push(item)
|
||||
active++
|
||||
try {
|
||||
if (item === 1) { await secondSigning.promise; throw failure }
|
||||
secondSigning.resolve()
|
||||
await releaseSecond.promise
|
||||
throw new Error('later hardware failure')
|
||||
} finally { active-- }
|
||||
},
|
||||
complete: async (item, sign) => {
|
||||
try { await sign(`staged-${item}`) }
|
||||
catch (error) { if (item === 1) firstFailed.resolve(); throw error }
|
||||
},
|
||||
})).then((result) => { settled = true; return result })
|
||||
try {
|
||||
await firstFailed.promise
|
||||
expect(settled).toBe(false)
|
||||
expect(active).toBe(1)
|
||||
expect(calls).toEqual([1, 2])
|
||||
} finally { releaseSecond.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) }
|
||||
expect(active).toBe(0)
|
||||
expect(calls).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('preserves an undefined completion failure and starts no further work', async () => {
|
||||
const sign = vi.fn(async () => {})
|
||||
const complete = vi.fn(async () => { throw undefined })
|
||||
expect(await observe(runSigningExperimentBatch([1, 2], {
|
||||
jobs: 1, hardwareConcurrency: 1, complete, sign,
|
||||
}))).toEqual({ ok: false, error: undefined })
|
||||
expect(complete).toHaveBeenCalledTimes(1)
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents a waiting signature when completion fails and drains the issued hardware request', async () => {
|
||||
const secondSigning = barrier()
|
||||
const releaseSecond = barrier()
|
||||
const failureObserved = barrier()
|
||||
const failure = new Error('timestamp failed')
|
||||
const calls: number[] = []
|
||||
let pending: ReturnType<typeof observe> | undefined
|
||||
let settled = false
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 1,
|
||||
sign: async (item) => {
|
||||
calls.push(item)
|
||||
if (item === 2) { secondSigning.resolve(); await releaseSecond.promise }
|
||||
},
|
||||
complete: async (item, sign) => {
|
||||
await sign(`staged-${item}`)
|
||||
if (item === 1) {
|
||||
await secondSigning.promise
|
||||
// Failure can interrupt a completion that already owns a queued request.
|
||||
const waiting = observe(sign(`staged-retry-${item}`)).then((result) => { failureObserved.resolve(); return result })
|
||||
pending = waiting
|
||||
throw failure
|
||||
}
|
||||
},
|
||||
})).then((result) => { settled = true; return result })
|
||||
try {
|
||||
await failureObserved.promise
|
||||
expect(settled).toBe(false)
|
||||
expect(calls).toEqual([1, 2])
|
||||
} finally { releaseSecond.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) }
|
||||
expect(calls).toEqual([1, 2])
|
||||
expect(await pending).toEqual({ ok: false, error: failure })
|
||||
})
|
||||
|
||||
it('drains both completion workers after one fails without starting more items', async () => {
|
||||
const secondPost = barrier()
|
||||
const releaseSecondPost = barrier()
|
||||
const failed = barrier()
|
||||
const failure = new Error('verification failed')
|
||||
const started: number[] = []
|
||||
let settled = false
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 2, sign: async () => {},
|
||||
complete: async (item, sign) => {
|
||||
started.push(item)
|
||||
await sign(`staged-${item}`)
|
||||
if (item === 1) { await secondPost.promise; failed.resolve(); throw failure }
|
||||
secondPost.resolve()
|
||||
await releaseSecondPost.promise
|
||||
},
|
||||
})).then((result) => { settled = true; return result })
|
||||
try {
|
||||
await failed.promise
|
||||
expect(settled).toBe(false)
|
||||
expect(started).toEqual([1, 2])
|
||||
} finally { releaseSecondPost.resolve(); expect(await outcome).toEqual({ ok: false, error: failure }) }
|
||||
expect(started).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('cancels waiting signatures and new items while waiting for issued hardware to finish', async () => {
|
||||
const controller = new AbortController()
|
||||
const secondStarted = barrier()
|
||||
const release = barrier()
|
||||
const waitingRejected = barrier()
|
||||
const reason = new Error('operator cancelled')
|
||||
const calls: number[] = []
|
||||
let active = false
|
||||
let settled = false
|
||||
const outcome = observe(runSigningExperimentBatch([1, 2, 3], {
|
||||
jobs: 2, hardwareConcurrency: 1, signal: controller.signal,
|
||||
sign: async (item) => {
|
||||
calls.push(item)
|
||||
active = true
|
||||
try { await release.promise } finally { active = false }
|
||||
},
|
||||
complete: async (item, sign) => {
|
||||
if (item === 2) secondStarted.resolve()
|
||||
try { await sign(`staged-${item}`) }
|
||||
catch (error) { if (item === 2) waitingRejected.resolve(); throw error }
|
||||
},
|
||||
})).then((result) => { settled = true; return result })
|
||||
try {
|
||||
await secondStarted.promise
|
||||
controller.abort(reason)
|
||||
await waitingRejected.promise
|
||||
expect(active).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
expect(calls).toEqual([1])
|
||||
} finally { release.resolve(); expect(await outcome).toEqual({ ok: false, error: reason }) }
|
||||
expect(active).toBe(false)
|
||||
expect(calls).toEqual([1])
|
||||
})
|
||||
|
||||
it('does not begin work when cancellation was already requested', async () => {
|
||||
const reason = new Error('already cancelled')
|
||||
const complete = vi.fn(async () => {})
|
||||
const sign = vi.fn(async () => {})
|
||||
expect(await observe(runSigningExperimentBatch([1], {
|
||||
jobs: 2, hardwareConcurrency: 2, signal: AbortSignal.abort(reason), complete, sign,
|
||||
}))).toEqual({ ok: false, error: reason })
|
||||
expect(complete).not.toHaveBeenCalled()
|
||||
expect(sign).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -63,15 +63,32 @@ async function fixture() {
|
||||
sequence.push('compile'); await writeFile(output, 'inert unsigned probe')
|
||||
})
|
||||
const sign = vi.fn(async () => { sequence.push('sign') })
|
||||
const inspectIdentity = vi.fn(async () => 'matched' as const)
|
||||
const inspect = vi.fn(async (_path: string) => {
|
||||
sequence.push('inspect')
|
||||
return sign.mock.calls.length === 0
|
||||
? { status: 'NotSigned', timestamped: false, thumbprint: null }
|
||||
: { status: 'Valid', timestamped: true, thumbprint: 'A'.repeat(40) }
|
||||
})
|
||||
return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect }
|
||||
return { root, compiler, sequence, runDirectory: run.directory, environment, stateDirectory: join(root, 'state'), compile, sign, inspect, inspectIdentity }
|
||||
}
|
||||
|
||||
it.each(['provider-unavailable', 'container-unavailable', 'certificate-mismatch', 'unknown'] as const)
|
||||
('stops before compilation and signing on public identity result %s', async (result) => {
|
||||
const options = await fixture()
|
||||
const inspectIdentity = vi.fn(async (_input: { environment: NodeJS.ProcessEnv }) => result)
|
||||
await expect(preflightWindowsSigning({ ...options, inspectIdentity })).rejects.toThrow('No PIN was supplied')
|
||||
expect(options.compile).not.toHaveBeenCalled()
|
||||
expect(options.sign).not.toHaveBeenCalled()
|
||||
expect(inspectIdentity).toHaveBeenCalledOnce()
|
||||
expect(inspectIdentity.mock.calls[0]![0].environment).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN')
|
||||
expect(inspectIdentity.mock.calls[0]![0].environment).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNTOOL')
|
||||
const events = await readFile(join(options.runDirectory, 'events.jsonl'), 'utf8')
|
||||
expect(events).toContain(`"result":"${result}"`)
|
||||
expect(events).not.toContain('fixture-pin')
|
||||
expect(events).not.toContain('fixture-container')
|
||||
})
|
||||
|
||||
it('compiles one new probe and verifies one production-format signature before success', async () => {
|
||||
const options = await fixture()
|
||||
await preflightWindowsSigning(options)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { X509Certificate } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { rootCertificates } from 'node:tls'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { inspectWindowsTokenIdentity, windowsTokenIdentityFailure } from '../scripts/windows-token-identity.ts'
|
||||
|
||||
const launch = vi.hoisted(() => vi.fn(async (_file: string, _args: string[], _options: { env: NodeJS.ProcessEnv; timeout: number }) => ({ stdout: '', stderr: '' })))
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('node:child_process')>()
|
||||
const { promisify } = await import('node:util')
|
||||
return { ...original, execFile: Object.assign(vi.fn(), { [promisify.custom]: launch }) }
|
||||
})
|
||||
|
||||
const certificate = new X509Certificate(rootCertificates.find(pem => new X509Certificate(pem).publicKey.asymmetricKeyType === 'rsa')!)
|
||||
const jwk = certificate.publicKey.export({ format: 'jwk' })
|
||||
const modulus = Buffer.from(jwk.n!, 'base64url')
|
||||
const exponent = Buffer.from(jwk.e!, 'base64url').reduce((value, byte) => value * 256 + byte, 0)
|
||||
const blob = Buffer.alloc(20 + modulus.length)
|
||||
blob[0] = 6; blob[1] = 2
|
||||
blob.writeUInt32LE(0x2400, 4); blob.writeUInt32LE(0x31415352, 8)
|
||||
blob.writeUInt32LE(modulus.length * 8, 12); blob.writeUInt32LE(exponent, 16)
|
||||
Buffer.from(modulus).reverse().copy(blob, 20)
|
||||
const input = { certificate: certificate.raw, container: 'fixture-container', environment: {} }
|
||||
|
||||
it('compares the returned public key with the certificate, not the container name', async () => {
|
||||
const query = vi.fn(async () => JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }))
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query })).toBe('matched')
|
||||
expect(query).toHaveBeenCalledOnce()
|
||||
const other = Buffer.from(blob)
|
||||
other[20] = other[20]! ^ 2
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query: async () => JSON.stringify({
|
||||
status: 'public-keys', publicKeys: [other.toString('base64')],
|
||||
}) })).toBe('certificate-mismatch')
|
||||
})
|
||||
|
||||
it.each(['provider-unavailable', 'container-unavailable', 'unknown'])('preserves public query result %s', async (status) => {
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query: async () => JSON.stringify({ status }) })).toBe(status)
|
||||
})
|
||||
|
||||
it.each(['not json', '{}', '{"status":"public-keys","publicKeys":[]}',
|
||||
'{"status":"public-keys","publicKeys":["bad"]}', '{"status":"matched"}',
|
||||
JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64'), 'bad'] })])
|
||||
('rejects incomplete or malformed public data without accepting a partial match: %s', async (output) => {
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query: async () => output })).toBe('unknown')
|
||||
})
|
||||
|
||||
it('contains query failures without retrying or exposing diagnostics', async () => {
|
||||
const query = vi.fn(async () => { throw new Error('private fixture diagnostic') })
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, query })).toBe('unknown')
|
||||
expect(query).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('launches only the public query with no PIN, signing tool or inherited secrets', async () => {
|
||||
launch.mockClear()
|
||||
launch.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }), stderr: '' })
|
||||
expect(await inspectWindowsTokenIdentity({ ...input, environment: {
|
||||
PATH: 'fixture-path', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'must-not-pass', API_KEY: 'must-not-pass',
|
||||
DSH_DESKTOP_WINDOWS_SIGNTOOL: 'must-not-run', NODE_OPTIONS: 'must-not-pass', NODE_PATH: 'must-not-pass',
|
||||
} })).toBe('matched')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
const [file, args, options] = launch.mock.calls[0]!
|
||||
expect(file).toBe('pwsh')
|
||||
expect(args).toContain('-NonInteractive')
|
||||
expect(args.at(-1)).toMatch(/windows-token-public-key\.ps1$/u)
|
||||
expect(options.timeout).toBe(15_000)
|
||||
expect(options.env).toEqual({ PATH: 'fixture-path', DSH_TOKEN_PUBLIC_CONTAINER: 'fixture-container' })
|
||||
})
|
||||
|
||||
it('reports a helper timeout or stderr as unknown without retry', async () => {
|
||||
launch.mockClear()
|
||||
launch.mockRejectedValueOnce(new Error('timeout with private diagnostic'))
|
||||
expect(await inspectWindowsTokenIdentity(input)).toBe('unknown')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
launch.mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'public-keys', publicKeys: [blob.toString('base64')] }), stderr: 'unexpected' })
|
||||
expect(await inspectWindowsTokenIdentity(input)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('reports a missing query launcher without exposing process diagnostics or retrying', async () => {
|
||||
launch.mockClear()
|
||||
launch.mockRejectedValueOnce(Object.assign(new Error('private launcher diagnostic'), { code: 'ENOENT' }))
|
||||
const result = await inspectWindowsTokenIdentity(input)
|
||||
expect(result).toBe('unknown')
|
||||
expect(launch).toHaveBeenCalledOnce()
|
||||
const message = windowsTokenIdentityFailure('unknown')
|
||||
expect(message).toContain('PowerShell 7 (pwsh) can start')
|
||||
expect(message).not.toContain('private launcher diagnostic')
|
||||
expect(message).toContain('no signing was attempted')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('exercises native control flow with a fake CSP and never constructs the native adapter', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'token-public-test-'))
|
||||
try {
|
||||
const source = resolve(import.meta.dirname, '../scripts/windows-token-public-key.cs')
|
||||
const runner = join(root, 'test.ps1')
|
||||
const fixture = resolve(import.meta.dirname, 'fixtures/windows-token-public-key-fake.cs')
|
||||
writeFileSync(runner, `param([string]$Source, [string]$Fixture)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -TypeDefinition ((Get-Content -LiteralPath $Source -Raw) + (Get-Content -LiteralPath $Fixture -Raw))
|
||||
[FakeTokenPublicKey]::Test()
|
||||
`)
|
||||
expect(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-File', runner, source, fixture], { encoding: 'utf8', timeout: 30_000 }))
|
||||
.toContain('fake CSP cases passed')
|
||||
const native = readFileSync(source, 'utf8')
|
||||
const provider = /Provider = "([^"]+)"/u.exec(native)![1]
|
||||
expect(readFileSync(resolve(import.meta.dirname, '../scripts/windows-sign.cmd'), 'utf8')).toContain(`/csp "${provider}"`)
|
||||
expect(native).toContain('CryptAcquireContextW(out context, container, Provider, 1, PublicContextFlags)')
|
||||
expect(native).toContain('CryptExportKey(key, IntPtr.Zero, 6, 0, bytes, ref length)')
|
||||
expect(native).not.toMatch(/CryptSign|CryptSetProvParam|CryptGenKey|CryptAcquireCertificatePrivateKey/u)
|
||||
} finally { rmSync(root, { recursive: true, force: true, maxRetries: 3 }) }
|
||||
})
|
||||
@@ -21,8 +21,8 @@
|
||||
en: bb889e9514678d1c
|
||||
zh: 48cbec1e907d6a2b
|
||||
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/supervised-startup:
|
||||
en: bff3bc48ef5d6545
|
||||
zh: 8ba75ab95a506b80
|
||||
en: cada81c15a20ecc2
|
||||
zh: 80b7a4af7b1e0e5d
|
||||
/windows-signing-and-runner-operations/machine-setup-and-runner-startup/optional-network-dependency:
|
||||
en: 60f34f8637b3c252
|
||||
zh: 805435964ab20263
|
||||
@@ -30,17 +30,17 @@
|
||||
en: 4b5548818375eb9a
|
||||
zh: 8c975adbb1dca9c8
|
||||
/windows-signing-and-runner-operations/signing-and-timestamp-completion:
|
||||
en: e42565e00283e3f6
|
||||
zh: 584a33f07b50fd5b
|
||||
en: a8d23c931cafc236
|
||||
zh: 28fd4860549cdc7b
|
||||
/windows-signing-and-runner-operations/failure-records-and-operator-recovery:
|
||||
en: 5401b605a2f3a44e
|
||||
zh: fc1f6ee667bcd20d
|
||||
/windows-signing-and-runner-operations/signature-cache-and-concurrent-stages:
|
||||
en: c2686bf2e9589521
|
||||
zh: 58dfecf61e4f95da
|
||||
en: 6e8208d19ad510e5
|
||||
zh: eee42e8938c0f603
|
||||
/windows-signing-and-runner-operations/verification-limits:
|
||||
en: 94a471410c84a1c7
|
||||
zh: 115228e4ca36581d
|
||||
en: 53f1fe23f54af84e
|
||||
zh: d219606367ff2d72
|
||||
/windows-signing-and-runner-operations/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
@@ -38,11 +38,11 @@ Run `pwsh -NoProfile -File apps/desktop/scripts/setup-windows-packaging.ps1 -Roo
|
||||
|
||||
For the complete registration, label, routing and approval sequence, start with [CI publication setup](../publishing/ci/README.md#setup-sequence). The task installer requires the Windows .NET Framework 4.5+ compiler at `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`. It compiles a GUI executable into `runner/dsh-task/launcher.exe`; this executable starts PowerShell with no console and waits for it to exit. The output and errors of `run.ps1` itself go to uniquely named `launcher-*.out.log` and `launcher-*.err.log`; filenames include UTC time, PID and a random identifier. Launcher failures disable periodic starts, including failures before the retry budget can be read. Task result `2` indicates launcher failure; `3` means its error log could not be written; `4` means disabling the task also failed and an operator must disable it in Task Scheduler. After the direct child exits, output draining is limited to five seconds. A timeout returns `5` with a `launcher-*.drain.log` diagnostic: output may be incomplete, but periodic recovery continues within the existing restart budget. Descendant processes may outlive the exited runner; draining logs does not terminate them. The recorded stop target is the launcher process, so stop includes the launcher and its descendants. Normal runner output remains in `run-*.log`.
|
||||
|
||||
Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token.
|
||||
Run `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging` as the runner's Windows user, using its installed PowerShell 7. The installer checks the environment and copies startup scripts into `runner/dsh-task`; registration, tools and secrets remain separate. It registers a disabled, limited-permission interactive Task Scheduler task without storing an account password. Task priority 4 gives the runner and its children normal CPU, I/O and memory priority; the scheduler default 7 lowers all three and slows dependency installation. Existing tasks retain their settings: while the runner is idle, stop, uninstall and reinstall the task before starting it again. For `-Action start` or `-Action status`, pass only `-RunnerRoot`; configuration and retry options apply only to installation. The task starts after this user's desktop login, including after reboot, and stops on logout. It does not provide unattended boot or unlock a signing Token.
|
||||
|
||||
The task permits one instance and has no execution time limit. A repeating trigger checks every `-RestartMinutes` minutes (default `1`) and restarts an exited launcher, including an unexpected zero exit. The persisted budget permits the initial launch plus `-RestartCount` restarts (default `10`) between explicit starts. Every desktop login also consumes this budget; `status` shows the remaining launches. After the budget is exhausted, login alone cannot restart the runner. Starting an already running task does not reset the budget. Missing or invalid budget/configuration files disable automatic starts until repaired. GitHub's launcher owns runner updates; task recovery does not rerun a failed workflow or clear signing interlocks. After retries are exhausted, inspect `runner/dsh-task/run-*.log`, the runner's `_diag` logs and `status`, correct the cause, then run `start`. A running task alone does not prove connectivity: verify `Connected to GitHub` and `Listening for Jobs`, then verify assignment in Actions. Both `run-*.log` and `launcher-*.log` are retained for operator-managed cleanup.
|
||||
|
||||
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the configuration directory and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
|
||||
Before maintenance, pause new workflow dispatch and wait for the runner to become idle. `-Action stop` refuses an active worker for this runner, disables future starts and stops its task process tree; the local check cannot prevent a concurrent GitHub assignment. `-Action uninstall` requires a stopped, disabled task and retains registration, configuration and logs. Pulling repository changes does not update the installed copy. To upgrade it, record the original -ConfigRoot, optional -NetworkRoot and retry settings, then run `stop`, `uninstall`, `install` with those settings, and `start` from the updated checkout. Confirm listening and job assignment again. When migrating a manually launched runner, stop its existing launcher only after it is idle, then start the task; never run both launchers together.
|
||||
|
||||
Operators can run `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1` on Windows to exercise installation, console-free initial launch and automatic recovery, duplicate-start prevention, stop, child-process teardown, containment of startup/logging failures and uninstall against an isolated fake runner. This opt-in test creates a temporary task and retains diagnostic files; it does not contact GitHub or access signing hardware. `pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` checks the GUI executable, child console absence, exit status, diagnostic output and bounded draining with an inherited writer without Task Scheduler; Windows CI runs it. Real-machine acceptance additionally checks an idle runner coming online without a visible Terminal window, an Actions job being assigned, and login after an operator-approved reboot. Signed packaging and logout/reboot acceptance require separate operator verification.
|
||||
|
||||
@@ -76,6 +76,8 @@ Follow the [version and build instructions](../packaging/README.md#release-versi
|
||||
|
||||
Signed Windows builds run a supervised signing preflight before compilation or dependency preparation. Static configuration, certificate validity, audit storage, compiler availability and any retained signing interlock are checked without token access. The local .NET Framework C# compiler creates a small private probe; the production signer signs it once, and verification requires the configured certificate and a timestamp before building continues. The probe is never executed. The overall 60-second preflight deadline includes timestamp attempts; deadline expiry, hardware signing errors and verification failures stop the run without another hardware attempt. Success proves the current signing path works, not that the PIN was independently authenticated: SafeNet may reuse login state. Do not log out or repeat authentication to test the PIN. `--check`, preparation-only and `--unsigned` modes do not run this hardware preflight; unsigned artifacts remain ineligible for release upload. Automated regression tests use a fake signer; release operators qualify real hardware separately.
|
||||
|
||||
Public identity queries require PowerShell 7 (`pwsh`) on the build account’s `PATH`; an unavailable or failed query process stops signing with an unknown identity result. Before compiling the probe, a separate noninteractive process reads public keys from the fixed eToken CSP using `CRYPT_VERIFYCONTEXT | CRYPT_SILENT`. It receives the container name but no PIN or signing credentials, and has a 15-second deadline within the existing preflight deadline. Both RSA key slots must be read completely (an absent slot is allowed); at least one public key must match the configured certificate. Provider/container access failure, unsupported public reads, malformed output, timeout or mismatch stops before hardware signing. A container-access error does not establish that the device or container is absent. Public information can be cached by middleware: a match does not authenticate the device, validate the PIN or establish remaining retries. This path is covered by a simulated CSP; the installed SafeNet version still requires separate operator qualification. No failure automatically retries authentication or clears the existing interlock.
|
||||
|
||||
Insert and unlock the token before packaging. The electron-builder hook passes each artifact to the CRLF `scripts/windows-sign.cmd`, which invokes the configured SignTool once with `/f`, SafeNet `/kc "[{{PIN}}]=container"`, `/csp "eToken Base Cryptographic Provider"`, a SHA-256 file digest, without a timestamp. The hook then completes a DigiCert SHA-256 RFC 3161 timestamp on isolated copies without signing credentials. The hook never substitutes electron-builder's bundled SignTool and never retries a failed signing request. Windows release packaging fails instead of emitting unsigned artifacts when the SignTool, certificate, container, PIN, token, or signature is unavailable.
|
||||
|
||||
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).
|
||||
@@ -98,9 +100,11 @@ Runtime signing shares complete signed files across the current Windows account'
|
||||
|
||||
Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery.
|
||||
|
||||
Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required.
|
||||
Cache-hit copying, hashing and per-file candidate trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). After all restores finish, bounded PowerShell batches verify every restored target's trust, timestamp and certificate before cache misses enter serial hardware signing. A restore failure stops new restores and drains active workers; a target-verification failure drains its inspection batch and prevents hardware signing. Runtime smoke checks and final integrity validation remain required.
|
||||
|
||||
Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. Setting the limit to `1` selects serial packing; standalone `release:pack` defaults to `1`.
|
||||
PE discovery reads every regular file's header with at most eight open files, rejects links and malformed Windows executables, and drains active reads and closes before reporting failure. Packaging records `windows:enumerate-code` and `windows:inspect-signatures` as nested phases of signing and artifact verification; their timings are included in the enclosing stages.
|
||||
|
||||
Windows packaging reads `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY` from `.env.windows` or the selected preset: a positive integer, defaulting to `4`. Packaging batches first-party and vendor tarballs with bounded workers, then validates every payload and records publication order. After the complete build succeeds, tarball packing overlaps Electron and primary-runtime preparation in separate output directories. Both must finish successfully before runtime signing; preparation failure stops new packing commands and drains active processes before returning. Setting the limit to `1` selects serial tarball workers; standalone `release:pack` defaults to `1`.
|
||||
|
||||
Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time.
|
||||
|
||||
@@ -110,7 +114,13 @@ Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect st
|
||||
|
||||
## Verification limits
|
||||
|
||||
Real hardware and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy.
|
||||
Signing performance evidence and replacement-runner retesting are owned by the [signing concurrency qualification](../tests/signing-concurrency/README.md). Update and installation evidence is owned by the [Desktop qualification record](../tests/README.md) and [installed-update procedure](../tests/installed-update/README.md). This document does not authorize PIN retries, automatic interlock deletion, device initialization or changes to the accepted isolation policy.
|
||||
|
||||
For operator-approved concurrency experiments, run `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <absolute-packaging-directory> --rounds 2`. Without `--execute`, it only prints usage. The default compares serial completion, two completion workers with one hardware request, and two concurrent hardware requests on six synthetic PE copies per batch. The second round reverses mode order.
|
||||
|
||||
Add `--corpus <absolute-manifest.json>` to compare 1, 2 and 4 workers on real release files. The manifest is a nonempty JSON array of objects with an absolute `path` and its lowercase hexadecimal `sha256`. Preparation copies each pinned file, verifies its signature and removes any valid signature from the copy; invalid signatures or changed bytes stop preparation. Signature removal requires SignTool with `remove /s`; `--strip-tool <absolute-signtool.exe>` overrides only that public preparation tool when the configured version lacks support. Hardware signing and timestamping still use the machine preset. Every batch uses fresh, byte-checked copies of the unsigned templates, preserving manifest order. `--pipeline-control` with a corpus instead runs four completion workers with one hardware request. Original files and release caches remain unchanged.
|
||||
|
||||
The experiment uses the machine test preset, checks public identity, holds the account signing-stage lock and retains one hardware-attempt interlock per batch. The first failure stops dispatch and drains issued work; up to four hardware calls may already be in flight. Only complete signature, timestamp and file verification permits normal interlock release. Files are never executed. Records remain under `.desktop-build/qualification/signing-concurrency`, including batch and per-file elapsed times, batch endpoint host CPU counters, free memory and driver-process memory samples. These memory samples exclude child processes and do not establish peak memory. Timings exclude input preparation, lock waiting and package assembly. Replaying ready-made artifacts also excludes build dependencies between them; the experiment does not enable production concurrency or establish full-release performance.
|
||||
|
||||
## Dev Note
|
||||
|
||||
|
||||
@@ -38,11 +38,11 @@ description: "配置和维护 Windows 打包 runner、签名凭据、EV Token
|
||||
|
||||
完整的注册、标签、路由和审批配置顺序见 [CI 发布配置](../publishing/ci/README.zh.md#setup-sequence)。任务安装器需要 Windows .NET Framework 4.5+ 编译器,路径为 `%SystemRoot%/Microsoft.NET/Framework64/v4.0.30319/csc.exe`。它将 GUI 可执行文件编译到 `runner/dsh-task/launcher.exe`;该程序不分配控制台地启动 PowerShell,并等待其退出。`run.ps1` 自身的输出和错误分别写入名称唯一的 `launcher-*.out.log` 和 `launcher-*.err.log`,文件名包含 UTC 时间、PID 和随机标识。启动器失败时禁用周期启动,包括尚未读取重试预算时发生的失败。任务结果 `2` 表示启动器失败,`3` 表示无法写入错误日志,`4` 表示禁用任务也失败,需要操作者在计划任务中手动禁用。直接子进程退出后,最多等待五秒排空输出;超时返回 `5`,将诊断写入 `launcher-*.drain.log`:输出可能不完整,但周期恢复仍受原有重试预算约束。后代进程可能在 runner 退出后继续运行;日志排空不会终止它们。记录的停止目标是启动器进程,因此 stop 覆盖启动器及其后代。正常 runner 输出仍保存在 `run-*.log`。
|
||||
|
||||
以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。
|
||||
以 runner 的 Windows 用户运行 `pwsh -NoProfile -File apps/desktop/scripts/windows-runner-task.ps1 -Action install -RunnerRoot <registered-runner-directory> -ConfigRoot <machine-root>/packaging`,使用该机器安装的 PowerShell 7。安装器检查环境,并将启动脚本复制到 `runner/dsh-task`;注册、工具和凭据仍单独管理。它注册一个初始禁用、权限受限的交互式计划任务,不保存账户密码。任务优先级 4 让 runner 及子进程使用普通 CPU、I/O 和内存优先级;计划任务默认值 7 会降低三者的优先级,使依赖安装变慢。已有任务保留原设置:在 runner 空闲时停止、卸载并重新安装任务,再启动。执行 `-Action start` 或 `-Action status` 时只传 `-RunnerRoot`;配置目录及重试选项仅适用于安装。任务在该用户登录桌面后启动,重启后也需要登录,注销时停止。它不提供无人值守开机,也不解锁签名 Token。
|
||||
|
||||
任务只允许一个实例,且没有执行时限。周期触发器每隔 `-RestartMinutes` 分钟(默认 `1`)检查一次,并恢复已退出的启动器,包括意外的零退出码。持久预算允许首次启动及 `-RestartCount` 次重启(默认 `10`),直到再次显式启动。每次桌面登录也会消耗预算,`status` 显示剩余启动次数;预算耗尽后,仅登录不能重启 runner。对正在运行的任务执行 start 不会重置预算。预算或配置文件缺失、无效时,自动启动会禁用,需修复后恢复。GitHub 启动器负责 runner 更新;任务恢复不会重跑失败的 workflow,也不会清除签名互锁。重试耗尽后,检查 `runner/dsh-task/run-*.log`、runner 的 `_diag` 日志和 `status`,修复原因后运行 `start`。任务运行不等于连接成功:确认出现 `Connected to GitHub` 和 `Listening for Jobs`,再到 Actions 确认任务分配。`run-*.log` 和 `launcher-*.log` 均保留,由操作者管理清理。
|
||||
|
||||
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录配置目录和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
|
||||
维护前暂停新 workflow 的触发,等待 runner 空闲。`-Action stop` 在该 runner 存在活跃 worker 时拒绝停止,否则禁用后续启动并停止任务进程树;本地检查无法阻止 GitHub 同时分配任务。`-Action uninstall` 要求任务已停止并禁用,保留注册、配置和日志。拉取仓库更新不会更新已安装的副本。升级时先记录原来的 -ConfigRoot、可选的 -NetworkRoot 和重试设置,再从更新后的 checkout 依次执行 `stop`、`uninstall`、携带原设置的 `install` 和 `start`,最后重新确认监听与 job 分配。迁移手动启动的 runner 时,只在空闲后停止原启动器,再启动计划任务;不要同时运行两个启动器。
|
||||
|
||||
操作者可在 Windows 上运行 `pwsh -NoProfile -File apps/desktop/tests/windows-runner-task.ps1`,使用隔离的假 runner 验证安装、无控制台的首次启动与自动恢复、防止重复启动、停止、子进程退出、启动/日志失败时的禁用处理和卸载。此显式运行的测试创建临时任务并保留诊断文件,不连接 GitHub 或访问签名硬件。`pwsh -NoProfile -File apps/desktop/tests/windows-runner-launcher.ps1` 无需计划任务,检查 GUI 可执行文件、子进程无控制台、退出状态、诊断输出以及存在继承管道写端时的有界排空;Windows CI 会执行该测试。真实机器验收还需检查空闲 runner 上线且不出现 Terminal 窗口、Actions job 分配,以及经操作者批准的重启后登录。签名打包和注销/重启验收需要操作者单独验证。
|
||||
|
||||
@@ -76,6 +76,8 @@ PIN 不能包含 `]`、引号或换行,因为这些字符用于分隔 SafeNet
|
||||
|
||||
Windows 签名构建在编译或准备依赖前执行受监督的签名预检。静态配置、证书有效期、审计存储、编译器可用性及遗留签名锁的检查不访问 Token。本地 .NET Framework C# 编译器生成一个专用小探针,由正式签名器仅签名一次,随后必须验出配置的证书和时间戳才能继续构建。探针绝不执行。预检的整体 60 秒期限包含时间戳尝试;超时、硬件签名报错或验签失败都会停止本轮流程,不再次调用硬件。成功只证明当前签名路径可用,不证明 PIN 已独立认证:SafeNet 可能复用登录状态。不要为了验证 PIN 而注销或重复认证。`--check`、仅准备和 `--unsigned` 模式不执行此硬件预检;未签名产物仍不能发布上传。自动回归测试使用假签名器,真实硬件由发布操作人员单独验收。
|
||||
|
||||
公开身份查询要求构建账户的 `PATH` 中有 PowerShell 7(`pwsh`);查询进程不可用或失败时,以身份未知结果停止签名。编译探针前,独立非交互进程使用 `CRYPT_VERIFYCONTEXT | CRYPT_SILENT` 从固定的 eToken CSP 读取公钥。该进程只接收容器名,不接收 PIN 或签名凭据;15 秒期限计入已有预检总期限。两个 RSA 密钥槽必须完整读取(允许槽不存在),且至少一个公钥与配置证书匹配。提供程序或容器访问失败、不支持公开读取、输出损坏、超时或不匹配时,在硬件签名前停止。容器访问错误不能证明设备或容器不存在。中间件可能缓存公开信息,因此匹配不代表设备已认证、PIN 正确或已确认剩余尝试次数。此路径已由模拟 CSP 覆盖,当前 SafeNet 版本仍需操作人员单独验收。任何失败都不会自动重试认证或清除已有保护锁。
|
||||
|
||||
打包前插入并解锁 Token。electron-builder 钩子把每个产物交给采用 CRLF 的 `scripts/windows-sign.cmd`;该 CMD 只调用一次已配置的 SignTool,并指定 `/f`、SafeNet `/kc "[{{PIN}}]=容器"`、`/csp "eToken Base Cryptographic Provider"`和 SHA-256 文件摘要,不请求时间戳。随后钩子在隔离副本上完成 DigiCert SHA-256 RFC 3161 时间戳,不传递签名凭据。钩子不会改用 electron-builder 内置的 SignTool,也不会重试失败的签名请求。SignTool、证书、容器、PIN、Token 或签名不可用时,Windows 发布打包会失败,不会生成未签名产物。
|
||||
|
||||
时间戳处理仅对正常退出但返回失败或警告的时间戳命令重试,最多尝试三次,间隔为一秒和两秒。每次均从同一份已验证的主签名开始。启动错误、终止状态不确定或验签失败会立即停止。SignTool 使用短的私有路径;发布时先把已验证字节复制到目标卷,再原子替换。最终必须通过 Windows 信任、证书、时间戳和规范化全文件相等检查。尝试耗尽后停止打包并保留证据,不再次调用硬件。参见[签名完成决策](../../../.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md)。
|
||||
@@ -98,9 +100,11 @@ Windows 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `.desktop-build/pac
|
||||
|
||||
预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。
|
||||
|
||||
缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。
|
||||
缓存命中的复制、摘要和逐文件候选信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复完成后,有界 PowerShell 批次逐项验证恢复目标的信任、时间戳和证书,通过后未命中项才进入串行硬件签名。恢复失败会停止派发新的恢复任务并等待在途工作结束;目标验签失败会等待当前检查批次结束并阻止硬件签名。运行时 smoke 检查和最终完整性校验仍须执行。
|
||||
|
||||
Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。设为 `1` 时串行打包;独立的 `release:pack` 默认使用 `1`。
|
||||
PE 发现会读取每个普通文件的头部,同时最多打开八个文件,拒绝链接和损坏的 Windows 可执行文件,并在报告失败前等待在途读取和关闭结束。打包将 `windows:enumerate-code` 与 `windows:inspect-signatures` 记录为签名和产物验证的嵌套阶段;其耗时已包含在外层阶段中。
|
||||
|
||||
Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK_CONCURRENCY`:必须为正整数,默认 `4`。Windows 使用有界 worker 批量打包第一方和 vendor tarball,随后校验每个产物并记录发布顺序。完整构建成功后,tarball 打包与 Electron、主要运行时准备在各自输出目录中并行执行。两者都成功结束后才开始运行时签名;准备失败会停止新的打包命令,并等待在途进程结束后返回。设为 `1` 时 tarball worker 串行执行;独立的 `release:pack` 默认使用 `1`。
|
||||
|
||||
每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。
|
||||
|
||||
@@ -110,7 +114,13 @@ Windows 打包从 `.env.windows` 或所选预设读取 `DSH_DESKTOP_WINDOWS_PACK
|
||||
|
||||
## 验证范围
|
||||
|
||||
真实硬件与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。
|
||||
签名性能证据与更换 runner 后的复测由[签名并发验收](../tests/signing-concurrency/README.zh.md)维护。更新与安装证据由[Desktop 验证记录](../tests/README.zh.md)和[已安装更新流程](../tests/installed-update/README.zh.md)维护。本文档不授权 PIN 重试、自动删除 interlock、设备初始化或改变已接受的隔离策略。
|
||||
|
||||
经操作人员授权后,可运行 `node apps/desktop/tests/windows-signing-concurrency.perf.ts --execute --config-root <打包配置目录的绝对路径> --rounds 2` 做并发实验。不加 `--execute` 只打印用法。默认比较完全串行、两个完成任务共用单路硬件签名,以及两路硬件签名,每批使用六个合成 PE 副本。第二轮反转模式顺序。
|
||||
|
||||
加上 `--corpus <清单 JSON 的绝对路径>` 后,改用真实发布文件比较 1、2、4 个并发任务。清单是非空 JSON 数组,每项包含绝对路径 `path` 及对应的小写十六进制 `sha256`。准备阶段复制哈希固定的文件,验证签名并仅移除副本上的有效签名;签名无效或字节变化会中止准备。移除签名要求 SignTool 支持 `remove /s`;配置中的版本不支持时,可用 `--strip-tool <signtool.exe 的绝对路径>` 仅覆盖这个公开准备工具。硬件签名和时间戳仍使用机器 preset。每批使用经过字节比对的全新无签名模板副本,保持清单顺序。配合语料使用 `--pipeline-control` 时,改为四个完成任务共用一个硬件请求。原文件和发布缓存保持不变。
|
||||
|
||||
实验读取机器 test preset,核对公开身份,持有账户签名阶段锁,每批保留一条硬件尝试保护记录。首错停止派发并排空已发出的工作;最多可能已有四条硬件请求在途。只有全部签名、时间戳和文件验证成功才正常释放保护记录。文件从不执行。记录保留在 `.desktop-build/qualification/signing-concurrency`,包含整批及单文件耗时、批次起止时的主机 CPU 计数、可用内存和驱动进程内存采样。内存采样不含子进程,不能用于确定峰值内存。计时不含输入准备、等锁和包组装。重放现成产物还排除了它们之间的构建依赖;实验不启用生产并发,也不代表完整发布性能。
|
||||
|
||||
## 开发备注
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 3221d1076554001f
|
||||
zh: 29f166be6a5c04d9
|
||||
/development-guide/contributor-reference/typescript-project-layout:
|
||||
en: def8c36722719720
|
||||
zh: 810eb0e6723349cc
|
||||
en: 8c004ba883c40653
|
||||
zh: ec5e679339408daa
|
||||
/development-guide/contributor-reference/environment-variables:
|
||||
en: dba2c72ee6ffa930
|
||||
zh: b65c27fbb9129ec8
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge
|
||||
|
||||
Seven packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, `session-query/session-log-export`, and `experimental/liveshare-protocol`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller; `liveshare-protocol` keeps the Node wire codecs on its Host leaf and the browser viewer carrier on its Client leaf. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits.
|
||||
|
||||
`pnpm run build:artifacts` emits every artifact in the generated dependency order, checking and compiling only the package references of each compiler face:
|
||||
`pnpm run build:artifacts` emits every artifact, checking and compiling only the package references of each compiler face. Client compilation waits for Host and Desktop bundling to finish: split compiler faces rewrite shared `lib/types` files read by those bundles. Client bundling follows Client compilation. The artifact order is:
|
||||
|
||||
```sh
|
||||
pnpm run build:native-system
|
||||
|
||||
@@ -78,7 +78,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下
|
||||
|
||||
拆分 Host/Client tsconfig 的包有七个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection`、`session-query/session-log-export` 与 `experimental/liveshare-protocol`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller;`liveshare-protocol` 把 Node 线路编解码放在 Host leaf、浏览器查看页载体放在 Client leaf。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。
|
||||
|
||||
`pnpm run build:artifacts` 按生成依赖顺序产出全部产物,只检查并编译各 compiler face 的包 reference:
|
||||
`pnpm run build:artifacts` 产出全部产物,只检查并编译各 compiler face 的包 reference。Client 编译等待 Host 和 Desktop 的 bundle 完成:拆分 compiler face 会重写这些 bundle 步骤读取的共享 `lib/types` 文件。Client bundle 在 Client 编译后执行。产物步骤顺序为:
|
||||
|
||||
```sh
|
||||
pnpm run build:native-system
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('repository build steps', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps artifact steps in serial dependency order', () => {
|
||||
it('finishes Host and Desktop readers before Client compilation rewrites shared outputs', () => {
|
||||
expect(graph(buildGates(root, 'artifacts'))).toEqual(artifactGraph)
|
||||
})
|
||||
|
||||
|
||||
@@ -166,10 +166,12 @@ function pnpmStep(id: LibraryStepId, label: string, args: readonly string[], nee
|
||||
/**
|
||||
* List the package-library build steps in Host, Desktop, then Client dependency order.
|
||||
* Source-only Typert generation supplies declarations before any package compilation.
|
||||
* Client compilation waits for Host and Desktop bundling because split compiler faces
|
||||
* rewrite shared lib/types files consumed by those bundles.
|
||||
* @param root - Repository root containing the compiler and bundle configurations.
|
||||
* @param faces - `host` lists Typert generation, Host compilation, and bundling; `all` adds Desktop and Client.
|
||||
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
|
||||
* @returns The ordered steps; each step needs its predecessor.
|
||||
* @returns Steps with explicit compiler and bundle dependencies.
|
||||
* @throws If an aggregate configuration is invalid.
|
||||
*/
|
||||
export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: CompilationMode = 'checked'): LibraryStep[] {
|
||||
|
||||
@@ -9,7 +9,16 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github
|
||||
concurrency: { group: string; 'cancel-in-progress': boolean }
|
||||
jobs: Record<string, {
|
||||
'runs-on': string[]
|
||||
steps: Array<{ id?: string; name?: string; if?: string; uses?: string; run?: string; with?: Record<string, unknown> }>
|
||||
env: Record<string, string>
|
||||
steps: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
if?: string
|
||||
uses?: string
|
||||
run?: string
|
||||
env?: Record<string, string>
|
||||
with?: Record<string, unknown>
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -29,6 +38,7 @@ it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silic
|
||||
expect(uploads[0]?.if).toBe("always() && steps.checkout.outcome == 'success'")
|
||||
expect(uploads[1]?.if).toBe('success()')
|
||||
expect(uploads[1]?.with?.path).toBe('${{ steps.retained.outputs.directory }}/*')
|
||||
expect(uploads[1]?.with?.['compression-level']).toBe(0)
|
||||
})
|
||||
|
||||
it('defaults to notarization and labels the explicit debugging option', () => {
|
||||
@@ -40,3 +50,21 @@ it('defaults to notarization and labels the explicit debugging option', () => {
|
||||
expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2)
|
||||
expect(steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ steps.settings.outputs.mode }}')
|
||||
})
|
||||
|
||||
it('retains build inputs outside checkout and run-specific pnpm installations', () => {
|
||||
const env = workflow.jobs.package!.env
|
||||
expect(JSON.stringify(env)).not.toContain('runner.')
|
||||
const steps = workflow.jobs.package!.steps
|
||||
const cache = steps.find(step => step.name === 'Configure persistent package caches')!
|
||||
expect(cache.env?.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache')
|
||||
expect(cache.run).toContain('PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store')
|
||||
expect(cache.run).toContain('DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR')
|
||||
expect(steps.indexOf(cache)).toBeLessThan(steps.findIndex(step => step.name === 'Install locked dependencies'))
|
||||
})
|
||||
|
||||
it('offers ZIP compression choices with a fast default', () => {
|
||||
expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { zip_compression_level: {
|
||||
type: 'choice', options: ['3', '7'], default: '3', required: true,
|
||||
} } })
|
||||
expect(workflow.jobs.package!.env.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL).toBe('${{ inputs.zip_compression_level }}')
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { prepareWindowsBuildWorkspace, removeWindowsBuildWorkspace } from '../apps/desktop/scripts/windows-build-workspace.mjs'
|
||||
|
||||
it('clones the exact clean commit and removes only its run-owned checkout', () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'package-workspace-')))
|
||||
try {
|
||||
const sourceDirectory = join(root, 'original')
|
||||
mkdirSync(sourceDirectory)
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd: sourceDirectory, encoding: 'utf8', stdio: 'pipe' }).trim()
|
||||
git('init')
|
||||
writeFileSync(join(sourceDirectory, 'source.txt'), 'selected source')
|
||||
writeFileSync(join(sourceDirectory, '.gitignore'), 'private.env\n')
|
||||
git('add', '.')
|
||||
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture')
|
||||
writeFileSync(join(sourceDirectory, 'private.env'), 'ignored fixture')
|
||||
const options = { sourceDirectory, buildRoot: join(root, 'builds'), runId: '123', runAttempt: '1', sourceSha: git('rev-parse', 'HEAD') }
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: sourceDirectory })).toThrow('outside existing Git')
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: 'relative' })).toThrow('absolute')
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, runId: '../other' })).toThrow('identity')
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, sourceSha: '0'.repeat(40) })).toThrow('match')
|
||||
writeFileSync(join(sourceDirectory, 'source.txt'), 'dirty')
|
||||
expect(() => prepareWindowsBuildWorkspace(options)).toThrow('clean')
|
||||
writeFileSync(join(sourceDirectory, 'source.txt'), 'selected source')
|
||||
const output = join(root, 'actions-output')
|
||||
execFileSync(process.execPath, [resolve(import.meta.dirname, '../apps/desktop/scripts/windows-build-workspace.mjs')], {
|
||||
cwd: sourceDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_WORKSPACE: sourceDirectory,
|
||||
PACKAGE_BUILD_ROOT: options.buildRoot,
|
||||
GITHUB_RUN_ID: options.runId,
|
||||
GITHUB_RUN_ATTEMPT: options.runAttempt,
|
||||
PACKAGE_SOURCE_SHA: options.sourceSha,
|
||||
GITHUB_OUTPUT: output,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
})
|
||||
const directory = join(options.buildRoot, 'windows-package-123-1', 'source')
|
||||
const temporaryDirectory = join(options.buildRoot, 'windows-package-123-1', 'temp')
|
||||
expect(readFileSync(output, 'utf8')).toBe(`directory=${directory}\ntemporaryDirectory=${temporaryDirectory}\n`)
|
||||
writeFileSync(join(temporaryDirectory, 'temporary.txt'), 'owned temporary data')
|
||||
expect(readFileSync(join(directory, 'source.txt'), 'utf8')).toBe('selected source')
|
||||
expect(existsSync(join(directory, 'private.env'))).toBe(false)
|
||||
expect(() => prepareWindowsBuildWorkspace(options)).toThrow()
|
||||
expect(() => { removeWindowsBuildWorkspace({ ...options, sourceSha: '0'.repeat(40) }) }).toThrow('ownership')
|
||||
expect(existsSync(directory)).toBe(true)
|
||||
const external = join(root, 'external')
|
||||
mkdirSync(external)
|
||||
writeFileSync(join(external, 'keep.txt'), 'keep')
|
||||
symlinkSync(external, join(directory, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
removeWindowsBuildWorkspace(options)
|
||||
expect(existsSync(directory)).toBe(false)
|
||||
expect(existsSync(temporaryDirectory)).toBe(false)
|
||||
expect(readFileSync(join(external, 'keep.txt'), 'utf8')).toBe('keep')
|
||||
expect(readFileSync(join(sourceDirectory, 'source.txt'), 'utf8')).toBe('selected source')
|
||||
const linked = join(root, 'linked')
|
||||
symlinkSync(external, linked, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: join(linked, 'builds') })).toThrow('links')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,13 +1,13 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { parse, resolve } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
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; default?: string; description?: string }> }>
|
||||
on: Record<string, { inputs: Record<string, { type?: string; required?: boolean; default?: string; description?: string }> }>
|
||||
permissions: Record<string, string>
|
||||
concurrency: { group: string; 'cancel-in-progress': boolean }
|
||||
jobs: Record<string, {
|
||||
@@ -19,6 +19,7 @@ const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows
|
||||
uses?: string
|
||||
run?: string
|
||||
if?: string
|
||||
'working-directory'?: string
|
||||
env?: Record<string, string>
|
||||
with?: Record<string, unknown>
|
||||
}>
|
||||
@@ -61,12 +62,17 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig
|
||||
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
|
||||
expect(workflow.jobs.package).not.toHaveProperty('name')
|
||||
expect(job['runs-on']).toBe('dsh-win-package-trial')
|
||||
expect(JSON.stringify(job.env)).not.toContain('runner.')
|
||||
expect(job.steps.find(step => step.name === 'Configure persistent pnpm store')?.env?.DSH_DESKTOP_PACKAGE_CACHE_DIR)
|
||||
.toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache')
|
||||
expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false })
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([
|
||||
'build_version', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode',
|
||||
'build_root', 'build_version', 'cache_namespace', 'compression_level', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode',
|
||||
])
|
||||
expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('signed')
|
||||
expect(workflow.on.workflow_dispatch!.inputs.compression_level).toMatchObject({ type: 'choice', options: ['3', '7'], default: '3', required: true })
|
||||
expect(job.env.PACKAGE_WINDOWS_COMPRESSION_LEVEL).toBe('${{ inputs.compression_level }}')
|
||||
expect(workflow.on.workflow_dispatch!.inputs.build_version!.required).toBe(false)
|
||||
expect(workflow.on.workflow_dispatch!.inputs.version_mode!.default).toBe('default')
|
||||
expect(workflow.on.workflow_dispatch!.inputs.source_ref!.default).toBe('master')
|
||||
@@ -101,9 +107,182 @@ it('retains failure diagnostics and uploads only the selected successful install
|
||||
const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))
|
||||
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 })
|
||||
expect(uploads[0]!.with).toMatchObject({
|
||||
path: '${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/',
|
||||
'include-hidden-files': true,
|
||||
})
|
||||
for (const mode of ['unsigned', 'signed']) {
|
||||
const upload = uploads.find(step => step.if === `success() && inputs.signing == '${mode}'`)!
|
||||
expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error' })
|
||||
expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error', 'compression-level': 0 })
|
||||
}
|
||||
})
|
||||
|
||||
it('routes all packaging consumers to the selected workspace and removes it only after successful uploads', () => {
|
||||
expect(workflow.on.workflow_dispatch!.inputs.build_root).toMatchObject({ type: 'string', default: '', required: false })
|
||||
expect(job.env.PACKAGE_BUILD_ROOT).toBe('${{ inputs.build_root }}')
|
||||
const selected = job.steps.find(step => step.id === 'build_workspace')!
|
||||
expect(selected.name).toBe('Select packaging workspace')
|
||||
expect(selected.env).toEqual({ PACKAGE_SOURCE_SHA: '${{ steps.checkout.outputs.commit }}' })
|
||||
expect(selected['working-directory']).toBeUndefined()
|
||||
expect(selected.run).toContain('if (!$env:PACKAGE_BUILD_ROOT)')
|
||||
expect(selected.run).toContain('"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT')
|
||||
expect(selected.run).toContain('} else {')
|
||||
expect(selected.run).toContain('node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs"')
|
||||
expect(selected.run).toContain('exit $LASTEXITCODE')
|
||||
const index = job.steps.indexOf(selected)
|
||||
expect(index).toBeGreaterThan(job.steps.findIndex(step => step.uses?.startsWith('actions/setup-node@')))
|
||||
expect(index).toBeLessThan(job.steps.findIndex(step => step.name === 'Configure persistent pnpm store'))
|
||||
const consumers = [
|
||||
'Install locked dependencies',
|
||||
'Validate preset and resolve build version',
|
||||
'Check packaging configuration and toolchain',
|
||||
'Build installer and verify packaged runtime',
|
||||
'Retain complete package outside the checkout',
|
||||
]
|
||||
for (const name of consumers) {
|
||||
const step = job.steps.find(entry => entry.name === name)!
|
||||
expect(step['working-directory']).toBe('${{ steps.build_workspace.outputs.directory }}')
|
||||
expect(job.steps.indexOf(step)).toBeGreaterThan(index)
|
||||
}
|
||||
expect(job.steps.filter(step => step['working-directory'] === '${{ steps.build_workspace.outputs.directory }}'))
|
||||
.toHaveLength(consumers.length)
|
||||
const store = job.steps.find(step => step.name === 'Configure persistent pnpm store')!
|
||||
expect(store.run).toContain('[IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)')
|
||||
expect(store['working-directory']).toBeUndefined()
|
||||
const cleanup = job.steps.at(-1)!
|
||||
expect(cleanup).toMatchObject({
|
||||
name: 'Remove isolated packaging workspace',
|
||||
if: "success() && inputs.build_root != ''",
|
||||
env: { PACKAGE_SOURCE_SHA: '${{ steps.checkout.outputs.commit }}' },
|
||||
run: 'node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove',
|
||||
})
|
||||
expect(cleanup['working-directory']).toBeUndefined()
|
||||
for (const upload of job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))) {
|
||||
expect(job.steps.indexOf(upload)).toBeLessThan(job.steps.indexOf(cleanup))
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes priority in the dependency and packaging shells before launching their children', () => {
|
||||
const command = '& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort'
|
||||
expect(job.steps.find(entry => entry.name === 'Validate selected source and adapter support')!.run)
|
||||
.not.toContain('windows-packaging-priority.ps1')
|
||||
for (const name of ['Install locked dependencies', 'Build installer and verify packaged runtime']) {
|
||||
const step = job.steps.find(entry => entry.name === name)!
|
||||
expect(step.run!.trimStart().startsWith(command + '\n')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes the optional temporary directory to installer building without changing Actions or later steps', () => {
|
||||
const build = job.steps.find(step => step.name === 'Build installer and verify packaged runtime')!
|
||||
expect(build.env?.PACKAGE_BUILD_TEMP).toBe('${{ steps.build_workspace.outputs.temporaryDirectory }}')
|
||||
expect(build.run).toContain([
|
||||
'if ($env:PACKAGE_BUILD_TEMP) {',
|
||||
' $env:TEMP = $env:PACKAGE_BUILD_TEMP',
|
||||
' $env:TMP = $env:PACKAGE_BUILD_TEMP',
|
||||
'}',
|
||||
].join('\n'))
|
||||
expect(build.run!.indexOf('if ($env:PACKAGE_BUILD_TEMP)')).toBeLessThan(build.run!.indexOf('$packageArgs ='))
|
||||
expect(build.run).not.toContain('GITHUB_ENV')
|
||||
expect(build.run).not.toContain('RUNNER_TEMP')
|
||||
expect(job.env).not.toHaveProperty('TEMP')
|
||||
expect(job.env).not.toHaveProperty('TMP')
|
||||
expect(job.env).not.toHaveProperty('PACKAGE_BUILD_TEMP')
|
||||
for (const step of job.steps.filter(step => step !== build)) {
|
||||
expect(step.env ?? {}).not.toHaveProperty('PACKAGE_BUILD_TEMP')
|
||||
expect(step.env ?? {}).not.toHaveProperty('TEMP')
|
||||
expect(step.env ?? {}).not.toHaveProperty('TMP')
|
||||
expect(step.run ?? '').not.toMatch(/\$env:(?:TEMP|TMP)\s*=/u)
|
||||
}
|
||||
const selected = job.steps.find(step => step.id === 'build_workspace')!
|
||||
const defaultBranch = selected.run!.split('} else {')[0]!
|
||||
expect(defaultBranch).toContain('"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT')
|
||||
expect(defaultBranch).not.toContain('temporaryDirectory')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => {
|
||||
const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-'))
|
||||
try {
|
||||
const workspace = resolve(directory, 'runner', 'repo')
|
||||
const packageCache = resolve(directory, 'tool-cache', 'desktop')
|
||||
const store = resolve(parse(directory).root, '.pnpm-store')
|
||||
const step = job.steps.find(entry => entry.name === 'Configure persistent pnpm store')!
|
||||
expect(job.steps.indexOf(step)).toBeLessThan(job.steps.findIndex(entry => entry.name === 'Install locked dependencies'))
|
||||
const script = resolve(directory, 'store.ps1')
|
||||
writeFileSync(script, "$ErrorActionPreference = 'Stop'\n" + step.run!)
|
||||
const outputs = ['first', 'second'].map((run) => {
|
||||
const environmentFile = resolve(directory, run + '.env')
|
||||
execFileSync('pwsh', ['-NoProfile', '-File', script], {
|
||||
env: { ...process.env, GITHUB_WORKSPACE: workspace,
|
||||
PACKAGE_CACHE_NAMESPACE: '', DSH_DESKTOP_PACKAGE_CACHE_DIR: packageCache,
|
||||
PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile },
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return readFileSync(environmentFile, 'utf8').trim()
|
||||
})
|
||||
expect(outputs.map(output => output.split(/\r?\n/u))).toEqual(Array.from({ length: 2 }, () => [
|
||||
`DSH_DESKTOP_PACKAGE_CACHE_DIR=${packageCache}`, `PNPM_CONFIG_STORE_DIR=${store}`,
|
||||
]))
|
||||
const runNamespace = (namespace: string) => {
|
||||
const output = resolve(directory, 'namespace.env')
|
||||
rmSync(output, { force: true })
|
||||
execFileSync('pwsh', ['-NoProfile', '-File', script], {
|
||||
env: { ...process.env, GITHUB_WORKSPACE: workspace,
|
||||
PACKAGE_CACHE_NAMESPACE: namespace, DSH_DESKTOP_PACKAGE_CACHE_DIR: packageCache, GITHUB_ENV: output },
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return readFileSync(output, 'utf8').trim().split(/\r?\n/u)
|
||||
}
|
||||
expect(runNamespace('perf-1')).toEqual([
|
||||
`DSH_DESKTOP_PACKAGE_CACHE_DIR=${resolve(packageCache, 'perf-1')}`, `PNPM_CONFIG_STORE_DIR=${resolve(store, 'perf-1')}`,
|
||||
])
|
||||
for (const invalid of ['../escape', 'Upper', 'has space', 'con', 'perf\n', 'a'.repeat(65)]) {
|
||||
expect(() => runNamespace(invalid)).toThrow()
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 3 })
|
||||
}
|
||||
})
|
||||
|
||||
it('pins environment helpers to the workflow commit before selecting product source', () => {
|
||||
const helper = job.steps.find(step => step.name === 'Checkout workflow helpers')!
|
||||
const stage = job.steps.find(step => step.name === 'Stage workflow helpers')!
|
||||
const source = job.steps.find(step => step.id === 'checkout')!
|
||||
expect(helper.with).toMatchObject({ ref: '${{ github.workflow_sha }}', 'persist-credentials': false })
|
||||
expect(job.steps.indexOf(helper)).toBeLessThan(job.steps.indexOf(stage))
|
||||
expect(job.steps.indexOf(stage)).toBeLessThan(job.steps.indexOf(source))
|
||||
expect(stage.run).toContain('RUNNER_TEMP')
|
||||
expect(stage.run).toContain('windows-build-workspace.mjs')
|
||||
expect(stage.run).toContain('windows-packaging-priority.ps1')
|
||||
for (const step of job.steps.filter(step => step.name !== 'Stage workflow helpers')) {
|
||||
expect(step.run ?? '').not.toContain('./apps/desktop/scripts/windows-packaging-priority.ps1')
|
||||
expect(step.run ?? '').not.toContain('node apps/desktop/scripts/windows-build-workspace.mjs')
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('stages helpers that remain usable when product source lacks them', () => {
|
||||
const directory = mkdtempSync(resolve(tmpdir(), 'workflow-helpers-'))
|
||||
try {
|
||||
const source = resolve(directory, 'source')
|
||||
const scripts = resolve(source, 'apps/desktop/scripts')
|
||||
const temporary = resolve(directory, 'temporary')
|
||||
mkdirSync(scripts, { recursive: true })
|
||||
mkdirSync(temporary)
|
||||
for (const name of ['windows-packaging-priority.ps1', 'windows-build-workspace.mjs']) {
|
||||
writeFileSync(resolve(scripts, name), readFileSync(resolve(root, 'apps/desktop/scripts', name)))
|
||||
}
|
||||
const stage = resolve(directory, 'stage.ps1')
|
||||
writeFileSync(stage, job.steps.find(step => step.name === 'Stage workflow helpers')!.run!)
|
||||
const env = { ...process.env, RUNNER_TEMP: temporary, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1', GITHUB_ENV: resolve(directory, 'env') }
|
||||
execFileSync('pwsh', ['-NoProfile', '-File', stage], { cwd: source, env, stdio: 'pipe' })
|
||||
rmSync(scripts, { recursive: true })
|
||||
const helper = resolve(temporary, 'packaging-helpers-123-1/windows-packaging-priority.ps1')
|
||||
expect(execFileSync('pwsh', ['-NoProfile', '-File', helper, '-InspectOnly'], { cwd: source, stdio: 'pipe' }).toString())
|
||||
.toContain('packaging-process-priority')
|
||||
expect(existsSync(resolve(temporary, 'packaging-helpers-123-1/windows-build-workspace.mjs'))).toBe(true)
|
||||
expect(() => execFileSync('pwsh', ['-NoProfile', '-File', stage], {
|
||||
cwd: source, env: { ...env, GITHUB_RUN_ATTEMPT: '2' }, stdio: 'pipe',
|
||||
})).toThrow()
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 3 })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user