Merge pull request #2905 from deepseek-harness/turtle/local-build-banner-version

Show build version in local Web banner
This commit is contained in:
imccyu
2026-08-24 20:46:43 +08:00
committed by GitHub
22 changed files with 445 additions and 67 deletions
+6 -7
View File
@@ -6,8 +6,9 @@ import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import {
CLIENT_BUILD_RECORD_PATH,
CLIENT_BUILD_PROFILE_SELECTOR,
clientBuildProcessEnvironment,
repositoryCommitHash,
repositoryClientBuildEnvironment,
resolveClientBuildEnvironment,
writeClientBuildRecord,
} from './client-build-environment.ts'
@@ -34,12 +35,10 @@ function main(): void {
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)
const repositoryEnvironment = repositoryClientBuildEnvironment(root, process.env)
const profile = values.profile ?? process.env[CLIENT_BUILD_PROFILE_SELECTOR]
const clientEnvironment = resolveClientBuildEnvironment(repositoryEnvironment, profile)
const buildEnvironment = clientBuildProcessEnvironment(process.env, clientEnvironment)
rmSync(resolve(root, CLIENT_BUILD_RECORD_PATH), { force: true })
runScript('build:lib', buildEnvironment)
@@ -1,3 +1,4 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
@@ -7,8 +8,12 @@ import {
assertClientBuildEnvironment,
clientBuildEnvironmentDefines,
clientBuildProcessEnvironment,
officialClientBuildEnvironment,
readClientBuildRecord,
repositoryClientBuildEnvironment,
repositoryCommitHash,
repositoryGitDirty,
repositoryVersion,
resolveClientBuildEnvironment,
writeClientBuildRecord,
} from './client-build-environment.ts'
@@ -51,12 +56,34 @@ function buildFixture(environment: Record<string, string>): string {
return fixtureRoot
}
function git(root: string, args: readonly string[]): string {
return execFileSync('git', [...args], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim()
}
function repositoryFixture(version = '1.2.3-rc.4'): string {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-repository-'))
roots.push(fixtureRoot)
write(join(fixtureRoot, 'package.json'), `${JSON.stringify({ version })}\n`)
write(join(fixtureRoot, 'tracked.txt'), 'committed\n')
git(fixtureRoot, ['init'])
git(fixtureRoot, ['config', 'user.name', 'DSH test'])
git(fixtureRoot, ['config', 'user.email', 'dsh-test@example.invalid'])
git(fixtureRoot, ['add', 'package.json', 'tracked.txt'])
git(fixtureRoot, ['commit', '-m', 'fixture'])
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',
DSH_CLIENT_VERSION: '1.2.3',
} as const
expect(() => { assertClientBuildEnvironment({ PATH: '/bin', ...expected }, expected) }).not.toThrow()
@@ -73,7 +100,9 @@ describe('client build environment', () => {
DSH_BUILD_CLIENT_PROFILE: 'official',
DSH_CLIENT_BUILD_PROFILE: 'local',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_GIT_DIRTY: 'true',
DSH_CLIENT_TITLE: 'Local title',
DSH_CLIENT_VERSION: '1.2.3',
DSH_CLIENT_EXTRA: 'local-extra',
}
@@ -84,24 +113,108 @@ describe('client build environment', () => {
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
DSH_CLIENT_VERSION: '1.2.3',
})
expect(() => {
resolveClientBuildEnvironment({ DSH_BUILD_CLIENT_PROFILE: 'official' })
}).toThrow(/DSH_CLIENT_COMMIT_HASH/)
expect(() => {
resolveClientBuildEnvironment({
DSH_BUILD_CLIENT_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
})
}).toThrow(/DSH_CLIENT_VERSION/)
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',
DSH_CLIENT_VERSION: '1.2.3',
})).toEqual({
PATH: '/bin',
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
DSH_CLIENT_VERSION: '1.2.3',
})
expect(repositoryCommitHash('/unused', { DSH_CLIENT_COMMIT_HASH: COMMIT_HASH })).toBe(COMMIT_HASH.slice(0, 7))
})
it('owns repository version, commit, and dirty metadata for complete builds', () => {
const fixtureRoot = repositoryFixture()
const commit = git(fixtureRoot, ['rev-parse', '--short=7', 'HEAD'])
expect(repositoryVersion(fixtureRoot)).toBe('1.2.3-rc.4')
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
expect(repositoryClientBuildEnvironment(fixtureRoot, {
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
DSH_CLIENT_EXTRA: 'preserved',
DSH_CLIENT_GIT_DIRTY: 'true',
DSH_CLIENT_VERSION: 'spoofed',
})).toEqual({
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_EXTRA: 'preserved',
DSH_CLIENT_VERSION: '1.2.3-rc.4',
})
expect(officialClientBuildEnvironment(fixtureRoot)).toEqual({
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: commit,
DSH_CLIENT_TITLE: 'DeepSeek Harness',
DSH_CLIENT_VERSION: '1.2.3-rc.4',
})
write(join(fixtureRoot, '.gitignore'), 'ignored.txt\n')
git(fixtureRoot, ['add', '.gitignore'])
git(fixtureRoot, ['commit', '-m', 'ignore fixture'])
write(join(fixtureRoot, 'ignored.txt'), 'ignored\n')
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
rmSync(join(fixtureRoot, 'ignored.txt'))
write(join(fixtureRoot, 'tracked.txt'), 'unstaged\n')
expect(repositoryGitDirty(fixtureRoot)).toBe(true)
write(join(fixtureRoot, 'tracked.txt'), 'committed\n')
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
write(join(fixtureRoot, 'tracked.txt'), 'staged\n')
git(fixtureRoot, ['add', 'tracked.txt'])
expect(repositoryGitDirty(fixtureRoot)).toBe(true)
git(fixtureRoot, ['commit', '-m', 'staged fixture'])
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
write(join(fixtureRoot, 'untracked.txt'), 'untracked\n')
expect(repositoryGitDirty(fixtureRoot)).toBe(true)
expect(repositoryClientBuildEnvironment(fixtureRoot, {
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
})).toEqual({
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_GIT_DIRTY: 'true',
DSH_CLIENT_VERSION: '1.2.3-rc.4',
})
rmSync(join(fixtureRoot, 'untracked.txt'))
const submoduleSource = repositoryFixture('9.8.7')
git(fixtureRoot, ['-c', 'protocol.file.allow=always', 'submodule', 'add', submoduleSource, 'submodule'])
git(fixtureRoot, ['commit', '-am', 'submodule fixture'])
expect(repositoryGitDirty(fixtureRoot)).toBe(false)
write(join(fixtureRoot, 'submodule/tracked.txt'), 'modified submodule\n')
expect(repositoryGitDirty(fixtureRoot)).toBe(true)
})
it('omits dirty metadata when repository metadata is unavailable', () => {
const fixtureRoot = mkdtempSync(join(tmpdir(), 'dsh-client-build-no-git-'))
roots.push(fixtureRoot)
write(join(fixtureRoot, 'package.json'), '{"version":"2.0.0"}\n')
expect(repositoryGitDirty(fixtureRoot)).toBeUndefined()
expect(repositoryClientBuildEnvironment(fixtureRoot, {
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH,
DSH_CLIENT_GIT_DIRTY: 'true',
})).toEqual({
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_VERSION: '2.0.0',
})
})
it('defines only public client values over a non-enumerable fallback', () => {
expect(clientBuildEnvironmentDefines({
PATH: '/bin',
@@ -151,6 +264,7 @@ describe('client build environment', () => {
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: COMMIT_HASH.slice(0, 7),
DSH_CLIENT_TITLE: 'DeepSeek Harness',
DSH_CLIENT_VERSION: '1.2.3',
}
const official = buildFixture(officialEnvironment)
const defaultBuild = buildFixture({})
+84 -2
View File
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { execFileSync, spawnSync } from 'node:child_process'
import {
existsSync,
globSync,
@@ -25,6 +25,9 @@ const OFFICIAL_CLIENT_BUILD_ENVIRONMENT = {
/** Public variable carrying the source commit embedded in client artifacts. */
const CLIENT_COMMIT_HASH_VARIABLE = 'DSH_CLIENT_COMMIT_HASH'
/** Public variable carrying the repository package version embedded in client artifacts. */
const CLIENT_VERSION_VARIABLE = 'DSH_CLIENT_VERSION'
/** Repository-relative path of the complete client build record. */
export const CLIENT_BUILD_RECORD_PATH = '.dsh-build/client-build-environment.json'
@@ -57,6 +60,76 @@ export function repositoryCommitHash(root: string, environment: NodeJS.ProcessEn
return value.slice(0, 7).toLowerCase()
}
/**
* Resolve the repository package version used by browser build metadata.
* @param root - repository root containing the authoritative package.json.
* @returns the repository's semver-compatible package version.
*/
export function repositoryVersion(root: string): string {
const path = resolve(root, 'package.json')
let manifest: unknown
try {
manifest = JSON.parse(readFileSync(path, 'utf8'))
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(`cannot read repository version from ${path}: ${detail}`)
}
if (!isObject(manifest) || typeof manifest.version !== 'string'
|| !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)) {
throw new Error(`repository package.json has an invalid version ${JSON.stringify(isObject(manifest) ? manifest.version : undefined)}`)
}
return manifest.version
}
/**
* Read whether Git reports any staged, unstaged, untracked, or submodule change.
* @param root - repository root whose worktree is inspected.
* @returns true or false inside a Git worktree; undefined without Git metadata.
*/
export function repositoryGitDirty(root: string): boolean | undefined {
const probe = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
})
if (probe.error !== undefined || probe.status !== 0 || probe.stdout.trim() !== 'true') return undefined
const status = spawnSync('git', ['status', '--porcelain=v1', '--untracked-files=normal'], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
if (status.error !== undefined) throw status.error
if (status.status !== 0) {
throw new Error(`git status failed in ${root}: ${status.stderr.trim() || String(status.status)}`)
}
return status.stdout !== ''
}
/**
* Resolve the public environment for a complete default build from one checkout.
* Repository-owned metadata replaces inherited values; other public values pass through.
* @param root - repository root supplying version and Git metadata.
* @param environment - caller environment supplying optional commit and public extensions.
* @returns complete public client environment for the default build.
*/
export function repositoryClientBuildEnvironment(
root: string,
environment: NodeJS.ProcessEnv = process.env,
): ClientBuildEnvironment {
const inherited = { ...clientBuildEnvironment(environment) }
delete inherited.DSH_CLIENT_COMMIT_HASH
delete inherited.DSH_CLIENT_GIT_DIRTY
delete inherited.DSH_CLIENT_VERSION
const dirty = repositoryGitDirty(root)
return {
...inherited,
DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment),
...(dirty === true ? { DSH_CLIENT_GIT_DIRTY: 'true' } : {}),
DSH_CLIENT_VERSION: repositoryVersion(root),
}
}
/**
* Resolve the exact public values required by an official build at one commit.
* @param root - repository root whose HEAD must match the built source.
@@ -69,6 +142,7 @@ export function officialClientBuildEnvironment(
): Readonly<Record<`DSH_CLIENT_${string}`, string>> {
return {
DSH_CLIENT_COMMIT_HASH: repositoryCommitHash(root, environment),
DSH_CLIENT_VERSION: repositoryVersion(root),
...OFFICIAL_CLIENT_BUILD_ENVIRONMENT,
}
}
@@ -115,10 +189,18 @@ export function resolveClientBuildEnvironment(
if (profile === undefined) return clientBuildEnvironment(environment)
if (profile === 'official') {
const commitHash = environment[CLIENT_COMMIT_HASH_VARIABLE]
const version = environment[CLIENT_VERSION_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 }
if (version === undefined) {
throw new Error(`${CLIENT_VERSION_VARIABLE} is required for the official client build profile`)
}
return {
DSH_CLIENT_COMMIT_HASH: commitHash,
DSH_CLIENT_VERSION: version,
...OFFICIAL_CLIENT_BUILD_ENVIRONMENT,
}
}
throw new Error(`unknown client build profile ${JSON.stringify(profile)}; expected "official"`)
}
+39 -1
View File
@@ -3,7 +3,45 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import type { TsdownBundle } from 'tsdown'
import { discoverLibraryDirs, discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
import { writeClientBuildRecord } from './client-build-environment.ts'
import {
devWebBuildEnvironment,
discoverLibraryDirs,
discoverPluginDirs,
watchClientPlugins,
} from './dev-web.ts'
it('samples one local environment at startup without validating watcher outputs', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-'))
try {
await mkdir(join(root, 'apps/web/dist'), { recursive: true })
await mkdir(join(root, 'packages/client/example/lib'), { recursive: true })
await writeFile(join(root, 'package.json'), JSON.stringify({ version: '1.2.3' }))
await writeFile(join(root, 'apps/web/dist/index.html'), '<main></main>')
await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
writeClientBuildRecord(root, {
DSH_CLIENT_BUILD_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: 'fffffff',
DSH_CLIENT_TITLE: 'DeepSeek Harness',
DSH_CLIENT_VERSION: '1.2.2',
})
await writeFile(join(root, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n')
expect(devWebBuildEnvironment(root, {
PATH: '/bin',
DSH_BUILD_CLIENT_PROFILE: 'official',
DSH_CLIENT_COMMIT_HASH: 'abc1234',
DSH_CLIENT_EXTRA: 'launch-value',
})).toEqual({
PATH: '/bin',
DSH_CLIENT_COMMIT_HASH: 'abc1234',
DSH_CLIENT_EXTRA: 'launch-value',
DSH_CLIENT_VERSION: '1.2.3',
})
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('discovers dsh.client packages with sibling roles', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
+28
View File
@@ -34,6 +34,11 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { build } from 'tsdown'
import type { TsdownBundle } from 'tsdown'
import {
CLIENT_BUILD_PROFILE_SELECTOR,
clientBuildProcessEnvironment,
repositoryClientBuildEnvironment,
} from './client-build-environment.ts'
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
@@ -49,6 +54,19 @@ const SHELL_PACKAGE = '@deepseek-ai/dsh-web-frontend'
*/
const TEST_INFRASTRUCTURE_PREFIX = 'packages/test-support/'
/**
* Sample one local public environment for every long-lived watcher stage.
* @param root - repository root supplying version and Git metadata.
* @param environment - watcher launch environment supplying public extensions.
* @returns process environment shared by tsdown and spawned watcher stages.
*/
export function devWebBuildEnvironment(
root: string,
environment: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment))
}
/**
* Discover the watch workspace by declaration: every packages/<group>/<name>
* whose package.json carries `dsh.client` with platform "web" is a client
@@ -175,6 +193,16 @@ interface StageHandle {
const invokedPath = process.argv[1]
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
if (isMain) {
const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env)
for (const name of Object.keys(process.env)) {
if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) {
Reflect.deleteProperty(process.env, name)
}
}
for (const [name, value] of Object.entries(buildEnvironment)) {
if (name.startsWith('DSH_CLIENT_') && value !== undefined) process.env[name] = value
}
const pluginDirs = discoverPluginDirs()
const libraryDirs = discoverLibraryDirs()
if (pluginDirs.length === 0) {
+5 -2
View File
@@ -29,6 +29,7 @@ function write(path: string, content: string): void {
function buildFixture(environment: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-release-build-'))
roots.push(root)
write(join(root, 'package.json'), `${JSON.stringify({ version: environment.DSH_CLIENT_VERSION ?? '0.0.1' })}\n`)
write(join(root, 'apps/web/dist/index.html'), '<main></main>')
write(join(root, 'packages/client/example/lib/client.js'), 'module.exports = {}\n')
writeClientBuildRecord(root, environment)
@@ -106,11 +107,13 @@ describe('release families', () => {
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', officialEnvironment.DSH_CLIENT_COMMIT_HASH)
const official = buildFixture(officialEnvironment)
const defaultBuild = buildFixture({})
const missing = join(defaultBuild, 'missing')
write(join(missing, 'package.json'), `${JSON.stringify({ version: officialEnvironment.DSH_CLIENT_VERSION })}\n`)
expect(() => { dsh.verifyBuildArtifacts(official) }).not.toThrow()
expect(() => { dsh.verifyBuildArtifacts(defaultBuild) }).toThrow(/DSH_CLIENT_TITLE/)
expect(() => { dsh.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).toThrow(/record.*missing/)
expect(() => { vendor.verifyBuildArtifacts(join(defaultBuild, 'missing')) }).not.toThrow()
expect(() => { dsh.verifyBuildArtifacts(missing) }).toThrow(/record.*missing/)
expect(() => { vendor.verifyBuildArtifacts(missing) }).not.toThrow()
write(join(official, 'packages/client/example/lib/client.js'), 'module.exports = { changed: true }\n')
expect(() => { dsh.verifyBuildArtifacts(official) }).toThrow(/artifacts differ/)