feat(release): [CI-NPM-03] add read-only registry recovery checks

This commit is contained in:
Harvey Zhao committed 2026-09-16 11:06:23 +08:00
1 parent 783d1e3fd4
commit 5795492f0f
13 files changed
+570 -7

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { parseArgs } from 'node:util'
import { buildLedger, root } from '../refactor/scripts/release-ledger.mjs'
import { assertCleanSource } from './release/prepare.ts'
import { checkReleaseRegistry } from './release/registry-check.ts'
try {
const { values } = parseArgs({ options: { 'directory': { type: 'string' }, 'packages': { type: 'string' }, 'tag': { type: 'string' }, 'source-commit': { type: 'string' }, 'manifest-sha256': { type: 'string' } }, strict: true })
assert(values.directory && values.packages && values.tag && values['source-commit'] && values['manifest-sha256'], 'Explicit --directory, --packages, --tag, --source-commit and --manifest-sha256 are required')
assert.equal(process.version, `v${fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim()}`, 'Use the canonical Node version')
assert(process.env.npm_config_user_agent?.startsWith('yarn/1.22.22 '), 'Use yarn release:registry')
const result = await checkReleaseRegistry(root, values.directory, { names: values.packages.split(','), tag: values.tag, sourceCommit: values['source-commit'], manifestSha256: values['manifest-sha256'] }, (directory, names) => {
assertCleanSource(directory)
return buildLedger(directory, names)
})
console.log(JSON.stringify(result, null, 2))
if (result.conflicts.length)
process.exitCode = 1
}
catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
}
+41
View File
@@ -1,5 +1,46 @@
# Preparing exact npm candidate files
## Read-only registry inspection
`yarn release:registry` accepts the same five required arguments as
`release:verify-bundle` below. It first runs that full verifier against a clean
repository and fresh gates, reads public npm metadata, then runs the verifier
again. It cannot inspect an unready bundle by trusting its downloaded report.
No package is built, published or retagged; no new dependency is required.
`registry.ts` owns bounded public GET requests (15 seconds including response
body, 10 MiB maximum, redirects disabled) and pure per-package classification.
`registry-check.ts` binds those observations to the verifier before and after
network activity. `check-release-registry.mjs` owns CLI inputs and exit status.
The registry origin is fixed and no npmrc or authentication token is loaded.
| State | Meaning and eventual recovery action |
| --- | --- |
| `not-observed` | Package/version was not found; do not claim the name/version is publishable. A removed historical version may be permanently unavailable. |
| `already-present` | Registry SHA-512 equals the verified candidate and selected tag already points there. Do not republish. |
| `tag-change-required` | SHA-512 matches, but the tag differs or is missing. Keep the tarball; any tag change needs separately authorized promotion/recovery. This can include moving a newer tag backwards. |
| `conflict` | Different/missing integrity or retained unpublish history. Stop the batch; never overwrite, unpublish or invent a replacement version. |
The output preserves each package decision for a partially completed batch,
timestamps and response digests. Conflicts exit 1 after printing the report;
HTTP/schema/transport/local-verification failures exit 1 without a successful
report. A zero exit means observations were collected without known conflicts,
not release approval. Every report has `publicationAuthorized: false` and
`workflowProvenanceVerified: false`. Registry tarballs are not downloaded by
this metadata check. Registry state can change immediately; CI-03 still needs
trusted workflow/artifact provenance, permission checks, exact-byte publication,
explicit tag control and readback at authorized use time. Never execute a saved
report as a command list or let it waive current release gates.
`release-registry.test.mjs` covers partial batches, conflicts, unpublish markers,
malformed data, bounded transport, stale local gates/content and CLI rejection.
Its injected complete ledgers are synthetic, not acceptance for real packages.
It runs through `test:release-bundle` and the existing `test:baseline` glob.
The new TypeScript modules are included in `typecheck:release` and root lint.
Sources checked 2026-09-16: [npm publish](https://docs.npmjs.com/cli/v11/commands/npm-publish/)
and [npm registry API](https://github.com/npm/registry/blob/main/docs/REGISTRY-API.md).
Run from the repository root with the pinned Node and Yarn:
```sh
+31
View File
@@ -0,0 +1,31 @@
import type { LedgerSnapshot } from './bundle.ts'
import type { BundleExpectation } from './verify.ts'
import assert from 'node:assert/strict'
import { buildLedger } from '../../refactor/scripts/release-ledger.mjs'
import { assessRegistry, observeRegistry, registry } from './registry.ts'
import { verifyReleaseBundle } from './verify.ts'
// The CLI supplies a clean-source inspector. Test seams never enter CLI inputs.
export async function checkReleaseRegistry(repository: string, directory: string, expected: BundleExpectation, inspect: (repository: string, names: string[]) => LedgerSnapshot = buildLedger, fetcher: typeof fetch = fetch) {
const verified = verifyReleaseBundle(repository, directory, expected, inspect)
const startedAt = new Date().toISOString()
const packages = []
for (const candidate of verified.packages)
packages.push(assessRegistry(candidate, expected.tag, await observeRegistry(candidate.name, fetcher)))
assert.deepEqual(verifyReleaseBundle(repository, directory, expected, inspect), verified, 'Candidate bundle changed during registry lookup')
return {
schemaVersion: 1,
sourceCommit: verified.sourceCommit,
manifestSha256: verified.manifestSha256,
registry,
tag: expected.tag,
startedAt,
finishedAt: new Date().toISOString(),
packages,
conflicts: packages.filter(item => item.state === 'conflict').map(item => item.name),
contentVerified: true,
workflowProvenanceVerified: false,
publicationAuthorized: false,
limitations: ['Read-only metadata observations, not downloaded registry tarball verification or publish permission.', 'Registry state can change immediately; recheck at authorized use time.', 'Not-observed versions may have been unpublished and cannot necessarily be reused.', 'Never execute this report as a publication or tag-change command.'],
}
}
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
export const registry = 'https://registry.npmjs.org/'
const versionPattern = /^\d+\.\d+\.\d+(?:-[a-z0-9]+(?:[.-][a-z0-9]+)*)?$/i
const maxBytes = 10 * 1024 * 1024
export interface RegistryObservation {
url: string
observedAt: string
status: 200 | 404
sha256: string
metadata: unknown
}
function record(value: unknown): Record<string, unknown> {
assert(value !== null && typeof value === 'object' && !Array.isArray(value), 'Invalid registry object')
return value as Record<string, unknown>
}
function own(value: Record<string, unknown>, key: string): unknown {
return Object.hasOwn(value, key) ? value[key] : undefined
}
function validateName(name: string): void {
assert(/^artplayer(?:-[a-z0-9]+)*$/.test(name), 'Invalid workspace package name')
}
// No npmrc, credentials, redirects, lifecycle commands or registry writes.
export async function observeRegistry(name: string, fetcher: typeof fetch = fetch): Promise<RegistryObservation> {
validateName(name)
const url = `${registry}${name}`
const response = await fetcher(url, { method: 'GET', headers: { 'accept': 'application/json', 'cache-control': 'no-cache' }, redirect: 'error', signal: AbortSignal.timeout(15000) })
if (response.status !== 200 && response.status !== 404) {
await response.body?.cancel()
throw new Error(`${name}: registry HTTP ${response.status}`)
}
assert(response.body, `${name}: empty registry response`)
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let length = 0
try {
while (true) {
const chunk = await reader.read()
if (chunk.done)
break
length += chunk.value.byteLength
assert(length <= maxBytes, `${name}: registry response exceeds 10 MiB`)
chunks.push(chunk.value)
}
}
finally {
await reader.cancel()
reader.releaseLock()
}
const bytes = Buffer.concat(chunks)
return { url, status: response.status, observedAt: new Date().toISOString(), sha256: createHash('sha256').update(bytes).digest('hex'), metadata: JSON.parse(bytes.toString('utf8')) as unknown }
}
export function assessRegistry(candidate: { name: string, version: string, integrity: string }, tag: string, observation: RegistryObservation) {
const { name, version, integrity } = candidate
validateName(name)
assert(versionPattern.test(version), 'Invalid candidate version')
assert(/^sha512-[A-Za-z0-9+/]{86}==$/.test(integrity), 'Candidate requires exact SHA-512 integrity')
assert(['next', 'alpha', 'beta', 'rc', 'latest'].includes(tag), 'Unsupported release tag')
assert(tag !== 'latest' || !version.includes('-'), 'Prerelease versions cannot use latest')
assert.equal(observation.url, `${registry}${name}`, 'Registry observation package differs')
const document = record(observation.metadata)
const base = { name, version, integrity, tag, observedAt: observation.observedAt, responseSha256: observation.sha256, httpStatus: observation.status }
if (observation.status === 404) {
assert.equal(own(document, 'error'), 'Not found', 'Unrecognized registry 404 response')
return { ...base, state: 'not-observed', currentTag: null, reason: 'Package not found; prior publication and publish permission are unknown.' }
}
assert.equal(observation.status, 200, 'Unexpected registry status')
assert.equal(own(document, 'name'), name, 'Registry package identity differs')
const time = own(document, 'time')
const timestamps = time === undefined ? {} : record(time)
if (own(timestamps, 'unpublished') !== undefined)
return { ...base, state: 'conflict', currentTag: null, reason: 'Registry records an unpublished package; do not attempt version reuse.' }
const versions = record(own(document, 'versions'))
const tags = record(own(document, 'dist-tags'))
for (const value of Object.values(tags))
assert(typeof value === 'string' && versionPattern.test(value), 'Invalid registry dist-tag version')
const currentTag = own(tags, tag) as string | undefined
const existing = own(versions, version)
if (existing === undefined) {
assert(currentTag !== version, 'Registry tag points to a missing candidate version')
const previouslyPublished = own(timestamps, version) !== undefined
return { ...base, state: previouslyPublished ? 'conflict' : 'not-observed', currentTag: currentTag ?? null, reason: previouslyPublished ? 'Registry retains this version in publication history.' : 'Version not found; absence does not prove it can be published.' }
}
const published = record(existing)
assert.equal(own(published, 'name'), name, 'Registry version package identity differs')
assert.equal(own(published, 'version'), version, 'Registry version identity differs')
const dist = own(published, 'dist')
const actual = dist === undefined ? undefined : own(record(dist), 'integrity')
if (actual !== integrity)
return { ...base, state: 'conflict', currentTag: currentTag ?? null, reason: 'Published integrity differs or is missing; never overwrite or republish.' }
return { ...base, state: currentTag === version ? 'already-present' : 'tag-change-required', currentTag: currentTag ?? null, reason: 'Registry SHA-512 matches; do not republish. Tag changes require separate authorization.' }
}
+1 -1
View File
@@ -6,7 +6,7 @@ import path from 'node:path'
import { buildLedger, localFile } from '../../refactor/scripts/release-ledger.mjs'
import { bundleManifest, checkBatch } from './bundle.ts'
interface BundleExpectation {
export interface BundleExpectation {
sourceCommit: string
manifestSha256: string
names: string[]