mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
feat(release): [CI-NPM-03] add read-only registry recovery checks
This commit is contained in:
1 parent
783d1e3fd4
commit
5795492f0f
13 files changed
+570
-7
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.'],
|
||||
}
|
||||
}
|
||||
@@ -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.' }
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
Reference in new issue
Block a user