perf(windows): support isolated packaging workspaces

This commit is contained in:
winewill committed 2026-10-20 20:47:05 +08:00
1 parent a12c0dfe46
commit 9ee94429b9
8 files changed
+262 -6

No files matched your search

+28 -1
View File
@@ -47,6 +47,11 @@ on:
type: string
default: ''
required: false
build_root:
description: '独立构建根目录:填写绝对路径,每次任务创建独立源码副本;留空使用 runner 工作区'
type: string
default: ''
required: false
permissions:
contents: read
@@ -71,6 +76,7 @@ jobs:
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
PACKAGE_SIGNING: ${{ inputs.signing }}
PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }}
PACKAGE_BUILD_ROOT: ${{ inputs.build_root }}
steps:
- name: Reset packaging checkout
working-directory: ${{ runner.temp }}
@@ -113,6 +119,17 @@ jobs:
- uses: actions/[email protected]
with:
node-version: '24'
- 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 apps/desktop/scripts/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:
@@ -129,6 +146,7 @@ jobs:
"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: |
pnpm store path
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
@@ -136,10 +154,12 @@ jobs:
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: |
@@ -148,6 +168,7 @@ 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 }}
run: |
@@ -157,6 +178,7 @@ jobs:
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 }}
@@ -167,7 +189,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
@@ -188,3 +210,8 @@ jobs:
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 apps/desktop/scripts/windows-build-workspace.mjs --remove
+2 -2
View File
@@ -24,8 +24,8 @@
en: 5b4f0f80a117f202
zh: ff05be196c181087
/desktop-packaging/windows-actions-packaging:
en: 79f09d4deac94041
zh: 6e0cfbda4c2f6518
en: 68d800ed10cac4a6
zh: f72ac7e939af7ad5
/desktop-packaging/macos-self-hosted-packaging:
en: a3af9523fdc60036
zh: 5e18d4740e9b7f57
+2
View File
@@ -113,6 +113,8 @@ 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. Dependency and signing caches retain their existing locations. Successful runs remove only their owned checkout after artifact uploads; failed runs retain it 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.
<a id="macos-actions"></a>
+2
View File
@@ -113,6 +113,8 @@ 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` 目录;不会复制忽略文件或此前的构建输出。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout;失败任务保留该目录供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。
测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。
<a id="macos-actions"></a>
@@ -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 to use for packaging.
*/
export function prepareWindowsBuildWorkspace(options: WindowsBuildWorkspaceOptions): { directory: 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,94 @@
/** 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') }
}
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}} Checkout to use for packaging.
*/
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' })
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 }
}
/**
* Remove only the matching run-owned directory after successful artifact upload.
* @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 })
}
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}\n`)
}
}
+63
View File
@@ -0,0 +1,63 @@
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 = 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', '[email protected]', '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')
expect(readFileSync(output, 'utf8')).toBe(`directory=${directory}\n`)
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(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 })
}
})
+52 -3
View File
@@ -7,7 +7,7 @@ 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>
}>
@@ -67,7 +68,7 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig
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', 'cache_namespace', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode',
'build_root', 'build_version', 'cache_namespace', '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.build_version!.required).toBe(false)
@@ -104,13 +105,61 @@ 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' })
}
})
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 apps/desktop/scripts/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 apps/desktop/scripts/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.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => {
const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-'))
try {