Merge origin/master into worktree/web-plugin-config

Master moved every workspace edge to workspace:^ and added release-member
fields; this branch's manifests follow, keeping only the dependency edges it
contributes. The client-runtime README keeps this branch's paragraph: master
did not touch it, and the base/user layers and `unset` it describes are what
this branch added.
This commit is contained in:
Yichen Jiang
2026-08-11 11:16:29 +08:00
348 changed files with 9479 additions and 5517 deletions
+31 -7
View File
@@ -43,6 +43,8 @@ def main() -> None:
args = parser.parse_args()
version = repository_version()
validate_release_tag(args.tag, version)
# Wheels carry the PEP 440 spelling; the tag keeps the repository spelling.
wheel_version = pep440_version(version)
if args.package == "runtime" and (args.platform is None or args.runtime_exe is None):
parser.error("runtime builds require --platform and --runtime-exe")
if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None):
@@ -53,19 +55,19 @@ def main() -> None:
with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary:
staging = Path(temporary) / args.package
if args.package == "sdk":
stage_sdk(staging, version)
stage_sdk(staging, wheel_version)
environment = None
expected = output_dir / f"deepseek_harness_sdk-{version}-py3-none-any.whl"
expected = output_dir / f"deepseek_harness_sdk-{wheel_version}-py3-none-any.whl"
else:
platform_tag, executable_name = PLATFORMS[args.platform]
stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name)
stage_runtime(staging, wheel_version, args.runtime_exe.resolve(), executable_name)
environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag}
expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl"
expected = output_dir / f"deepseek_harness_runtime_bin-{wheel_version}-py3-none-{platform_tag}.whl"
command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)]
subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True)
if not expected.is_file():
raise RuntimeError(f"build did not produce expected wheel: {expected}")
verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform])
verify_wheel(expected, args.package, wheel_version, None if args.platform is None else PLATFORMS[args.platform])
print(expected)
@@ -76,13 +78,35 @@ def repository_version(root: Path = ROOT) -> str:
except (OSError, json.JSONDecodeError) as error:
raise ValueError(f"could not read repository version from {package_json}") from error
version = payload.get("version") if isinstance(payload, dict) else None
if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+", version) is None:
if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?", version) is None:
raise ValueError(
f"{package_json} version must be stable X.Y.Z, got {version!r}"
f"{package_json} version must be X.Y.Z with an optional prerelease segment, got {version!r}"
)
return version
def pep440_version(version: str) -> str:
"""The Python spelling of a repository version.
A release candidate is `0.0.1-rc.1` in the repository and `0.0.1rc1` under
PEP 440. Build backends normalize to the latter, so the wheel filename and
metadata carry it: comparing them against the repository spelling would
reject every prerelease build.
"""
stable, separator, prerelease = version.partition("-")
if not separator:
return stable
match = re.fullmatch(r"(a|b|c|rc|alpha|beta|pre|preview)\.?(\d+)", prerelease)
if match is None:
raise ValueError(
f"prerelease segment {prerelease!r} has no PEP 440 spelling; use rc.N, alpha.N, or beta.N"
)
identifier = {"alpha": "a", "beta": "b", "c": "rc", "pre": "rc", "preview": "rc"}.get(
match.group(1), match.group(1)
)
return f"{stable}{identifier}{match.group(2)}"
def validate_release_tag(tag: str | None, version: str) -> None:
if tag is None:
return
+61 -5
View File
@@ -41,6 +41,14 @@ const publicationSourceAllowlist: Readonly<Record<string, readonly string[]>> =
'@deepseek-ai/node-addon-landlock-run': ['src/main.c'],
}
const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git'
/**
* Source home the published packages point consumers at. It differs from
* {@link repositoryUrl}, which the Landlock packages keep because npm resolves
* their trusted publishing against the repository that runs the workflow.
*/
const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git'
/** Directories whose packages this repository publishes: one release member each. */
const releaseMemberDirectory = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/
const localArtifactDirs = new Set(['node_modules'])
const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
@@ -72,6 +80,8 @@ interface PackageManifest {
repository?: { type?: string; url?: string; directory?: string }
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
dependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
}
/** One workspace manifest and its repo-relative path. */
@@ -232,8 +242,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
if (manifest.private === true) {
errors.push(`${label}: published Landlock package must not set "private": true`)
}
if (manifest.publishConfig?.access !== 'public') {
errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`)
if (manifest.publishConfig?.access !== 'restricted') {
errors.push(`${label}: published Landlock package must set publishConfig.access to "restricted"`)
}
const expectedDirectory = dir
if (manifest.repository?.type !== 'git'
@@ -241,6 +251,21 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|| manifest.repository.directory !== expectedDirectory) {
errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`)
}
} else if (releaseMemberDirectory.test(dir)) {
// Release members state that they are publishable: npm refuses a private
// package, the scope is published privately, and the repository field is
// how a consumer of a private package finds its source.
if (manifest.private === true) {
errors.push(`${label}: release member must not set "private": true`)
}
if (manifest.publishConfig?.access !== 'restricted') {
errors.push(`${label}: release member must set publishConfig.access to "restricted"`)
}
if (manifest.repository?.type !== 'git'
|| manifest.repository.url !== publishedRepositoryUrl
|| manifest.repository.directory !== dir) {
errors.push(`${label}: release member repository must use ${publishedRepositoryUrl} with directory ${dir}`)
}
} else if (manifest.private !== true) {
errors.push(`${label}: package.json must set "private": true`)
}
@@ -353,13 +378,44 @@ function checkHierarchyShape(): string[] {
}
function checkRepositoryVersion(): string[] {
if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return []
return ['package.json: version must be stable X.Y.Z']
// The root carries the dsh release family's version, so a prerelease such as
// 0.0.1-rc.1 is a valid state between `release:dsh` and its publication.
if (repositoryVersion && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(repositoryVersion)) return []
return ['package.json: version must be X.Y.Z with an optional prerelease segment']
}
/** Dependency sections whose ranges reach a published tarball or a local install. */
const dependencySections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const
/**
* Require the `workspace:` protocol for every reference to a workspace member.
*
* A hand-written range says nothing about the version the workspace actually
* carries, and `pnpm pack` leaves it alone: `^0.0.1` published from version
* `0.0.2` names a version that does not exist. The protocol makes pack
* substitute the member's real version, so no release step rewrites ranges.
* @param manifests - every workspace manifest.
* @returns One error per reference that names a workspace member without the protocol.
*/
function checkWorkspaceProtocol(manifests: readonly WorkspaceManifest[]): string[] {
const members = new Set(manifests.map(entry => entry.manifest.name).filter(name => name !== undefined))
const errors: string[] = []
for (const { dir, manifest } of manifests) {
for (const section of dependencySections) {
for (const [name, range] of Object.entries(manifest[section] ?? {})) {
if (!members.has(name) || range.startsWith('workspace:')) continue
errors.push(`${manifest.name ?? dir}: ${section}.${name} must use the workspace: protocol, got ${range}`)
}
}
}
return errors
}
const manifests = workspaceManifests()
const errors = [
...checkRepositoryVersion(),
...workspaceManifests().flatMap(checkWorkspace),
...manifests.flatMap(checkWorkspace),
...checkWorkspaceProtocol(manifests),
...checkHierarchyShape(),
...collectProjectReferenceFaceViolations(root),
]
-1
View File
@@ -132,7 +132,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',
+1 -1
View File
@@ -49,7 +49,7 @@ function fixture(options: {
},
files: ['lib/index.js', 'lib/invariant.js'],
peerDependencies: options.invariantDependency === false ? {} : {
'@deepseek-ai/dsh-invariants': '^0.0.1',
'@deepseek-ai/dsh-invariants': 'workspace:^',
},
devDependencies: options.invariantDependency === false ? {} : {
'@deepseek-ai/dsh-invariants': 'workspace:^',
+2 -2
View File
@@ -96,11 +96,11 @@ function checkManifest(
addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
}
if (owner.packageName === '@deepseek-ai/dsh-invariants') return
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') {
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
addViolation(
violations,
owner.manifestPath,
'@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency',
'@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency',
)
}
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
+398
View File
@@ -0,0 +1,398 @@
/**
* Bump one release family's version and commit it, so the published version is
* readable from the repository rather than derived inside CI
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
*
* The dsh family shares one version across its members and the workspace root:
* `major`, `minor`, `patch`, or an explicit `x.y.z` (including a prerelease such
* as `0.0.1-rc.1`). The vendored family has one version line per package and
* publishes only what changed since that package's own `vendor-<package>-v*`
* tag, which is the record of the commit it last published from.
*
* The version lands in the manifests, the lockfile follows, and a human creates
* the tag after the commit merges. CI never writes to the repository.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { join, matchesGlob } from 'node:path'
import { parseArgs } from 'node:util'
import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
import { attempt, capture, isEntry } from './process.ts'
/** Files npm publishes whether or not `files` lists them. */
const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const
/**
* Inputs that decide what a built payload contains. A package whose `files`
* selects `lib/` publishes build output that git does not track, so a change to
* the sources or the build configuration changes the tarball while no published
* path appears in the diff.
*/
const BUILD_INPUTS = ['src/**', 'tsconfig*.json', 'tsdown.config.*', 'build.config.*'] as const
/** Release types the dsh family accepts besides an explicit version. */
const RELEASE_TYPES = ['major', 'minor', 'patch'] as const
/** The workspace root manifest, which carries the dsh family's version. */
const ROOT_MANIFEST = 'package.json'
/** One manifest the bump rewrites, and the tag its new version will carry. */
interface PlannedVersion {
/** Repository-relative manifest path. */
readonly manifestPath: string
/** Label for the log line. */
readonly label: string
/** The version the manifest currently carries. */
readonly from: string
/** The version to write. */
readonly to: string
/** The tag this version publishes from, or undefined for the workspace root. */
readonly tag: string | undefined
}
/**
* Split a version into its release numbers, discarding any prerelease segment.
* @param version - the current version.
* @returns Major, minor, and patch.
*/
function releaseNumbers(version: string): [number, number, number] {
const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version)
if (match === null) throw new Error(`cannot read release numbers from version ${version}`)
return [Number(match[1]), Number(match[2]), Number(match[3])]
}
/**
* Order two versions by their release numbers alone.
* @param left - one version.
* @param right - the other version.
* @returns Negative when `left` is lower, positive when higher, zero when equal.
*/
function compareReleaseNumbers(left: string, right: string): number {
const [leftMajor, leftMinor, leftPatch] = releaseNumbers(left)
const [rightMajor, rightMinor, rightPatch] = releaseNumbers(right)
return leftMajor - rightMajor || leftMinor - rightMinor || leftPatch - rightPatch
}
/**
* The prerelease segment of a version, or undefined when it has none.
* @param version - the version to read.
* @returns The segment after the first `-`.
*/
function prereleaseOf(version: string): string | undefined {
const index = version.indexOf('-')
return index === -1 ? undefined : version.slice(index + 1)
}
/**
* Order two versions by semver precedence.
*
* Git's version sort cannot stand in for this: `--sort=v:refname` places
* `4.0.1-rc.1` above `4.0.1`, while semver gives a prerelease lower precedence
* than the release it precedes. Prerelease identifiers compare field by field,
* numeric fields numerically, so `rc.10` outranks `rc.1`.
* @param left - one version.
* @param right - the other version.
* @returns Negative when `left` is lower, positive when higher, zero when equal.
*/
export function compareVersions(left: string, right: string): number {
const numbers = compareReleaseNumbers(left, right)
if (numbers !== 0) return numbers
const leftPre = prereleaseOf(left)
const rightPre = prereleaseOf(right)
if (leftPre === undefined || rightPre === undefined) {
if (leftPre === rightPre) return 0
return leftPre === undefined ? 1 : -1
}
const leftFields = leftPre.split('.')
const rightFields = rightPre.split('.')
for (let index = 0; index < Math.max(leftFields.length, rightFields.length); index += 1) {
const leftField = leftFields[index]
const rightField = rightFields[index]
// A shorter identifier list has lower precedence when all its fields match.
if (leftField === undefined) return -1
if (rightField === undefined) return 1
if (leftField === rightField) continue
const leftNumeric = /^\d+$/.test(leftField)
const rightNumeric = /^\d+$/.test(rightField)
if (leftNumeric && rightNumeric) return Number(leftField) - Number(rightField)
// Numeric fields have lower precedence than alphanumeric ones.
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
return leftField < rightField ? -1 : 1
}
return 0
}
/**
* The next dsh version.
* @param current - the family's current shared version.
* @param request - `major`, `minor`, `patch`, or an explicit version.
* @returns The target version.
*/
function nextSharedVersion(current: string, request: string): string {
if (!RELEASE_TYPES.includes(request as typeof RELEASE_TYPES[number])) {
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(request)) {
throw new Error(`usage: release:dsh <major|minor|patch|x.y.z>, got ${request}`)
}
return request
}
const [major, minor, patch] = releaseNumbers(current)
if (request === 'major') return `${String(major + 1)}.0.0`
if (request === 'minor') return `${String(major)}.${String(minor + 1)}.0`
return `${String(major)}.${String(minor)}.${String(patch + 1)}`
}
/**
* The version a vendored package publishes next.
*
* The baseline is the higher of the manifest version and the last published
* version: a vendor re-sync restores upstream's version, which is lower than
* what this repository already published, and incrementing that would name a
* version the registry already carries.
*
* A prerelease does not consume its own release numbers. Publishing
* `4.0.1-rc.1` leaves `4.0.1` free, so the next stable version is `4.0.1`
* rather than `4.0.2`, and a second prerelease keeps those numbers too.
* @param current - the package's manifest version.
* @param published - the version its newest tag names, when it has one.
* @param prerelease - prerelease identifier to append, for a rehearsal publication.
* @returns The target version.
*/
export function nextVendorVersion(
current: string,
published: string | undefined,
prerelease?: string,
): string {
const ahead = published !== undefined && compareReleaseNumbers(published, current) > 0
const baseline = ahead ? published : current
const [major, minor, patch] = releaseNumbers(baseline)
// Reuse the numbers when the published version that set them is a prerelease
// of them; increment when a stable release already holds them.
const reuse = ahead && published.includes('-')
const numbers = reuse
? `${String(major)}.${String(minor)}.${String(patch)}`
: `${String(major)}.${String(minor)}.${String(patch + 1)}`
return prerelease === undefined ? numbers : `${numbers}-${prerelease}`
}
/**
* Whether a repository-relative path reaches the member's published payload.
* @param member - the member the path belongs to.
* @param path - repository-relative path.
* @returns True when `files`, npm's always-published set, or a build input selects it.
*/
export function reachesPayload(member: ReleaseMember, path: string): boolean {
const relative = path.slice(member.directory.length + 1)
const files = member.manifest.files
const selected = Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : []
const built = selected.some(pattern => pattern.startsWith('lib'))
const patterns = [...ALWAYS_PUBLISHED, ...selected, ...built ? BUILD_INPUTS : []]
return patterns.some(pattern =>
matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern)
}
/**
* The newest version a member published, read from its tags.
* @param family - the member's family.
* @param member - the member.
* @returns The version, or undefined when the member never published.
*/
function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined {
const prefix = family.tagPrefixFor(member)
const versions = capture('git', ['tag', '--list', `${prefix}*`])
.split('\n').filter(line => line !== '').map(tag => tag.slice(prefix.length))
if (versions.length === 0) return undefined
return versions.reduce((newest, candidate) => compareVersions(candidate, newest) > 0 ? candidate : newest)
}
/**
* Confirm the registry carries the version a tag names.
*
* A tag is a commit pointer, not proof of publication: a tag pushed for a
* publication that then failed would otherwise read as "already published" and
* skip the package indefinitely. Querying a private package needs credentials,
* so an unauthenticated machine reports the gap instead of failing.
* @param name - package name.
* @param version - the version the tag names.
*/
function confirmPublished(name: string, version: string): void {
const result = attempt('npm', ['view', `${name}@${version}`, 'version'])
if (result.status === 0) return
const output = `${result.stdout}${result.stderr}`
if (output.includes('ENEEDAUTH') || output.includes('E401') || output.includes('E403')) {
console.log(`release bump: cannot reach the registry for ${name}@${version}; skipping the tag check`)
return
}
if (output.includes('E404') || output.includes('404 Not Found')) {
throw new Error(
`${name}@${version} is tagged but absent from the registry.`
+ '\nThe tag was pushed for a publication that did not complete: re-run that publish, or delete the tag.',
)
}
throw new Error(`npm view ${name}@${version} failed:\n${output}`)
}
/**
* Write a version into a manifest, preserving formatting and key order.
* @param root - repository root.
* @param manifestPath - repository-relative manifest path.
* @param from - the version the manifest currently carries.
* @param to - the target version.
*/
function writeVersion(root: string, manifestPath: string, from: string, to: string): void {
const path = join(root, manifestPath)
const text = readFileSync(path, 'utf8')
const line = `"version": "${from}"`
if (!text.includes(line)) throw new Error(`${manifestPath}: cannot locate ${line}`)
writeFileSync(path, text.replace(line, `"version": "${to}"`))
}
/**
* Read the workspace root version.
* @param root - repository root.
* @returns The root manifest version.
*/
function rootVersion(root: string): string {
const manifest: unknown = JSON.parse(readFileSync(join(root, ROOT_MANIFEST), 'utf8'))
const version = (manifest as Record<string, unknown>).version
if (typeof version !== 'string') throw new Error('package.json must declare a string version')
return version
}
/**
* Plan the dsh family's rewrite: one version for every member and the root.
* @param family - the dsh family.
* @param root - repository root.
* @param members - the family's members.
* @param request - `major`, `minor`, `patch`, or an explicit version.
* @returns The manifests to rewrite and the shared target version.
*/
function planShared(
family: ReleaseFamily,
root: string,
members: readonly ReleaseMember[],
request: string,
): { planned: PlannedVersion[]; version: string } {
const [first] = members
if (first === undefined) throw new Error(`release family ${family.id} has no members`)
const version = nextSharedVersion(first.version, request)
// The workspace root carries the family version too: the workspace constraint
// requires every member's version to equal the root's.
const planned: PlannedVersion[] = [
{ manifestPath: ROOT_MANIFEST, label: ROOT_MANIFEST, from: rootVersion(root), to: version, tag: undefined },
]
for (const member of members) {
planned.push({
manifestPath: join(member.directory, 'package.json'),
label: member.directory,
from: member.version,
to: version,
tag: family.tagFor({ ...member, version }),
})
}
return { planned, version }
}
/**
* Plan the vendored family's rewrite: every package whose payload changed since
* it last published.
* @param family - the vendored family.
* @param members - the family's members.
* @param prerelease - prerelease identifier to append, for a rehearsal publication.
* @returns The manifests to rewrite.
*/
function planPerPackage(
family: ReleaseFamily,
members: readonly ReleaseMember[],
prerelease: string | undefined,
): PlannedVersion[] {
const planned: PlannedVersion[] = []
for (const member of members) {
const published = lastPublishedVersion(family, member)
if (published !== undefined) {
confirmPublished(member.name, published)
const since = `${family.tagPrefixFor(member)}${published}`
const changed = capture('git', ['diff', '--name-only', `${since}..HEAD`, '--', member.directory])
.split('\n').filter(line => line !== '')
if (!changed.some(path => reachesPayload(member, path))) continue
}
const to = nextVendorVersion(member.version, published, prerelease)
planned.push({
manifestPath: join(member.directory, 'package.json'),
label: member.directory,
from: member.version,
to,
tag: family.tagFor({ ...member, version: to }),
})
}
return planned
}
/**
* Bump the family named by `--family` and commit; `--dry-run` only reports the
* plan. `--prerelease rc.1` makes the vendored family publish a rehearsal
* version, which never takes the stable dist-tag.
*/
function main(): void {
const { values, positionals } = parseArgs({
options: {
family: { type: 'string' },
prerelease: { type: 'string' },
'dry-run': { type: 'boolean', default: false },
},
allowPositionals: true,
})
if (values.family === undefined) throw new Error('usage: bump.ts --family <dsh|vendor> [version]')
const family = releaseFamily(values.family)
const root = process.cwd()
const members = family.members(root)
family.verifyVersions(members)
let planned: PlannedVersion[]
let sharedVersion: string | undefined
if (family.id === 'dsh') {
const request = positionals[0]
if (request === undefined) throw new Error('usage: release:dsh <major|minor|patch|x.y.z>')
if (values.prerelease !== undefined) {
throw new Error('release:dsh takes the prerelease in its version argument, as in 0.0.1-rc.1')
}
const shared = planShared(family, root, members, request)
planned = shared.planned
sharedVersion = shared.version
} else {
if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch')
if (values.prerelease !== undefined && !/^[0-9A-Za-z.-]+$/.test(values.prerelease)) {
throw new Error(`--prerelease must be a semver prerelease identifier, got ${values.prerelease}`)
}
planned = planPerPackage(family, members, values.prerelease)
}
if (planned.length === 0) {
console.log(`release bump: family ${family.id}, nothing changed since publication`)
return
}
const dryRun = values['dry-run']
if (!dryRun) {
for (const entry of planned) writeVersion(root, entry.manifestPath, entry.from, entry.to)
capture('pnpm', ['install', '--lockfile-only'])
}
const summary = sharedVersion
?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ')
console.log(`release bump: family ${family.id} -> ${summary}`)
for (const entry of planned) console.log(` ${entry.label}: ${entry.from} -> ${entry.to}`)
if (dryRun) {
console.log('release bump: dry run, nothing written')
return
}
capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)])
capture('git', ['commit', '-m', `release(${family.id}): ${summary}`])
console.log('release bump: committed. After this merges to master, tag it:')
for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) {
console.log(` git tag ${tag} <merge commit> && git push origin ${tag}`)
}
}
if (isEntry(import.meta.url)) main()
+176
View File
@@ -0,0 +1,176 @@
/** Release family discovery, publish order, tag naming, and the bump judgements. */
import { describe, expect, it } from 'vitest'
import { releaseFamily, type ReleaseMember } from './families.ts'
import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts'
/**
* A release member standing in for a manifest on disk.
* @param directory - repository-relative package directory.
* @param name - package name.
* @param manifest - manifest fields the subject reads.
* @returns The member.
*/
function member(directory: string, name: string, manifest: Record<string, unknown> = {}): ReleaseMember {
return { directory, name, version: '0.0.1', manifest }
}
describe('release families', () => {
it('names one tag for the whole dsh family and one per vendored package', () => {
const dsh = releaseFamily('dsh')
const vendor = releaseFamily('vendor')
const cli = member('apps/cli', '@deepseek-ai/dsh')
const cordis = { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' }
expect(dsh.tagFor(cli)).toBe('dsh-v0.0.1')
expect(vendor.tagFor(cordis)).toBe('vendor-cordis-v4.0.1')
// The prefix is constructed, not recovered from a tag: a version with a
// hyphen would defeat any suffix-stripping.
expect(vendor.tagPrefixFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v')
expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7')
})
it('rejects a family whose members disagree on the shared version', () => {
const dsh = releaseFamily('dsh')
const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-frontend'), version: '0.0.2' }]
expect(() => { dsh.verifyVersions(members) }).toThrow(/must share one version/)
expect(() => { dsh.verifyVersions([members[0]!]) }).not.toThrow()
})
it('accepts independent vendored versions and rejects an unpublishable one', () => {
const vendor = releaseFamily('vendor')
const members = [
{ ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' },
{ ...member('vendor/cosmokit', '@deepseek-ai/cosmokit'), version: '1.8.2' },
]
expect(() => { vendor.verifyVersions(members) }).not.toThrow()
expect(() => { vendor.verifyVersions([{ ...members[0]!, version: 'latest' }]) }).toThrow(/unpublishable version/)
})
it('publishes a dependency before its consumer, and orders ties by name', () => {
const dsh = releaseFamily('dsh')
const members = [
member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { dependencies: { '@deepseek-ai/dsh-library': 'workspace:^' } }),
member('packages/a/library', '@deepseek-ai/dsh-library'),
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
]
expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([
'@deepseek-ai/dsh-library',
'@deepseek-ai/dsh-consumer',
'@deepseek-ai/dsh-zebra',
])
})
it('reports a runtime dependency cycle instead of emitting an arbitrary order', () => {
const dsh = releaseFamily('dsh')
const members = [
member('packages/a/left', '@deepseek-ai/dsh-left', { dependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }),
member('packages/a/right', '@deepseek-ai/dsh-right', { dependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }),
]
expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/)
})
it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => {
const dsh = releaseFamily('dsh')
const vendor = releaseFamily('vendor')
const harness = member('packages/a/library', '@deepseek-ai/dsh-library')
const vendored = member('vendor/cordis', '@deepseek-ai/cordis')
expect(() => { dsh.validatePayload(harness, ['package/lib/index.js', 'package/src/index.ts']) })
.toThrow(/publishes source file/)
expect(() => { vendor.validatePayload(vendored, ['package/lib/index.js', 'package/src/index.ts']) }).not.toThrow()
expect(() => { vendor.validatePayload(vendored, []) }).toThrow(/empty tarball/)
})
it('drives the installed entry only for the family that publishes one', () => {
expect(releaseFamily('dsh').installedEntry).toEqual({ packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' })
expect(releaseFamily('vendor').installedEntry).toBeUndefined()
})
it('rejects an unknown family identifier', () => {
expect(() => { releaseFamily('native') }).toThrow(/unknown release family/)
})
})
describe('vendored version baseline', () => {
it('drops an upstream prerelease segment and increments the patch', () => {
expect(nextVendorVersion('4.0.0-rc.7', undefined)).toBe('4.0.1')
expect(nextVendorVersion('1.0.0-rc.5', undefined)).toBe('1.0.1')
expect(nextVendorVersion('1.8.1', undefined)).toBe('1.8.2')
})
it('increments from the last published version when a re-sync restored a lower one', () => {
// Upstream moved rc.7 -> rc.8 after this repository published 4.0.1;
// incrementing the manifest alone would name 4.0.1 a second time.
expect(nextVendorVersion('4.0.0-rc.8', '4.0.1')).toBe('4.0.2')
expect(nextVendorVersion('4.1.0', '4.0.1')).toBe('4.1.1')
})
it('appends a rehearsal prerelease without consuming its release numbers', () => {
// A rehearsal burns 4.0.1-rc.1 and leaves 4.0.1 free, so the stable release
// that follows takes those same numbers instead of skipping to 4.0.2.
expect(nextVendorVersion('4.0.0-rc.7', undefined, 'rc.1')).toBe('4.0.1-rc.1')
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1', 'rc.2')).toBe('4.0.1-rc.2')
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1')).toBe('4.0.1')
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1')).toBe('4.0.2')
})
})
describe('version precedence', () => {
it('ranks a release above the prerelease it follows', () => {
// git --sort=v:refname disagrees, placing 4.0.1-rc.1 above 4.0.1, which is
// why the newest published version is chosen here rather than by git.
expect(compareVersions('4.0.1', '4.0.1-rc.1')).toBeGreaterThan(0)
expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0)
})
it('compares numeric prerelease fields numerically', () => {
expect(compareVersions('4.0.1-rc.10', '4.0.1-rc.1')).toBeGreaterThan(0)
expect(compareVersions('4.0.1-rc.2', '4.0.1-rc.10')).toBeLessThan(0)
})
it('ranks a numeric field below an alphanumeric one, and a shorter list below a longer', () => {
expect(compareVersions('4.0.1-1', '4.0.1-alpha')).toBeLessThan(0)
expect(compareVersions('4.0.1-rc', '4.0.1-rc.1')).toBeLessThan(0)
expect(compareVersions('4.0.2', '4.0.1')).toBeGreaterThan(0)
expect(compareVersions('4.0.1-rc.1', '4.0.1-rc.1')).toBe(0)
})
})
describe('payload change judgement', () => {
const sourceShipping = member('vendor/cosmokit', '@deepseek-ai/cosmokit', {
files: ['lib/index.js', 'lib/types/**/*.d.ts', 'src'],
})
const buildOutputOnly = member('vendor/cordis', '@deepseek-ai/cordis', {
files: ['lib/index.js', 'lib/types/**/*.d.ts', 'bin.js'],
})
it('counts the manifest and the files npm always publishes', () => {
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/package.json')).toBe(true)
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.md')).toBe(true)
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/src/index.ts')).toBe(true)
})
it('counts build inputs for a package whose payload is build output', () => {
// cordis publishes lib/ only, and lib/ is not tracked: without this, a real
// source change reads as "nothing changed" and the next publish fails on a
// version whose bytes moved.
expect(reachesPayload(buildOutputOnly, 'vendor/cordis/src/context.ts')).toBe(true)
expect(reachesPayload(buildOutputOnly, 'vendor/cordis/tsconfig.json')).toBe(true)
})
it('ignores paths no tarball carries', () => {
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/tests/unit.spec.ts')).toBe(false)
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/CHANGELOG.md')).toBe(false)
// The README pattern is deliberately loose: over-reporting a change costs one
// unnecessary patch bump, while under-reporting fails the next publish on a
// version whose bytes moved.
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.i18n.yaml')).toBe(true)
expect(reachesPayload(member('packages/a/library', '@deepseek-ai/dsh-library', { files: ['lib/index.js'] }),
'packages/a/library/tests/library.spec.ts')).toBe(false)
})
})
+310
View File
@@ -0,0 +1,310 @@
/**
* 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`
}
+61
View File
@@ -0,0 +1,61 @@
/**
* 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/implemented/process/2026-08-10-npm-release-sequences.md)).
*/
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'
import { isEntry, run } from './process.ts'
import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts'
/** Where pack output lands when `--out` is omitted. */
const DEFAULT_OUTPUT = 'dist/npm'
/**
* 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}`)
}
if (isEntry(import.meta.url)) main()
+82
View File
@@ -0,0 +1,82 @@
/**
* Process helpers shared by the release scripts: the release steps drive `git`,
* `pnpm`, `npm`, and `tar`, and each needs one of three failure behaviours.
*/
import { spawnSync } from 'node:child_process'
import { realpathSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
/** Where and with what environment a release step runs a command. */
export interface RunOptions {
/** Working directory; defaults to the current one. */
readonly cwd?: string
/** Child environment; defaults to this process's. */
readonly env?: NodeJS.ProcessEnv
}
/** What a command produced, for a caller that decides what a failure means. */
export interface CommandResult {
/** Exit status, or null when a signal ended the process. */
readonly status: number | null
/** Captured standard output. */
readonly stdout: string
/** Captured standard error. */
readonly stderr: string
}
/**
* Run a command and capture its output without judging the exit status.
* @param command - executable name.
* @param args - command arguments.
* @param options - working directory and environment.
* @returns The exit status and captured streams.
*/
export function attempt(command: string, args: readonly string[], options: RunOptions = {}): CommandResult {
const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, encoding: 'utf8' })
if (result.error !== undefined) throw result.error
return { status: result.status, stdout: result.stdout, stderr: result.stderr }
}
/**
* Run a command, capture its standard output, and fail on a non-zero exit.
* @param command - executable name.
* @param args - command arguments.
* @param options - working directory and environment.
* @returns The trimmed standard output.
*/
export function capture(command: string, args: readonly string[], options: RunOptions = {}): string {
const result = attempt(command, args, options)
if (result.status !== 0) {
throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}:\n${result.stdout}\n${result.stderr}`)
}
return result.stdout.trim()
}
/**
* Run a command with inherited streams, so its progress reaches the log, and
* fail on a non-zero exit.
* @param command - executable name.
* @param args - command arguments.
* @param options - working directory and environment.
*/
export function run(command: string, args: readonly string[], options: RunOptions = {}): void {
const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
}
/**
* Whether this module is the process entry point.
*
* The release scripts are both commands and modules: a test imports their pure
* logic, and importing a module runs its body, so an unguarded `main()` would
* run the wrong command with the wrong arguments.
* @param moduleUrl - the caller's `import.meta.url`.
* @returns True when Node started this module.
*/
export function isEntry(moduleUrl: string): boolean {
const invoked = process.argv[1]
if (invoked === undefined) return false
return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl))
}
+101
View File
@@ -0,0 +1,101 @@
/**
* 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/implemented/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 { 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 { attempt, isEntry, run } from './process.ts'
import { packedIdentity, readPublishOrder } from './tarball.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 }
/**
* 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 = attempt('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'])
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 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)
let published = 0
let skipped = 0
for (const filename of readPublishOrder(directory)) {
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
}
// A prerelease version never takes the latest dist-tag.
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
run('npm', ['publish', tarball, '--access', ACCESS, ...tagArgs])
published += 1
}
console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
}
if (isEntry(import.meta.url)) main()
+53
View File
@@ -0,0 +1,53 @@
/**
* Reading packed npm tarballs and the order file that accompanies them.
*
* The release steps after pack treat a directory of tarballs as the unit of
* work, so they read what a tarball declares rather than what the checkout
* currently says.
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { capture } from './process.ts'
/** Name of the file recording the order in which a packed family uploads. */
export const PUBLISH_ORDER_FILE = 'publish-order.txt'
/** What a packed tarball calls itself. */
export interface PackedIdentity {
/** Package name from the packed manifest. */
readonly name: string
/** Package version from the packed manifest. */
readonly version: string
}
/**
* List a tarball's members.
* @param tarball - absolute tarball path.
* @returns Every path inside the archive.
*/
export function tarballFiles(tarball: string): string[] {
return capture('tar', ['-tzf', tarball]).split('\n').filter(line => line !== '')
}
/**
* Read a packed tarball's own manifest.
* @param tarball - absolute tarball path.
* @returns The name and version the tarball declares.
*/
export function packedIdentity(tarball: string): PackedIdentity {
const manifest: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json']))
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 }
}
/**
* Read a packed directory's upload order.
* @param directory - absolute path of a pack output directory.
* @returns Tarball filenames in upload order.
*/
export function readPublishOrder(directory: string): string[] {
return readFileSync(join(directory, PUBLISH_ORDER_FILE), 'utf8').split('\n').filter(line => line !== '')
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Install packed tarballs into a throwaway consumer outside the repository and
* drive the installed executable with plain Node.
*
* Every tarball the installed tree needs comes from `--from`, so the only
* registry traffic is for external dependencies. That matters beyond hermetic
* verification: the harness packages declare the vendored framework as a peer,
* and those packages live in another release sequence that this credential-free
* job cannot fetch from a private registry — so a dsh verification passes the
* vendored family's pack output too, while publishing only its own
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
*
* What this proves is that `files` selected a complete payload and that the
* published dependency ranges resolve. A workspace link or a stale `lib/` in the
* checkout cannot stand in for a missing file here.
*/
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { parseArgs } from 'node:util'
import { releaseFamily } from './families.ts'
import { capture, isEntry } from './process.ts'
import { packedIdentity } from './tarball.ts'
/**
* Environment for the installed artifact: no host Node hooks, no host DeepSeek
* Harness home, and no ambient npm user agent that would confuse npm.
* @param consumerRoot - the throwaway consumer directory.
* @returns The child environment.
*/
function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv {
const environment = { ...process.env }
delete environment.npm_config_user_agent
delete environment.NPM_CONFIG_USER_AGENT
delete environment.NODE_OPTIONS
delete environment.NODE_PATH
environment.DSH_HOME = resolve(consumerRoot, '.dsh')
environment.DSH_AGENTS_HOME = resolve(consumerRoot, '.agents')
environment.DSH_TELEMETRY_DISABLED = '1'
return environment
}
/**
* Every packed tarball in the given directories, as `file:` dependency entries.
*
* The directories are read by their contents rather than a pack order file: a
* directory here can hold tarballs packed only to satisfy a cross-sequence
* dependency, which no release order describes.
* @param directories - absolute directories holding packed tarballs.
* @returns Package name to tarball file URL, and the version each carries.
*/
function packedDependencies(directories: readonly string[]): Map<string, { url: string; version: string }> {
const dependencies = new Map<string, { url: string; version: string }>()
for (const directory of directories) {
const tarballs = readdirSync(directory).filter(name => name.endsWith('.tgz')).sort()
if (tarballs.length === 0) throw new Error(`${directory} holds no packed tarball`)
for (const filename of tarballs) {
const tarball = join(directory, filename)
const { name, version } = packedIdentity(tarball)
dependencies.set(name, { url: pathToFileURL(tarball).href, version })
}
}
return dependencies
}
/** Install every tarball under `--from` and drive the `--family` entry. */
function main(): void {
const { values } = parseArgs({
options: { family: { type: 'string' }, from: { type: 'string', multiple: true } },
allowPositionals: false,
})
if (values.family === undefined || values.from === undefined || values.from.length === 0) {
throw new Error('usage: verify-packed-install.ts --family <dsh|vendor> --from <packed directory> [--from ...]')
}
const family = releaseFamily(values.family)
const entry = family.installedEntry
if (entry === undefined) {
console.log(`release verify-packed-install: family ${family.id} publishes no executable, nothing to drive`)
return
}
const root = process.cwd()
const packed = packedDependencies(values.from.map(directory => resolve(root, directory)))
const expected = packed.get(entry.packageName)
if (expected === undefined) throw new Error(`${entry.packageName} is not among the packed tarballs`)
const consumerRoot = mkdtempSync(join(tmpdir(), `dsh-packed-${family.id}-`))
try {
writeFileSync(join(consumerRoot, 'package.json'), `${JSON.stringify({
name: `dsh-packed-install-${family.id}`,
version: '0.0.0',
private: true,
dependencies: Object.fromEntries([...packed].map(([name, entryPacked]) => [name, entryPacked.url])),
}, null, 2)}\n`)
const environment = consumerEnvironment(consumerRoot)
console.log(`release verify-packed-install: installing ${String(packed.size)} tarball(s) into ${consumerRoot}`)
// Optional dependencies are omitted: the Landlock platform packages behind
// them need a musl toolchain and one build per architecture, and a consumer
// that cannot install them must still start — which is what optional means
// here. Their entry package is a plain dependency of dsh-sandbox-local, so
// its tarball is supplied through --from.
capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional'],
{ cwd: consumerRoot, env: environment })
const bin = join(consumerRoot, 'node_modules', ...entry.packageName.split('/'), entry.binPath)
const version = capture(process.execPath, [bin, '--version'], { cwd: consumerRoot, env: environment })
if (version !== expected.version) {
throw new Error(`installed ${entry.packageName} --version reported ${JSON.stringify(version)}, expected ${expected.version}`)
}
console.log(`release verify-packed-install: installed ${entry.packageName} reports ${version}`)
} finally {
rmSync(consumerRoot, { recursive: true, force: true })
}
}
if (isEntry(import.meta.url)) main()
+70
View File
@@ -0,0 +1,70 @@
/**
* 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/implemented/process/2026-08-10-npm-release-sequences.md)).
*/
import { parseArgs } from 'node:util'
import { isEntry } from './process.ts'
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' : ''}`)
}
if (isEntry(import.meta.url)) main()