|
|
@@ -0,0 +1,282 @@
|
|
|
+/**
|
|
|
+ * 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`
|
|
|
+}
|