feat(build): bind client artifacts to build profiles

This commit is contained in:
imccyu
2026-08-19 18:21:27 +08:00
parent 93cbb3799d
commit 66a7081c15
24 changed files with 533 additions and 39 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-client-build-environment.md
2026-08-18-client-build-environment.md: 771fdb936d71daad982cb25f58033284941df679
2026-08-18-client-build-environment.zh.md: 2a5948d64f585f8eade49cff7d925b6a1c73f129
2026-08-18-client-build-environment.md: 45ed6c8bc68e0f08157fb56a91ae4f6165e6e431
2026-08-18-client-build-environment.zh.md: bb9633721401f66b443a65253dcbc0241f45d328
@@ -18,6 +18,8 @@ The Vite config and the shared tsdown preset for dynamic client bundles use one
The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, paths, and other Host- or CI-only values must not use it.
The root build wrapper supplies one exact public environment to both bundlers. It derives `DSH_CLIENT_COMMIT_HASH` as the seven-character prefix of the source Git HEAD for every complete build; an explicit value supports build environments without repository metadata. `pnpm run build` otherwise inherits the caller's `DSH_CLIENT_*` values, while `pnpm run build:official` selects the repository's official artifact profile without shell-specific environment syntax and sets `DSH_CLIENT_BUILD_PROFILE=official` for deployment-specific business registrations. A successful complete build writes the exact public environment and a digest covering the Vite output and every dynamic client bundle. Partial build commands do not replace that record.
## Alternatives considered
**Replace values only in Vite.** A dynamic plugin's `lib/client.js` is loaded as an independent script and never enters Vite's module graph, so the expression would remain in a browser that has no `process`.
@@ -30,6 +32,6 @@ The `DSH_CLIENT_*` prefix itself declares that a value is public. Credentials, p
## Consequences
The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. CI workflows that produce DSH client artifacts set the required variables explicitly; workflows that do not produce those artifacts do not need them.
The Vite static shell and shared tsdown dynamic bundles receive the same string for a given `DSH_CLIENT_*` build-process variable. An unset static property read evaluates to `undefined`; non-`DSH_CLIENT_*` values cannot enter browser artifacts through this mechanism, and business code cannot enumerate the build process environment. Every complete build carries its short source revision as public display metadata. CI build gates select the official profile without exposing its public values to source tests or unrelated workflow steps. npm packing and built Web tests verify the recorded environment and current artifact digest, so a default build followed by an official pack request, a partial rebuild, or modified output fails before consumption.
Every `DSH_CLIENT_*` value referenced by business code becomes public artifact content, so a misnamed value can disclose information. Build choices are fixed when the artifact is generated; a setting that must change after deployment requires a validated, transported, and documented runtime configuration mechanism.
@@ -18,6 +18,8 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define
`DSH_CLIENT_*` 的名称本身表示公开性。凭据、路径和其他仅供 Host 或 CI 使用的值不得使用该前缀。
根构建包装脚本向两个 bundler 提供同一份精确的公开环境。每次完整构建都会把源码 Git HEAD 的七位前缀派生为 `DSH_CLIENT_COMMIT_HASH`;没有仓库元数据的构建环境可显式提供该值。除此之外,`pnpm run build` 继承调用方的 `DSH_CLIENT_*` 值,`pnpm run build:official` 则不依赖特定 shell 的环境变量语法,直接选择仓库的官方产物 profile,并设置 `DSH_CLIENT_BUILD_PROFILE=official` 供部署专属业务注册使用。完整构建成功后会写入精确的公开环境,以及覆盖 Vite 输出和所有动态 client bundle 的摘要;局部构建命令不会替换该记录。
## Alternatives considered
**只在 Vite 中替换。** 动态插件的 `lib/client.js` 作为独立脚本由浏览器加载,不进入 Vite 模块图,表达式会残留到无 `process` 的浏览器。
@@ -30,6 +32,6 @@ Vite 配置与动态 client bundle 的共享 tsdown preset 使用同一 define
## Consequences
Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。生成 DSH client 产物的 CI workflow 显式提供所需变量;不生成这些产物的 workflow 不需要携带它们
Vite 静态壳和共享 tsdown 动态 bundle 对同一 `DSH_CLIENT_*` 构建进程变量产生相同字符串值。未设置的静态点访问得到 `undefined`,非 `DSH_CLIENT_*` 值不会通过该机制进入浏览器产物,业务代码也无法枚举构建进程环境。每次完整构建都携带可公开展示的短源码 revision。CI 构建门禁选择官方 profile,而不把其中的公开值暴露给源码测试或无关 workflow 步骤。npm 打包与 built Web 测试会校验记录中的环境及当前产物摘要,因此默认构建后请求官方打包、局部重建或修改输出都会在消费产物前失败
任何被业务代码引用的 `DSH_CLIENT_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。
@@ -49,7 +49,6 @@ permissions:
contents: read
env:
DSH_CLIENT_BRAND: official
# CI runs must never report to the production telemetry endpoint baked
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
DSH_TELEMETRY_DISABLED: '1'
@@ -220,6 +219,8 @@ jobs:
}
- name: Build single-exe
env:
DSH_BUILD_CLIENT_PROFILE: official
run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
- name: Resolve platform outputs
+2 -1
View File
@@ -35,7 +35,6 @@ permissions:
contents: read
env:
DSH_CLIENT_BRAND: official
PRIMARY_NODE_VERSION: '24'
# CI runs must never report to the production telemetry endpoint baked
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
@@ -300,6 +299,8 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Run compatibility smokes
env:
DSH_BUILD_CLIENT_PROFILE: official
run: pnpm run check:node-compat
python-sdk:
+1 -2
View File
@@ -9,7 +9,6 @@ permissions:
contents: read
env:
DSH_CLIENT_BRAND: official
# CI runs must never report to the production telemetry endpoint baked
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
DSH_TELEMETRY_DISABLED: '1'
@@ -47,7 +46,7 @@ jobs:
# The Loader smoke runs package exports under plain Node in lib mode.
- name: Build (lib for the E2B Loader smoke)
run: pnpm run build
run: pnpm run build:official
- name: E2B tests (live sandbox)
env:
+1 -2
View File
@@ -47,7 +47,6 @@ permissions:
contents: read
env:
DSH_CLIENT_BRAND: official
# CI runs must never report to the production telemetry endpoint baked
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
DSH_TELEMETRY_DISABLED: '1'
@@ -105,7 +104,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 build
run: pnpm run build:official
# Real-API end-to-end tests only. The keyless gates (lint/typecheck/
# coverage/snapshot/etc.) already run in ci.yml on every push/PR.
+1 -2
View File
@@ -29,7 +29,6 @@ concurrency:
cancel-in-progress: false
env:
DSH_CLIENT_BRAND: official
PRIMARY_NODE_VERSION: '24'
DSH_TELEMETRY_DISABLED: '1'
@@ -76,7 +75,7 @@ jobs:
run: pnpm run release:verify --family dsh
- name: Build
run: pnpm run build
run: pnpm run build:official
- name: Pack release tarballs
run: pnpm run release:pack --family dsh --out dist/npm
+1 -2
View File
@@ -19,7 +19,6 @@ permissions:
contents: read
env:
DSH_CLIENT_BRAND: official
# CI runs must never report to the production telemetry endpoint baked
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
DSH_TELEMETRY_DISABLED: '1'
@@ -118,7 +117,7 @@ jobs:
# together so registry state cannot mask source/package drift.
- name: Build packages for the pack rehearsal
if: matrix.runner == 'landlock'
run: pnpm run build
run: pnpm run build:official
- name: Packed-distribution e2e (pack → install → confine)
if: matrix.runner == 'landlock'
+1
View File
@@ -32,6 +32,7 @@ python/**/__pycache__/
python/**/.pytest_cache/
apps/web/dist/
.artifacts/
.dsh-build/
.playwright-mcp/
.orig
.worktrees/
+21
View File
@@ -10,12 +10,33 @@
// benches over src). This smoke additionally pins the resident interaction
// fixture's cross-plugin projection because only the built connection/runtime/
// workspace graph can prove that transport-to-row path end to end.
import { resolve } from 'node:path'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { expect, it } from 'vitest'
import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
installAssembledBootEnv()
const buildEnvironmentModulePath = '../../../scripts/client-build-environment.ts'
const buildEnvironmentModule: unknown = await import(buildEnvironmentModulePath)
if (typeof buildEnvironmentModule !== 'object' || buildEnvironmentModule === null) {
throw new TypeError('client build environment module must be an object')
}
const readClientBuildRecord: unknown = Reflect.get(buildEnvironmentModule, 'readClientBuildRecord')
if (!isBuildRecordReader(readClientBuildRecord)) {
throw new TypeError('client build environment module must export readClientBuildRecord')
}
const record: unknown = readClientBuildRecord(resolve(import.meta.dirname, '../../..'))
if (typeof record !== 'object' || record === null) throw new TypeError('client build record must be an object')
const clientBuildEnvironment: unknown = Reflect.get(record, 'environment')
if (typeof clientBuildEnvironment !== 'object' || clientBuildEnvironment === null) {
throw new TypeError('client build record environment must be an object')
}
function isBuildRecordReader(value: unknown): value is (root: string) => unknown {
return typeof value === 'function'
}
it('boots the built plugin graph and renders a fixture session end to end', async () => {
mountAssembledApp()
+14 -5
View File
@@ -1,6 +1,6 @@
/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
import { existsSync } from 'node:fs'
import { existsSync, globSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,6 +10,7 @@ import { Context } from '@deepseek-ai/cordis'
import type { Fiber } from '@deepseek-ai/cordis'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { readClientBuildRecord } from '../../../scripts/client-build-environment.ts'
import { REPO_ROOT } from './support.ts'
function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
@@ -70,11 +71,13 @@ async function stopTree(child: SubprocessHandle): Promise<void> {
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
const clientBuildEnvironment = readClientBuildRecord(REPO_ROOT).environment
const clientBundlePaths = globSync('packages/*/*/lib/client.js{,.map}', { cwd: REPO_ROOT })
.map(path => join(REPO_ROOT, path))
const originalClientBundles = await Promise.all(clientBundlePaths.map(async path => [path, await readFile(path)] as const))
const originalSource = await readFile(sourcePath)
const originalBundle = await readFile(bundlePath)
const oldText = 'Into the Unknown'
const sourceNeedle = "'hero.headline': 'Into the Unknown'"
const newText = `HMR UPDATED ${'x'.repeat(80)}`
@@ -89,7 +92,11 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
const failures: unknown[] = []
try {
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime)
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
watcher = subprocessCtx.subprocess.spawn(spawnSpec(
['pnpm', 'run', 'dev:web'],
REPO_ROOT,
{ ...clientBuildEnvironment },
))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--no-open', '--port', '0'],
@@ -122,7 +129,9 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
} finally {
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
await Promise.all(originalClientBundles.map(async ([path, content]) => {
await writeFile(path, content).catch((error: unknown) => failures.push(error))
}))
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
await browser?.close().catch((error: unknown) => failures.push(error))
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/development.md
development.md: 4ef2a195fdf525599ad48da32ee4bccfbd9a8c1d
development.zh.md: 66799906e22c3dfc164459ba9f4c97e3972ef94a
development.md: 904245f93546122e4e3a54e020b302a3bcd39d1a
development.zh.md: fe160763ffc80bda9bbc7317fb7b3b69df1b3f88
+2
View File
@@ -75,6 +75,8 @@ Both tsdown passes use the same complete workspace match. They neither scan buil
Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.
`pnpm run build` embeds the caller's exact `DSH_CLIENT_*` environment and uses no public client values when none are set. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build. Each successful complete build writes a gitignored record that binds those values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build.
Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.
Business services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.
+2
View File
@@ -75,6 +75,8 @@ pnpm run build:web
Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。
`pnpm run build` 会内联调用方精确的 `DSH_CLIENT_*` 环境;未设置时不使用任何公开 client 值。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令。每次完整构建成功后都会写入一份被 gitignore 的记录,把这些值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。
静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck``lint``doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md)tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。
业务服务在 Host 使用 `@Remote``@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。
+2 -1
View File
@@ -17,7 +17,8 @@
"website"
],
"scripts": {
"build": "npm run build:lib && npm run build:web",
"build": "tsx scripts/build.ts",
"build:official": "tsx scripts/build.ts --profile official",
"build:lib": "npm run build:lib:host && npm run build:lib:client",
"build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host",
"build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client",
+1 -1
View File
@@ -68,7 +68,7 @@ Npm sections describe installation and development relationships; each build fac
## Build-time browser environment
Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. Use runtime configuration for choices that must change after build.
Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. A complete root build records the exact public values and a digest of all client artifacts; release and built-artifact consumers reject a missing or stale record. Use runtime configuration for choices that must change after build.
## Shared modules and the module graph
+55
View File
@@ -0,0 +1,55 @@
/** Run the complete repository build and bind its client artifacts to their public environment. */
import { spawnSync } from 'node:child_process'
import { rmSync } from 'node:fs'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import {
CLIENT_BUILD_RECORD_PATH,
clientBuildProcessEnvironment,
repositoryCommitHash,
resolveClientBuildEnvironment,
writeClientBuildRecord,
} from './client-build-environment.ts'
/** Run one package script through the package manager that invoked this build. */
function runScript(script: string, environment: NodeJS.ProcessEnv): void {
const packageManager = process.env.npm_execpath
if (packageManager === undefined || packageManager === '') {
throw new Error('build: npm_execpath is unavailable; invoke the build through a package script')
}
const result = spawnSync(process.execPath, [packageManager, 'run', script], {
cwd: resolve(import.meta.dirname, '..'),
env: environment,
stdio: 'inherit',
})
if (result.error !== undefined) throw result.error
if (result.status !== 0) {
throw new Error(`build: ${script} exited with ${String(result.status ?? result.signal)}`)
}
}
/** Run the full build selected by `--profile` or `DSH_BUILD_CLIENT_PROFILE`. */
function main(): void {
const { values } = parseArgs({
options: { profile: { type: 'string' } },
allowPositionals: false,
})
const root = resolve(import.meta.dirname, '..')
const parentEnvironment = {
...process.env,
DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, process.env),
}
const clientEnvironment = resolveClientBuildEnvironment(parentEnvironment, values.profile)
const buildEnvironment = clientBuildProcessEnvironment(parentEnvironment, clientEnvironment)
rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true })
runScript('build:lib', buildEnvironment)
runScript('build:web', buildEnvironment)
const record = writeClientBuildRecord(root, clientEnvironment)
console.log(
`build: recorded ${String(record.artifacts.fileCount)} client artifact(s) with ${String(Object.keys(record.environment).length)} public value(s)`,
)
}
if (import.meta.main) main()
+2
View File
@@ -38,6 +38,7 @@ describe('RepositoryCleaner', () => {
write(join(root, 'products/shell/lib/types/index.js'))
write(join(root, 'products/shell/lib/index.js'))
write(join(root, '.typecheck/legacy.tsbuildinfo'))
write(join(root, '.dsh-build/client-build-environment.json'))
write(join(root, 'root.tsbuildinfo'))
write(join(root, 'packages/removed/ghost/node_modules/.bin/tool'))
@@ -46,6 +47,7 @@ describe('RepositoryCleaner', () => {
expect(existsSync(join(root, 'products/shell/lib'))).toBe(false)
expect(existsSync(join(root, 'products/shell/src/index.ts'))).toBe(true)
expect(existsSync(join(root, '.typecheck'))).toBe(false)
expect(existsSync(join(root, '.dsh-build'))).toBe(false)
expect(existsSync(join(root, 'root.tsbuildinfo'))).toBe(false)
expect(existsSync(join(root, 'packages/removed/ghost'))).toBe(false)
})
+2
View File
@@ -67,6 +67,8 @@ export class RepositoryCleaner {
const unsafeOrphans: string[] = []
const canonicalRoot = await realpath(this.root)
await this.addIfPresent(targets, join(this.root, '.dsh-build'), canonicalRoot)
// These checks cover legacy root-level incremental state emitted by older configs.
await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot)
for (const entry of await readdir(this.root, { withFileTypes: true })) {
@@ -1,14 +1,25 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import yaml from 'js-yaml'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clientBuildEnvironmentDefines } from './client-build-environment.ts'
import {
assertClientBuildEnvironment,
clientBuildEnvironmentDefines,
clientBuildProcessEnvironment,
readClientBuildRecord,
repositoryCommitHash,
resolveClientBuildEnvironment,
writeClientBuildRecord,
} from './client-build-environment.ts'
import { clientBundle } from '../packages/client/tsdown.client.ts'
const root = resolve(import.meta.dirname, '..')
const PROBE_NAME = 'DSH_CLIENT_BUILD_TEST'
const COMMIT_HASH = '0123456789abcdef0123456789abcdef01234567'
const PROBE_KEY = `process.env.${PROBE_NAME}`
const originalProbe = process.env[PROBE_NAME]
const roots: string[] = []
const dshBuildWorkflows = [
'build-exe-for-python-sdk.yml',
'ci.yml',
@@ -22,9 +33,74 @@ afterEach(() => {
if (originalProbe === undefined) Reflect.deleteProperty(process.env, PROBE_NAME)
else process.env[PROBE_NAME] = originalProbe
vi.resetModules()
for (const fixtureRoot of roots.splice(0)) rmSync(fixtureRoot, { recursive: true, force: true })
})
function write(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content)
}
function buildFixture(environment: Record<string, string>): string {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-'))
roots.push(fixtureRoot)
write(join(fixtureRoot, 'apps/web/dist/index.html'), '<main></main>')
write(join(fixtureRoot, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
writeClientBuildRecord(fixtureRoot, environment)
return fixtureRoot
}
describe('client build environment', () => {
it('requires an exact public environment for a named artifact profile', () => {
const expected = {
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
} as const
expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow()
expect(() => { assertClientBuildEnvironment({}, expected) }).toThrow(/DSH_CLIENT_TITLE/)
expect(() => { assertClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Other' }, expected) }).toThrow(/DSH_CLIENT_TITLE/)
expect(() => {
assertClientBuildEnvironment({ ...expected, DSH_CLIENT_UNDECLARED: 'value' }, expected)
}).toThrow(/DSH_CLIENT_UNDECLARED/)
})
it('inherits public values by default and isolates an explicit official profile', () => {
const parent = {
PATH: '/bin',
DSH_BUILD_CLIENT_PROFILE: 'official',
DSH_CLIENT_BUILD_PROFILE: 'local',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'Local title',
DSH_CLIENT_EXTRA: 'local-extra',
}
expect(resolveClientBuildEnvironment({ DSH_CLIENT_TITLE: 'Local title' })).toEqual({
DSH_CLIENT_TITLE: 'Local title',
})
expect(resolveClientBuildEnvironment(parent)).toEqual({
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
})
expect(() => {
resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' })
}).toThrow(/DSH_CLIENT_COMMIT_HASH/)
expect(() => { resolveClientBuildEnvironment({}, 'unknown') }).toThrow(/unknown client build profile/)
expect(clientBuildProcessEnvironment(parent, {
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
})).toEqual({
PATH: '/bin',
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
})
expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7))
})
it('defines only public client values over a non-enumerable fallback', () => {
expect(clientBuildEnvironmentDefines({
PATH: '/bin',
@@ -69,15 +145,31 @@ describe('client build environment', () => {
})
})
it('sets the official client build variant in DSH artifact build workflows', () => {
it('binds the recorded environment to a complete set of client artifacts', () => {
const officialEnvironment = {
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
}
const official = buildFixture(officialEnvironment)
const defaultBuild = buildFixture({})
expect(readClientBuildRecord(official, officialEnvironment).environment).toEqual(officialEnvironment)
expect(() => { readClientBuildRecord(defaultBuild, officialEnvironment) }).toThrow(/DSH_CLIENT_/)
expect(() => { readClientBuildRecord(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/)
write(join(official, 'apps/web/dist/index.html'), '<main>changed</main>')
expect(() => { readClientBuildRecord(official) }).toThrow(/artifacts differ/)
})
it('keeps public client values out of workflow-wide environments', () => {
for (const name of dshBuildWorkflows) {
const path = `.github/workflows/${name}`
const document: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8'))
if (typeof document !== 'object' || document === null || Array.isArray(document)) {
throw new TypeError(`${path} must contain a workflow object`)
}
const environment: unknown = Reflect.get(document, 'env')
expect(environment, path).toMatchObject({ DSH_CLIENT_BRAND: 'official' })
expect(JSON.stringify(document), path).not.toContain('DSH_CLIENT_')
}
})
})
+289 -2
View File
@@ -1,6 +1,172 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import {
existsSync,
globSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs'
import { dirname, resolve } from 'node:path'
/** Prefix reserved for build-time values that may be embedded in browser artifacts. */
const CLIENT_BUILD_ENV_PREFIX = 'DSH_CLIENT_'
/** Non-public selector used by build orchestration to request a named client profile. */
export const CLIENT_BUILD_PROFILE_SELECTOR = 'DSH_BUILD_CLIENT_PROFILE'
/** Public client environment required by official DSH artifacts. */
const OFFICIAL_CLIENT_BUILD_ENVIRONMENT = {
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_TITLE: 'DeepSeek Harness',
} as const
/** Public variable carrying the source commit embedded in client artifacts. */
const CLIENT_COMMIT_HASH_VARIABLE = 'DSH_CLIENT_COMMIT_HASH'
/** Repository-relative path of the complete client build record. */
export const CLIENT_BUILD_RECORD_PATH = '.dsh-build/client-build-environment.json'
const CLIENT_BUILD_RECORD_FORMAT = 1
const CLIENT_ARTIFACT_PATTERNS = [
'apps/web/dist/**/*',
'packages/*/*/lib/client.js',
'packages/*/*/lib/client.js.map',
] as const
/** Public values embedded in one set of client artifacts. */
export type ClientBuildEnvironment = Readonly<Record<string, string>>
/**
* Resolve the short source commit used by browser build metadata.
* @param root - repository root used when no explicit value is supplied.
* @param environment - environment that may already carry a commit value.
* @returns lowercase 7-character Git commit prefix.
*/
export function repositoryCommitHash(root: string, environment: NodeJS.ProcessEnv = process.env): string {
const explicit = environment[CLIENT_COMMIT_HASH_VARIABLE]
const value = explicit ?? execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
if (!/^[0-9a-f]{7,40}$/iu.test(value)) {
throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} must be a Git commit hash; got ${JSON.stringify(value)}`)
}
return value.slice(0, 7).toLowerCase()
}
/**
* Resolve the exact public values required by an official build at one commit.
* @param root - repository root whose HEAD must match the built source.
* @param environment - optional explicit commit source for non-Git build environments.
* @returns complete official client environment.
*/
export function officialClientBuildEnvironment(
root: string,
environment: NodeJS.ProcessEnv = process.env,
): Readonly<Record<`DSH_CLIENT_${string}`, string>> {
return {
DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment),
...OFFICIAL_CLIENT_BUILD_ENVIRONMENT,
}
}
/** Digest of every client artifact produced by the complete root build. */
interface ClientArtifactDigest {
/** Number of files covered by the digest. */
readonly fileCount: number
/** Lowercase SHA-256 digest of sorted paths and file contents. */
readonly sha256: string
}
/** Durable description of one complete root client build. */
export interface ClientBuildRecord {
/** Record schema version. */
readonly formatVersion: number
/** Exact public environment embedded by Vite and tsdown. */
readonly environment: ClientBuildEnvironment
/** Digest that binds the environment to the current artifacts. */
readonly artifacts: ClientArtifactDigest
}
/**
* Collect the public client environment in deterministic key order.
* @param environment - environment inherited by the build process.
* @returns defined `DSH_CLIENT_*` values only.
*/
function clientBuildEnvironment(environment: NodeJS.ProcessEnv): ClientBuildEnvironment {
return Object.fromEntries(Object.entries(environment)
.filter(([name, value]) => name.startsWith(CLIENT_BUILD_ENV_PREFIX) && value !== undefined)
.sort(([left], [right]) => left.localeCompare(right))) as Record<string, string>
}
/**
* Resolve the exact public environment selected for a complete client build.
* @param environment - parent process environment.
* @param profile - explicit profile, or the non-public selector when omitted.
* @returns the inherited public values when no profile is selected, otherwise the named profile.
*/
export function resolveClientBuildEnvironment(
environment: NodeJS.ProcessEnv,
profile: string | undefined = environment[CLIENT_BUILD_PROFILE_SELECTOR],
): ClientBuildEnvironment {
if (profile === undefined) return clientBuildEnvironment(environment)
if (profile === 'official') {
const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE]
if (commitHash === undefined) {
throw new Error(`${CLIENT_COMMIT_HASH_VARIABLE} is required for the official client build profile`)
}
return { DSH_CLIENT_COMMIT_HASH: commitHash, ...OFFICIAL_CLIENT_BUILD_ENVIRONMENT }
}
throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`)
}
/**
* Construct a subprocess environment containing exactly the selected public values.
* @param environment - parent process environment.
* @param clientEnvironment - complete public environment selected for the build.
* @returns the parent environment with selectors and inherited public values replaced.
*/
export function clientBuildProcessEnvironment(
environment: NodeJS.ProcessEnv,
clientEnvironment: ClientBuildEnvironment,
): NodeJS.ProcessEnv {
const child: NodeJS.ProcessEnv = {}
for (const [name, value] of Object.entries(environment)) {
if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith(CLIENT_BUILD_ENV_PREFIX)) continue
child[name] = value
}
return { ...child, ...clientEnvironment }
}
/**
* Require the public client environment to match an artifact profile exactly.
*
* An exact key set matters because every prefixed value is eligible for
* inlining: an unexpected variable can change published bytes just as surely
* as a missing or incorrect required value.
*
* @param environment - public environment from a build process or build record.
* @param expected - complete public client environment for the artifact profile.
*/
export function assertClientBuildEnvironment(
environment: Readonly<Record<string, string | undefined>>,
expected: Readonly<Record<`DSH_CLIENT_${string}`, string>>,
): void {
const actual = Object.fromEntries(Object.entries(environment)
.filter(([name, value]) => name.startsWith(CLIENT_BUILD_ENV_PREFIX) && value !== undefined)
.sort(([left], [right]) => left.localeCompare(right)))
const normalizedExpected = Object.fromEntries(Object.entries(expected)
.sort(([left], [right]) => left.localeCompare(right)))
if (JSON.stringify(actual) === JSON.stringify(normalizedExpected)) return
const names = [...new Set([...Object.keys(actual), ...Object.keys(normalizedExpected)])].sort()
const differences = names.filter(name => actual[name] !== normalizedExpected[name])
throw new Error(`client build environment differs from the required artifact profile: ${differences.join(', ')}`)
}
/**
* Create bundler substitutions for public client build environment variables.
*
@@ -16,9 +182,130 @@ export function clientBuildEnvironmentDefines(
environment: NodeJS.ProcessEnv,
): Record<string, string> {
const defines: Record<string, string> = { 'process.env': '{}' }
for (const [name, value] of Object.entries(environment).sort(([left], [right]) => left.localeCompare(right))) {
if (!name.startsWith(CLIENT_BUILD_ENV_PREFIX) || value === undefined) continue
for (const [name, value] of Object.entries(clientBuildEnvironment(environment))) {
defines[`process.env.${name}`] = JSON.stringify(value)
}
return defines
}
/**
* Write the build record after a complete root build succeeds.
* @param root - repository root containing the generated artifacts.
* @param environment - exact public environment supplied to both bundlers.
* @returns the record written to disk.
*/
export function writeClientBuildRecord(
root: string,
environment: ClientBuildEnvironment,
): ClientBuildRecord {
const record: ClientBuildRecord = {
formatVersion: CLIENT_BUILD_RECORD_FORMAT,
environment: clientBuildEnvironment(environment),
artifacts: clientArtifactDigest(root),
}
const path = resolve(root, CLIENT_BUILD_RECORD_PATH)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`)
return record
}
/**
* Read a complete build record and prove it still describes the current artifacts.
* @param root - repository root containing the record and generated artifacts.
* @param expected - optional exact public environment required by a consumer.
* @returns the parsed and artifact-verified record.
*/
export function readClientBuildRecord(
root: string,
expected?: Readonly<Record<`DSH_CLIENT_${string}`, string>>,
): ClientBuildRecord {
const path = resolve(root, CLIENT_BUILD_RECORD_PATH)
if (!existsSync(path)) {
throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} is missing; run a complete pnpm run build first`)
}
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} is invalid JSON: ${detail}`)
}
const record = parseClientBuildRecord(parsed)
if (expected !== undefined) assertClientBuildEnvironment(record.environment, expected)
const current = clientArtifactDigest(root)
if (current.fileCount !== record.artifacts.fileCount || current.sha256 !== record.artifacts.sha256) {
throw new Error(
`client artifacts differ from ${CLIENT_BUILD_RECORD_PATH}; run a complete pnpm run build before consuming them`,
)
}
return record
}
/** Return the deterministic digest of every artifact affected by the public client environment. */
function clientArtifactDigest(root: string): ClientArtifactDigest {
const paths = globSync([...CLIENT_ARTIFACT_PATTERNS], { cwd: root })
.map(path => path.replaceAll('\\', '/'))
.filter(path => statSync(resolve(root, path)).isFile())
.sort()
if (paths.length === 0) throw new Error('complete client build produced no Vite or dynamic client artifacts')
const digest = createHash('sha256')
for (const path of paths) {
const content = readFileSync(resolve(root, path))
digest.update(`${Buffer.byteLength(path)}:`)
digest.update(path)
digest.update(`${content.byteLength}:`)
digest.update(content)
}
return { fileCount: paths.length, sha256: digest.digest('hex') }
}
/** Parse and validate the persisted record before any consumer trusts it. */
function parseClientBuildRecord(value: unknown): ClientBuildRecord {
if (!isObject(value) || !hasExactKeys(value, ['artifacts', 'environment', 'formatVersion'])) {
throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid top-level schema`)
}
if (value.formatVersion !== CLIENT_BUILD_RECORD_FORMAT) {
throw new Error(
`client build record ${CLIENT_BUILD_RECORD_PATH} uses format ${String(value.formatVersion)}; expected ${String(CLIENT_BUILD_RECORD_FORMAT)}`,
)
}
if (!isObject(value.environment)) {
throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid environment`)
}
const environment: Record<string, string> = {}
for (const [name, entry] of Object.entries(value.environment).sort(([left], [right]) => left.localeCompare(right))) {
if (!name.startsWith(CLIENT_BUILD_ENV_PREFIX) || typeof entry !== 'string') {
throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid environment entry ${name}`)
}
environment[name] = entry
}
if (!isObject(value.artifacts) || !hasExactKeys(value.artifacts, ['fileCount', 'sha256'])) {
throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid artifact digest`)
}
if (!Number.isSafeInteger(value.artifacts.fileCount) || Number(value.artifacts.fileCount) < 1) {
throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid artifact count`)
}
if (typeof value.artifacts.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.artifacts.sha256)) {
throw new Error(`client build record ${CLIENT_BUILD_RECORD_PATH} has an invalid SHA-256 digest`)
}
return {
formatVersion: CLIENT_BUILD_RECORD_FORMAT,
environment,
artifacts: {
fileCount: Number(value.artifacts.fileCount),
sha256: value.artifacts.sha256,
},
}
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort()
return actual.length === expected.length && actual.every((key, index) => key === expected[index])
}
+6
View File
@@ -344,6 +344,12 @@ describe('Node 24 lane ownership', () => {
'built-bin-smoke',
])
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
expect(subject.find(item => item.id === 'build')?.env).toEqual({
DSH_BUILD_CLIENT_PROFILE: 'official',
})
expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({
DSH_BUILD_CLIENT_PROFILE: 'official',
})
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['build'])
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
for (const id of [
+20 -8
View File
@@ -9,6 +9,7 @@ import { spawn } from 'node:child_process'
import { availableParallelism } from 'node:os'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { CLIENT_BUILD_PROFILE_SELECTOR } from './client-build-environment.ts'
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts'
import {
COVERAGE_PARTITIONS_ENV,
@@ -176,6 +177,14 @@ function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Ga
}
}
/** Build official client artifacts inside a CI aggregate without changing sibling gate environments. */
function ciBuildGate(id = 'build', options: Partial<Gate> = {}): Gate {
return pnpmScript(id, 'build', {
...options,
env: { ...options.env, [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
})
}
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
return {
id,
@@ -216,7 +225,7 @@ export function gatesForMode(selected: Mode): Gate[] {
case 'ci-coverage':
return coverageGates()
case 'ci-snapshot':
return [pnpmScript('build', 'build'), snapshotGate()]
return [ciBuildGate(), snapshotGate()]
case 'ci-artifacts':
return ciArtifactGates()
case 'ci-consumers':
@@ -287,7 +296,7 @@ function ciPrimaryGates(): Gate[] {
// The prepared typecheck and build both drive Client tsc, while build also
// repeats the Host contract pass. Wait for all three consumers so build
// neither races tsbuildinfo nor replaces declarations while they are read.
pnpmScript('build', 'build', { needs: ['typecheck', 'lint', 'doc-typecheck'] }),
ciBuildGate('build', { needs: ['typecheck', 'lint', 'doc-typecheck'] }),
pnpmScript('publint', 'publint', { needs: ['build'] }),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
@@ -369,7 +378,7 @@ function runningNodeMajor(): number {
function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
return [
...ciSharedStaticGates(),
...options.ownsBuild ? [pnpmScript('build', 'build')] : [],
...options.ownsBuild ? [ciBuildGate()] : [],
...docSyncLeafGates({
includeDocTypecheck: options.ownsBuild,
...options.ownsBuild
@@ -388,7 +397,7 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
function ciArtifactGates(): Gate[] {
return [
pnpmScript('build', 'build'),
ciBuildGate(),
pnpmScript('publint', 'publint', { needs: ['build'] }),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
@@ -403,8 +412,11 @@ function ciConsumerGates(): Gate[] {
const builtTree = ['build']
const validatedBuild = ['built-package-invariants']
return [
pnpmScript('build', 'build'),
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
ciBuildGate(),
pnpmScript('node-compat', 'check:node-compat', {
label: 'Node compatibility',
env: { [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
}),
pnpmScript('publint', 'publint', { needs: builtTree }),
builtPackageInvariantsGate(builtTree),
pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', {
@@ -450,7 +462,7 @@ function webSnapshotGate(needs: string[]): Gate {
function ciWindowsBlockingGates(): Gate[] {
return [
pnpmScript('windows-build', 'build', { label: 'build' }),
ciBuildGate('windows-build', { label: 'build' }),
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
]
}
@@ -470,7 +482,7 @@ function ciWindowsCompleteGates(): Gate[] {
after: [...new Set([...coverageAfter, ...(gate.after ?? [])])],
}))
return [
pnpmScript('build', 'build'),
ciBuildGate(),
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
...coverage,
...observational,