fix(build): serialize release builds and give Client checks 8 GiB (#7435)

* fix(build): serialize publication and raise client aggregate heap

* docs: refresh development guide translation pairing

* fix(release): align pack runner and allow explicit tag-check bypass
This commit is contained in:
imccyu
2026-10-28 01:58:28 +08:00
committed by GitHub
parent 635d9c88f6
commit 7509c174d9
8 changed files with 78 additions and 28 deletions
+22 -5
View File
@@ -1,12 +1,18 @@
# Publish the dsh release sequence to npm. This workflow is manual-only
# (workflow_dispatch) and intentionally does not listen to pull_request or push:
# publication must always be an explicit, reviewed act from a dsh-v* tag, and it
# must never appear as a PR check. It repacks the current tree before publishing
# publication is an explicit, reviewed act; dsh-v* tag validation is enabled by
# default and may be skipped through the dispatch input. It never appears as a PR check. It repacks the current tree before publishing
# so the bytes uploaded are exactly what this dispatch produced.
name: Release publish (dsh)
on:
workflow_dispatch:
inputs:
skip-tag-check:
description: Skip release tag validation for the selected ref; other publication checks still apply.
type: boolean
required: false
default: false
permissions:
contents: read
@@ -18,7 +24,18 @@ env:
jobs:
pack:
name: Pack npm tarballs
runs-on: ubuntu-24.04
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.repository == 'deepseek-harness/deepseek-harness'
&& github.actor != 'dependabot[bot]'
&& (github.event_name == 'workflow_dispatch'
|| (github.event_name == 'push' && github.ref == 'refs/heads/master')
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
&& github.event.pull_request.head.repo.fork == false
&& github.event.pull_request.user.login != 'dependabot[bot]'))
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'ubuntu-24.04' }}
steps:
# Complete history: the release scripts read tags.
- uses: actions/checkout@v7.0.1
@@ -55,10 +72,10 @@ jobs:
- name: Verify release version
env:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family dsh
run: pnpm run release:verify --family dsh ${{ inputs.skip-tag-check && '--skip-tag-check' || '' }}
- name: Build
run: pnpm run build:official
run: pnpm run build:official --serial
- name: Pack release tarballs
run: pnpm run release:pack --family dsh --out dist/npm
+2 -2
View File
@@ -21,8 +21,8 @@
en: 3221d1076554001f
zh: 29f166be6a5c04d9
/development-guide/contributor-reference/typescript-project-layout:
en: 12810e1c6ca8b8e4
zh: b505d34cdf27856d
en: eb79b6f8ea745a9f
zh: de676c78231c823a
/development-guide/contributor-reference/environment-variables:
en: dba2c72ee6ffa930
zh: b65c27fbb9129ec8
+1 -1
View File
@@ -66,7 +66,7 @@ The repository uses isolated Host and Client aggregates. An ordinary package is
| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |
| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |
Host aggregate type checks run with a 8 GiB Node old-space limit across build, release, and Windows diagnostic commands. This is a per-compiler limit; concurrent build steps need additional memory.
Host and Client aggregate type checks run with an 8 GiB Node old-space limit. This is a per-compiler limit; concurrent build steps need additional memory. `pnpm run build:official --serial` prevents build steps from overlapping, even with `DSH_GATE_CONCURRENCY` set; the DSH publication workflow uses this option.
Host and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:
+1 -1
View File
@@ -70,7 +70,7 @@ pnpm run typecheck
| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |
| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` 包 extends。 | 否 |
构建、发布和 Windows 诊断命令中的 Host aggregate 类型检查为 Node 老生代堆设置 8 GiB 上限。这是单个编译进程的上限;并发构建步骤还需要额外内存。
Host 和 Client aggregate 类型检查为 Node 老生代堆设置 8 GiB 上限。这是单个编译进程的上限;并发构建步骤还需要额外内存。`pnpm run build:official --serial` 阻止构建步骤重叠,即使设置了 `DSH_GATE_CONCURRENCY` 也不例外;DSH 发布 workflow 使用此选项。
Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:
+22 -7
View File
@@ -43,10 +43,10 @@ describe('repository build CLI', () => {
})
it('selects the complete build by default and one step set by scope', () => {
expect(parseBuildArguments([])).toEqual({ scope: 'complete', profile: undefined })
expect(parseBuildArguments(['--scope', 'artifacts', '--profile', 'official'])).toEqual({ scope: 'artifacts', profile: 'official' })
expect(parseBuildArguments(['--scope', 'check'])).toEqual({ scope: 'check', profile: undefined })
expect(parseBuildArguments(['--scope', 'typecheck'])).toEqual({ scope: 'typecheck', profile: undefined })
expect(parseBuildArguments([])).toEqual({ scope: 'complete', profile: undefined, serial: false })
expect(parseBuildArguments(['--scope', 'artifacts', '--profile', 'official'])).toEqual({ scope: 'artifacts', profile: 'official', serial: false })
expect(parseBuildArguments(['--scope', 'check'])).toEqual({ scope: 'check', profile: undefined, serial: false })
expect(parseBuildArguments(['--scope', 'typecheck'])).toEqual({ scope: 'typecheck', profile: undefined, serial: false })
})
it.each([
@@ -60,12 +60,27 @@ describe('repository build CLI', () => {
})
describe('repository build steps', () => {
it.each(['complete', 'check', 'typecheck'] as const)('gives Host compilers an 8 GiB heap in %s scope', (scope) => {
it('parses the explicit serial build option', () => {
expect(parseBuildArguments(['--profile', 'official', '--serial'])).toEqual({ scope: 'complete', profile: 'official', serial: true })
})
it.each(['complete', 'artifacts', 'check', 'typecheck'] as const)('chains every %s step when serial execution is selected', (scope) => {
const original = buildGates(root, scope)
const serial = buildGates(root, scope, {}, true)
expect(serial.map(gate => gate.id)).toEqual(original.map(gate => gate.id))
for (const [index, gate] of serial.entries()) {
const previous = serial[index - 1]
if (previous !== undefined) expect(gate.needs).toContain(previous.id)
for (const dependency of original[index]!.needs ?? []) expect(gate.needs).toContain(dependency)
}
})
it.each(['complete', 'check', 'typecheck'] as const)('gives Host compilers and both aggregate checks an 8 GiB heap in %s scope', (scope) => {
const gates = buildGates(root, scope)
for (const gate of gates.filter(gate => gate.id === 'host-src' || gate.id === 'host-check')) {
for (const gate of gates.filter(gate => gate.id === 'host-src' || gate.id === 'host-check' || gate.id === 'client-check')) {
expect(gate.args[0]).toBe('--max-old-space-size=8192')
}
for (const gate of gates.filter(gate => gate.id === 'client-src' || gate.id === 'client-check')) {
for (const gate of gates.filter(gate => gate.id === 'client-src')) {
expect(gate.args.some(arg => arg.startsWith('--max-old-space-size'))).toBe(false)
}
})
+17 -6
View File
@@ -38,6 +38,8 @@ export interface BuildArguments {
readonly scope: BuildScope
/** Client build profile requested by `--profile`; `complete` and `artifacts` scopes only. */
readonly profile: string | undefined
/** Run build steps without overlap, including when a concurrency override is set. */
readonly serial: boolean
}
/**
@@ -53,6 +55,7 @@ export function parseBuildArguments(args: readonly string[]): BuildArguments {
options: {
profile: { type: 'string' },
scope: { type: 'string' },
serial: { type: 'boolean', default: false },
},
allowPositionals: false,
})
@@ -63,7 +66,7 @@ export function parseBuildArguments(args: readonly string[]): BuildArguments {
if ((scope === 'check' || scope === 'typecheck') && values.profile !== undefined) {
throw new Error(`build: --profile selects artifact values and does not apply to --scope ${scope}`)
}
return { scope, profile: values.profile }
return { scope, profile: values.profile, serial: values.serial }
}
/**
@@ -79,6 +82,7 @@ export function parseBuildArguments(args: readonly string[]): BuildArguments {
* @param root - Repository root containing the compiler and bundle configurations.
* @param scope - Steps to include.
* @param environment - Environment overlay applied to every step; the parent value of a key mapped to `undefined` is removed.
* @param serial - Whether each step must wait for the preceding step in report order.
* @returns The gate graph in report order.
* @throws If an aggregate configuration is invalid.
*/
@@ -86,6 +90,7 @@ export function buildGates(
root: string,
scope: BuildScope,
environment: Readonly<Record<string, string | undefined>> = {},
serial = false,
): Gate[] {
const env = { ...environment }
const gates: Gate[] = []
@@ -112,6 +117,12 @@ export function buildGates(
})
}
}
if (serial) {
return gates.map((gate, index) => {
const previous = gates[index - 1]
return previous === undefined ? gate : { ...gate, needs: [...new Set([...(gate.needs ?? []), previous.id])] }
})
}
return gates
}
@@ -153,16 +164,16 @@ async function main(): Promise<number> {
if (!process.features.typescript) {
throw new Error('build: Node.js TypeScript type stripping is unavailable in this Node.js process; remove --no-experimental-strip-types from NODE_OPTIONS or use a Node.js build with TypeScript support')
}
const { scope, profile } = parseBuildArguments(process.argv.slice(2))
const { scope, profile, serial } = parseBuildArguments(process.argv.slice(2))
const root = resolve(import.meta.dirname, '..')
const name = scope === 'complete' ? 'build' : scope === 'typecheck' ? 'typecheck' : `build:${scope}`
const budget = defaultParallelism()
const workers = (gates: readonly Gate[]) => ({
workers: Math.min(gates.length, budget),
source: describeDefaultParallelism(),
workers: serial ? 1 : Math.min(gates.length, budget),
source: serial ? '--serial' : describeDefaultParallelism(),
})
if (scope === 'check' || scope === 'typecheck') {
const gates = buildGates(root, scope)
const gates = buildGates(root, scope, {}, serial)
return runAggregate(name, gates, workers(gates))
}
@@ -170,7 +181,7 @@ async function main(): Promise<number> {
const clientEnvironment = resolveClientBuildEnvironment(repositoryEnvironment, profile ?? process.env[CLIENT_BUILD_PROFILE_SELECTOR])
const environment = environmentOverlay(process.env, clientBuildProcessEnvironment(process.env, clientEnvironment))
rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true })
const gates = buildGates(root, scope, environment)
const gates = buildGates(root, scope, environment, serial)
const exitCode = await runAggregate(name, gates, workers(gates))
if (exitCode !== 0) return exitCode
const record = writeClientBuildRecord(root, clientEnvironment)
+2 -2
View File
@@ -94,7 +94,7 @@ export function referencedProjectsCompilerArgs(
* @returns Arguments for `process.execPath`, run from the repository root.
*/
export function aggregateCheckArgs(face: 'host' | 'client'): string[] {
return [...hostHeapArgs(face), fileURLToPath(import.meta.resolve('typescript/bin/tsc')), '-p', `tsconfig.${face}.json`]
return ['--max-old-space-size=8192', fileURLToPath(import.meta.resolve('typescript/bin/tsc')), '-p', `tsconfig.${face}.json`]
}
/**
@@ -105,7 +105,7 @@ export function aggregateCheckArgs(face: 'host' | 'client'): string[] {
* @returns Arguments for `process.execPath`, run from the repository root.
*/
export function aggregateBuildArgs(face: 'host' | 'client'): string[] {
return [...hostHeapArgs(face), fileURLToPath(import.meta.resolve('typescript/bin/tsc')), '-b', `tsconfig.${face}.json`]
return ['--max-old-space-size=8192', fileURLToPath(import.meta.resolve('typescript/bin/tsc')), '-b', `tsconfig.${face}.json`]
}
function hostHeapArgs(face: 'host' | 'client'): string[] {
+11 -4
View File
@@ -2,8 +2,8 @@
* Verify a release family's version baseline, and — when publishing — that the
* run comes from the family's tag and its members are publishable.
*
* Publication happens only from GitHub Actions, so the tag and publishability
* checks are gates on the workflow, not advisory local warnings
* Publication happens only from GitHub Actions. Publishability is mandatory;
* tag validation may be explicitly bypassed with `--skip-tag-check`
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
*/
@@ -73,7 +73,10 @@ function verifyTag(family: ReleaseFamily, members: readonly ReleaseMember[], ref
/** Run the verification for the family named by `--family`. */
function main(): void {
const { values } = parseArgs({
options: { family: { type: 'string' } },
options: {
family: { type: 'string' },
'skip-tag-check': { type: 'boolean', default: false },
},
allowPositionals: false,
})
if (values.family === undefined) throw new Error('usage: verify.ts --family <dsh|vendor>')
@@ -95,7 +98,11 @@ function main(): void {
const publishing = process.env.RELEASE_PUBLISH === 'true'
if (publishing) {
verifyPublishable(members)
verifyTag(family, members, process.env.GITHUB_REF ?? '')
if (values['skip-tag-check']) {
console.warn(`release verify: WARNING tag validation explicitly skipped for ref ${process.env.GITHUB_REF || '(no ref)'}`)
} else {
verifyTag(family, members, process.env.GITHUB_REF ?? '')
}
}
const versions = [...new Set(members.map(member => member.version))]