From 66a7081c158e5840c0ef717f6c242111482cc563 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:51:58 +0800 Subject: [PATCH] feat(build): bind client artifacts to build profiles --- ...6-08-18-client-build-environment.i18n.yaml | 4 +- .../2026-08-18-client-build-environment.md | 4 +- .../2026-08-18-client-build-environment.zh.md | 4 +- .../workflows/build-exe-for-python-sdk.yml | 3 +- .github/workflows/ci.yml | 3 +- .github/workflows/e2b-e2e.yml | 3 +- .github/workflows/e2e.yml | 3 +- .github/workflows/release.yml | 3 +- .github/workflows/sandbox.yml | 3 +- .gitignore | 1 + apps/web/tests/built-boot.snapshot.ts | 21 ++ apps/web/tests/hmr-live.e2e.ts | 19 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 + docs/development.zh.md | 2 + package.json | 3 +- packages/client/AGENTS.md | 2 +- scripts/build.ts | 55 ++++ scripts/clean.spec.ts | 2 + scripts/clean.ts | 2 + .../client-build-environment.client.spec.ts | 104 ++++++- scripts/client-build-environment.ts | 291 +++++++++++++++++- scripts/run-gates.spec.ts | 6 + scripts/run-gates.ts | 28 +- 24 files changed, 533 insertions(+), 39 deletions(-) create mode 100644 scripts/build.ts diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml index cc9b43da5d..b97fb2d1e8 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md index 771fdb936d..45ed6c8bc6 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md index 2a5948d64f..bb96337214 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-client-build-environment.zh.md @@ -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_*` 值都会成为公开产物内容,命名错误可能泄露信息。构建选择在产物生成时固定;需要部署后变化的设置必须使用拥有校验、传输和文档的运行时配置机制。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 12f6daeeae..ba17869f29 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 264843264a..741a6c4d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/e2b-e2e.yml b/.github/workflows/e2b-e2e.yml index 7e8d33072e..0442b0e093 100644 --- a/.github/workflows/e2b-e2e.yml +++ b/.github/workflows/e2b-e2e.yml @@ -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: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 227033892c..3cd2f515ab 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b4fab5e1f2..08296468bf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index bbc19cf49e..d13e4f7c50 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -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' diff --git a/.gitignore b/.gitignore index 3d0fd8e322..70c355e391 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ python/**/__pycache__/ python/**/.pytest_cache/ apps/web/dist/ .artifacts/ +.dsh-build/ .playwright-mcp/ .orig .worktrees/ diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 3d7bb3c249..2b5cb35886 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -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() diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 99a1105fa6..0f0418c5d1 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -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): SubprocessSpawnSpec { @@ -70,11 +71,13 @@ async function stopTree(child: SubprocessHandle): Promise { 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)) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 6a67241ba0..3dd3b32c87 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -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 diff --git a/docs/development.md b/docs/development.md index 4ef2a195fd..904245f935 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/docs/development.zh.md b/docs/development.zh.md index 66799906e2..fe160763ff 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -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)。 diff --git a/package.json b/package.json index 3ec9a6c1c5..d42108564d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 0a3e21852a..9baa35ebd0 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -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 diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000000..b6f12b0564 --- /dev/null +++ b/scripts/build.ts @@ -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() diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts index c724453283..bd0c3be9af 100644 --- a/scripts/clean.spec.ts +++ b/scripts/clean.spec.ts @@ -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) }) diff --git a/scripts/clean.ts b/scripts/clean.ts index 68e4ff4e71..c0f7d71763 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -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 })) { diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts index 2f4f6cb178..cce5cff2a0 100644 --- a/scripts/client-build-environment.client.spec.ts +++ b/scripts/client-build-environment.client.spec.ts @@ -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 { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-')) + roots.push(fixtureRoot) + write(join(fixtureRoot, 'apps/web/dist/index.html'), '
') + 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'), '
changed
') + 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_') } }) }) diff --git a/scripts/client-build-environment.ts b/scripts/client-build-environment.ts index 037e491790..2331db5f42 100644 --- a/scripts/client-build-environment.ts +++ b/scripts/client-build-environment.ts @@ -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> + +/** + * 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> { + 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 +} + +/** + * 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>, + expected: Readonly>, +): 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 { const defines: Record = { '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>, +): 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 = {} + 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 { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort() + return actual.length === expected.length && actual.every((key, index) => key === expected[index]) +} diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index dce448b2e0..8de5d1e095 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -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 [ diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1f65fed97e..ee021782f9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -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 = {}): Ga } } +/** Build official client artifacts inside a CI aggregate without changing sibling gate environments. */ +function ciBuildGate(id = 'build', options: Partial = {}): Gate { + return pnpmScript(id, 'build', { + ...options, + env: { ...options.env, [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' }, + }) +} + function pnpmExec(id: string, args: string[], options: Partial = {}): 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,