Files
hy 1eedabb914 feat: add support for language and service route preferences
- Updated ModelSource type to include 'huggingface'.
- Enhanced MODEL_SOURCE_LABELS for better clarity.
- Introduced defaultModelSource function to determine default source based on service route.
- Modified AppSettings to include language and serviceRoute.
- Created end-to-end tests for language and service route settings.
- Added unit tests for application language and service preferences.
- Updated free models test to reflect new catalog naming conventions.
- Enhanced official API tests to ensure correct handling of service regions.
- Adjusted release artifact tests to accommodate internationalization.
- Updated SQLite migration and storage tests to include new preferences.
2026-10-21 10:05:42 +08:00

238 lines
9.7 KiB
JavaScript

import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import { resolveReleaseAssetPath } from './r2/fragment-contract.mjs'
import { assertLivePublishAllowed } from './storage/config.mjs'
import { parseReleaseTag } from './tag.mjs'
import { fetchWithRetry } from './shared/http.mjs'
export const GITHUB_RELEASE_REPOSITORY = 'Yoan98/Ncurator'
const REPOSITORY_PATH = `repos/${GITHUB_RELEASE_REPOSITORY}`
const REPOSITORY_URL = `https://github.com/${GITHUB_RELEASE_REPOSITORY}`
const execFileAsync = promisify(execFile)
const sha256 = (content) => createHash('sha256').update(content).digest('hex')
export const createGitHubReleasePublisher = ({
root = process.cwd(),
execute = execFileAsync,
fetchImpl = fetchWithRetry
} = {}) => {
const gh = async (args) => {
try {
return await execute('gh', args, {
cwd: root,
timeout: args[0] === 'release' && args[1] === 'upload' ? 60 * 60_000 : 20 * 60_000,
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(
'GitHub CLI is missing; install gh and run gh auth login --hostname github.com'
)
}
throw error
}
}
const api = async (endpoint) =>
JSON.parse((await gh(['api', '--hostname', 'github.com', '--method', 'GET', endpoint])).stdout)
const releaseArgs = (tag) => [tag, '--repo', `github.com/${GITHUB_RELEASE_REPOSITORY}`]
const readRelease = async (tag) => {
try {
// CLI resolves draft releases too; REST's /releases/tags endpoint only finds published ones.
const { databaseId } = JSON.parse(
(await gh(['release', 'view', ...releaseArgs(tag), '--json', 'databaseId'])).stdout
)
return await api(`${REPOSITORY_PATH}/releases/${databaseId}`)
} catch (error) {
if (/^release not found\s*$/mu.test(String(error.stderr || error.message))) return undefined
throw error
}
}
const verifySource = async (bundle) => {
const repository = await api(REPOSITORY_PATH)
if (repository.private || !repository.permissions?.push) {
throw new Error(
`GitHub publication requires write access to public ${GITHUB_RELEASE_REPOSITORY}`
)
}
const reference = await api(`${REPOSITORY_PATH}/git/ref/tags/${bundle.tag}`)
if (reference.object?.type !== 'tag') {
throw new Error(`GitHub ${bundle.tag} must be an annotated tag`)
}
const tag = await api(`${REPOSITORY_PATH}/git/tags/${reference.object.sha}`)
if (tag.object?.type !== 'commit' || tag.object.sha !== bundle.commit) {
throw new Error(`GitHub ${bundle.tag} does not match candidate commit ${bundle.commit}`)
}
return tag.message.trim()
}
const verifyAssets = async (release, files) => {
if (!release || release.prerelease || release.assets.length !== files.length) {
throw new Error('GitHub Release must contain only the installer and SHA256SUMS')
}
for (const file of files) {
const asset = release.assets.find((entry) => entry.name === file.name)
if (!asset || asset.state !== 'uploaded' || asset.size !== file.size) {
throw new Error(`GitHub Release asset is missing or incomplete: ${file.name}`)
}
if (asset.digest) {
if (asset.digest !== `sha256:${file.sha256}`) {
throw new Error(`GitHub Release asset hash differs: ${file.name}`)
}
} else {
// Older GitHub assets may have no digest. Verify their bytes before reusing them.
const directory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-github-verify-'))
try {
await gh([
'release',
'download',
...releaseArgs(release.tag_name),
'--pattern',
file.name,
'--dir',
directory
])
const content = await readFile(path.join(directory, file.name))
if (content.length !== file.size || sha256(content) !== file.sha256) {
throw new Error(`GitHub Release asset hash differs: ${file.name}`)
}
} finally {
await rm(directory, { recursive: true, force: true })
}
}
}
}
return {
async prepare(candidate) {
assertLivePublishAllowed()
if (!['ready', 'published'].includes(candidate.state.status)) {
throw new Error('GitHub publication requires a tested release candidate')
}
const { bundle, directory } = candidate.verified
if (bundle.distribution !== 'international')
throw new Error('GitHub requires the international installer')
const annotation = await verifySource(bundle)
const installer = bundle.files.find((file) => file.role === 'installer')
const githubInstaller = installer
const staging = path.join(candidate.directory, 'github')
await mkdir(staging, { recursive: true })
const installerPath = await resolveReleaseAssetPath(directory, installer)
const uploadPath =
path.basename(installerPath) === githubInstaller.name
? installerPath
: path.join(staging, githubInstaller.name)
if (uploadPath !== installerPath) await copyFile(installerPath, uploadPath)
const sums = `${installer.sha256} ${githubInstaller.name}\n`
const sumsPath = path.join(staging, 'SHA256SUMS')
await writeFile(sumsPath, sums, 'utf8')
const files = [
{ ...githubInstaller, path: uploadPath },
{ name: 'SHA256SUMS', path: sumsPath, size: Buffer.byteLength(sums), sha256: sha256(sums) }
]
const { family, version } = parseReleaseTag(bundle.tag)
const platform = family === 'windows' ? 'Windows x64' : 'macOS arm64'
const notesPath = path.join(staging, 'release-notes.md')
await writeFile(
notesPath,
[
annotation,
'',
`平台:${platform}。下载下方的 ${family === 'windows' ? 'EXE' : 'DMG'} 安装包。`,
'安装包与官网下载版本一致;SHA256SUMS 提供 SHA-256 校验值。',
'',
`[对应源码](${REPOSITORY_URL}/tree/${bundle.tag}) · [官网更新日志](https://www.guanzhangai.cn/zh/changelog)`,
''
].join('\n'),
'utf8'
)
let release = await readRelease(bundle.tag)
if (!release) {
await gh([
'release',
'create',
...releaseArgs(bundle.tag),
'--verify-tag',
'--draft',
'--latest=false',
'--title',
`NCurator ${version} · ${platform}`,
'--notes-file',
notesPath
])
release = await readRelease(bundle.tag)
}
if (!release) throw new Error(`GitHub Release draft is missing: ${bundle.tag}`)
if (release.draft) {
const expectedNames = new Set(files.map((file) => file.name))
if (release.prerelease || release.assets.some((asset) => !expectedNames.has(asset.name))) {
throw new Error('GitHub draft contains unexpected assets or is marked prerelease')
}
for (const file of files) {
const existing = release.assets.find((asset) => asset.name === file.name)
if (existing?.state === 'starter') {
await gh(['release', 'delete-asset', ...releaseArgs(bundle.tag), file.name, '--yes'])
} else if (existing) {
continue
}
await gh(['release', 'upload', ...releaseArgs(bundle.tag), file.path])
}
release = await readRelease(bundle.tag)
}
await verifyAssets(release, files)
process.stdout.write(`[release] GitHub assets verified for ${bundle.tag}\n`)
return { bundle, files, releaseId: release.id }
},
async publish(prepared) {
assertLivePublishAllowed()
const { bundle, files, releaseId } = prepared
await verifySource(bundle)
let release = await readRelease(bundle.tag)
if (release?.id !== releaseId) throw new Error('GitHub Release changed during publication')
await verifyAssets(release, files)
if (release.draft) {
await gh(['release', 'edit', ...releaseArgs(bundle.tag), '--draft=false', '--latest=false'])
release = await readRelease(bundle.tag)
}
if (!release || release.draft) throw new Error('GitHub Release is not public')
await verifyAssets(release, files)
// Anonymous downloads verify that users can fetch the same bytes, independently of CLI auth.
for (const file of files) {
const asset = release.assets.find((entry) => entry.name === file.name)
const response = await fetchImpl(asset.browser_download_url, {
cache: 'no-store',
signal: AbortSignal.timeout(20 * 60_000)
})
if (!response.ok || !response.body) {
throw new Error(`GitHub public download failed: ${file.name} (HTTP ${response.status})`)
}
const hash = createHash('sha256')
let size = 0
for await (const chunk of response.body) {
size += chunk.length
hash.update(chunk)
}
if (size !== file.size || hash.digest('hex') !== file.sha256) {
throw new Error(`GitHub public download hash differs: ${file.name}`)
}
}
process.stdout.write(`[release] GitHub ${bundle.tag} is public: ${release.html_url}\n`)
},
async deleteDraft(tag) {
const release = await readRelease(tag)
if (!release) return
if (!release.draft) throw new Error(`GitHub ${tag} is published; refuse to abort`)
await gh(['release', 'delete', ...releaseArgs(tag), '--yes'])
}
}
}