From 536aaef0b331505579f487b549df7bcbe810b002 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:53:52 +0800 Subject: [PATCH] fix(ci): preserve workflow concurrency and portable artifact checks --- ...026-10-05-pr-artifact-typechecks.i18n.yaml | 4 +- .../2026-10-05-pr-artifact-typechecks.md | 2 + .../2026-10-05-pr-artifact-typechecks.zh.md | 2 + .../workflows/build-exe-for-python-sdk.yml | 2 +- .../workflows/build-preview-cloudflare.yml | 2 +- .github/workflows/e2e.yml | 2 +- .github/workflows/release-vendor.yml | 2 +- .github/workflows/release.yml | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- package.json | 6 +- scripts/build.ts | 19 +++- scripts/ci-workflow.spec.ts | 16 +-- scripts/compile-referenced-projects.spec.ts | 103 ++++++++++++++++-- scripts/compile-referenced-projects.ts | 55 +++++++++- scripts/preview-workflow.spec.ts | 6 +- scripts/release/pack.spec.ts | 3 +- scripts/run-ci-bench.ts | 19 ++++ scripts/run-gates.spec.ts | 13 ++- scripts/run-gates.ts | 6 +- scripts/tests/ci-release-selfhosted.spec.ts | 4 +- 22 files changed, 223 insertions(+), 53 deletions(-) create mode 100644 scripts/run-ci-bench.ts diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml index 0569fcc474..d8ebf5838e 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.i18n.yaml @@ -9,8 +9,8 @@ en: e5c984fa32cc5e69 zh: 25d5742b2106d830 /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/decision: - en: 184aa812c4c857fe - zh: 953c2ec2f4e4031f + en: f4ae626ef54d10e0 + zh: c2e8c30930832e12 /agent-note-pr-artifact-builds-and-aggregate-typescript-checks/alternatives-considered: en: f61da066f9ddcd62 zh: 816fd6e0063017cd diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md index bfe998c2a3..ea5e5b19ff 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md @@ -14,6 +14,8 @@ Required Linux consumer and native Windows build jobs retain normal builds, incl PR artifact builds compile every existing project reference from each compiler face with one serial `tsc -b` process, then run the ordinary Typert, bundling, Desktop, and Web stages. The helper reads the existing Host and Client aggregates rather than maintaining another package list. It omits only each root's additional test and script program; package diagnostics, emitted outputs, and Host-before-Client ordering remain required. Non-PR release and real-API workflow builds retain their normal checks. +CI invokes the internal build helpers directly and keeps the root package manifest unchanged. Root manifest edits activate the separate native-addon platform matrix even when only build aliases change; avoiding that trigger preserves the existing workflow set and concurrent-job ceiling without weakening native validation. + Job inventory, runner selectors, gate budgets, worker counts, and benchmark sample counts stay fixed. Coverage partitions reuse compact file-cost metadata within their platform and runner pool. Parallel release packing uses pnpm's existing bounded scheduler; serial publication keeps family order. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md index 2f40ec42a5..0342b28f4a 100644 --- a/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md +++ b/.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md @@ -14,6 +14,8 @@ PR job 需要相同的编译包来运行 benchmark、兼容性、打包和运行 PR 产物构建通过一个串行 `tsc -b` 进程编译各 compiler face 的全部现有 project reference,然后运行普通 Typert、bundling、Desktop 和 Web 阶段。helper 读取现有 Host 与 Client aggregate,而不维护另一份包清单。它只省略各 root 附加的测试和脚本 program;包诊断、emit 输出及 Host 先于 Client 的顺序仍然必需。非 PR 的 release 和真实 API 工作流构建保留普通检查。 +CI 直接调用内部 build helper,并保持 root package manifest 不变。即使只修改 build alias,root manifest 改动也会激活独立的 native-addon 平台矩阵;避免这一触发条件,可以在不削弱 native 验证的情况下保留现有 workflow 集合与并发 job 上限。 + job 清单、runner 选择、gate 预算、worker 数量和 benchmark sample 数量保持固定。覆盖率分区在各自的平台和 runner pool 内复用精简的文件成本元数据。并行 release 打包使用 pnpm 现有的有界调度器;串行发布保持 family 顺序。 ## Alternatives considered diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index e5056f2d1a..33af04a4ce 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -260,7 +260,7 @@ jobs: if: inputs.ci && github.event_name == 'pull_request' env: DSH_BUILD_CLIENT_PROFILE: official - run: pnpm run build:artifacts + run: pnpm run build --artifacts-only - name: Build single-exe env: diff --git a/.github/workflows/build-preview-cloudflare.yml b/.github/workflows/build-preview-cloudflare.yml index 7787fa3c93..93e41abee5 100644 --- a/.github/workflows/build-preview-cloudflare.yml +++ b/.github/workflows/build-preview-cloudflare.yml @@ -71,7 +71,7 @@ jobs: # build:preview packs the image through the packer's installed bin # (lib/bin.js), so neither half exists before the full build runs. - name: Build workspace - run: pnpm run build:artifacts + run: pnpm run build --artifacts-only - name: Build the preview page and pack the VFS image env: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ddd29a425b..6fb50bde2d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -107,7 +107,7 @@ jobs: # the built artifact under plain Node, resolving plugins through real package # exports — the shape a real consumer runs. That requires a prior build. - name: Build (lib for the e2e example bins) - run: pnpm run ${{ github.event_name == 'pull_request' && 'build:artifacts --profile official' || 'build:official' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }} - name: Prepare Office runtime run: pnpm run prepare:primary-runtime --target linux-x64 --output .cache/e2e-office diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml index bb5f40be6f..bf0bdf082e 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -89,7 +89,7 @@ jobs: # The vendored packages publish their own sources and build outputs; the # host build produces what their manifests select. - name: Build - run: pnpm run ${{ github.event_name == 'pull_request' && 'build:lib:host:artifacts' || 'build:lib:host' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm exec tsx scripts/compile-referenced-projects.ts host-libraries' || 'pnpm run build:lib:host' }} # Concurrency here is rehearsal-only: the credentialed publish workflows # invoke release:pack without the flag and keep the strictly serial path. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3857f569a0..ba151e22e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,7 +146,7 @@ jobs: run: pnpm run release:verify --family dsh - name: Build - run: pnpm run ${{ github.event_name == 'pull_request' && 'build:artifacts --profile official' || 'build:official' }} + run: ${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }} # Concurrency here is rehearsal-only: the credentialed publish workflows # invoke release:pack without the flag and keep the strictly serial path. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 2b7e3c5e46..5aa5a9e15b 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -21,8 +21,8 @@ en: 3221d1076554001f zh: 29f166be6a5c04d9 /development-guide/contributor-reference/typescript-project-layout: - en: b89173a1dde2f502 - zh: e57bf1d7cf1e6490 + en: baf046df0bea3c1a + zh: 228e218fd8fea054 /development-guide/contributor-reference/environment-variables: en: dba2c72ee6ffa930 zh: b65c27fbb9129ec8 diff --git a/docs/development.md b/docs/development.md index b58c82cef3..aaddf30323 100644 --- a/docs/development.md +++ b/docs/development.md @@ -85,7 +85,7 @@ tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build:artifacts` emits the same project and application artifacts while compiling only the existing package references of each compiler face. Required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs; PR artifact jobs reuse that ownership ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). +`pnpm run build --artifacts-only` emits the same project and application artifacts while compiling only the existing package references of each compiler face. Required Linux and Windows normal builds and public `typecheck` also check the repository-wide test and script programs; PR artifact jobs reuse that ownership ([rationale](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.md)). Both tsdown passes match `vendor/*`, `packages/*/*`, and `apps/cli`; the Host pass also matches `apps/desktop-host`. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase. Tsdown builds the matched workspace members concurrently, so `apps/desktop`, whose main bundle inlines workspace devDependencies from their `lib/` output, bundles in its own step after the Host pass ([Desktop README](../apps/desktop/README.md#bundled-workspace-dependencies)). diff --git a/docs/development.zh.md b/docs/development.zh.md index 4bfaba369f..b7c487532f 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -89,7 +89,7 @@ tsdown --env.DSH_BUILD_FACE client pnpm run build:web ``` -`pnpm run build:artifacts` 产出相同的 project 和应用产物,但只编译各 compiler face 的现有包 reference。必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program;PR 产物 job 复用这一职责划分([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 +`pnpm run build --artifacts-only` 产出相同的 project 和应用产物,但只编译各 compiler face 的现有包 reference。必需的 Linux 与 Windows 普通构建以及公开 `typecheck` 还检查仓库级测试和脚本 program;PR 产物 job 复用这一职责划分([理由](../.agents/notes/implemented/process/2026-10-05-pr-artifact-typechecks.zh.md))。 两次 tsdown 都匹配 `vendor/*`、`packages/*/*` 与 `apps/cli`,Host 阶段另外匹配 `apps/desktop-host`;两者都不扫描构建产物来发现 Client 包,也不维护 Host/Client 包过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client 插件在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。tsdown 并发构建匹配到的 workspace 成员;`apps/desktop` 的主进程 bundle 需要从工作区 devDependencies 的 `lib/` 产物内联这些包,因此在 Host 阶段之后单独一步打 bundle([Desktop README](../apps/desktop/README.zh.md#bundled-workspace-dependencies))。 diff --git a/package.json b/package.json index 323ede5cdb..f4970a1730 100644 --- a/package.json +++ b/package.json @@ -19,15 +19,11 @@ "scripts": { "prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts", "build": "tsx scripts/build.ts", - "build:artifacts": "tsx scripts/build.ts --artifacts-only", - "build:bench": "npm run build:native-system && npm run build:lib:artifacts && tsdown --config-loader native --config benchmarks/tsdown.config.ts", + "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --config-loader native --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --config-loader native --env.DSH_BUILD_FACE client", - "build:lib:artifacts": "pnpm run build:lib:host:artifacts && pnpm run build:lib:client:artifacts", - "build:lib:host:artifacts": "tsx scripts/compile-referenced-projects.ts host && tsdown --config-loader native --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle", - "build:lib:client:artifacts": "tsx scripts/compile-referenced-projects.ts client && tsdown --config-loader native --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", "dev:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run dev", diff --git a/scripts/build.ts b/scripts/build.ts index e90b4cf4a4..8cd6cfff8f 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -14,9 +14,9 @@ import { } from './client-build-environment.ts' import { pnpmInvocation } from './pnpm-invocation.ts' -/** Run one package script through the package manager that invoked this build. */ -function runScript(script: string, environment: NodeJS.ProcessEnv): void { - const invocation = pnpmInvocation(['run', script], environment) +/** Run one package-manager command with the selected public build environment. */ +function runCommand(args: readonly string[], environment: NodeJS.ProcessEnv, label: string): void { + const invocation = pnpmInvocation(args, environment) const result = spawnSync(invocation.command, invocation.args, { cwd: resolve(import.meta.dirname, '..'), env: environment, @@ -24,10 +24,15 @@ function runScript(script: string, environment: NodeJS.ProcessEnv): void { }) if (result.error !== undefined) throw result.error if (result.status !== 0) { - throw new Error(`build: ${script} exited with ${String(result.status ?? result.signal)}`) + throw new Error(`build: ${label} exited with ${String(result.status ?? result.signal)}`) } } +/** Run one package script through the package manager that invoked this build. */ +function runScript(script: string, environment: NodeJS.ProcessEnv): void { + runCommand(['run', script], environment, script) +} + /** Build client artifacts and optionally omit the repository test and script typechecks. */ function main(): void { // tsdown.config.ts loads only through Node type stripping (`--config-loader native`); this names the cause before tsdown fails. @@ -49,7 +54,11 @@ function main(): void { rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true }) runScript('build:native-system', buildEnvironment) - runScript(values['artifacts-only'] ? 'build:lib:artifacts' : 'build:lib', buildEnvironment) + if (values['artifacts-only']) { + runCommand(['exec', 'tsx', 'scripts/compile-referenced-projects.ts', 'libraries'], buildEnvironment, 'artifact libraries') + } else { + runScript('build:lib', buildEnvironment) + } runScript('build:web', buildEnvironment) const record = writeClientBuildRecord(root, clientEnvironment) console.log( diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 5b885a9b74..9568057334 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -37,8 +37,8 @@ describe('CI workflow', () => { }) it('selects artifact builds only for PR release and API rehearsals', () => { - const official = "pnpm run ${{ github.event_name == 'pull_request' && 'build:artifacts --profile official' || 'build:official' }}" - const vendor = "pnpm run ${{ github.event_name == 'pull_request' && 'build:lib:host:artifacts' || 'build:lib:host' }}" + const official = "${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }}" + const vendor = "${{ github.event_name == 'pull_request' && 'pnpm exec tsx scripts/compile-referenced-projects.ts host-libraries' || 'pnpm run build:lib:host' }}" for (const [file, jobName, expected] of [ ['release.yml', 'pack', official], ['release-vendor.yml', 'pack', vendor], @@ -47,14 +47,16 @@ describe('CI workflow', () => { const job = workflowJob(loadWorkflow('.github/workflows/' + file), jobName) if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`) const step = job.steps.filter(isRecord).find(candidate => typeof candidate.run === 'string' - && (candidate.run.includes('build:artifacts') || candidate.run.includes('build:lib:host:artifacts'))) + && (candidate.run.includes('--artifacts-only') || candidate.run.includes('host-libraries'))) expect(step?.run).toBe(expected) for (const event of ['pull_request', 'push', 'workflow_dispatch']) { - const expression = expected.slice('pnpm run ${{ '.length, -' }}'.length) + const expression = expected.slice('${{ '.length, -' }}'.length) const selected: unknown = runInNewContext(expression, { github: { event_name: event } }, { timeout: 1000 }) expect(selected).toBe(event === 'pull_request' - ? file === 'release-vendor.yml' ? 'build:lib:host:artifacts' : 'build:artifacts --profile official' - : file === 'release-vendor.yml' ? 'build:lib:host' : 'build:official') + ? file === 'release-vendor.yml' + ? 'pnpm exec tsx scripts/compile-referenced-projects.ts host-libraries' + : 'pnpm run build --artifacts-only --profile official' + : file === 'release-vendor.yml' ? 'pnpm run build:lib:host' : 'pnpm run build:official') } } }) @@ -63,7 +65,7 @@ describe('CI workflow', () => { const job = workflowJob(loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml'), 'build') if (!Array.isArray(job.steps)) throw new TypeError('Python runtime builder must define steps') const steps = job.steps.filter(isRecord) - const prepare = steps.findIndex(step => step.run === 'pnpm run build:artifacts') + const prepare = steps.findIndex(step => step.run === 'pnpm run build --artifacts-only') const execute = steps.findIndex(step => step.name === 'Build single-exe') expect(prepare).toBeGreaterThanOrEqual(0) expect(execute).toBeGreaterThan(prepare) diff --git a/scripts/compile-referenced-projects.spec.ts b/scripts/compile-referenced-projects.spec.ts index 4b8aa9b84e..6165e7d6b2 100644 --- a/scripts/compile-referenced-projects.spec.ts +++ b/scripts/compile-referenced-projects.spec.ts @@ -2,15 +2,17 @@ import { spawnSync } from 'node:child_process' import { globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it, onTestFinished } from 'vitest' -import { compileReferencedProjects } from './compile-referenced-projects.ts' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { buildLibraryArtifacts, compileReferencedProjects } from './compile-referenced-projects.ts' +import { runCiBench } from './run-ci-bench.ts' +import { removeFixtureSafely } from './test-fixture-cleanup.ts' const compiler = fileURLToPath(import.meta.resolve('typescript/bin/tsc')) function fixture(): { root: string; leaf: string } { const root = mkdtempSync(join(tmpdir(), 'dsh-referenced-projects-')) - onTestFinished(() => { rmSync(root, { recursive: true, force: true }) }) + onTestFinished(() => { removeFixtureSafely(root) }) const leaf = join(root, 'leaf package') mkdirSync(join(leaf, 'src'), { recursive: true }) writeFileSync(join(root, 'package.json'), JSON.stringify({ type: 'module' })) @@ -51,10 +53,97 @@ function artifacts(leaf: string): Record { return Object.fromEntries(globSync('**/*', { cwd: output }) .filter(path => statSync(join(output, path)).isFile()) .sort() - .map(path => [path, readFileSync(join(output, path)).toString('base64')])) + .map(path => [path.replaceAll('\\', '/'), readFileSync(join(output, path)).toString('base64')])) } +function recordedCommands(root: string, failHostBundle = false): () => { args: string[]; title: string }[] { + const log = join(root, 'commands.jsonl') + const entrypoint = join(root, 'pnpm', 'bin', 'pnpm.cjs') + mkdirSync(join(root, 'pnpm', 'bin'), { recursive: true }) + writeFileSync(entrypoint, [ + 'const { appendFileSync } = require("node:fs")', + 'const args = process.argv.slice(2)', + `appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args, title: process.env.DSH_CLIENT_TITLE }) + "\\n")`, + ...failHostBundle ? ['if (args.includes("host")) process.exit(7)'] : [], + ].join('\n') + '\n') + vi.stubEnv('npm_execpath', entrypoint) + vi.stubEnv('DSH_CLIENT_TITLE', 'Inherited Build Title') + onTestFinished(() => { vi.unstubAllEnvs() }) + return () => readFileSync(log, 'utf8').trim().split('\n') + .map(line => JSON.parse(line) as { args: string[]; title: string }) +} + +describe('internal artifact build order', () => { + it('resolves pnpm for a direct CLI without npm_execpath and preserves its environment', () => { + const { root } = fixture() + const marker = join(root, 'direct-cli-title.txt') + const environment: NodeJS.ProcessEnv = { ...process.env, DSH_CLIENT_TITLE: 'Direct CLI Title' } + delete environment.npm_execpath + const helper = pathToFileURL(resolve(import.meta.dirname, 'compile-referenced-projects.ts')).href + const code = `import { runPnpmCommand } from ${JSON.stringify(helper)}; runPnpmCommand(${JSON.stringify(root)}, ${JSON.stringify([ + 'exec', process.execPath, '-e', + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, process.env.DSH_CLIENT_TITLE)`, + ])})` + const result = spawnSync(process.execPath, [ + '--import', fileURLToPath(import.meta.resolve('tsx/esm')), + '--input-type=module', '-e', code, + ], { cwd: root, env: environment, encoding: 'utf8' }) + + expect(result.status, result.stdout + result.stderr).toBe(0) + expect(readFileSync(marker, 'utf8')).toBe('Direct CLI Title') + }) + + it.each([true, false])('preserves bundler order and the public environment with hostOnly=%s', (hostOnly) => { + const { root } = fixture() + const commands = recordedCommands(root) + + buildLibraryArtifacts(root, hostOnly) + + const expected = [ + ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'], + ['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'], + ...hostOnly ? [] : [['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client']], + ] + expect(commands()).toEqual(expected.map(args => ({ args, title: 'Inherited Build Title' }))) + }) + + it('stops after a failed Host bundler before Desktop and Client builds', () => { + const { root } = fixture() + const commands = recordedCommands(root, true) + + expect(() => { buildLibraryArtifacts(root, false) }).toThrow('exited with 7') + expect(commands()).toHaveLength(1) + }) + + it('prepares the original benchmark inputs in order before running its existing suite', () => { + const { root } = fixture() + const commands = recordedCommands(root) + + runCiBench(root) + + expect(commands()).toEqual([ + ['run', 'build:native-system'], + ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'], + ['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'], + ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'], + ['exec', 'tsdown', '--config-loader', 'native', '--config', 'benchmarks/tsdown.config.ts'], + ['run', 'build:web'], + ['run', 'test:bench:built'], + ].map(args => ({ args, title: 'Inherited Build Title' }))) + }) +}) + describe('package project compilation', () => { + it('records Windows-style glob keys without changing artifact bytes', () => { + const { leaf } = fixture() + const output = join(leaf, 'lib') + mkdirSync(join(output, 'types'), { recursive: true }) + const bytes = Buffer.from([0, 255, 13, 10]) + writeFileSync(join(output, 'types\\index.js'), bytes) + + expect(artifacts(leaf)).toEqual({ 'types/index.js': bytes.toString('base64') }) + }) + it.each(['host', 'client'] as const)('preserves every %s package artifact byte', (face) => { const { root, leaf } = fixture() const complete = spawnSync(process.execPath, [compiler, '-b', `tsconfig.${face}.json`], { @@ -142,7 +231,7 @@ describe('package project compilation', () => { expect(() => { compileReferencedProjects(root, 'host') }).toThrow("Argument for '--target' option must be") }) - it('rejects an unsupported compiler face before building', () => { + it('rejects an unsupported build mode before building', () => { const repository = resolve(import.meta.dirname, '..') const result = spawnSync(process.execPath, [ '--import', 'tsx/esm', @@ -151,6 +240,6 @@ describe('package project compilation', () => { ], { cwd: repository, encoding: 'utf8' }) expect(result.status).not.toBe(0) - expect(result.stderr).toContain('expected exactly one compiler face: host or client') + expect(result.stderr).toContain('expected exactly one mode: host, client, host-libraries, or libraries') }) }) diff --git a/scripts/compile-referenced-projects.ts b/scripts/compile-referenced-projects.ts index 9551c88f69..3233743ef1 100644 --- a/scripts/compile-referenced-projects.ts +++ b/scripts/compile-referenced-projects.ts @@ -1,10 +1,26 @@ -/** Compile package projects with their existing diagnostics and outputs, excluding the aggregate test program. */ +/** Compile and bundle package projects without repeating aggregate test and script typechecks. */ import { spawnSync } from 'node:child_process' import { relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' import ts from 'typescript' +import { pnpmCommand } from './release/process.ts' + +/** + * Run one package-manager command serially with the inherited build environment. + * @param root - Repository root supplying the command's working directory. + * @param args - Package-manager arguments, without shell quoting. + * @throws If the command cannot start or exits unsuccessfully. + */ +export function runPnpmCommand(root: string, args: readonly string[]): void { + const [command, ...prefix] = pnpmCommand() + const result = spawnSync(command, [...prefix, ...args], { cwd: root, stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) { + throw new Error(`compile-referenced-projects: pnpm ${args.join(' ')} exited with ${String(result.status ?? result.signal)}`) + } +} /** * Compile one aggregate's project references with the ordinary serial TypeScript builder. @@ -47,11 +63,40 @@ export function compileReferencedProjects(root: string, face: 'host' | 'client') } } +/** + * Emit package libraries in Host, Desktop, then Client dependency order. + * @param root - Repository root containing the compiler and bundle configurations. + * @param hostOnly - Omit the Client compiler and bundler after the Host artifacts are ready. + * @throws If any compiler or bundler fails. + */ +export function buildLibraryArtifacts(root: string, hostOnly: boolean): void { + compileReferencedProjects(root, 'host') + runPnpmCommand(root, ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host']) + runPnpmCommand(root, ['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle']) + if (hostOnly) return + compileReferencedProjects(root, 'client') + runPnpmCommand(root, ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client']) +} + if (import.meta.main) { const { positionals } = parseArgs({ allowPositionals: true }) - const [face] = positionals - if (positionals.length !== 1 || (face !== 'host' && face !== 'client')) { - throw new Error('compile-referenced-projects: expected exactly one compiler face: host or client') + const [mode] = positionals + if (positionals.length !== 1) { + throw new Error('compile-referenced-projects: expected exactly one mode: host, client, host-libraries, or libraries') + } + const root = resolve(import.meta.dirname, '..') + switch (mode) { + case 'host': + case 'client': + compileReferencedProjects(root, mode) + break + case 'host-libraries': + buildLibraryArtifacts(root, true) + break + case 'libraries': + buildLibraryArtifacts(root, false) + break + default: + throw new Error('compile-referenced-projects: expected exactly one mode: host, client, host-libraries, or libraries') } - compileReferencedProjects(resolve(import.meta.dirname, '..'), face) } diff --git a/scripts/preview-workflow.spec.ts b/scripts/preview-workflow.spec.ts index f3d14ce484..224f5a66d3 100644 --- a/scripts/preview-workflow.spec.ts +++ b/scripts/preview-workflow.spec.ts @@ -24,14 +24,14 @@ describe('PR preview workflow', () => { expect(preview.steps.find(step => step.uses === 'actions/checkout@v6')?.with).toEqual({ 'persist-credentials': false }) }) - it('keeps the immutable full build and restore-only dependency cache', () => { + it('keeps the immutable complete artifact build and restore-only dependency cache', () => { expect(workflow.env.PRIMARY_NODE_VERSION).toBe('24') expect(workflow.env.DSH_TELEMETRY_DISABLED).toBe('1') const commands = preview.steps.map(step => step.run) expect(commands).toContain('pnpm install --frozen-lockfile') - expect(commands).toContain('pnpm run build') + expect(commands).toContain('pnpm run build --artifacts-only') expect(commands).toContain('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview') - expect(commands.indexOf('pnpm run build')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview')) + expect(commands.indexOf('pnpm run build --artifacts-only')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview')) expect(preview.steps.filter(step => step.uses?.startsWith('actions/cache'))).toHaveLength(1) expect(preview.steps.find(step => step.uses === 'actions/cache/restore@v4')?.with).toMatchObject({ key: "${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}", diff --git a/scripts/release/pack.spec.ts b/scripts/release/pack.spec.ts index 65fb196527..5f7fff2f63 100644 --- a/scripts/release/pack.spec.ts +++ b/scripts/release/pack.spec.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFil import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { officialClientBuildEnvironment, writeClientBuildRecord } from '../client-build-environment.ts' import { runGate, type Gate } from '../run-gates.ts' @@ -13,7 +14,7 @@ import { packedIdentity, PUBLISH_ORDER_FILE, readPublishOrder } from './tarball. const require = createRequire(import.meta.url) const packScript = resolve(import.meta.dirname, 'pack.ts') -const tsxHook = require.resolve('tsx/esm') +const tsxHook = pathToFileURL(require.resolve('tsx/esm')).href const pnpmEntry = join(dirname(require.resolve('pnpm')), 'bin/pnpm.cjs') const roots: string[] = [] const commit = 'abcdef0' diff --git a/scripts/run-ci-bench.ts b/scripts/run-ci-bench.ts new file mode 100644 index 0000000000..244f407299 --- /dev/null +++ b/scripts/run-ci-bench.ts @@ -0,0 +1,19 @@ +/** Build the existing benchmark artifacts without repeating aggregate test and script typechecks. */ + +import { resolve } from 'node:path' +import { buildLibraryArtifacts, runPnpmCommand } from './compile-referenced-projects.ts' + +/** + * Build native support, libraries, benchmark workers, and Web assets before running the existing benchmark suite. + * @param root - Repository root containing all build configurations. + * @throws If any build or the benchmark suite fails. + */ +export function runCiBench(root: string): void { + runPnpmCommand(root, ['run', 'build:native-system']) + buildLibraryArtifacts(root, false) + runPnpmCommand(root, ['exec', 'tsdown', '--config-loader', 'native', '--config', 'benchmarks/tsdown.config.ts']) + runPnpmCommand(root, ['run', 'build:web']) + runPnpmCommand(root, ['run', 'test:bench:built']) +} + +if (import.meta.main) runCiBench(resolve(import.meta.dirname, '..')) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index a125e9db3b..40f07a84d8 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -250,7 +250,7 @@ describe('gate graph validation', () => { await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length) }) - it('builds the native addon before benchmarks through the ci-bench script chain', () => { + it('uses the internal CI benchmark runner without changing the public benchmark scripts', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-bench')) const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { scripts: Record @@ -260,12 +260,12 @@ describe('gate graph validation', () => { expect(subject).toHaveLength(1) expect(subject[0]).toMatchObject({ id: 'bench', - displayCommand: 'pnpm run test:bench', - args: ['/private/pnpm.cjs', 'run', 'test:bench'], + displayCommand: 'pnpm exec tsx scripts/run-ci-bench.ts', + args: ['/private/pnpm.cjs', 'exec', 'tsx', 'scripts/run-ci-bench.ts'], }) expect(scripts['test:bench']).toBe('npm run build:bench && npm run build:web && npm run test:bench:built') expect(scripts['build:bench']).toBe( - 'npm run build:native-system && npm run build:lib:artifacts && tsdown --config-loader native --config benchmarks/tsdown.config.ts', + 'npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts', ) expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only') expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts') @@ -841,7 +841,10 @@ describe('Node compatibility graph', () => { withEnv('DSH_NODE_COMPAT_SKIP_TYPECHECK', skipTypecheck, () => withPnpmEntrypoint(() => gatesForMode('node-compat')))) - expect(subject.filter(item => item.displayCommand === 'pnpm run build:artifacts')).toHaveLength(1) + expect(subject.filter(item => item.displayCommand === 'pnpm run build --artifacts-only')).toHaveLength(1) + expect(subject.find(item => item.id === 'build')?.args).toEqual([ + '/private/pnpm.cjs', 'run', 'build', '--artifacts-only', + ]) expect(subject.map(item => item.id)).not.toContain('build:web') expect(subject.find(item => item.id === 'cli-lazy-search-startup-smoke')).toMatchObject({ needs: ['build'], diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 2132c877e7..b7ccc0a7d4 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -285,7 +285,7 @@ export function gatesForMode(selected: Mode): Gate[] { case 'ci-unit': return ciUnitGates() case 'ci-bench': - return [pnpmScript('bench', 'test:bench', { label: 'performance benchmarks' })] + return [pnpmExec('bench', ['tsx', 'scripts/run-ci-bench.ts'], { label: 'performance benchmarks' })] case 'ci-snapshot': return [ciBuildGate(), snapshotGate()] case 'ci-artifacts': @@ -407,7 +407,9 @@ function nodeCompatGates(): Gate[] { } return [ ...typecheck, - pnpmScript('build', 'build:artifacts', { + pnpmScript('build', 'build', { + ...pnpmInvocation(['run', 'build', '--artifacts-only']), + displayCommand: 'pnpm run build --artifacts-only', ...typecheck.length === 0 ? {} : { needs: ['typecheck'] }, }), ...nodeCompatSmokeGates({ cliSmoke: true }), diff --git a/scripts/tests/ci-release-selfhosted.spec.ts b/scripts/tests/ci-release-selfhosted.spec.ts index 668efe22df..23dcc7a8df 100644 --- a/scripts/tests/ci-release-selfhosted.spec.ts +++ b/scripts/tests/ci-release-selfhosted.spec.ts @@ -147,8 +147,8 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea expect(job.steps[0]?.with?.['fetch-depth']).toBe(0) expect(commands).toContain('pnpm run release:verify --family ' + family) expect(commands).toContain(family === 'dsh' - ? "pnpm run ${{ github.event_name == 'pull_request' && 'build:artifacts --profile official' || 'build:official' }}" - : "pnpm run ${{ github.event_name == 'pull_request' && 'build:lib:host:artifacts' || 'build:lib:host' }}") + ? "${{ github.event_name == 'pull_request' && 'pnpm run build --artifacts-only --profile official' || 'pnpm run build:official' }}" + : "${{ github.event_name == 'pull_request' && 'pnpm exec tsx scripts/compile-referenced-projects.ts host-libraries' || 'pnpm run build:lib:host' }}") expect(commands).toContain('pnpm run release:pack --family ' + family + ' --out ' + output + ' --concurrency 8') expect(commands).toContain('pnpm run release:verify-packed-install --family ' + family + ' --from ' + output + (family === 'dsh' ? ' --from dist/npm-vendor --from dist/npm-landlock' : ''))