Files
deepseek-harness/scripts/release/families.ts
T
imccyu d9dcf5a484 fix(release): close the review findings on the release sequences
The root manifest carries the dsh family version. bump writes it with the
members, because the workspace constraint requires them to match, and that
constraint now accepts a prerelease segment: without both, release:dsh 0.0.2
left the root behind and 0.0.1-rc.1 could satisfy neither check.

The Landlock workflow no longer passes --access public, which overrode the
restricted publishConfig this repository just adopted for those packages.

Vendored change detection reads build inputs when a package publishes build
output, and vendor/cordis publishes the src its export map already pointed at:
its lib/ is untracked, so a real source edit read as 'nothing changed' and the
next publish would fail on a version whose bytes moved. The next version also
takes the last published version as its baseline, so a re-sync that restores a
lower upstream version cannot recompute a version already on the registry, and
bump confirms the registry carries what the newest tag names.

Tag prefixes are constructed rather than recovered from a full tag, which a
hyphenated version defeated. Pack runs group per ref so concurrent pull requests
stop displacing each other, the publish job carries the global group, and the
unused id-token permission is gone.

Every release script sits behind an entry guard, which is what lets the pure
judgements carry tests: tag naming, publish order and cycle reporting, version
arithmetic, payload policy, and the change judgement.

The Agent Note moves to implemented and states what shipped: one probe command,
the registry confirmation that now exists, and byte reproducibility recorded as
assumed rather than measured.
2026-08-11 01:26:36 +08:00

311 lines
12 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 { 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
}
/** 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.
* @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 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, {
typeRTRemoteNavigation: hasTypeRTRemoteNavigation(member.manifest),
})
}
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`
}