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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:^',
|
||||
|
||||
@@ -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:^') {
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 !== '')
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user