publish-npm-baseline.ts 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087
  1. /** Build, publish, and verify one commit-addressed npm workspace baseline. */
  2. import { spawnSync, type SpawnSyncReturns } from 'node:child_process'
  3. import { createHash } from 'node:crypto'
  4. import {
  5. existsSync,
  6. globSync,
  7. mkdirSync,
  8. mkdtempSync,
  9. readFileSync,
  10. realpathSync,
  11. readdirSync,
  12. rmSync,
  13. writeFileSync,
  14. } from 'node:fs'
  15. import { tmpdir } from 'node:os'
  16. import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
  17. import { createInterface } from 'node:readline/promises'
  18. import { pathToFileURL } from 'node:url'
  19. import { parseArgs } from 'node:util'
  20. import { hasTypeRTRemoteNavigation, validateTarballPayload } from './publication-payload.ts'
  21. const DEFAULT_REGISTRY = 'https://registry.npm.harnessment.com'
  22. const DEFAULT_OUTPUT_DIRECTORY = '.artifacts/npm-baseline'
  23. const PACKAGE_PATTERNS = [
  24. 'vendor/*/package.json',
  25. 'packages/*/*/package.json',
  26. 'apps/*/package.json',
  27. ] as const
  28. const DEPENDENCY_SECTIONS = [
  29. 'dependencies',
  30. 'devDependencies',
  31. 'optionalDependencies',
  32. 'peerDependencies',
  33. ] as const
  34. const RELEASE_MANIFEST_NAME = 'manifest.json'
  35. const RELEASE_ENTRY_PACKAGE = '@deepseek-ai/dsh'
  36. const LATEST_DIST_TAG = 'latest'
  37. const POSIX_WEB_PROBE = String.raw`
  38. import errno, os, pty, select, signal, sys, time
  39. node, bin_path, cwd, timeout_seconds = sys.argv[1:]
  40. pid, fd = pty.fork()
  41. if pid == 0:
  42. os.chdir(cwd)
  43. os.execvpe(node, [node, bin_path, "web", "--host", "127.0.0.1", "--port", "0"], os.environ.copy())
  44. output = bytearray()
  45. ready_seen = False
  46. termination_sent = False
  47. deadline = time.monotonic() + float(timeout_seconds)
  48. status = None
  49. while time.monotonic() < deadline:
  50. ready, _, _ = select.select([fd], [], [], 0.05)
  51. if ready:
  52. try:
  53. chunk = os.read(fd, 65536)
  54. except OSError as error:
  55. if error.errno != errno.EIO:
  56. raise
  57. chunk = b""
  58. if chunk:
  59. output.extend(chunk)
  60. snapshot = bytes(output)
  61. if not termination_sent and b"dsh web: http://127.0.0.1:" in snapshot:
  62. ready_seen = True
  63. os.kill(pid, signal.SIGTERM)
  64. termination_sent = True
  65. waited, candidate = os.waitpid(pid, os.WNOHANG)
  66. if waited == pid:
  67. status = candidate
  68. break
  69. if status is None:
  70. os.kill(pid, signal.SIGKILL)
  71. _, status = os.waitpid(pid, 0)
  72. sys.stdout.buffer.write(output)
  73. if not ready_seen:
  74. sys.stderr.write("installed dsh web did not reach its ready URL\n")
  75. sys.exit(124)
  76. actual_exit = os.waitstatus_to_exitcode(status)
  77. if actual_exit != 0:
  78. sys.stderr.write(f"installed dsh web exited {actual_exit}, expected 0\n")
  79. sys.exit(125)
  80. `
  81. interface CommandResult {
  82. status: number
  83. stdout: string
  84. stderr: string
  85. }
  86. interface PackageTarget {
  87. name: string
  88. directory: string
  89. origin: PackageOrigin
  90. }
  91. type PackageOrigin = 'harness' | 'vendor'
  92. interface PackedPackage {
  93. name: string
  94. tarball: string
  95. sha256: string
  96. integrity: string
  97. origin: PackageOrigin
  98. }
  99. interface ReleaseManifest {
  100. schemaVersion: 1
  101. commit: string
  102. version: string
  103. distTag: string
  104. registry: string
  105. packages: PackedPackage[]
  106. }
  107. interface PackOptions {
  108. ref: string
  109. registry: string
  110. outputDirectory: string
  111. }
  112. /** Fixes the identity of one pack attempt before any expensive work begins. */
  113. class BaselinePackPlan {
  114. constructor(
  115. readonly commit: string,
  116. readonly shortCommit: string,
  117. readonly timestamp: string,
  118. readonly baseVersion: string,
  119. readonly version: string,
  120. readonly distTag: string,
  121. readonly registry: string,
  122. readonly artifactDirectory: string,
  123. ) {}
  124. async confirm(assumeYes: boolean): Promise<void> {
  125. console.log('publish-npm-baseline: planned pack')
  126. console.log(` commit: ${this.commit}`)
  127. console.log(` timestamp: ${this.timestamp} UTC`)
  128. console.log(` version: ${this.version}`)
  129. console.log(` dist-tag: ${this.distTag}`)
  130. console.log(` registry: ${this.registry}`)
  131. console.log(` output: ${this.artifactDirectory}`)
  132. if (assumeYes) return
  133. await confirmEnter(
  134. 'Press Enter to start packing or type anything to cancel: ',
  135. 'pack requires an interactive terminal or --yes',
  136. 'pack cancelled',
  137. )
  138. }
  139. }
  140. /** Runs child processes without involving a command shell. */
  141. class CommandRunner {
  142. run(
  143. command: string,
  144. args: string[],
  145. cwd: string,
  146. environment: NodeJS.ProcessEnv = process.env,
  147. ): void {
  148. const result = spawnSync(command, args, { cwd, env: environment, stdio: 'inherit' })
  149. if (result.error !== undefined) throw result.error
  150. if (result.status !== 0) {
  151. throw new Error(`${formatCommand(command, args)} exited with status ${String(result.status)}`)
  152. }
  153. }
  154. capture(
  155. command: string,
  156. args: string[],
  157. cwd: string,
  158. environment: NodeJS.ProcessEnv = process.env,
  159. ): string {
  160. const result = this.result(command, args, cwd, environment)
  161. if (result.status !== 0) throw commandFailure(command, args, result)
  162. return result.stdout.trim()
  163. }
  164. result(
  165. command: string,
  166. args: string[],
  167. cwd: string,
  168. environment: NodeJS.ProcessEnv = process.env,
  169. ): CommandResult {
  170. const result: SpawnSyncReturns<string> = spawnSync(command, args, {
  171. cwd,
  172. encoding: 'utf8',
  173. env: environment,
  174. maxBuffer: 16 * 1024 * 1024,
  175. })
  176. if (result.error !== undefined) throw result.error
  177. return {
  178. status: result.status ?? 1,
  179. stdout: result.stdout,
  180. stderr: result.stderr,
  181. }
  182. }
  183. }
  184. /** Owns a temporary detached worktree and removes it after staging. */
  185. class DetachedWorktree {
  186. private constructor(
  187. readonly path: string,
  188. private readonly temporaryRoot: string,
  189. private readonly repositoryRoot: string,
  190. private readonly runner: CommandRunner,
  191. ) {}
  192. static create(repositoryRoot: string, commit: string, runner: CommandRunner): DetachedWorktree {
  193. const temporaryRoot = mkdtempSync(join(tmpdir(), 'dsh-npm-baseline-'))
  194. const path = join(temporaryRoot, 'worktree')
  195. try {
  196. runner.run('git', ['worktree', 'add', '--detach', path, commit], repositoryRoot)
  197. return new DetachedWorktree(path, temporaryRoot, repositoryRoot, runner)
  198. } catch (error: unknown) {
  199. rmSync(temporaryRoot, { recursive: true, force: true })
  200. throw error
  201. }
  202. }
  203. dispose(): void {
  204. const result = this.runner.result(
  205. 'git',
  206. ['worktree', 'remove', '--force', this.path],
  207. this.repositoryRoot,
  208. )
  209. if (result.status !== 0) {
  210. console.error(`publish-npm-baseline: could not remove worktree ${this.path}`)
  211. if (result.stderr.trim() !== '') console.error(result.stderr.trim())
  212. }
  213. rmSync(this.temporaryRoot, { recursive: true, force: true })
  214. }
  215. }
  216. /** Discovers and stages every package published in one repository baseline. */
  217. class WorkspacePackageSet {
  218. private constructor(
  219. readonly packages: PackageTarget[],
  220. readonly baseVersion: string,
  221. ) {}
  222. static discover(root: string): WorkspacePackageSet {
  223. const manifestPaths = globSync(PACKAGE_PATTERNS, { cwd: root }).sort()
  224. if (manifestPaths.length === 0) {
  225. throw new Error('no package manifests found under vendor/, packages/, or apps/')
  226. }
  227. const packages: PackageTarget[] = []
  228. const names = new Set<string>()
  229. const baseVersion = expectString(readObject(resolve(root, 'package.json')), 'version', 'package.json')
  230. if (!/^\d+\.\d+\.\d+$/.test(baseVersion)) {
  231. throw new Error(`package.json must have a stable X.Y.Z version, got ${baseVersion}`)
  232. }
  233. for (const manifestPath of manifestPaths) {
  234. const manifest = readObject(resolve(root, manifestPath))
  235. const name = expectString(manifest, 'name', manifestPath)
  236. const version = expectString(manifest, 'version', manifestPath)
  237. const isVendored = manifestPath.startsWith('vendor/')
  238. // Vendored packages are rescoped too (vendor/README.md), so publication
  239. // never carries an upstream name that would squat it on the registry.
  240. if (!name.startsWith('@deepseek-ai/')) {
  241. throw new Error(`${manifestPath} must name an @deepseek-ai package`)
  242. }
  243. if (name === '@deepseek-ai/dsh-root') {
  244. throw new Error(`${manifestPath} unexpectedly selected the workspace root`)
  245. }
  246. if (names.has(name)) throw new Error(`duplicate package name: ${name}`)
  247. if (!isVendored && version !== baseVersion) {
  248. throw new Error(`${manifestPath} has version ${version}; expected ${baseVersion}`)
  249. }
  250. names.add(name)
  251. packages.push({
  252. name,
  253. directory: dirname(manifestPath),
  254. origin: isVendored ? 'vendor' : 'harness',
  255. })
  256. }
  257. packages.sort((left, right) => left.name.localeCompare(right.name))
  258. return new WorkspacePackageSet(packages, baseVersion)
  259. }
  260. stage(root: string, releaseVersion: string): void {
  261. const internalNames = new Set(this.packages.map(pkg => pkg.name))
  262. for (const target of this.packages) {
  263. const manifestPath = resolve(root, target.directory, 'package.json')
  264. const manifest = readObject(manifestPath)
  265. manifest.version = releaseVersion
  266. delete manifest.private
  267. stageInternalDependencies(manifest, internalNames, releaseVersion, manifestPath)
  268. writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
  269. }
  270. }
  271. }
  272. /** Immutable local release bundle consumed by publish and verify. */
  273. class ReleaseBundle {
  274. private constructor(
  275. readonly directory: string,
  276. readonly manifest: ReleaseManifest,
  277. ) {}
  278. static create(
  279. directory: string,
  280. expectedPackages: PackageTarget[],
  281. commit: string,
  282. version: string,
  283. distTag: string,
  284. registry: string,
  285. runner: CommandRunner,
  286. ): ReleaseBundle {
  287. const internalNames = new Set(expectedPackages.map(pkg => pkg.name))
  288. const expectedByName = new Map(expectedPackages.map(pkg => [pkg.name, pkg]))
  289. const missingNames = new Set(internalNames)
  290. const packages = readdirSync(directory)
  291. .filter(name => name.endsWith('.tgz'))
  292. .sort()
  293. .map((tarball) => {
  294. const artifact = inspectTarball(resolve(directory, tarball), runner)
  295. const expected = expectedByName.get(artifact.name)
  296. if (expected === undefined || !missingNames.delete(artifact.name)) {
  297. throw new Error(`unexpected or duplicate packed package: ${artifact.name}`)
  298. }
  299. if (expected.origin === 'harness') {
  300. validateTarballPayload(artifact.files, tarball, {
  301. typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest),
  302. })
  303. }
  304. if (artifact.version !== version) {
  305. throw new Error(`${tarball} has version ${artifact.version}; expected ${version}`)
  306. }
  307. if (artifact.private === true) throw new Error(`${tarball} is still private`)
  308. if (containsWorkspaceProtocol(artifact.manifest)) {
  309. throw new Error(`${tarball} still contains a workspace: dependency`)
  310. }
  311. validateInternalDependencyPins(artifact.manifest, internalNames, version, tarball)
  312. return packedPackage(artifact.name, resolve(directory, tarball), expected.origin)
  313. })
  314. .sort((left, right) => left.name.localeCompare(right.name))
  315. if (missingNames.size !== 0) {
  316. throw new Error(`missing tarballs for: ${[...missingNames].sort().join(', ')}`)
  317. }
  318. const manifest: ReleaseManifest = {
  319. schemaVersion: 1,
  320. commit,
  321. version,
  322. distTag,
  323. registry,
  324. packages,
  325. }
  326. writeFileSync(resolve(directory, RELEASE_MANIFEST_NAME), `${JSON.stringify(manifest, null, 2)}\n`)
  327. writeFileSync(
  328. resolve(directory, 'SHA256SUMS'),
  329. `${packages.map(pkg => `${pkg.sha256} ${pkg.tarball}`).join('\n')}\n`,
  330. )
  331. return new ReleaseBundle(directory, manifest)
  332. }
  333. static load(manifestPath: string, runner: CommandRunner): ReleaseBundle {
  334. const absoluteManifestPath = resolve(manifestPath)
  335. const raw = readObject(absoluteManifestPath)
  336. if (raw.schemaVersion !== 1) {
  337. throw new Error(`unsupported release manifest schema: ${String(raw.schemaVersion)}`)
  338. }
  339. const directory = dirname(absoluteManifestPath)
  340. const packageValues = raw.packages
  341. if (!Array.isArray(packageValues) || packageValues.length === 0) {
  342. throw new Error('release manifest contains no packages')
  343. }
  344. const packages = packageValues.map((value, index) => parsePackedPackage(value, index))
  345. const names = new Set<string>()
  346. for (const pkg of packages) {
  347. if (names.has(pkg.name)) throw new Error(`duplicate package in release manifest: ${pkg.name}`)
  348. names.add(pkg.name)
  349. }
  350. const manifest: ReleaseManifest = {
  351. schemaVersion: 1,
  352. commit: expectString(raw, 'commit', RELEASE_MANIFEST_NAME),
  353. version: expectString(raw, 'version', RELEASE_MANIFEST_NAME),
  354. distTag: expectString(raw, 'distTag', RELEASE_MANIFEST_NAME),
  355. registry: normalizeRegistry(expectString(raw, 'registry', RELEASE_MANIFEST_NAME)),
  356. packages,
  357. }
  358. const bundle = new ReleaseBundle(directory, manifest)
  359. bundle.verifyLocal(runner)
  360. return bundle
  361. }
  362. private verifyLocal(runner: CommandRunner): void {
  363. const internalNames = new Set(this.manifest.packages.map(pkg => pkg.name))
  364. for (const pkg of this.manifest.packages) {
  365. if (isAbsolute(pkg.tarball) || dirname(pkg.tarball) !== '.' || normalize(pkg.tarball) !== pkg.tarball) {
  366. throw new Error(`invalid tarball path for ${pkg.name}: ${pkg.tarball}`)
  367. }
  368. const path = resolve(this.directory, pkg.tarball)
  369. const actual = packedPackage(pkg.name, path, pkg.origin)
  370. if (actual.sha256 !== pkg.sha256 || actual.integrity !== pkg.integrity) {
  371. throw new Error(`tarball checksum mismatch: ${pkg.tarball}`)
  372. }
  373. const artifact = inspectTarball(path, runner)
  374. if (pkg.origin === 'harness') {
  375. validateTarballPayload(artifact.files, pkg.tarball, {
  376. typeRTRemoteNavigation: hasTypeRTRemoteNavigation(artifact.manifest),
  377. })
  378. }
  379. if (artifact.name !== pkg.name || artifact.version !== this.manifest.version) {
  380. throw new Error(`tarball identity mismatch: ${pkg.tarball}`)
  381. }
  382. if (artifact.private === true) throw new Error(`${pkg.tarball} is still private`)
  383. if (containsWorkspaceProtocol(artifact.manifest)) {
  384. throw new Error(`${pkg.tarball} still contains a workspace: dependency`)
  385. }
  386. validateInternalDependencyPins(
  387. artifact.manifest,
  388. internalNames,
  389. this.manifest.version,
  390. pkg.tarball,
  391. )
  392. }
  393. }
  394. tarballPath(pkg: PackedPackage): string {
  395. return resolve(this.directory, pkg.tarball)
  396. }
  397. }
  398. /** Installs one complete bundle outside the workspace and probes the shipped dsh entry. */
  399. class InstalledBundleSmoke {
  400. constructor(
  401. private readonly bundle: ReleaseBundle,
  402. private readonly runner: CommandRunner,
  403. ) {}
  404. run(): void {
  405. const consumerRoot = mkdtempSync(join(tmpdir(), 'dsh-npm-consumer-'))
  406. try {
  407. const dependencies = Object.fromEntries(this.bundle.manifest.packages.map(pkg => [
  408. pkg.name,
  409. pathToFileURL(this.bundle.tarballPath(pkg)).href,
  410. ]))
  411. writeFileSync(resolve(consumerRoot, 'package.json'), `${JSON.stringify({
  412. name: 'dsh-npm-baseline-consumer',
  413. version: '0.0.0',
  414. private: true,
  415. dependencies,
  416. }, null, 2)}\n`)
  417. console.log(
  418. `publish-npm-baseline: installing ${this.bundle.manifest.packages.length} local tarballs`,
  419. )
  420. this.runner.run('npm', [
  421. 'install',
  422. '--no-audit',
  423. '--no-fund',
  424. '--package-lock=false',
  425. `--registry=${this.bundle.manifest.registry}`,
  426. ], consumerRoot, npmClientEnvironment())
  427. const bin = resolve(consumerRoot, 'node_modules/@deepseek-ai/dsh/lib/bin.js')
  428. assertPathWithin(consumerRoot, bin, 'installed dsh bin')
  429. const environment = installedArtifactEnvironment(consumerRoot)
  430. const version = this.runner.capture(
  431. process.execPath,
  432. [bin, '--version'],
  433. consumerRoot,
  434. environment,
  435. )
  436. if (version !== this.bundle.manifest.version) {
  437. throw new Error(
  438. `installed dsh --version returned ${JSON.stringify(version)}; `
  439. + `expected ${this.bundle.manifest.version}`,
  440. )
  441. }
  442. this.probeWeb(bin, consumerRoot, environment)
  443. console.log('publish-npm-baseline: installed dsh entry and Web startup probes passed')
  444. } finally {
  445. rmSync(consumerRoot, { recursive: true, force: true })
  446. }
  447. }
  448. private probeWeb(bin: string, consumerRoot: string, environment: NodeJS.ProcessEnv): void {
  449. if (process.platform === 'win32') {
  450. throw new Error('installed dsh Web probe requires a POSIX host with python3')
  451. }
  452. const result = this.runner.result(
  453. 'python3',
  454. ['-c', POSIX_WEB_PROBE, process.execPath, bin, consumerRoot, '60'],
  455. consumerRoot,
  456. environment,
  457. )
  458. if (result.status !== 0) {
  459. throw commandFailure('python3', ['installed-dsh-web-probe'], result)
  460. }
  461. }
  462. }
  463. /** Builds a release bundle without mutating the caller's checkout. */
  464. class BaselinePackager {
  465. constructor(
  466. private readonly repositoryRoot: string,
  467. private readonly runner: CommandRunner,
  468. private readonly now: () => Date = () => new Date(),
  469. ) {}
  470. plan(options: PackOptions): BaselinePackPlan {
  471. const timestamp = formatUtcTimestamp(this.now())
  472. const registry = normalizeRegistry(options.registry)
  473. const commit = this.runner.capture(
  474. 'git',
  475. ['rev-parse', '--verify', `${options.ref}^{commit}`],
  476. this.repositoryRoot,
  477. )
  478. const shortCommit = this.runner.capture(
  479. 'git',
  480. ['rev-parse', '--short=10', commit],
  481. this.repositoryRoot,
  482. )
  483. const rootManifest = parseObject(
  484. this.runner.capture('git', ['show', `${commit}:package.json`], this.repositoryRoot),
  485. `${commit}:package.json`,
  486. )
  487. const baseVersion = expectString(rootManifest, 'version', `${commit}:package.json`)
  488. validateBaseVersion(baseVersion, `${commit}:package.json`)
  489. const version = `${baseVersion}-${timestamp}-${shortCommit}`
  490. const distTag = `dev-${baseVersion}`
  491. validateDistTag(distTag)
  492. const artifactDirectory = resolve(options.outputDirectory, version)
  493. if (existsSync(artifactDirectory)) {
  494. throw new Error(`output already exists: ${artifactDirectory}`)
  495. }
  496. return new BaselinePackPlan(
  497. commit,
  498. shortCommit,
  499. timestamp,
  500. baseVersion,
  501. version,
  502. distTag,
  503. registry,
  504. artifactDirectory,
  505. )
  506. }
  507. pack(plan: BaselinePackPlan): ReleaseBundle {
  508. const { artifactDirectory } = plan
  509. if (existsSync(artifactDirectory)) {
  510. throw new Error(`output already exists: ${artifactDirectory}`)
  511. }
  512. const worktree = DetachedWorktree.create(this.repositoryRoot, plan.commit, this.runner)
  513. let createdArtifactDirectory = false
  514. try {
  515. const packageSet = WorkspacePackageSet.discover(worktree.path)
  516. if (packageSet.baseVersion !== plan.baseVersion) {
  517. throw new Error(
  518. `workspace package version ${packageSet.baseVersion} does not match root version `
  519. + `${plan.baseVersion} at ${plan.commit}`,
  520. )
  521. }
  522. console.log(`publish-npm-baseline: installing detached worktree ${plan.shortCommit}`)
  523. this.runner.run('pnpm', ['install', '--frozen-lockfile'], worktree.path)
  524. this.runner.run('pnpm', ['run', 'constraints'], worktree.path)
  525. packageSet.stage(worktree.path, plan.version)
  526. mkdirSync(artifactDirectory, { recursive: true })
  527. createdArtifactDirectory = true
  528. console.log(
  529. `publish-npm-baseline: building ${packageSet.packages.length} packages as ${plan.version}`,
  530. )
  531. this.runner.run('pnpm', ['run', 'build'], worktree.path)
  532. this.runner.run('pnpm', ['run', 'publint'], worktree.path)
  533. this.runner.run('pnpm', ['run', 'verify-built-package-invariants'], worktree.path)
  534. this.runner.run('pnpm', [
  535. '--filter', './vendor/**',
  536. '--filter', './packages/**',
  537. '--filter', './apps/**',
  538. '--recursive',
  539. 'pack',
  540. '--pack-destination', artifactDirectory,
  541. ], worktree.path)
  542. const bundle = ReleaseBundle.create(
  543. artifactDirectory,
  544. packageSet.packages,
  545. plan.commit,
  546. plan.version,
  547. plan.distTag,
  548. plan.registry,
  549. this.runner,
  550. )
  551. new InstalledBundleSmoke(bundle, this.runner).run()
  552. createdArtifactDirectory = false
  553. console.log(`publish-npm-baseline: packed ${bundle.manifest.packages.length} packages`)
  554. console.log(` version: ${bundle.manifest.version}`)
  555. console.log(` dist-tag: ${bundle.manifest.distTag}`)
  556. console.log(` manifest: ${resolve(bundle.directory, RELEASE_MANIFEST_NAME)}`)
  557. console.log(' publish: ' + formatCopyableCommand('pnpm', [
  558. '--dir',
  559. this.repositoryRoot,
  560. 'exec',
  561. 'tsx',
  562. resolve(this.repositoryRoot, 'scripts/publish-npm-baseline.ts'),
  563. 'publish',
  564. '--manifest',
  565. resolve(bundle.directory, RELEASE_MANIFEST_NAME),
  566. '--yes',
  567. ]))
  568. return bundle
  569. } finally {
  570. worktree.dispose()
  571. if (createdArtifactDirectory) {
  572. rmSync(artifactDirectory, { recursive: true, force: true })
  573. }
  574. }
  575. }
  576. }
  577. /** Publishes and verifies a release bundle against its recorded registry. */
  578. class RegistryPublication {
  579. private readonly npmEnvironment = npmClientEnvironment()
  580. private readonly npmWorkingDirectory = tmpdir()
  581. constructor(
  582. private readonly bundle: ReleaseBundle,
  583. private readonly runner: CommandRunner,
  584. ) {}
  585. async publish(assumeYes: boolean): Promise<void> {
  586. this.pingRegistry()
  587. this.requireIdentity()
  588. if (!assumeYes) await this.confirm()
  589. for (const pkg of this.bundle.manifest.packages) {
  590. const existingIntegrity = this.remoteIntegrity(pkg.name)
  591. if (existingIntegrity === undefined) {
  592. this.runner.run('npm', [
  593. 'publish',
  594. this.bundle.tarballPath(pkg),
  595. `--registry=${this.bundle.manifest.registry}`,
  596. `--tag=${this.bundle.manifest.distTag}`,
  597. ], this.npmWorkingDirectory, this.npmEnvironment)
  598. } else {
  599. if (existingIntegrity !== pkg.integrity) {
  600. throw new Error(
  601. `${pkg.name}@${this.bundle.manifest.version} already exists with different integrity`,
  602. )
  603. }
  604. console.log(
  605. `publish-npm-baseline: already published ${pkg.name}@${this.bundle.manifest.version}`,
  606. )
  607. }
  608. this.ensureDistTag(pkg.name, this.bundle.manifest.distTag)
  609. }
  610. this.ensureDistTag(RELEASE_ENTRY_PACKAGE, LATEST_DIST_TAG)
  611. this.verifyRemote()
  612. this.verifyReleaseEntryDistTag()
  613. }
  614. verify(): void {
  615. this.pingRegistry()
  616. this.verifyRemote()
  617. this.verifyReleaseEntryDistTag()
  618. }
  619. private verifyRemote(): void {
  620. for (const pkg of this.bundle.manifest.packages) {
  621. const integrity = this.remoteIntegrity(pkg.name)
  622. if (integrity === undefined) {
  623. throw new Error(`package is missing: ${pkg.name}@${this.bundle.manifest.version}`)
  624. }
  625. if (integrity !== pkg.integrity) {
  626. throw new Error(`integrity mismatch: ${pkg.name}@${this.bundle.manifest.version}`)
  627. }
  628. const tagVersion = this.remoteDistTag(pkg.name, this.bundle.manifest.distTag)
  629. if (tagVersion !== this.bundle.manifest.version) {
  630. throw new Error(
  631. `${pkg.name}@${this.bundle.manifest.distTag} points to ${tagVersion ?? '<missing>'}; `
  632. + `expected ${this.bundle.manifest.version}`,
  633. )
  634. }
  635. console.log(`publish-npm-baseline: verified ${pkg.name}@${this.bundle.manifest.version}`)
  636. }
  637. console.log(
  638. `publish-npm-baseline: verified ${this.bundle.manifest.packages.length} packages and `
  639. + `dist-tag ${this.bundle.manifest.distTag}`,
  640. )
  641. }
  642. private verifyReleaseEntryDistTag(): void {
  643. const tagVersion = this.remoteDistTag(RELEASE_ENTRY_PACKAGE, LATEST_DIST_TAG)
  644. if (tagVersion !== this.bundle.manifest.version) {
  645. throw new Error(
  646. `${RELEASE_ENTRY_PACKAGE}@${LATEST_DIST_TAG} points to ${tagVersion ?? '<missing>'}; `
  647. + `expected ${this.bundle.manifest.version}`,
  648. )
  649. }
  650. console.log(
  651. `publish-npm-baseline: verified ${RELEASE_ENTRY_PACKAGE}@${LATEST_DIST_TAG} at `
  652. + this.bundle.manifest.version,
  653. )
  654. }
  655. private pingRegistry(): void {
  656. const { registry } = this.bundle.manifest
  657. this.runner.capture(
  658. 'npm', ['ping', `--registry=${registry}`], this.npmWorkingDirectory, this.npmEnvironment,
  659. )
  660. }
  661. private requireIdentity(): void {
  662. const { registry } = this.bundle.manifest
  663. const identity = this.runner.capture(
  664. 'npm', ['whoami', `--registry=${registry}`], this.npmWorkingDirectory, this.npmEnvironment,
  665. )
  666. console.log(`publish-npm-baseline: registry identity ${identity} at ${registry}`)
  667. }
  668. private async confirm(): Promise<void> {
  669. await confirmEnter(
  670. `Publish ${this.bundle.manifest.packages.length} packages as `
  671. + `${this.bundle.manifest.version} to ${this.bundle.manifest.registry}? `
  672. + 'Press Enter to continue or type anything to cancel: ',
  673. 'publish requires an interactive terminal or --yes',
  674. 'publication cancelled',
  675. )
  676. }
  677. private remoteIntegrity(name: string): string | undefined {
  678. const { registry, version } = this.bundle.manifest
  679. const result = this.runner.result(
  680. 'npm',
  681. ['view', `${name}@${version}`, 'dist.integrity', '--json', `--registry=${registry}`],
  682. this.npmWorkingDirectory,
  683. this.npmEnvironment,
  684. )
  685. if (result.status !== 0) {
  686. if (/E404|NOT_FOUND|404 Not Found/.test(`${result.stdout}\n${result.stderr}`)) return undefined
  687. throw commandFailure('npm', ['view', `${name}@${version}`], result)
  688. }
  689. const value: unknown = result.stdout.trim() === '' ? undefined : JSON.parse(result.stdout)
  690. if (typeof value !== 'string' || !value.startsWith('sha512-')) {
  691. throw new Error(`registry returned no integrity for ${name}@${version}`)
  692. }
  693. return value
  694. }
  695. private remoteDistTag(name: string, distTag: string): string | undefined {
  696. const { registry } = this.bundle.manifest
  697. const raw = this.runner.capture(
  698. 'npm',
  699. ['dist-tag', 'ls', name, `--registry=${registry}`],
  700. this.npmWorkingDirectory,
  701. this.npmEnvironment,
  702. )
  703. return parseDistTagListing(raw, name).get(distTag)
  704. }
  705. private ensureDistTag(name: string, distTag: string): void {
  706. if (this.remoteDistTag(name, distTag) === this.bundle.manifest.version) return
  707. const { registry, version } = this.bundle.manifest
  708. this.runner.run(
  709. 'npm',
  710. ['dist-tag', 'add', `${name}@${version}`, distTag, `--registry=${registry}`],
  711. this.npmWorkingDirectory,
  712. this.npmEnvironment,
  713. )
  714. }
  715. }
  716. interface InspectedTarball {
  717. name: string
  718. version: string
  719. private: unknown
  720. manifest: Record<string, unknown>
  721. files: string[]
  722. }
  723. function inspectTarball(path: string, runner: CommandRunner): InspectedTarball {
  724. const manifest = JSON.parse(
  725. runner.capture('tar', ['-xOf', path, 'package/package.json'], dirname(path)),
  726. ) as unknown
  727. if (!isRecord(manifest)) throw new Error(`${path} contains an invalid package.json`)
  728. return {
  729. name: expectString(manifest, 'name', path),
  730. version: expectString(manifest, 'version', path),
  731. private: manifest.private,
  732. manifest,
  733. files: runner.capture('tar', ['-tf', path], dirname(path)).split(/\r?\n/),
  734. }
  735. }
  736. function packedPackage(name: string, path: string, origin: PackageOrigin): PackedPackage {
  737. const bytes = readFileSync(path)
  738. return {
  739. name,
  740. tarball: basename(path),
  741. sha256: createHash('sha256').update(bytes).digest('hex'),
  742. integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
  743. origin,
  744. }
  745. }
  746. function parsePackedPackage(value: unknown, index: number): PackedPackage {
  747. if (!isRecord(value)) throw new Error(`invalid release manifest package at index ${index}`)
  748. const context = `release manifest package at index ${index}`
  749. const name = expectString(value, 'name', context)
  750. const origin = value.origin === undefined ? 'harness' : value.origin
  751. if (origin !== 'harness' && origin !== 'vendor') {
  752. throw new Error(`invalid package origin in release manifest: ${JSON.stringify(origin)}`)
  753. }
  754. if (origin === 'harness' && (!name.startsWith('@deepseek-ai/') || name === '@deepseek-ai/dsh-root')) {
  755. throw new Error(`invalid package name in release manifest: ${name}`)
  756. }
  757. return {
  758. name,
  759. tarball: expectString(value, 'tarball', context),
  760. sha256: expectString(value, 'sha256', context),
  761. integrity: expectString(value, 'integrity', context),
  762. origin,
  763. }
  764. }
  765. function containsWorkspaceProtocol(value: unknown): boolean {
  766. if (typeof value === 'string') return value.startsWith('workspace:')
  767. if (Array.isArray(value)) return value.some(containsWorkspaceProtocol)
  768. return isRecord(value) && Object.values(value).some(containsWorkspaceProtocol)
  769. }
  770. function stageInternalDependencies(
  771. manifest: Record<string, unknown>,
  772. internalNames: ReadonlySet<string>,
  773. releaseVersion: string,
  774. context: string,
  775. ): void {
  776. for (const { dependencies, name } of internalDependencyEntries(manifest, internalNames, context)) {
  777. dependencies[name] = releaseVersion
  778. }
  779. }
  780. function validateInternalDependencyPins(
  781. manifest: Record<string, unknown>,
  782. internalNames: ReadonlySet<string>,
  783. releaseVersion: string,
  784. context: string,
  785. ): void {
  786. for (const { section, name, range } of internalDependencyEntries(manifest, internalNames, context)) {
  787. if (range !== releaseVersion) {
  788. throw new Error(
  789. `${context} has internal ${section} ${name}@${String(range)}; `
  790. + `expected exact version ${releaseVersion}`,
  791. )
  792. }
  793. }
  794. }
  795. function* internalDependencyEntries(
  796. manifest: Record<string, unknown>,
  797. internalNames: ReadonlySet<string>,
  798. context: string,
  799. ): Generator<{
  800. section: typeof DEPENDENCY_SECTIONS[number]
  801. dependencies: Record<string, unknown>
  802. name: string
  803. range: unknown
  804. }> {
  805. for (const section of DEPENDENCY_SECTIONS) {
  806. const dependencies = manifest[section]
  807. if (dependencies === undefined) continue
  808. if (!isRecord(dependencies)) throw new Error(`${context} ${section} must be an object`)
  809. for (const [name, range] of Object.entries(dependencies)) {
  810. if (!internalNames.has(name)) continue
  811. yield { section, dependencies, name, range }
  812. }
  813. }
  814. }
  815. function readObject(path: string): Record<string, unknown> {
  816. return parseObject(readFileSync(path, 'utf8'), path)
  817. }
  818. function parseObject(source: string, context: string): Record<string, unknown> {
  819. const value: unknown = JSON.parse(source)
  820. if (!isRecord(value)) throw new Error(`${context} must contain a JSON object`)
  821. return value
  822. }
  823. function isRecord(value: unknown): value is Record<string, unknown> {
  824. return value !== null && typeof value === 'object' && !Array.isArray(value)
  825. }
  826. function expectString(value: Record<string, unknown>, key: string, context: string): string {
  827. const result = value[key]
  828. if (typeof result !== 'string' || result === '') {
  829. throw new Error(`${context} must contain a non-empty ${key}`)
  830. }
  831. return result
  832. }
  833. function normalizeRegistry(value: string): string {
  834. const url = new URL(value)
  835. if (url.protocol !== 'http:' && url.protocol !== 'https:') {
  836. throw new Error(`registry must use HTTP or HTTPS: ${value}`)
  837. }
  838. return value.replace(/\/+$/, '')
  839. }
  840. function npmClientEnvironment(): NodeJS.ProcessEnv {
  841. const environment = { ...process.env }
  842. delete environment.npm_config_user_agent
  843. delete environment.NPM_CONFIG_USER_AGENT
  844. return environment
  845. }
  846. function installedArtifactEnvironment(consumerRoot: string): NodeJS.ProcessEnv {
  847. const environment = npmClientEnvironment()
  848. delete environment.NODE_OPTIONS
  849. delete environment.NODE_PATH
  850. environment.DSH_HOME = resolve(consumerRoot, '.dsh')
  851. environment.DSH_AGENTS_HOME = resolve(consumerRoot, '.agents')
  852. environment.DSH_TELEMETRY_DISABLED = '1'
  853. environment.DEEPSEEK_API_KEY = 'keyless-installed-web-no-call'
  854. environment.LANG = 'en_US.UTF-8'
  855. environment.LC_ALL = 'en_US.UTF-8'
  856. environment.LC_CTYPE = 'en_US.UTF-8'
  857. environment.TERM = 'xterm-256color'
  858. environment.COLUMNS = '100'
  859. environment.LINES = '30'
  860. delete environment.COLORTERM
  861. return environment
  862. }
  863. function assertPathWithin(root: string, path: string, label: string): void {
  864. const rootPath = realpathSync.native(root)
  865. const candidate = realpathSync.native(path)
  866. const fromRoot = relative(rootPath, candidate)
  867. if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
  868. throw new Error(`${label} resolved outside the isolated consumer: ${candidate}`)
  869. }
  870. }
  871. function validateDistTag(value: string): void {
  872. if (value === '' || /\s/.test(value)) throw new Error(`invalid dist-tag: ${JSON.stringify(value)}`)
  873. }
  874. function validateBaseVersion(value: string, context: string): void {
  875. if (!/^\d+\.\d+\.\d+$/.test(value)) {
  876. throw new Error(`${context} must have a stable X.Y.Z version, got ${value}`)
  877. }
  878. }
  879. function parseDistTagListing(raw: string, name: string): Map<string, string> {
  880. const tags = new Map<string, string>()
  881. for (const line of raw.split(/\r?\n/)) {
  882. if (line === '') continue
  883. const separator = line.indexOf(': ')
  884. if (separator <= 0 || separator + 2 === line.length) {
  885. throw new Error(`registry returned an invalid dist-tag for ${name}: ${line}`)
  886. }
  887. const tag = line.slice(0, separator)
  888. if (tags.has(tag)) throw new Error(`registry returned duplicate dist-tag ${tag} for ${name}`)
  889. tags.set(tag, line.slice(separator + 2))
  890. }
  891. return tags
  892. }
  893. async function confirmEnter(
  894. prompt: string,
  895. nonInteractiveError: string,
  896. cancellationError: string,
  897. ): Promise<void> {
  898. if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error(nonInteractiveError)
  899. const readline = createInterface({ input: process.stdin, output: process.stdout })
  900. try {
  901. const answer = await readline.question(prompt)
  902. if (answer !== '') throw new Error(cancellationError)
  903. } finally {
  904. readline.close()
  905. }
  906. }
  907. function formatUtcTimestamp(value: Date): string {
  908. if (!Number.isFinite(value.getTime())) throw new Error('pack timestamp must be a valid date')
  909. return value.toISOString().replaceAll(/[-:TZ.]/g, '').slice(0, 14)
  910. }
  911. function commandFailure(command: string, args: string[], result: CommandResult): Error {
  912. const detail = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n')
  913. return new Error(
  914. `${formatCommand(command, args)} exited with status ${result.status}${detail === '' ? '' : `\n${detail}`}`,
  915. )
  916. }
  917. function formatCommand(command: string, args: string[]): string {
  918. return [command, ...args].map(value => JSON.stringify(value)).join(' ')
  919. }
  920. function formatCopyableCommand(command: string, args: string[]): string {
  921. return [command, ...args].map(quoteShellArgument).join(' ')
  922. }
  923. function quoteShellArgument(value: string): string {
  924. if (/^[\w./:@=+-]+$/.test(value)) return value
  925. const singleQuote = String.fromCodePoint(39)
  926. const escapedSingleQuote = `${singleQuote}"${singleQuote}"${singleQuote}`
  927. return `${singleQuote}${value.replaceAll(singleQuote, escapedSingleQuote)}${singleQuote}`
  928. }
  929. function printUsage(): void {
  930. console.log(`Usage:
  931. pnpm exec tsx scripts/publish-npm-baseline.ts pack [options]
  932. pnpm exec tsx scripts/publish-npm-baseline.ts release [options] [--yes]
  933. pnpm exec tsx scripts/publish-npm-baseline.ts publish --manifest <path> [--yes]
  934. pnpm exec tsx scripts/publish-npm-baseline.ts verify --manifest <path>
  935. Pack/release options:
  936. --ref <git-ref> Git commit to stage (default: HEAD)
  937. --registry <url> npm registry (default: ${DEFAULT_REGISTRY})
  938. --output-dir <path> Artifact root (default: ${DEFAULT_OUTPUT_DIRECTORY})
  939. --yes pack/release without waiting for Enter`)
  940. }
  941. async function main(): Promise<void> {
  942. const command = process.argv[2]
  943. if (command === undefined || command === 'help' || command === '--help' || command === '-h') {
  944. printUsage()
  945. return
  946. }
  947. if (process.argv.slice(3).some(value => value === '--help' || value === '-h')) {
  948. printUsage()
  949. return
  950. }
  951. const runner = new CommandRunner()
  952. const repositoryRoot = runner.capture('git', ['rev-parse', '--show-toplevel'], process.cwd())
  953. if (command === 'pack' || command === 'release') {
  954. const { values } = parseArgs({
  955. args: process.argv.slice(3),
  956. options: {
  957. ref: { type: 'string', default: 'HEAD' },
  958. registry: { type: 'string', default: DEFAULT_REGISTRY },
  959. 'output-dir': { type: 'string', default: resolve(repositoryRoot, DEFAULT_OUTPUT_DIRECTORY) },
  960. yes: { type: 'boolean', default: false },
  961. },
  962. strict: true,
  963. })
  964. const packager = new BaselinePackager(repositoryRoot, runner)
  965. const plan = packager.plan({
  966. ref: values.ref,
  967. registry: values.registry,
  968. outputDirectory: resolve(values['output-dir']),
  969. })
  970. await plan.confirm(values.yes)
  971. const bundle = packager.pack(plan)
  972. if (command === 'release') {
  973. await new RegistryPublication(bundle, runner).publish(values.yes)
  974. }
  975. return
  976. }
  977. if (command === 'publish' || command === 'verify') {
  978. const { values } = parseArgs({
  979. args: process.argv.slice(3),
  980. options: {
  981. manifest: { type: 'string' },
  982. yes: { type: 'boolean', default: false },
  983. },
  984. strict: true,
  985. })
  986. if (values.manifest === undefined) throw new Error(`${command} requires --manifest`)
  987. if (command === 'verify' && values.yes) throw new Error('verify does not accept --yes')
  988. const bundle = ReleaseBundle.load(values.manifest, runner)
  989. const publication = new RegistryPublication(bundle, runner)
  990. if (command === 'publish') await publication.publish(values.yes)
  991. else publication.verify()
  992. return
  993. }
  994. throw new Error(`unknown command: ${command}`)
  995. }
  996. try {
  997. await main()
  998. } catch (error: unknown) {
  999. console.error(`publish-npm-baseline: ${error instanceof Error ? error.message : String(error)}`)
  1000. process.exitCode = 1
  1001. }