From a943e67798d7cd8c7d72c788e9184ab75174d605 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:36:39 +0800 Subject: [PATCH] feat(release): bump and commit a release family in one command release:dsh takes major, minor, patch, or an explicit version and writes one version across the family; release:vendor takes none and increments each package's own patch, but only for packages whose published payload changed since their vendor--v* tag. That tag is the record of the commit a package last published from, so the change judgement needs no state file, and the diff is filtered through the manifest's files rules - editing a vendored comment does not trigger a release. Both refresh the lockfile, commit, and print the tag to create after the commit merges. --dry-run reports the plan and writes nothing. Incrementing the release numbers is also what drops an upstream prerelease segment: cordis 4.0.0-rc.7 publishes as 4.0.1, because a prerelease version would not satisfy a consumer's plain range. --- package.json | 2 + scripts/release/bump.ts | 199 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 scripts/release/bump.ts diff --git a/package.json b/package.json index 0d17549909..277bd7fe85 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,8 @@ "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:dsh": "tsx scripts/release/bump.ts --family dsh", + "release:vendor": "tsx scripts/release/bump.ts --family vendor", "release:verify": "tsx scripts/release/verify.ts", "release:pack": "tsx scripts/release/pack.ts", "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts new file mode 100644 index 0000000000..83e1b3a2da --- /dev/null +++ b/scripts/release/bump.ts @@ -0,0 +1,199 @@ +/** + * 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/proposed/process/2026-08-10-npm-release-sequences.md)). + * + * The dsh family shares one version: `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--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 { spawnSync } from 'node:child_process' +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' + +/** Files npm publishes whether or not `files` lists them. */ +const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const + +/** Release types the dsh family accepts besides an explicit version. */ +const RELEASE_TYPES = ['major', 'minor', 'patch'] as const + +/** + * Run a command and fail the process on a non-zero exit. + * @param command - executable name. + * @param args - command arguments. + * @returns The captured stdout, trimmed. + */ +function run(command: string, args: readonly string[]): string { + const result = spawnSync(command, [...args], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + 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() +} + +/** + * 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])] +} + +/** + * 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 , 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: its release numbers with the + * patch incremented, which also drops an upstream prerelease segment. + * @param current - the package's current version. + * @returns The target version. + */ +function nextVendorVersion(current: string): string { + const [major, minor, patch] = releaseNumbers(current) + return `${String(major)}.${String(minor)}.${String(patch + 1)}` +} + +/** + * 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` (or npm's always-published set) selects it. + */ +function reachesPayload(member: ReleaseMember, path: string): boolean { + const relative = path.slice(member.directory.length + 1) + const files = member.manifest.files + const patterns = [ + ...ALWAYS_PUBLISHED, + ...Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : [], + ] + return patterns.some(pattern => + matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern) +} + +/** + * The newest tag a member published from, or undefined when it never published. + * @param family - the member's family. + * @param member - the member. + * @returns The tag name. + */ +function lastPublishedTag(family: ReleaseFamily, member: ReleaseMember): string | undefined { + const prefix = family.tagFor(member).replace(/-v[^-]*$/, '-v') + const tags = run('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']).split('\n').filter(line => line !== '') + return tags[0] +} + +/** + * Whether a member's published payload changed since it last published. + * @param family - the member's family. + * @param member - the member. + * @returns True when the member needs a new version. + */ +function changedSincePublication(family: ReleaseFamily, member: ReleaseMember): boolean { + const tag = lastPublishedTag(family, member) + if (tag === undefined) return true + const changed = run('git', ['diff', '--name-only', `${tag}..HEAD`, '--', member.directory]) + .split('\n').filter(line => line !== '') + return changed.some(path => reachesPayload(member, path)) +} + +/** + * Write a version into a member's manifest, preserving formatting and key order. + * @param root - repository root. + * @param member - the member to rewrite. + * @param version - the target version. + */ +function writeVersion(root: string, member: ReleaseMember, version: string): void { + const path = join(root, member.directory, 'package.json') + const text = readFileSync(path, 'utf8') + const line = `"version": "${member.version}"` + if (!text.includes(line)) throw new Error(`${member.directory}: cannot locate ${line}`) + writeFileSync(path, text.replace(line, `"version": "${version}"`)) +} + +/** Bump the family named by `--family` and commit; `--dry-run` only reports the plan. */ +function main(): void { + const { values, positionals } = parseArgs({ + options: { family: { type: 'string' }, 'dry-run': { type: 'boolean', default: false } }, + allowPositionals: true, + }) + if (values.family === undefined) throw new Error('usage: bump.ts --family [version]') + + const family = releaseFamily(values.family) + const root = process.cwd() + const members = family.members(root) + family.verifyVersions(members) + + const planned: { member: ReleaseMember; version: string }[] = [] + let sharedVersion: string | undefined + if (family.id === 'dsh') { + const request = positionals[0] + if (request === undefined) throw new Error('usage: release:dsh ') + const [first] = members + if (first === undefined) throw new Error(`release family ${family.id} has no members`) + sharedVersion = nextSharedVersion(first.version, request) + for (const member of members) planned.push({ member, version: sharedVersion }) + } else { + if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch') + for (const member of members) { + if (!changedSincePublication(family, member)) continue + planned.push({ member, version: nextVendorVersion(member.version) }) + } + } + + 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 { member, version } of planned) writeVersion(root, member, version) + run('pnpm', ['install', '--lockfile-only']) + } + + const summary = sharedVersion + ?? planned.map(entry => `${entry.member.name.replace('@deepseek-ai/', '')} ${entry.version}`).join(', ') + console.log(`release bump: family ${family.id} -> ${summary}`) + for (const { member, version } of planned) console.log(` ${member.directory}: ${member.version} -> ${version}`) + + if (dryRun) { + console.log('release bump: dry run, nothing written') + return + } + run('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => join(entry.member.directory, 'package.json'))]) + run('git', ['commit', '-m', `release(${family.id}): ${summary}`]) + // The dsh family tags once for its shared version; vendor tags each package. + const tags = [...new Set(planned.map(entry => family.tagFor({ ...entry.member, version: entry.version })))] + console.log('release bump: committed. After this merges to master, tag it:') + for (const tag of tags) console.log(` git tag ${tag} && git push origin ${tag}`) +} + +main()