feat(release): add release family metadata, pack, verify, and publish

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.
This commit is contained in:
imccyu
2026-08-10 23:35:23 +08:00
parent a7c056c512
commit 8cd38945f1
5 changed files with 570 additions and 0 deletions

View File

@@ -121,6 +121,9 @@
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
"hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links",
"publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts",
"release:verify": "tsx scripts/release/verify.ts",
"release:pack": "tsx scripts/release/pack.ts",
"release:publish": "tsx scripts/release/publish.ts",
"dsh": "node --import tsx/esm apps/cli/src/bin.ts",
"demo:headless": "node --import tsx/esm apps/cli/src/bin.ts --profile headless",
"demo:code-mode": "node scripts/demo-code-mode.mjs",

282
scripts/release/families.ts Normal file
View File

@@ -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`
}

86
scripts/release/pack.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* Pack one release family's whole publish set into a single directory, in
* publish order, and record that order for the publish step.
*
* The pack step is the release boundary: it runs without credentials, produces
* every tarball from one commit, and hands the publish step exactly those bytes
* ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)).
*/
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts'
/** Where pack output lands when `--out` is omitted. */
const DEFAULT_OUTPUT = 'dist/npm'
/** Name of the file the publish step reads to learn the upload order. */
export const PUBLISH_ORDER_FILE = 'publish-order.txt'
/**
* Run a command, inheriting stdio, and fail the process on a non-zero exit.
* @param command - executable name.
* @param args - command arguments.
*/
function run(command: string, args: readonly string[]): void {
const result = spawnSync(command, [...args], { stdio: 'inherit' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
}
/**
* List a tarball's members.
* @param tarball - absolute tarball path.
* @returns Every path inside the archive.
*/
function tarballFiles(tarball: string): string[] {
const result = spawnSync('tar', ['-tzf', tarball], { encoding: 'utf8' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(`tar -tzf ${tarball} exited with ${String(result.status)}:\n${result.stderr}`)
return result.stdout.split('\n').filter(line => line !== '')
}
/**
* Pack one member and check what its tarball carries.
* @param family - the release family being packed.
* @param member - the member to pack.
* @param destination - absolute output directory.
* @returns The tarball filename.
*/
function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string {
run('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination])
const filename = tarballName(member)
const tarball = join(destination, filename)
if (!existsSync(tarball)) throw new Error(`${member.name} produced no tarball at ${tarball}`)
family.validatePayload(member, tarballFiles(tarball))
return filename
}
/** Pack the family named by `--family` into `--out`. */
function main(): void {
const { values } = parseArgs({
options: { family: { type: 'string' }, out: { type: 'string' } },
allowPositionals: false,
})
if (values.family === undefined) throw new Error('usage: pack.ts --family <dsh|vendor> [--out dist/npm]')
const family = releaseFamily(values.family)
const root = process.cwd()
const destination = resolve(root, values.out ?? DEFAULT_OUTPUT)
const members = family.publishOrder(family.members(root))
family.verifyVersions(members)
rmSync(destination, { recursive: true, force: true })
mkdirSync(destination, { recursive: true })
const order: string[] = []
for (const member of members) order.push(packMember(family, member, destination))
writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`)
console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`)
}
main()

130
scripts/release/publish.ts Normal file
View File

@@ -0,0 +1,130 @@
/**
* Publish one packed release family from the tarballs the pack step produced.
*
* Publication is decided per package against the registry, never from a list of
* "what this release includes": a version the registry lacks is published, a
* version whose published tarball has the same integrity is skipped, and a
* version whose published tarball differs fails the run — that last case means
* the content changed without a version bump
* ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)).
*
* Skipping on identical integrity is what makes re-running the publish step over
* the same artifact safe.
*/
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { releaseFamily } from './families.ts'
import { PUBLISH_ORDER_FILE } from './pack.ts'
/** npm access level for every package this repository publishes. */
const ACCESS = 'restricted'
/** What the registry knows about one version. */
type RegistryState =
| { readonly kind: 'absent' }
| { readonly kind: 'present'; readonly integrity: string }
/**
* Read a packed tarball's own manifest.
* @param tarball - absolute tarball path.
* @returns The packed `package.json` name and version.
*/
function packedIdentity(tarball: string): { name: string; version: string } {
const result = spawnSync('tar', ['-xOzf', tarball, 'package/package.json'], { encoding: 'utf8' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(`cannot read ${tarball}:\n${result.stderr}`)
const manifest: unknown = JSON.parse(result.stdout)
if (manifest === null || typeof manifest !== 'object') throw new Error(`${tarball} has no manifest`)
const { name, version } = manifest as Record<string, unknown>
if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`)
return { name, version }
}
/**
* The subresource integrity string npm records for a tarball.
* @param tarball - absolute tarball path.
* @returns A `sha512-<base64>` string.
*/
function integrityOf(tarball: string): string {
return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`
}
/**
* Ask the registry whether a version exists, and with what integrity.
* @param name - package name.
* @param version - package version.
* @returns The registry state for that version.
*/
function registryState(name: string, version: string): RegistryState {
const result = spawnSync('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'], { encoding: 'utf8' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) {
const output = `${result.stdout}${result.stderr}`
if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' }
throw new Error(`npm view ${name}@${version} failed:\n${output}`)
}
const parsed: unknown = JSON.parse(result.stdout)
if (typeof parsed !== 'string' || parsed === '') {
throw new Error(`registry reported no dist.integrity for ${name}@${version}`)
}
return { kind: 'present', integrity: parsed }
}
/**
* Publish one tarball.
* @param tarball - absolute tarball path.
* @param version - the version being published; a prerelease never takes `latest`.
*/
function publish(tarball: string, version: string): void {
const args = ['publish', tarball, '--access', ACCESS]
if (version.includes('-')) args.push('--tag', 'next')
const result = spawnSync('npm', args, { stdio: 'inherit' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(`npm publish ${tarball} exited with ${String(result.status)}`)
}
/** Publish the family named by `--family` from the directory named by `--from`. */
function main(): void {
const { values } = parseArgs({
options: { family: { type: 'string' }, from: { type: 'string' } },
allowPositionals: false,
})
if (values.family === undefined || values.from === undefined) {
throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory>')
}
const family = releaseFamily(values.family)
const directory = resolve(process.cwd(), values.from)
const order = readFileSync(join(directory, PUBLISH_ORDER_FILE), 'utf8').split('\n').filter(line => line !== '')
let published = 0
let skipped = 0
for (const filename of order) {
const tarball = join(directory, filename)
const { name, version } = packedIdentity(tarball)
const state = registryState(name, version)
if (state.kind === 'present') {
const local = integrityOf(tarball)
if (state.integrity !== local) {
throw new Error(
`${name}@${version} is already published with different content`
+ `\n registry: ${state.integrity}\n packed: ${local}`
+ '\nBump the version, or investigate why the build is not reproducible.',
)
}
console.log(`release publish: ${name}@${version} already published, skipping`)
skipped += 1
continue
}
publish(tarball, version)
published += 1
}
console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
}
main()

69
scripts/release/verify.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* Verify a release family's version baseline, and — when publishing — that the
* run comes from the family's tag and its members are publishable.
*
* Publication happens only from GitHub Actions, so the tag and publishability
* checks are gates on the workflow, not advisory local warnings
* ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)).
*/
import { parseArgs } from 'node:util'
import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
/**
* Assert every member may be published: npm refuses a `private` package.
* @param members - the family's members.
*/
function verifyPublishable(members: readonly ReleaseMember[]): void {
const priv = members.filter(member => member.manifest.private === true)
if (priv.length > 0) {
throw new Error(`publishing requires removing "private": true from:\n${priv.map(member => member.directory).join('\n')}`)
}
}
/**
* Assert the workflow runs from a tag this family publishes from, and that the
* tag names a version the family actually carries.
* @param family - the release family.
* @param members - the family's members.
* @param ref - the `GITHUB_REF` value.
*/
function verifyTag(family: ReleaseFamily, members: readonly ReleaseMember[], ref: string): void {
const prefix = 'refs/tags/'
if (!ref.startsWith(prefix)) {
throw new Error(`publishing release family ${family.id} requires running from a ${family.tagPrefix}* tag, got ${ref || '(no ref)'}`)
}
const tag = ref.slice(prefix.length)
if (!tag.startsWith(family.tagPrefix)) {
throw new Error(`tag ${tag} does not belong to release family ${family.id} (expected ${family.tagPrefix}*)`)
}
const expected = members.map(member => family.tagFor(member))
if (!expected.includes(tag)) {
throw new Error(`tag ${tag} names no version this family carries; its members would tag as:\n${[...new Set(expected)].join('\n')}`)
}
}
/** Run the verification for the family named by `--family`. */
function main(): void {
const { values } = parseArgs({
options: { family: { type: 'string' } },
allowPositionals: false,
})
if (values.family === undefined) throw new Error('usage: verify.ts --family <dsh|vendor>')
const family = releaseFamily(values.family)
const members = family.members(process.cwd())
family.verifyVersions(members)
const publishing = process.env.RELEASE_PUBLISH === 'true'
if (publishing) {
verifyPublishable(members)
verifyTag(family, members, process.env.GITHUB_REF ?? '')
}
const versions = [...new Set(members.map(member => member.version))]
const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions`
console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`)
}
main()