diff --git a/package.json b/package.json index 77816c319d..32138d03e8 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,9 @@ "doc-sync": "tsx scripts/run-gates.ts doc-sync", "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure && pnpm run verify-vendored-links", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", + "release:verify": "tsx scripts/release/verify.ts", + "release:pack": "tsx scripts/release/pack.ts", + "release:publish": "tsx scripts/release/publish.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts --profile headless", "demo:code-mode": "node scripts/demo-code-mode.mjs", diff --git a/scripts/release/families.ts b/scripts/release/families.ts new file mode 100644 index 0000000000..e989e54525 --- /dev/null +++ b/scripts/release/families.ts @@ -0,0 +1,282 @@ +/** + * The three independent publish sequences this repository releases from + * (`packages/` + `apps/`, `vendor/`, and `native/`) and the two this module + * owns: `dsh` and `vendor`. Each family carries its own version baseline, tag + * naming, and publish set, so releasing one never republishes another + * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * + * The family dimension lives here only. A new sequence adds a subclass and a + * `releaseFamilies()` entry; nothing else in the release scripts branches on it. + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { hasTypeRTRemoteNavigation, validateTarballPayload } from '../publication-payload.ts' + +/** Dependency sections that constrain publish order: a consumer must publish after its dependency. */ +const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const + +/** The workspace root manifest, which is never a release member. */ +const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root' + +/** One publishable package of a release family. */ +export interface ReleaseMember { + /** Repository-relative package directory, for example `packages/core/session`. */ + readonly directory: string + /** Package name from its manifest. */ + readonly name: string + /** Package version from its manifest. */ + readonly version: string + /** The parsed manifest, for payload policy and publication checks. */ + readonly manifest: Readonly> +} + +/** + * Read and parse a JSON file. + * @param path - absolute file path. + * @returns The parsed object. + */ +function readManifest(path: string): Record { + 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 +} + +/** + * 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, field: string, context: string): string { + const value = manifest[field] + if (typeof value !== 'string' || value === '') throw new Error(`${context} must declare a string ${field}`) + return value +} + +/** A release sequence: its members, its version baseline, and its tag naming. */ +export abstract class ReleaseFamily { + /** Workflow-facing identifier, also the `--family` argument. */ + abstract readonly id: string + + /** Glob patterns, relative to the repository root, that select this family's manifests. */ + abstract readonly patterns: readonly string[] + + /** Git tag prefix this family publishes from. */ + abstract readonly tagPrefix: string + + /** + * Discover this family's members. + * @param root - repository root. + * @returns Members sorted by directory, with names validated and deduplicated. + */ + members(root: string): ReleaseMember[] { + const manifestPaths = globSync([...this.patterns], { cwd: root }).sort() + if (manifestPaths.length === 0) throw new Error(`release family ${this.id} matched no manifests`) + + const members: ReleaseMember[] = [] + const seen = new Set() + 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() + const visiting = new Set() + + 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): ReleaseMember[] { + const edges: ReleaseMember[] = [] + for (const section of ORDER_SECTIONS) { + const dependencies = member.manifest[section] + if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue + for (const name of Object.keys(dependencies)) { + const dependency = byName.get(name) + if (dependency !== undefined && dependency.name !== member.name) edges.push(dependency) + } + } + return edges.sort((left, right) => left.name.localeCompare(right.name)) + } + + /** + * Assert this family's version baseline holds across its members. + * @param members - this family's members. + */ + abstract verifyVersions(members: readonly ReleaseMember[]): void + + /** + * The tag a member publishes from. + * @param member - the member being published. + * @returns The full tag name, without `refs/tags/`. + */ + abstract tagFor(member: ReleaseMember): string + + /** + * Check what a member's packed tarball carries. + * @param member - the packed member. + * @param files - every path inside its tarball. + */ + abstract validatePayload(member: ReleaseMember, files: readonly string[]): void +} + +/** `packages/*` and `apps/*`: one shared version across the whole family. */ +class DshFamily extends ReleaseFamily { + readonly id = 'dsh' + readonly patterns = ['packages/*/*/package.json', 'apps/*/package.json'] as const + readonly tagPrefix = 'dsh-v' + + /** + * Require one version across the family, the way a single tag can name it. + * @param members - this family's members. + */ + verifyVersions(members: readonly ReleaseMember[]): void { + const versions = new Set(members.map(member => member.version)) + if (versions.size !== 1) { + const detail = members.map(member => `${member.directory}: ${member.version}`).join('\n') + throw new Error(`dsh release members must share one version:\n${detail}`) + } + } + + /** + * The single family tag. + * @param member - any family member; all carry the same version. + * @returns `dsh-v`. + */ + tagFor(member: ReleaseMember): string { + return `${this.tagPrefix}${member.version}` + } + + /** + * Reject source and declaration-map members, the repository's publication policy. + * @param member - the packed member. + * @param files - every path inside its tarball. + */ + validatePayload(member: ReleaseMember, files: readonly string[]): void { + validateTarballPayload(files, member.name, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(member.manifest), + }) + } +} + +/** `vendor/*`: every package keeps its own version line, so every package has its own tag. */ +class VendorFamily extends ReleaseFamily { + readonly id = 'vendor' + readonly patterns = ['vendor/*/package.json'] as const + readonly tagPrefix = 'vendor-' + + /** + * Accept independent versions; only reject a version this repository cannot publish. + * @param members - this family's members. + */ + verifyVersions(members: readonly ReleaseMember[]): void { + for (const member of members) { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(member.version)) { + throw new Error(`${member.directory} has an unpublishable version: ${member.version}`) + } + } + } + + /** + * The member's own tag, because one vendor release can carry several versions. + * @param member - the member being published. + * @returns `vendor--v`. + */ + tagFor(member: ReleaseMember): string { + return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v${member.version}` + } + + /** + * Require the payload the vendored manifest declares, including upstream's + * `src` tree and declaration maps. + * + * The harness policy that rejects both does not apply here: these manifests + * export `./src/*` for source navigation, so dropping `src` would publish a + * package whose export map points at absent files. What must hold instead is + * that every path the manifest selects is present, which `files` already + * decides and `pnpm pack` already enforces. + * @param member - the packed member. + * @param files - every path inside its tarball. + */ + validatePayload(member: ReleaseMember, files: readonly string[]): void { + if (files.length === 0) throw new Error(`${member.name} packed an empty tarball`) + } +} + +/** Every release family this module owns, in workflow order. */ +export function releaseFamilies(): readonly ReleaseFamily[] { + return [new DshFamily(), new VendorFamily()] +} + +/** + * Resolve a family by its `--family` identifier. + * @param id - family identifier. + * @returns The family. + */ +export function releaseFamily(id: string): ReleaseFamily { + const family = releaseFamilies().find(candidate => candidate.id === id) + if (family === undefined) { + const known = releaseFamilies().map(candidate => candidate.id).join(', ') + throw new Error(`unknown release family ${id}; expected one of ${known}`) + } + return family +} + +/** + * The npm tarball filename `pnpm pack` writes for a member. + * @param member - the packed member. + * @returns The tarball filename. + */ +export function tarballName(member: ReleaseMember): string { + const unscoped = member.name.startsWith('@') ? member.name.slice(1).replace('/', '-') : member.name + return `${unscoped}-${member.version}.tgz` +} diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts new file mode 100644 index 0000000000..1b128817b9 --- /dev/null +++ b/scripts/release/pack.ts @@ -0,0 +1,86 @@ +/** + * Pack one release family's whole publish set into a single directory, in + * publish order, and record that order for the publish step. + * + * The pack step is the release boundary: it runs without credentials, produces + * every tarball from one commit, and hands the publish step exactly those bytes + * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + */ + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts' + +/** Where pack output lands when `--out` is omitted. */ +const DEFAULT_OUTPUT = 'dist/npm' + +/** Name of the file the publish step reads to learn the upload order. */ +export const PUBLISH_ORDER_FILE = 'publish-order.txt' + +/** + * Run a command, inheriting stdio, and fail the process on a non-zero exit. + * @param command - executable name. + * @param args - command arguments. + */ +function run(command: string, args: readonly string[]): void { + const result = spawnSync(command, [...args], { stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) +} + +/** + * List a tarball's members. + * @param tarball - absolute tarball path. + * @returns Every path inside the archive. + */ +function tarballFiles(tarball: string): string[] { + const result = spawnSync('tar', ['-tzf', tarball], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`tar -tzf ${tarball} exited with ${String(result.status)}:\n${result.stderr}`) + return result.stdout.split('\n').filter(line => line !== '') +} + +/** + * Pack one member and check what its tarball carries. + * @param family - the release family being packed. + * @param member - the member to pack. + * @param destination - absolute output directory. + * @returns The tarball filename. + */ +function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string { + run('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination]) + + const filename = tarballName(member) + const tarball = join(destination, filename) + if (!existsSync(tarball)) throw new Error(`${member.name} produced no tarball at ${tarball}`) + family.validatePayload(member, tarballFiles(tarball)) + return filename +} + +/** Pack the family named by `--family` into `--out`. */ +function main(): void { + const { values } = parseArgs({ + options: { family: { type: 'string' }, out: { type: 'string' } }, + allowPositionals: false, + }) + if (values.family === undefined) throw new Error('usage: pack.ts --family [--out dist/npm]') + + const family = releaseFamily(values.family) + const root = process.cwd() + const destination = resolve(root, values.out ?? DEFAULT_OUTPUT) + const members = family.publishOrder(family.members(root)) + family.verifyVersions(members) + + rmSync(destination, { recursive: true, force: true }) + mkdirSync(destination, { recursive: true }) + + const order: string[] = [] + for (const member of members) order.push(packMember(family, member, destination)) + writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`) + + console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`) +} + +main() diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts new file mode 100644 index 0000000000..68dae1701a --- /dev/null +++ b/scripts/release/publish.ts @@ -0,0 +1,130 @@ +/** + * Publish one packed release family from the tarballs the pack step produced. + * + * Publication is decided per package against the registry, never from a list of + * "what this release includes": a version the registry lacks is published, a + * version whose published tarball has the same integrity is skipped, and a + * version whose published tarball differs fails the run — that last case means + * the content changed without a version bump + * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * + * Skipping on identical integrity is what makes re-running the publish step over + * the same artifact safe. + */ + +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { releaseFamily } from './families.ts' +import { PUBLISH_ORDER_FILE } from './pack.ts' + +/** npm access level for every package this repository publishes. */ +const ACCESS = 'restricted' + +/** What the registry knows about one version. */ +type RegistryState = + | { readonly kind: 'absent' } + | { readonly kind: 'present'; readonly integrity: string } + +/** + * Read a packed tarball's own manifest. + * @param tarball - absolute tarball path. + * @returns The packed `package.json` name and version. + */ +function packedIdentity(tarball: string): { name: string; version: string } { + const result = spawnSync('tar', ['-xOzf', tarball, 'package/package.json'], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`cannot read ${tarball}:\n${result.stderr}`) + const manifest: unknown = JSON.parse(result.stdout) + if (manifest === null || typeof manifest !== 'object') throw new Error(`${tarball} has no manifest`) + const { name, version } = manifest as Record + if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`) + return { name, version } +} + +/** + * The subresource integrity string npm records for a tarball. + * @param tarball - absolute tarball path. + * @returns A `sha512-` string. + */ +function integrityOf(tarball: string): string { + return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}` +} + +/** + * Ask the registry whether a version exists, and with what integrity. + * @param name - package name. + * @param version - package version. + * @returns The registry state for that version. + */ +function registryState(name: string, version: string): RegistryState { + const result = spawnSync('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) { + const output = `${result.stdout}${result.stderr}` + if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' } + throw new Error(`npm view ${name}@${version} failed:\n${output}`) + } + const parsed: unknown = JSON.parse(result.stdout) + if (typeof parsed !== 'string' || parsed === '') { + throw new Error(`registry reported no dist.integrity for ${name}@${version}`) + } + return { kind: 'present', integrity: parsed } +} + +/** + * Publish one tarball. + * @param tarball - absolute tarball path. + * @param version - the version being published; a prerelease never takes `latest`. + */ +function publish(tarball: string, version: string): void { + const args = ['publish', tarball, '--access', ACCESS] + if (version.includes('-')) args.push('--tag', 'next') + const result = spawnSync('npm', args, { stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`npm publish ${tarball} exited with ${String(result.status)}`) +} + +/** Publish the family named by `--family` from the directory named by `--from`. */ +function main(): void { + const { values } = parseArgs({ + options: { family: { type: 'string' }, from: { type: 'string' } }, + allowPositionals: false, + }) + if (values.family === undefined || values.from === undefined) { + throw new Error('usage: publish.ts --family --from ') + } + + const family = releaseFamily(values.family) + const directory = resolve(process.cwd(), values.from) + const order = readFileSync(join(directory, PUBLISH_ORDER_FILE), 'utf8').split('\n').filter(line => line !== '') + + let published = 0 + let skipped = 0 + for (const filename of order) { + const tarball = join(directory, filename) + const { name, version } = packedIdentity(tarball) + const state = registryState(name, version) + if (state.kind === 'present') { + const local = integrityOf(tarball) + if (state.integrity !== local) { + throw new Error( + `${name}@${version} is already published with different content` + + `\n registry: ${state.integrity}\n packed: ${local}` + + '\nBump the version, or investigate why the build is not reproducible.', + ) + } + console.log(`release publish: ${name}@${version} already published, skipping`) + skipped += 1 + continue + } + publish(tarball, version) + published += 1 + } + + console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`) +} + +main() diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts new file mode 100644 index 0000000000..98988de18d --- /dev/null +++ b/scripts/release/verify.ts @@ -0,0 +1,69 @@ +/** + * Verify a release family's version baseline, and — when publishing — that the + * run comes from the family's tag and its members are publishable. + * + * Publication happens only from GitHub Actions, so the tag and publishability + * checks are gates on the workflow, not advisory local warnings + * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + */ + +import { parseArgs } from 'node:util' +import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' + +/** + * Assert every member may be published: npm refuses a `private` package. + * @param members - the family's members. + */ +function verifyPublishable(members: readonly ReleaseMember[]): void { + const priv = members.filter(member => member.manifest.private === true) + if (priv.length > 0) { + throw new Error(`publishing requires removing "private": true from:\n${priv.map(member => member.directory).join('\n')}`) + } +} + +/** + * Assert the workflow runs from a tag this family publishes from, and that the + * tag names a version the family actually carries. + * @param family - the release family. + * @param members - the family's members. + * @param ref - the `GITHUB_REF` value. + */ +function verifyTag(family: ReleaseFamily, members: readonly ReleaseMember[], ref: string): void { + const prefix = 'refs/tags/' + if (!ref.startsWith(prefix)) { + throw new Error(`publishing release family ${family.id} requires running from a ${family.tagPrefix}* tag, got ${ref || '(no ref)'}`) + } + const tag = ref.slice(prefix.length) + if (!tag.startsWith(family.tagPrefix)) { + throw new Error(`tag ${tag} does not belong to release family ${family.id} (expected ${family.tagPrefix}*)`) + } + const expected = members.map(member => family.tagFor(member)) + if (!expected.includes(tag)) { + throw new Error(`tag ${tag} names no version this family carries; its members would tag as:\n${[...new Set(expected)].join('\n')}`) + } +} + +/** Run the verification for the family named by `--family`. */ +function main(): void { + const { values } = parseArgs({ + options: { family: { type: 'string' } }, + allowPositionals: false, + }) + if (values.family === undefined) throw new Error('usage: verify.ts --family ') + + const family = releaseFamily(values.family) + const members = family.members(process.cwd()) + family.verifyVersions(members) + + const publishing = process.env.RELEASE_PUBLISH === 'true' + if (publishing) { + verifyPublishable(members) + verifyTag(family, members, process.env.GITHUB_REF ?? '') + } + + const versions = [...new Set(members.map(member => member.version))] + const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions` + console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`) +} + +main()