fix(release): close the review findings on the release sequences
The root manifest carries the dsh family version. bump writes it with the members, because the workspace constraint requires them to match, and that constraint now accepts a prerelease segment: without both, release:dsh 0.0.2 left the root behind and 0.0.1-rc.1 could satisfy neither check. The Landlock workflow no longer passes --access public, which overrode the restricted publishConfig this repository just adopted for those packages. Vendored change detection reads build inputs when a package publishes build output, and vendor/cordis publishes the src its export map already pointed at: its lib/ is untracked, so a real source edit read as 'nothing changed' and the next publish would fail on a version whose bytes moved. The next version also takes the last published version as its baseline, so a re-sync that restores a lower upstream version cannot recompute a version already on the registry, and bump confirms the registry carries what the newest tag names. Tag prefixes are constructed rather than recovered from a full tag, which a hyphenated version defeated. Pack runs group per ref so concurrent pull requests stop displacing each other, the publish job carries the global group, and the unused id-token permission is gone. Every release script sits behind an entry guard, which is what lets the pure judgements carry tests: tag naming, publish order and cycle reporting, version arithmetic, payload policy, and the change judgement. The Agent Note moves to implemented and states what shipped: one probe command, the registry confirmation that now exists, and byte reproducibility recorded as assumed rather than measured.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* 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)).
|
||||
* ([rationale](../../.agents/notes/implemented/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-<package>-v*` tag, which is the record of the commit it
|
||||
* last published from.
|
||||
* 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.
|
||||
@@ -17,14 +17,39 @@ 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 { capture } from './process.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.
|
||||
@@ -36,6 +61,18 @@ function releaseNumbers(version: string): [number, number, number] {
|
||||
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 next dsh version.
|
||||
* @param current - the family's current shared version.
|
||||
@@ -56,13 +93,19 @@ function nextSharedVersion(current: string, request: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The version a vendored package publishes next: the higher of its manifest
|
||||
* version and its last published version, with the patch incremented.
|
||||
*
|
||||
* The manifest alone is not the baseline. 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.
|
||||
* @param current - the package's manifest version.
|
||||
* @param published - the version its newest tag names, when it has one.
|
||||
* @returns The target version.
|
||||
*/
|
||||
function nextVendorVersion(current: string): string {
|
||||
const [major, minor, patch] = releaseNumbers(current)
|
||||
export function nextVendorVersion(current: string, published: string | undefined): string {
|
||||
const baseline = published !== undefined && compareReleaseNumbers(published, current) > 0 ? published : current
|
||||
const [major, minor, patch] = releaseNumbers(baseline)
|
||||
return `${String(major)}.${String(minor)}.${String(patch + 1)}`
|
||||
}
|
||||
|
||||
@@ -70,57 +113,147 @@ function nextVendorVersion(current: string): string {
|
||||
* 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.
|
||||
* @returns True when `files`, npm's always-published set, or a build input selects it.
|
||||
*/
|
||||
function reachesPayload(member: ReleaseMember, path: string): boolean {
|
||||
export 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') : [],
|
||||
]
|
||||
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 tag a member published from, or undefined when it never published.
|
||||
* The newest version a member published, read from its tags.
|
||||
* @param family - the member's family.
|
||||
* @param member - the member.
|
||||
* @returns The tag name.
|
||||
* @returns The version, or undefined when the member never published.
|
||||
*/
|
||||
function lastPublishedTag(family: ReleaseFamily, member: ReleaseMember): string | undefined {
|
||||
const prefix = family.tagFor(member).replace(/-v[^-]*$/, '-v')
|
||||
const tags = capture('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 = capture('git', ['diff', '--name-only', `${tag}..HEAD`, '--', member.directory])
|
||||
function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined {
|
||||
const prefix = family.tagPrefixFor(member)
|
||||
const [newest] = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname'])
|
||||
.split('\n').filter(line => line !== '')
|
||||
return changed.some(path => reachesPayload(member, path))
|
||||
return newest === undefined ? undefined : newest.slice(prefix.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 writeVersion(root: string, member: ReleaseMember, version: string): void {
|
||||
const path = join(root, member.directory, 'package.json')
|
||||
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": "${member.version}"`
|
||||
if (!text.includes(line)) throw new Error(`${member.directory}: cannot locate ${line}`)
|
||||
writeFileSync(path, text.replace(line, `"version": "${version}"`))
|
||||
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.
|
||||
* @returns The manifests to rewrite.
|
||||
*/
|
||||
function planPerPackage(family: ReleaseFamily, members: readonly ReleaseMember[]): 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)
|
||||
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. */
|
||||
@@ -136,21 +269,17 @@ function main(): void {
|
||||
const members = family.members(root)
|
||||
family.verifyVersions(members)
|
||||
|
||||
const planned: { member: ReleaseMember; version: string }[] = []
|
||||
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>')
|
||||
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 })
|
||||
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')
|
||||
for (const member of members) {
|
||||
if (!changedSincePublication(family, member)) continue
|
||||
planned.push({ member, version: nextVendorVersion(member.version) })
|
||||
}
|
||||
planned = planPerPackage(family, members)
|
||||
}
|
||||
|
||||
if (planned.length === 0) {
|
||||
@@ -160,25 +289,25 @@ function main(): void {
|
||||
|
||||
const dryRun = values['dry-run']
|
||||
if (!dryRun) {
|
||||
for (const { member, version } of planned) writeVersion(root, member, version)
|
||||
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.member.name.replace('@deepseek-ai/', '')} ${entry.version}`).join(', ')
|
||||
?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ')
|
||||
console.log(`release bump: family ${family.id} -> ${summary}`)
|
||||
for (const { member, version } of planned) console.log(` ${member.directory}: ${member.version} -> ${version}`)
|
||||
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 => join(entry.member.directory, 'package.json'))])
|
||||
capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)])
|
||||
capture('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} <merge commit> && git push origin ${tag}`)
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
if (isEntry(import.meta.url)) main()
|
||||
|
||||
146
scripts/release/families.spec.ts
Normal file
146
scripts/release/families.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/** 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 { 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')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
* (`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)).
|
||||
* ([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.
|
||||
@@ -162,12 +162,22 @@ export abstract class ReleaseFamily {
|
||||
*/
|
||||
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/`.
|
||||
*/
|
||||
abstract tagFor(member: ReleaseMember): string
|
||||
tagFor(member: ReleaseMember): string {
|
||||
return `${this.tagPrefixFor(member)}${member.version}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check what a member's packed tarball carries.
|
||||
@@ -202,12 +212,11 @@ class DshFamily extends ReleaseFamily {
|
||||
}
|
||||
|
||||
/**
|
||||
* The single family tag.
|
||||
* @param member - any family member; all carry the same version.
|
||||
* @returns `dsh-v<version>`.
|
||||
* The single family prefix: every member shares one version, so one tag names it.
|
||||
* @returns `dsh-v`.
|
||||
*/
|
||||
tagFor(member: ReleaseMember): string {
|
||||
return `${this.tagPrefix}${member.version}`
|
||||
tagPrefixFor(): string {
|
||||
return this.tagPrefix
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,12 +252,12 @@ class VendorFamily extends ReleaseFamily {
|
||||
}
|
||||
|
||||
/**
|
||||
* The member's own tag, because one vendor release can carry several versions.
|
||||
* A prefix per member, because one vendor release can carry several versions.
|
||||
* @param member - the member being published.
|
||||
* @returns `vendor-<unscoped name>-v<version>`.
|
||||
* @returns `vendor-<unscoped name>-v`.
|
||||
*/
|
||||
tagFor(member: ReleaseMember): string {
|
||||
return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v${member.version}`
|
||||
tagPrefixFor(member: ReleaseMember): string {
|
||||
return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
*
|
||||
* 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)).
|
||||
* ([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 { run } from './process.ts'
|
||||
import { isEntry, run } from './process.ts'
|
||||
import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts'
|
||||
|
||||
/** Where pack output lands when `--out` is omitted. */
|
||||
@@ -58,4 +58,4 @@ function main(): void {
|
||||
console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`)
|
||||
}
|
||||
|
||||
main()
|
||||
if (isEntry(import.meta.url)) main()
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -63,3 +65,18 @@ export function run(command: string, args: readonly string[], options: RunOption
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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)).
|
||||
* ([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.
|
||||
@@ -17,7 +17,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { attempt, run } from './process.ts'
|
||||
import { attempt, isEntry, run } from './process.ts'
|
||||
import { packedIdentity, readPublishOrder } from './tarball.ts'
|
||||
|
||||
/** npm access level for every package this repository publishes. */
|
||||
@@ -98,4 +98,4 @@ function main(): void {
|
||||
console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
|
||||
}
|
||||
|
||||
main()
|
||||
if (isEntry(import.meta.url)) main()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 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/proposed/process/2026-08-10-npm-release-sequences.md)).
|
||||
* ([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
|
||||
@@ -21,7 +21,7 @@ import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { capture } from './process.ts'
|
||||
import { capture, isEntry } from './process.ts'
|
||||
import { packedIdentity, readPublishOrder } from './tarball.ts'
|
||||
|
||||
/**
|
||||
@@ -105,4 +105,4 @@ function main(): void {
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
if (isEntry(import.meta.url)) main()
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
*
|
||||
* 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)).
|
||||
* ([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'
|
||||
|
||||
/**
|
||||
@@ -66,4 +67,4 @@ function main(): void {
|
||||
console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`)
|
||||
}
|
||||
|
||||
main()
|
||||
if (isEntry(import.meta.url)) main()
|
||||
|
||||
Reference in New Issue
Block a user