publish-npm-baseline.ts 37 KB

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