diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 70ed365682..e61bef0640 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -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/setup-node@v7.0.0 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 diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 7542fbf49d..e432323ac3 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -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 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index ffed19d45c..ccba7ac5ff 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -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---` and `windows-x64-unsigned---` contain the corresponding EXE; `windows-package-logs--` 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--/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. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index d3b5e2c241..c07f58026c 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -113,6 +113,8 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s 在 Actions 打开完成的运行并下载 Artifacts。`windows-x64-signed---` 与 `windows-x64-unsigned---` 包含对应 EXE;`windows-package-logs--` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。 +测量 Windows 构建路径对性能的影响时,可用 `build_root` 指定现有 Git checkout 之外的绝对目录。每轮任务将已验证的源码提交克隆到新的 `windows-package--/source` 目录;不会复制忽略文件或此前的构建输出。依赖缓存和签名缓存沿用原有位置。成功任务在产物上传后仅删除本轮所属的 checkout;失败任务保留该目录供检查。输入留空时使用原 Actions checkout。完整 Job 耗时须包含 checkout 创建和清理。 + 测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。 diff --git a/apps/desktop/scripts/windows-build-workspace.d.mts b/apps/desktop/scripts/windows-build-workspace.d.mts new file mode 100644 index 0000000000..a9037d78c1 --- /dev/null +++ b/apps/desktop/scripts/windows-build-workspace.d.mts @@ -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 diff --git a/apps/desktop/scripts/windows-build-workspace.mjs b/apps/desktop/scripts/windows-build-workspace.mjs new file mode 100644 index 0000000000..a15ade90dd --- /dev/null +++ b/apps/desktop/scripts/windows-build-workspace.mjs @@ -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`) + } +} diff --git a/scripts/windows-build-workspace.spec.ts b/scripts/windows-build-workspace.spec.ts new file mode 100644 index 0000000000..f67ee577d5 --- /dev/null +++ b/scripts/windows-build-workspace.spec.ts @@ -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', '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') + 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 }) + } +}) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 395fe48a4e..bf2109c6b4 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -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 }> + on: Record }> permissions: Record concurrency: { group: string; 'cancel-in-progress': boolean } jobs: Record with?: Record }> @@ -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 {