| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 |
- import { spawnSync } from 'node:child_process'
- import { dirname, join, resolve } from 'node:path'
- import { fileURLToPath } from 'node:url'
- import { loadEcosystemReleaseContext, readJson } from './lib/ecosystem-packages.mjs'
- import { normalizeReleaseError } from './lib/release-error-normalizer.mjs'
- import { describeReleaseGaps } from './lib/release-gap-classifier.mjs'
- import { comparePublicMirrorTrees } from './lib/git-remote-tree.mjs'
- const scriptDir = dirname(fileURLToPath(import.meta.url))
- const sourceRoot = resolve(scriptDir, '..')
- const argv = process.argv.slice(2)
- const args = new Set(argv)
- const strict = args.has('--strict')
- const fast = args.has('--fast')
- function readArg(name, fallback) {
- const index = argv.indexOf(name)
- return index >= 0 ? argv[index + 1] : fallback
- }
- function readNumberArg(name, fallback) {
- const value = Number(readArg(name, fallback))
- if (!Number.isFinite(value) || value <= 0) {
- throw new Error(`${name} must be a positive number, got ${value}`)
- }
- return value
- }
- const defaultGitTimeout = fast ? 5_000 : 20_000
- const defaultNpmTimeout = fast ? 8_000 : 30_000
- const defaultGhTimeout = fast ? 8_000 : 20_000
- const gitTimeout = readNumberArg(
- '--git-timeout-ms',
- process.env.FILE_VIEWER_AUDIT_GIT_TIMEOUT_MS || defaultGitTimeout
- )
- const npmTimeout = readNumberArg(
- '--npm-timeout-ms',
- process.env.FILE_VIEWER_AUDIT_NPM_TIMEOUT_MS || defaultNpmTimeout
- )
- const ghTimeout = readNumberArg(
- '--gh-timeout-ms',
- process.env.FILE_VIEWER_AUDIT_GH_TIMEOUT_MS || defaultGhTimeout
- )
- const { rootPackage, wrapperManifest, entries } = await loadEcosystemReleaseContext(sourceRoot)
- const branchRoles = await readJson(join(sourceRoot, 'ecosystem', 'branch-roles.json'))
- const publicRepoDir = resolve(
- sourceRoot,
- readArg('--public-repo-dir', process.env.FILE_VIEWER_PUBLIC_REPO_DIR || '../file-viewer-public')
- )
- function run(command, commandArgs, options = {}) {
- const result = spawnSync(command, commandArgs, {
- cwd: options.cwd ?? sourceRoot,
- encoding: 'utf8',
- stdio: 'pipe',
- timeout: options.timeout ?? 20_000
- })
- const errorMessage = result.error instanceof Error ? result.error.message : ''
- return {
- ok: result.status === 0,
- status: result.status,
- signal: result.signal || '',
- stdout: (result.stdout || '').trim(),
- stderr: (result.stderr || '').trim(),
- error: errorMessage
- }
- }
- function firstRefHash(output) {
- return output.split('\n').find(Boolean)?.split('\t')[0] || ''
- }
- function lsRemoteHead(url, branch = 'main') {
- const result = run('git', ['ls-remote', url, `refs/heads/${branch}`], { timeout: gitTimeout })
- return {
- ok: result.ok && Boolean(firstRefHash(result.stdout)),
- hash: firstRefHash(result.stdout),
- error: result.ok ? '' : normalizeReleaseError(result.error || result.stderr || result.stdout || result.signal)
- }
- }
- function npmVersion(packageName) {
- const result = run('npm', ['view', packageName, 'version'], { timeout: npmTimeout })
- return {
- ok: result.ok && Boolean(result.stdout),
- version: result.stdout.replace(/^"|"$/g, ''),
- error: result.ok ? '' : normalizeReleaseError(result.error || result.stderr || result.stdout || result.signal)
- }
- }
- function ghRelease(tag) {
- const result = run('gh', [
- 'release',
- 'view',
- tag,
- '-R',
- 'flyfish-dev/file-viewer',
- '--json',
- 'tagName,url,assets'
- ], { timeout: ghTimeout })
- if (!result.ok) {
- return {
- ok: false,
- tag,
- url: '',
- assetCount: 0,
- hasManifest: false,
- hasStatus: false,
- hasSchema: false,
- error: normalizeReleaseError(result.error || result.stderr || result.stdout || result.signal)
- }
- }
- try {
- const release = JSON.parse(result.stdout)
- const assets = Array.isArray(release.assets) ? release.assets : []
- return {
- ok: true,
- tag: release.tagName,
- url: release.url,
- assetCount: assets.length,
- hasManifest: assets.some(asset => asset.name === 'release-manifest.json'),
- hasStatus: assets.some(asset => asset.name === 'release-status.json'),
- hasSchema: assets.some(asset => asset.name === 'release-status.schema.json'),
- error: ''
- }
- } catch (error) {
- return {
- ok: false,
- tag,
- url: '',
- assetCount: 0,
- hasManifest: false,
- hasStatus: false,
- hasSchema: false,
- error: normalizeReleaseError(error instanceof Error ? error.message : String(error))
- }
- }
- }
- function formatHash(value) {
- return value ? `\`${value.slice(0, 12)}\`` : '`missing`'
- }
- function okLabel(ok) {
- return ok ? 'ok' : 'missing'
- }
- function syncLabel(left, right, treeStatus) {
- if (!left.ok || !right.ok) {
- return 'missing'
- }
- if (left.hash === right.hash) {
- return 'ok'
- }
- if (treeStatus?.mode === 'tree') {
- return 'ok-tree'
- }
- if (treeStatus?.mode === 'unchecked') {
- return 'unchecked'
- }
- return 'stale'
- }
- function parseWorktrees(output) {
- return output
- .split(/\n(?=worktree )/)
- .map(block => block.trim())
- .filter(Boolean)
- .map(block => {
- const entry = {
- path: '',
- head: '',
- branch: ''
- }
- for (const line of block.split('\n')) {
- const [key, ...valueParts] = line.split(' ')
- const value = valueParts.join(' ')
- if (key === 'worktree') {
- entry.path = value
- }
- if (key === 'HEAD') {
- entry.head = value
- }
- if (key === 'branch') {
- entry.branch = value.replace(/^refs\/heads\//, '')
- }
- }
- return entry
- })
- }
- function worktreeStatus(path) {
- if (!path) {
- return {
- dirty: false,
- changeCount: 0
- }
- }
- const result = run('git', ['status', '--porcelain'], { cwd: path })
- const changes = result.stdout.split('\n').filter(Boolean)
- return {
- dirty: changes.length > 0,
- changeCount: changes.length
- }
- }
- const localBranch = run('git', ['branch', '--show-current']).stdout
- const sourceBranch = branchRoles.currentSourceBranch || localBranch
- const sourceHead = run('git', ['rev-parse', 'HEAD']).stdout
- const sourceRemoteHead = lsRemoteHead(branchRoles.sourceRemote.url, sourceBranch)
- const sourceHeadInSync = sourceRemoteHead.ok && sourceHead === sourceRemoteHead.hash
- const worktrees = parseWorktrees(run('git', ['worktree', 'list', '--porcelain']).stdout)
- const sourceBranchWorktree = worktrees.find(worktree => worktree.branch === sourceBranch) || null
- const sourceBranchWorktreeStatus = sourceBranchWorktree ? worktreeStatus(sourceBranchWorktree.path) : null
- const sourceBranchWorktreeInSync =
- !sourceBranchWorktree || (sourceRemoteHead.ok && sourceBranchWorktree.head === sourceRemoteHead.hash)
- const branchRows = branchRoles.branches.map(branch => ({
- ...branch,
- remote: lsRemoteHead(branchRoles.sourceRemote.url, branch.name)
- }))
- const publicGithubHead = lsRemoteHead(branchRoles.publicMainRepository.github, 'main')
- const publicGiteeHead = lsRemoteHead(branchRoles.publicMainRepository.gitee, 'main')
- const publicMainTreeStatus = comparePublicMirrorTrees({
- publicRepoDir,
- githubHead: publicGithubHead,
- giteeHead: publicGiteeHead,
- giteeUrl: branchRoles.publicMainRepository.gitee,
- branch: 'main',
- timeout: gitTimeout
- })
- const publicMainInSync = publicGithubHead.ok && publicGiteeHead.ok && publicMainTreeStatus.inSync
- const release = ghRelease(`v${rootPackage.version}`)
- const remoteTargets = [
- {
- id: 'core',
- packageName: wrapperManifest.corePackage.packageName,
- github: wrapperManifest.corePackage.github,
- gitee: wrapperManifest.corePackage.gitee
- },
- ...wrapperManifest.wrappers.map(wrapper => ({
- id: wrapper.id,
- packageName: wrapper.packageName,
- github: wrapper.github,
- gitee: wrapper.gitee
- }))
- ]
- const remoteRows = remoteTargets.map(target => {
- const github = lsRemoteHead(target.github)
- const gitee = lsRemoteHead(target.gitee)
- return {
- ...target,
- github,
- gitee
- }
- })
- const npmRows = entries.map(entry => ({
- packageName: entry.packageName,
- expectedVersion: entry.version,
- npm: npmVersion(entry.packageName)
- }))
- const failures = [
- !sourceRemoteHead.ok && `source remote ${branchRoles.sourceRemote.url} missing ${sourceBranch}`,
- sourceRemoteHead.ok &&
- !sourceHeadInSync &&
- `local HEAD ${sourceHead.slice(0, 12)} does not match source ${sourceBranch} ${sourceRemoteHead.hash.slice(0, 12)}`,
- ...branchRows.map(row => !row.remote.ok && `source remote missing branch ${row.name}`),
- !publicGithubHead.ok && 'open-source main GitHub repository missing main',
- !release.ok && `GitHub Release v${rootPackage.version} missing`,
- release.ok && !release.hasManifest && `GitHub Release v${rootPackage.version} missing release-manifest.json`,
- release.ok && !release.hasStatus && `GitHub Release v${rootPackage.version} missing release-status.json`,
- release.ok && !release.hasSchema && `GitHub Release v${rootPackage.version} missing release-status.schema.json`,
- ...remoteRows.flatMap(row => [
- !row.github.ok && `${row.id} GitHub repository missing`
- ]),
- ...npmRows.map(row => {
- if (!row.npm.ok) {
- return `${row.packageName} is not published to npm`
- }
- if (row.npm.version !== row.expectedVersion) {
- return `${row.packageName} npm version ${row.npm.version} !== ${row.expectedVersion}`
- }
- return false
- })
- ].filter(Boolean)
- const gapReport = describeReleaseGaps(failures)
- const nextActions = [
- failures.some(failure => failure.includes('npm')) &&
- 'Run `npm login` / passkey in an interactive terminal, then `pnpm release:ecosystem:publish`.',
- 'Use `pnpm release:channels:preflight -- --skip-external` for a fast local release gate.'
- ].filter(Boolean)
- console.log(`# File Viewer Ecosystem Status\n`)
- console.log(`Generated: ${new Date().toISOString()}`)
- console.log(`Workspace: \`${sourceRoot}\``)
- console.log(`Version target: \`${rootPackage.version}\`\n`)
- if (fast) {
- console.log(`Mode: \`fast\` (git ${gitTimeout}ms, npm ${npmTimeout}ms, gh ${ghTimeout}ms)\n`)
- }
- console.log(`## Aggregate Source\n`)
- console.log(`- Local checkout branch: \`${localBranch}\``)
- console.log(`- Source branch: \`${sourceBranch}\``)
- console.log(`- Local HEAD: ${formatHash(sourceHead)}`)
- console.log(
- `- Remote \`${branchRoles.sourceRemote.name}/${sourceBranch}\`: ${formatHash(sourceRemoteHead.hash)} (${sourceHeadInSync ? 'ok' : okLabel(sourceRemoteHead.ok)})\n`
- )
- console.log(`## Local Worktrees\n`)
- console.log(`| branch | path | head | status |`)
- console.log(`| --- | --- | --- | --- |`)
- for (const worktree of worktrees) {
- const status = worktreeStatus(worktree.path)
- const notes = []
- if (worktree.branch === sourceBranch && !sourceBranchWorktreeInSync) {
- notes.push('stale-source-worktree')
- }
- if (status.dirty) {
- notes.push(`${status.changeCount} local changes`)
- }
- console.log(
- `| \`${worktree.branch || '(detached)'}\` | \`${worktree.path}\` | ${formatHash(worktree.head)} | ${notes.length ? notes.join(', ') : 'clean'} |`
- )
- }
- if (sourceBranchWorktree && sourceBranchWorktreeStatus?.dirty) {
- console.log(
- `\n> Note: \`${sourceBranch}\` is checked out at \`${sourceBranchWorktree.path}\` with ${sourceBranchWorktreeStatus.changeCount} local change(s). Treat \`${sourceRoot}\` and remote \`${branchRoles.sourceRemote.name}/${sourceBranch}\` as the release audit source until that worktree is reconciled.`
- )
- }
- console.log()
- console.log(`## Source Branch Roles\n`)
- console.log(`| branch | role | package | remote |`)
- console.log(`| --- | --- | --- | --- |`)
- for (const row of branchRows) {
- console.log(
- `| \`${row.name}\` | ${row.role} | \`${row.packageName}\` | ${formatHash(row.remote.hash)} (${okLabel(row.remote.ok)}) |`
- )
- }
- console.log()
- console.log(`## Open-Source Main Repository\n`)
- console.log(`- GitHub main: ${formatHash(publicGithubHead.hash)} (${okLabel(publicGithubHead.ok)})`)
- console.log(`- Gitee main: ${formatHash(publicGiteeHead.hash)} (${syncLabel(publicGithubHead, publicGiteeHead, publicMainTreeStatus)})`)
- if (publicGithubHead.ok && publicGiteeHead.ok && publicGithubHead.hash !== publicGiteeHead.hash) {
- console.log(
- `- Mirror tree sync: ${publicMainTreeStatus.mode}${publicMainTreeStatus.referenceTree ? `, reference ${formatHash(publicMainTreeStatus.referenceTree)}` : ''}${publicMainTreeStatus.giteeTree ? `, gitee ${formatHash(publicMainTreeStatus.giteeTree)}` : ''}${publicMainTreeStatus.error ? `, ${publicMainTreeStatus.error}` : ''}`
- )
- }
- console.log(
- `- GitHub Release: \`${release.tag}\` (${okLabel(release.ok)}, assets: ${release.assetCount}, manifest: ${okLabel(release.hasManifest)}, status: ${okLabel(release.hasStatus)}, schema: ${okLabel(release.hasSchema)}${release.url ? `, ${release.url}` : ''})\n`
- )
- console.log(`## Core And Component Repositories\n`)
- console.log(`| id | package | GitHub | Gitee |`)
- console.log(`| --- | --- | --- | --- |`)
- for (const row of remoteRows) {
- console.log(
- `| ${row.id} | \`${row.packageName}\` | ${formatHash(row.github.hash)} (${okLabel(row.github.ok)}) | ${formatHash(row.gitee.hash)} (${okLabel(row.gitee.ok)}) |`
- )
- }
- console.log()
- console.log(`## npm Registry\n`)
- console.log(`| package | target | npm |`)
- console.log(`| --- | --- | --- |`)
- for (const row of npmRows) {
- console.log(
- `| \`${row.packageName}\` | \`${row.expectedVersion}\` | ${row.npm.ok ? `\`${row.npm.version}\`` : '`unpublished`'} |`
- )
- }
- console.log()
- if (failures.length) {
- console.log(`## Gap Summary\n`)
- console.log(`- Total: ${gapReport.summary.total}`)
- console.log(`- External blockers: ${gapReport.summary.externalBlockers}`)
- console.log(`- Local actionable: ${gapReport.summary.localActionable}`)
- console.log(`- External blocker channels: ${gapReport.summary.externalBlockerChannels.length ? gapReport.summary.externalBlockerChannels.join(', ') : 'none'}`)
- console.log()
- console.log(`| channel | count | external blocker |`)
- console.log(`| --- | --- | --- |`)
- for (const [channel, count] of Object.entries(gapReport.summary.byChannel).sort(([left], [right]) => left.localeCompare(right))) {
- const externalBlocker = gapReport.details.some(detail => detail.channel === channel && detail.externalBlocker)
- console.log(`| ${channel} | ${count} | ${externalBlocker ? 'yes' : 'no'} |`)
- }
- console.log()
- console.log(`## Remaining Gaps\n`)
- for (const detail of gapReport.details) {
- console.log(`- [${detail.channel}] ${detail.message}`)
- }
- console.log()
- }
- console.log(`## Next Actions\n`)
- for (const action of nextActions) {
- console.log(`- ${action}`)
- }
- console.log()
- if (strict && failures.length) {
- process.exitCode = 1
- }
|