A release family owns its member discovery, version baseline, tag naming, and packed-payload rule; the dsh family shares one version across packages/ and apps/, while every vendor/ package keeps its own version line. Publish order is topological over runtime dependencies so no package reaches the registry before one it depends on. pack packs the whole family into one directory and records the upload order; publish decides per package against the registry, skipping a version whose published tarball has the same integrity and failing when it differs, which is what makes re-running publish over one artifact safe. The vendored packages keep upstream's payload: their manifests export ./src/*, so the harness rule that rejects sources and declaration maps would publish an export map pointing at absent files.
283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
/**
|
|
* The three independent publish sequences this repository releases from
|
|
* (`packages/` + `apps/`, `vendor/`, and `native/`) and the two this module
|
|
* owns: `dsh` and `vendor`. Each family carries its own version baseline, tag
|
|
* naming, and publish set, so releasing one never republishes another
|
|
* ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)).
|
|
*
|
|
* The family dimension lives here only. A new sequence adds a subclass and a
|
|
* `releaseFamilies()` entry; nothing else in the release scripts branches on it.
|
|
*/
|
|
|
|
import { globSync, readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { hasTypeRTRemoteNavigation, validateTarballPayload } from '../publication-payload.ts'
|
|
|
|
/** Dependency sections that constrain publish order: a consumer must publish after its dependency. */
|
|
const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const
|
|
|
|
/** The workspace root manifest, which is never a release member. */
|
|
const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root'
|
|
|
|
/** One publishable package of a release family. */
|
|
export interface ReleaseMember {
|
|
/** Repository-relative package directory, for example `packages/core/session`. */
|
|
readonly directory: string
|
|
/** Package name from its manifest. */
|
|
readonly name: string
|
|
/** Package version from its manifest. */
|
|
readonly version: string
|
|
/** The parsed manifest, for payload policy and publication checks. */
|
|
readonly manifest: Readonly<Record<string, unknown>>
|
|
}
|
|
|
|
/**
|
|
* Read and parse a JSON file.
|
|
* @param path - absolute file path.
|
|
* @returns The parsed object.
|
|
*/
|
|
function readManifest(path: string): Record<string, unknown> {
|
|
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error(`${path} is not a JSON object`)
|
|
}
|
|
return parsed as Record<string, unknown>
|
|
}
|
|
|
|
/**
|
|
* Read a required string field.
|
|
* @param manifest - parsed manifest.
|
|
* @param field - field name.
|
|
* @param context - manifest path for the error message.
|
|
* @returns The field value.
|
|
*/
|
|
function requireString(manifest: Record<string, unknown>, field: string, context: string): string {
|
|
const value = manifest[field]
|
|
if (typeof value !== 'string' || value === '') throw new Error(`${context} must declare a string ${field}`)
|
|
return value
|
|
}
|
|
|
|
/** A release sequence: its members, its version baseline, and its tag naming. */
|
|
export abstract class ReleaseFamily {
|
|
/** Workflow-facing identifier, also the `--family` argument. */
|
|
abstract readonly id: string
|
|
|
|
/** Glob patterns, relative to the repository root, that select this family's manifests. */
|
|
abstract readonly patterns: readonly string[]
|
|
|
|
/** Git tag prefix this family publishes from. */
|
|
abstract readonly tagPrefix: string
|
|
|
|
/**
|
|
* Discover this family's members.
|
|
* @param root - repository root.
|
|
* @returns Members sorted by directory, with names validated and deduplicated.
|
|
*/
|
|
members(root: string): ReleaseMember[] {
|
|
const manifestPaths = globSync([...this.patterns], { cwd: root }).sort()
|
|
if (manifestPaths.length === 0) throw new Error(`release family ${this.id} matched no manifests`)
|
|
|
|
const members: ReleaseMember[] = []
|
|
const seen = new Set<string>()
|
|
for (const manifestPath of manifestPaths) {
|
|
const normalized = manifestPath.replaceAll('\\', '/')
|
|
const manifest = readManifest(resolve(root, manifestPath))
|
|
const name = requireString(manifest, 'name', normalized)
|
|
const version = requireString(manifest, 'version', normalized)
|
|
if (name === WORKSPACE_ROOT_PACKAGE) throw new Error(`${normalized} selected the workspace root`)
|
|
if (!name.startsWith('@deepseek-ai/')) throw new Error(`${normalized} must name an @deepseek-ai package`)
|
|
if (seen.has(name)) throw new Error(`${name} appears twice in release family ${this.id}`)
|
|
seen.add(name)
|
|
members.push({
|
|
directory: normalized.slice(0, normalized.length - '/package.json'.length),
|
|
name,
|
|
version,
|
|
manifest,
|
|
})
|
|
}
|
|
return members
|
|
}
|
|
|
|
/**
|
|
* Order members so every package publishes after the family members it depends on.
|
|
* @param members - this family's members.
|
|
* @returns The same members in publish order; ties break by name for determinism.
|
|
*/
|
|
publishOrder(members: readonly ReleaseMember[]): ReleaseMember[] {
|
|
const byName = new Map(members.map(member => [member.name, member]))
|
|
const ordered: ReleaseMember[] = []
|
|
const placed = new Set<string>()
|
|
const visiting = new Set<string>()
|
|
|
|
const visit = (member: ReleaseMember, path: readonly string[]): void => {
|
|
if (placed.has(member.name)) return
|
|
if (visiting.has(member.name)) {
|
|
throw new Error(`dependency cycle in release family ${this.id}: ${[...path, member.name].join(' -> ')}`)
|
|
}
|
|
visiting.add(member.name)
|
|
for (const dependency of this.orderEdges(member, byName)) {
|
|
visit(dependency, [...path, member.name])
|
|
}
|
|
visiting.delete(member.name)
|
|
placed.add(member.name)
|
|
ordered.push(member)
|
|
}
|
|
|
|
for (const member of [...members].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
visit(member, [])
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
/**
|
|
* The family members one member depends on at runtime.
|
|
* @param member - the dependent member.
|
|
* @param byName - every family member by package name.
|
|
* @returns Dependencies inside this family, sorted by name.
|
|
*/
|
|
private orderEdges(member: ReleaseMember, byName: ReadonlyMap<string, ReleaseMember>): ReleaseMember[] {
|
|
const edges: ReleaseMember[] = []
|
|
for (const section of ORDER_SECTIONS) {
|
|
const dependencies = member.manifest[section]
|
|
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue
|
|
for (const name of Object.keys(dependencies)) {
|
|
const dependency = byName.get(name)
|
|
if (dependency !== undefined && dependency.name !== member.name) edges.push(dependency)
|
|
}
|
|
}
|
|
return edges.sort((left, right) => left.name.localeCompare(right.name))
|
|
}
|
|
|
|
/**
|
|
* Assert this family's version baseline holds across its members.
|
|
* @param members - this family's members.
|
|
*/
|
|
abstract verifyVersions(members: readonly ReleaseMember[]): void
|
|
|
|
/**
|
|
* The tag a member publishes from.
|
|
* @param member - the member being published.
|
|
* @returns The full tag name, without `refs/tags/`.
|
|
*/
|
|
abstract tagFor(member: ReleaseMember): string
|
|
|
|
/**
|
|
* Check what a member's packed tarball carries.
|
|
* @param member - the packed member.
|
|
* @param files - every path inside its tarball.
|
|
*/
|
|
abstract validatePayload(member: ReleaseMember, files: readonly string[]): void
|
|
}
|
|
|
|
/** `packages/*` and `apps/*`: one shared version across the whole family. */
|
|
class DshFamily extends ReleaseFamily {
|
|
readonly id = 'dsh'
|
|
readonly patterns = ['packages/*/*/package.json', 'apps/*/package.json'] as const
|
|
readonly tagPrefix = 'dsh-v'
|
|
|
|
/**
|
|
* Require one version across the family, the way a single tag can name it.
|
|
* @param members - this family's members.
|
|
*/
|
|
verifyVersions(members: readonly ReleaseMember[]): void {
|
|
const versions = new Set(members.map(member => member.version))
|
|
if (versions.size !== 1) {
|
|
const detail = members.map(member => `${member.directory}: ${member.version}`).join('\n')
|
|
throw new Error(`dsh release members must share one version:\n${detail}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The single family tag.
|
|
* @param member - any family member; all carry the same version.
|
|
* @returns `dsh-v<version>`.
|
|
*/
|
|
tagFor(member: ReleaseMember): string {
|
|
return `${this.tagPrefix}${member.version}`
|
|
}
|
|
|
|
/**
|
|
* Reject source and declaration-map members, the repository's publication policy.
|
|
* @param member - the packed member.
|
|
* @param files - every path inside its tarball.
|
|
*/
|
|
validatePayload(member: ReleaseMember, files: readonly string[]): void {
|
|
validateTarballPayload(files, member.name, {
|
|
typeRTRemoteNavigation: hasTypeRTRemoteNavigation(member.manifest),
|
|
})
|
|
}
|
|
}
|
|
|
|
/** `vendor/*`: every package keeps its own version line, so every package has its own tag. */
|
|
class VendorFamily extends ReleaseFamily {
|
|
readonly id = 'vendor'
|
|
readonly patterns = ['vendor/*/package.json'] as const
|
|
readonly tagPrefix = 'vendor-'
|
|
|
|
/**
|
|
* Accept independent versions; only reject a version this repository cannot publish.
|
|
* @param members - this family's members.
|
|
*/
|
|
verifyVersions(members: readonly ReleaseMember[]): void {
|
|
for (const member of members) {
|
|
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(member.version)) {
|
|
throw new Error(`${member.directory} has an unpublishable version: ${member.version}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The member's own tag, because one vendor release can carry several versions.
|
|
* @param member - the member being published.
|
|
* @returns `vendor-<unscoped name>-v<version>`.
|
|
*/
|
|
tagFor(member: ReleaseMember): string {
|
|
return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v${member.version}`
|
|
}
|
|
|
|
/**
|
|
* Require the payload the vendored manifest declares, including upstream's
|
|
* `src` tree and declaration maps.
|
|
*
|
|
* The harness policy that rejects both does not apply here: these manifests
|
|
* export `./src/*` for source navigation, so dropping `src` would publish a
|
|
* package whose export map points at absent files. What must hold instead is
|
|
* that every path the manifest selects is present, which `files` already
|
|
* decides and `pnpm pack` already enforces.
|
|
* @param member - the packed member.
|
|
* @param files - every path inside its tarball.
|
|
*/
|
|
validatePayload(member: ReleaseMember, files: readonly string[]): void {
|
|
if (files.length === 0) throw new Error(`${member.name} packed an empty tarball`)
|
|
}
|
|
}
|
|
|
|
/** Every release family this module owns, in workflow order. */
|
|
export function releaseFamilies(): readonly ReleaseFamily[] {
|
|
return [new DshFamily(), new VendorFamily()]
|
|
}
|
|
|
|
/**
|
|
* Resolve a family by its `--family` identifier.
|
|
* @param id - family identifier.
|
|
* @returns The family.
|
|
*/
|
|
export function releaseFamily(id: string): ReleaseFamily {
|
|
const family = releaseFamilies().find(candidate => candidate.id === id)
|
|
if (family === undefined) {
|
|
const known = releaseFamilies().map(candidate => candidate.id).join(', ')
|
|
throw new Error(`unknown release family ${id}; expected one of ${known}`)
|
|
}
|
|
return family
|
|
}
|
|
|
|
/**
|
|
* The npm tarball filename `pnpm pack` writes for a member.
|
|
* @param member - the packed member.
|
|
* @returns The tarball filename.
|
|
*/
|
|
export function tarballName(member: ReleaseMember): string {
|
|
const unscoped = member.name.startsWith('@') ? member.name.slice(1).replace('/', '-') : member.name
|
|
return `${unscoped}-${member.version}.tgz`
|
|
}
|