docs(release): [REL-01] freeze independent major version and migration plans

This commit is contained in:
Harvey Zhao committed 2026-09-16 00:30:33 +08:00
1 parent 267b21226a
commit 69ad6fb17e
14 files changed
+6747 -9

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const root = path.resolve(fileURLToPath(new URL('../../', import.meta.url)))
const read = file => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'))
const mode = process.argv[2]
assert(['--check', '--prepared'].includes(mode) && process.argv.length === 3, 'Use --check or --prepared')
const plan = read('refactor/version-plan.json')
const ledger = read('refactor/release-ledger.json')
const inventory = read('refactor/package-inventory.json').packages
const registry = read(plan.registrySnapshot)
assert.equal(plan.schemaVersion, 1)
assert.equal(plan.policy.publicationAuthorized, false)
assert.equal(plan.policy.siteNpmPublication, false)
assert.equal(plan.policy.prereleaseSuffix, null)
assert.deepEqual(plan.packages.map(item => item.name).sort(), ledger.packages.map(item => item.name).sort())
for (const item of plan.packages) {
const original = inventory.find(pkg => pkg.name === item.name)
const row = ledger.packages.find(pkg => pkg.name === item.name)
const observation = registry.observations.find(pkg => pkg.name === item.name)
const manifest = read(`packages/${item.name}/package.json`)
assert.equal(item.sourceVersion, original.version)
assert.equal(item.targetVersion, row.targetVersion)
assert.equal(item.targetVersion, `${Number(original.version.split('.')[0]) + 1}.0.0`)
assert.equal(item.distribution, row.distribution)
assert.equal(observation.target, item.targetVersion)
assert.equal(observation.targetState, 'not-observed', `Target was used: ${item.name}`)
assert.deepEqual(observation.majorConflicts, [], `Registry major conflict: ${item.name}`)
assert.equal(item.registry.ownershipVerified, false)
assert(item.changelog.changes.length && item.maintenanceDocs.length)
assert.equal(item.changelog.path, `packages/${item.name}/CHANGELOG.md`)
assert.equal(item.changelog.heading, `${item.targetVersion} (unreleased)`)
for (const file of item.maintenanceDocs)
assert(fs.existsSync(path.join(root, file)), `Missing maintenance guide: ${file}`)
for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta', 'devDependencies'])
assert.deepEqual(manifest[field], item.dependencyPolicy.sourceFields[field], `Unplanned dependency change: ${item.name}/${field}`)
assert([item.sourceVersion, item.targetVersion].includes(manifest.version), `Unplanned current version: ${item.name}`)
if (mode === '--prepared') {
assert.equal(manifest.version, item.targetVersion, `Version not prepared: ${item.name}`)
assert(fs.readFileSync(path.join(root, item.changelog.path), 'utf8').includes(item.changelog.heading), `Missing target changelog: ${item.name}`)
}
}
for (const example of plan.examples) {
const manifest = read(example.manifest)
assert.equal(manifest.private, true)
for (const [name, range] of Object.entries(example.dependencies))
assert((mode === '--prepared' ? [range.planned] : [range.before, range.planned]).includes(manifest.dependencies[name]), `Example dependency not prepared: ${example.manifest}/${name}`)
}
console.log(`Version plan ${mode}: ${plan.packages.length} independent major targets; registry snapshot checked, publication remains unauthorized`)
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const root = path.resolve(fileURLToPath(new URL('../../', import.meta.url)))
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
export function versionObservation(status, metadata, target) {
assert(status === 200 || status === 404, `Registry status is not an absence observation: ${status}`)
assert(metadata && typeof metadata === 'object' && !Array.isArray(metadata), 'Malformed registry metadata')
if (status === 404) {
assert(metadata.error === 'Not found', 'Unexpected registry 404 response')
return { nameState: 'not-found', targetState: 'not-observed', tags: {}, activeVersions: [], historicalVersions: [], majorConflicts: [], ownershipVerified: false }
}
assert(typeof metadata.name === 'string', 'Registry metadata has no package name')
assert((metadata.versions && typeof metadata.versions === 'object' && !Array.isArray(metadata.versions)) || metadata.time?.unpublished, 'Registry metadata has no versions or unpublish record')
const activeVersions = Object.keys(metadata.versions || {})
const historicalVersions = [...new Set([
...Object.keys(metadata.time || {}).filter(version => /^\d+\.\d+\.\d+(?:-.+)?$/.test(version)),
...metadata.time?.unpublished?.versions || [],
])]
const used = new Set([...activeVersions, ...historicalVersions])
const major = Number(target.split('.')[0])
return {
nameState: metadata.time?.unpublished ? 'unpublished' : 'published',
targetState: activeVersions.includes(target) ? 'published' : historicalVersions.includes(target) ? 'previously-used' : 'not-observed',
tags: metadata['dist-tags'] || {},
activeVersions,
historicalVersions,
majorConflicts: [...used].filter(version => /^\d+\.\d+\.\d+$/.test(version) && Number(version.split('.')[0]) >= major),
prereleasesInTargetMajor: [...used].filter(version => version.startsWith(`${major}.`) && version.includes('-')),
unpublished: metadata.time?.unpublished || null,
ownershipVerified: false,
}
}
export async function observePackage(name, target, fetcher = fetch) {
assert(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(name), 'Unexpected package name')
assert(/^\d+\.0\.0$/.test(target), 'Expected a frozen next-major version')
const url = `https://registry.npmjs.org/${name}`
const response = await fetcher(url, { headers: { 'accept': 'application/json', 'cache-control': 'no-cache' }, redirect: 'error', signal: AbortSignal.timeout(30000) })
const bytes = new Uint8Array(await response.arrayBuffer())
const metadata = JSON.parse(new TextDecoder().decode(bytes))
if (response.status === 200)
assert.equal(metadata.name, name, 'Registry returned a different package')
return { name, target, url, status: response.status, observedAt: new Date().toISOString(), responseSha256: digest(bytes), responseBytes: bytes.length, ...versionObservation(response.status, metadata, target) }
}
async function main() {
const [flag, output, ...extra] = process.argv.slice(2)
assert(flag === '--capture' && output && !extra.length, 'Use --capture <new repository-local JSON file>')
const destination = path.resolve(root, output)
assert(destination.startsWith(root + path.sep) && !fs.existsSync(destination), 'Snapshot must be a new repository-local file')
const ledger = JSON.parse(fs.readFileSync(path.join(root, 'refactor/release-ledger.json'), 'utf8'))
const targets = ledger.packages.map(row => ({ name: row.name, target: row.targetVersion, distribution: row.distribution }))
targets.push({ name: 'artplayer-plugin-iframe', target: '2.0.0', distribution: 'historical-name-only' })
const observations = []
for (let index = 0; index < targets.length; index += 4) {
const batch = targets.slice(index, index + 4)
const results = await Promise.allSettled(batch.map(async item => ({ ...await observePackage(item.name, item.target), distribution: item.distribution })))
for (let offset = 0; offset < results.length; offset++) {
const result = results[offset]
observations.push(result.status === 'fulfilled' ? result.value : { ...batch[offset], error: String(result.reason), targetState: 'unknown' })
}
}
const report = { schemaVersion: 1, capturedAt: new Date().toISOString(), registry: 'https://registry.npmjs.org', policy: 'Absence is an observation, not name ownership, reservation or publication authorization. Previously used versions cannot be reused even after unpublish.', observations }
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.writeFileSync(destination, `${JSON.stringify(report, null, 2)}\n`)
for (const row of observations)
console.log(`${row.name}: ${row.nameState || 'error'}, latest=${row.tags?.latest || '-'}, target=${row.target} ${row.targetState}, major-conflicts=${row.majorConflicts?.length ?? '?'}`)
if (observations.some(row => row.error || (!['site', 'historical-name-only'].includes(row.distribution) && (row.targetState !== 'not-observed' || row.majorConflicts.length))))
process.exitCode = 1
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
await main()
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Release version reuse and registry failure boundaries.
import test from 'node:test'
import { observePackage, versionObservation } from './version-registry.mjs'
test('Registry version planning rejects active and unpublished version reuse', () => {
assert.equal(versionObservation(200, { name: 'test', versions: { '2.0.0': {} } }, '2.0.0').targetState, 'published')
const unpublished = versionObservation(200, { name: 'test', time: { unpublished: { versions: ['2.0.0'] } } }, '2.0.0')
assert.equal(unpublished.targetState, 'previously-used')
assert.deepEqual(unpublished.majorConflicts, ['2.0.0'])
assert.equal(versionObservation(200, { name: 'test', versions: {}, time: { '2.0.0': '2025-01-01' } }, '2.0.0').targetState, 'previously-used')
})
test('A different stable release in the target major prevents silently using an older major plan', () => {
const value = versionObservation(200, { name: 'test', versions: { '2.1.0': {}, '3.0.0': {}, '2.0.0-rc.1': {} } }, '2.0.0')
assert.equal(value.targetState, 'not-observed')
assert.deepEqual(value.majorConflicts, ['2.1.0', '3.0.0'])
assert.deepEqual(value.prereleasesInTargetMajor, ['2.0.0-rc.1'])
})
test('Missing names and empty version histories never establish ownership or reservation', () => {
const missing = versionObservation(404, { error: 'Not found' }, '2.0.0')
assert.equal(missing.nameState, 'not-found')
assert.equal(missing.targetState, 'not-observed')
assert.equal(missing.ownershipVerified, false)
assert.equal(versionObservation(200, { name: 'test', versions: {} }, '2.0.0').ownershipVerified, false)
})
test('Registry authentication, throttling, server failures and malformed responses are not availability evidence', () => {
for (const status of [401, 403, 429, 500])
assert.throws(() => versionObservation(status, { error: 'Not found' }, '2.0.0'), /not an absence/)
for (const data of [null, [], {}, { name: 'test' }])
assert.throws(() => versionObservation(200, data, '2.0.0'))
assert.throws(() => versionObservation(404, { error: 'Forbidden' }, '2.0.0'))
})
test('Live probe uses only the public registry and verifies response identity', async () => {
const urls = []
const fetcher = async (url, options) => {
urls.push(url)
assert.equal(options.redirect, 'error')
assert.equal(options.headers.authorization, undefined)
return new Response(JSON.stringify({ name: 'artplayer', versions: { '5.4.0': {} } }), { status: 200 })
}
const result = await observePackage('artplayer', '6.0.0', fetcher)
assert.deepEqual(urls, ['https://registry.npmjs.org/artplayer'])
assert.equal(result.targetState, 'not-observed')
assert.match(result.responseSha256, /^[a-f0-9]{64}$/)
await assert.rejects(observePackage('../private', '6.0.0', fetcher), /Unexpected package/)
await assert.rejects(observePackage('other', '6.0.0', fetcher), /different package/)
})