Publish order exists to make a partial publication self-consistent: an interrupted run should leave a prefix whose packages never point at a version absent from the registry. It read only dependencies and optionalDependencies, so peer declarations — how sibling harness packages reference each other, 1088 edges in the dsh family — constrained nothing. Peer edges now order the publication too. devDependencies still do not: a dev dependency is absent from the published package. Peers cannot constrain it absolutely. Sibling packages declare each other as peers, which is what closes the two cycles here, and npm treats an unmet peer as a warning rather than a resolution failure. Install edges therefore win: a peer edge is dropped where the peer installs the member declaring it, or where following it would revisit a member already being visited. One peer edge is dropped in the dsh family and two in the vendored family; every install edge is honoured. A cycle among install edges stays a defect rather than something to order around, and release:verify now reports it before the build instead of letting it surface once pack is already writing tarballs. Install-edge acyclicity is checked on its own graph, because a peer edge leading into an install edge otherwise reads as a cycle where the install edges are perfectly orderable.
370 lines
15 KiB
TypeScript
370 lines
15 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/implemented/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 { validateTarballPayload } from '../publication-payload.ts'
|
|
|
|
/**
|
|
* Dependency sections a consumer must publish after, because npm resolves them
|
|
* when the package is installed: publishing a consumer first would leave a
|
|
* window where its own tree cannot be assembled.
|
|
*/
|
|
const INSTALL_SECTIONS = ['dependencies', 'optionalDependencies'] as const
|
|
|
|
/**
|
|
* Peer declarations also order the publication, but they cannot constrain it.
|
|
* npm never installs a peer on the package's behalf — an unmet peer is a
|
|
* warning, not a resolution failure — and sibling packages legitimately declare
|
|
* each other as peers, which makes these edges the ones that close cycles. They
|
|
* order what they can and are dropped where they would deadlock.
|
|
*/
|
|
const PEER_SECTIONS = ['peerDependencies'] 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
|
|
}
|
|
|
|
/** The executable a family's installed artifacts are driven through. */
|
|
export interface InstalledEntry {
|
|
/** Package that carries the executable. */
|
|
readonly packageName: string
|
|
/** Path to the executable inside that package. */
|
|
readonly binPath: string
|
|
}
|
|
|
|
/** 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, which is what makes a partial publication self-consistent: an
|
|
* interrupted run leaves a prefix whose packages never point at something
|
|
* absent from the registry.
|
|
*
|
|
* Install edges are honoured absolutely — a cycle among them is a defect this
|
|
* reports rather than works around. Peer edges order what they can and are
|
|
* dropped where honouring one would deadlock: sibling packages declare each
|
|
* other as peers, and npm treats an unmet peer as a warning rather than a
|
|
* resolution failure ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
|
* @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 byNameSorted = [...members].sort((left, right) => left.name.localeCompare(right.name))
|
|
const edges = (member: ReleaseMember, sections: readonly string[]): ReleaseMember[] =>
|
|
this.orderEdges(member, byName, sections)
|
|
|
|
// Install edges alone must be acyclic, and that is checked on its own graph:
|
|
// a peer edge leading into an install edge would otherwise read as a cycle
|
|
// where the install edges are perfectly orderable.
|
|
const installVisiting = new Set<string>()
|
|
const installDone = new Set<string>()
|
|
const checkInstall = (member: ReleaseMember, path: readonly string[]): void => {
|
|
if (installDone.has(member.name)) return
|
|
if (installVisiting.has(member.name)) {
|
|
throw new Error(`dependency cycle in release family ${this.id}: ${[...path, member.name].join(' -> ')}`)
|
|
}
|
|
installVisiting.add(member.name)
|
|
for (const dependency of edges(member, INSTALL_SECTIONS)) checkInstall(dependency, [...path, member.name])
|
|
installVisiting.delete(member.name)
|
|
installDone.add(member.name)
|
|
}
|
|
for (const member of byNameSorted) checkInstall(member, [])
|
|
|
|
// Emit the order over both kinds of edge. A node already on the stack is a
|
|
// cycle only peer edges can form, and skipping it drops just that edge.
|
|
const ordered: ReleaseMember[] = []
|
|
const placed = new Set<string>()
|
|
const onStack = new Set<string>()
|
|
// Members reachable from one member through install edges. A peer edge is
|
|
// dropped when the peer installs the member declaring it: honouring it would
|
|
// emit a package before something it installs, and the install edge wins.
|
|
const installClosure = (member: ReleaseMember): Set<string> => {
|
|
const reached = new Set<string>()
|
|
const walk = (current: ReleaseMember): void => {
|
|
for (const dependency of edges(current, INSTALL_SECTIONS)) {
|
|
if (reached.has(dependency.name)) continue
|
|
reached.add(dependency.name)
|
|
walk(dependency)
|
|
}
|
|
}
|
|
walk(member)
|
|
return reached
|
|
}
|
|
const visit = (member: ReleaseMember): void => {
|
|
if (placed.has(member.name) || onStack.has(member.name)) return
|
|
onStack.add(member.name)
|
|
for (const dependency of edges(member, INSTALL_SECTIONS)) visit(dependency)
|
|
for (const peer of edges(member, PEER_SECTIONS)) {
|
|
if (installClosure(peer).has(member.name)) continue
|
|
visit(peer)
|
|
}
|
|
onStack.delete(member.name)
|
|
if (placed.has(member.name)) return
|
|
placed.add(member.name)
|
|
ordered.push(member)
|
|
}
|
|
for (const member of byNameSorted) visit(member)
|
|
return ordered
|
|
}
|
|
|
|
/**
|
|
* The family members one member declares in the given sections.
|
|
* @param member - the dependent member.
|
|
* @param byName - every family member by package name.
|
|
* @param sections - manifest sections to read.
|
|
* @returns Members of this family named there, sorted by name.
|
|
*/
|
|
private orderEdges(
|
|
member: ReleaseMember,
|
|
byName: ReadonlyMap<string, ReleaseMember>,
|
|
sections: readonly string[],
|
|
): ReleaseMember[] {
|
|
const edges: ReleaseMember[] = []
|
|
for (const section of 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 prefix a member's versions are tagged under. Every tag for that
|
|
* member starts with it, which is how the last published version is found.
|
|
* @param member - the member being published.
|
|
* @returns The prefix, ending in `-v`.
|
|
*/
|
|
abstract tagPrefixFor(member: ReleaseMember): string
|
|
|
|
/**
|
|
* The tag a member publishes from.
|
|
* @param member - the member being published.
|
|
* @returns The full tag name, without `refs/tags/`.
|
|
*/
|
|
tagFor(member: ReleaseMember): string {
|
|
return `${this.tagPrefixFor(member)}${member.version}`
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
|
|
/**
|
|
* The executable that proves this family's artifacts install and run, or
|
|
* `undefined` for a family that publishes no executable.
|
|
*/
|
|
abstract readonly installedEntry: InstalledEntry | undefined
|
|
}
|
|
|
|
/** `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 prefix: every member shares one version, so one tag names it.
|
|
* @returns `dsh-v`.
|
|
*/
|
|
tagPrefixFor(): string {
|
|
return this.tagPrefix
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
readonly installedEntry = { packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' }
|
|
}
|
|
|
|
/** `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}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A prefix per member, because one vendor release can carry several versions.
|
|
* @param member - the member being published.
|
|
* @returns `vendor-<unscoped name>-v`.
|
|
*/
|
|
tagPrefixFor(member: ReleaseMember): string {
|
|
return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v`
|
|
}
|
|
|
|
/**
|
|
* 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`)
|
|
}
|
|
|
|
/** No installed-entry probe: these are libraries a consumer imports, with no executable. */
|
|
readonly installedEntry = undefined
|
|
}
|
|
|
|
/** Every release family this module owns, in workflow order. */
|
|
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`
|
|
}
|