2 Commits
Author SHA1 Message Date
lofyer 81f7e4f9e5 chore: release 0.9.1
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub Release (push) Canceled after 0s
2026-08-14 14:42:49 +08:00
lofyer d070091350 fix: stage target runtime dependencies for packaging 2026-08-14 14:42:31 +08:00
5 changed files with 389 additions and 3 deletions
+259
View File
@@ -1,9 +1,12 @@
const { spawn } = require('node:child_process') const { spawn } = require('node:child_process')
const { createHash } = require('node:crypto')
const { const {
createReadStream, createReadStream,
createWriteStream, createWriteStream,
existsSync, existsSync,
closeSync, closeSync,
mkdirSync,
mkdtempSync,
openSync, openSync,
readFileSync, readFileSync,
readSync, readSync,
@@ -14,6 +17,7 @@ const {
writeFileSync writeFileSync
} = require('node:fs') } = require('node:fs')
const { once } = require('node:events') const { once } = require('node:events')
const { tmpdir } = require('node:os')
const { const {
basename, basename,
dirname, dirname,
@@ -36,6 +40,9 @@ const root = join(__dirname, '..')
const packageJson = JSON.parse( const packageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8') readFileSync(join(root, 'package.json'), 'utf8')
) )
const packageLock = JSON.parse(
readFileSync(join(root, 'package-lock.json'), 'utf8')
)
const productName = packageJson.build?.productName ?? packageJson.name const productName = packageJson.build?.productName ?? packageJson.name
const releaseRoot = join(root, 'dist', 'release') const releaseRoot = join(root, 'dist', 'release')
const manifestName = 'release-manifest.json' const manifestName = 'release-manifest.json'
@@ -205,6 +212,29 @@ function npmInvocation(environment = process.env) {
prefixArgs: [environment.npm_execpath] prefixArgs: [environment.npm_execpath]
} }
} }
const npmCli = [
join(
dirname(process.execPath),
'node_modules',
'npm',
'bin',
'npm-cli.js'
),
join(
dirname(dirname(process.execPath)),
'lib',
'node_modules',
'npm',
'bin',
'npm-cli.js'
)
].find((candidate) => existsSync(candidate))
if (npmCli) {
return {
command: process.execPath,
prefixArgs: [npmCli]
}
}
return { return {
command: process.platform === 'win32' ? 'npm.cmd' : 'npm', command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
prefixArgs: [] prefixArgs: []
@@ -246,6 +276,38 @@ function run(command, args, environment = process.env) {
}) })
} }
function runCapture(command, args, environment = process.env) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, {
cwd: root,
env: environment,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout = `${stdout}${chunk}`.slice(-1024 * 1024)
})
child.stderr.on('data', (chunk) => {
stderr = `${stderr}${chunk}`.slice(-64 * 1024)
})
child.once('error', rejectRun)
child.once('close', (code) => {
if (code === 0) {
resolveRun(stdout)
return
}
const error = new Error(
`命令执行失败(code ${code ?? 1}):${command} ${args.join(' ')}`
)
error.outputTail = stderr
rejectRun(error)
})
})
}
function buildElectronBuilderArguments(options, outputDirectory) { function buildElectronBuilderArguments(options, outputDirectory) {
const definition = platformDefinitions[options.platform] const definition = platformDefinitions[options.platform]
const builderFormats = [...new Set( const builderFormats = [...new Set(
@@ -443,6 +505,195 @@ function targetHarnessPaths(options) {
} }
} }
function targetRuntimePackageNames(options) {
const target = targetHarnessPaths(options)
return [
target.koffiPackage,
...(target.landlockPackage ? [target.landlockPackage] : [])
]
}
function lockedTargetRuntimePackage(packageName) {
const expectedVersion =
packageJson.optionalDependencies?.[packageName]
const lockEntry =
packageLock.packages?.[`node_modules/${packageName}`]
if (
typeof expectedVersion !== 'string' ||
lockEntry?.version !== expectedVersion ||
typeof lockEntry.resolved !== 'string' ||
typeof lockEntry.integrity !== 'string'
) {
throw new Error(
`目标 Runtime 依赖未完整锁定:${packageName}`
)
}
return {
name: packageName,
version: expectedVersion,
resolved: lockEntry.resolved,
integrity: lockEntry.integrity
}
}
function parsePackedPackageMetadata(output, expected) {
let entries
try {
entries = JSON.parse(output)
} catch (error) {
throw new Error(
`目标 Runtime 依赖 npm pack 输出无效:${expected.name}`,
{ cause: error }
)
}
const metadata =
Array.isArray(entries) && entries.length === 1
? entries[0]
: undefined
if (
metadata?.name !== expected.name ||
metadata.version !== expected.version ||
metadata.integrity !== expected.integrity ||
typeof metadata.filename !== 'string' ||
basename(metadata.filename) !== metadata.filename
) {
throw new Error(
`目标 Runtime 依赖 npm pack 元数据不匹配:${expected.name}`
)
}
return metadata
}
function verifyArchiveIntegrity(filePath, expectedIntegrity) {
const match = /^(sha(?:256|384|512))-(\S+)$/u.exec(
expectedIntegrity
)
if (!match) {
throw new Error(`不支持的依赖完整性格式:${expectedIntegrity}`)
}
const actual = createHash(match[1])
.update(readFileSync(filePath))
.digest('base64')
if (actual !== match[2]) {
throw new Error(`目标 Runtime 依赖完整性校验失败:${filePath}`)
}
}
function installedPackageMatches(packageName, expectedVersion) {
const manifestPath = join(
root,
'node_modules',
...packageName.split('/'),
'package.json'
)
if (!existsSync(manifestPath)) {
return false
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
if (
manifest.name !== packageName ||
manifest.version !== expectedVersion
) {
throw new Error(
`目标 Runtime 依赖版本错误:${packageName}`
)
}
return true
}
async function stageTargetRuntimeDependencies(options) {
const missing = targetRuntimePackageNames(options)
.map(lockedTargetRuntimePackage)
.filter(
(dependency) =>
!installedPackageMatches(
dependency.name,
dependency.version
)
)
if (missing.length === 0) {
return () => undefined
}
const stagingRoot = mkdtempSync(
join(tmpdir(), 'goodbuddy-release-dependencies-')
)
const stagedDirectories = []
const cleanup = () => {
for (const directory of stagedDirectories.reverse()) {
rmSync(directory, { recursive: true, force: true })
}
rmSync(stagingRoot, { recursive: true, force: true })
}
try {
const npm = npmInvocation()
for (const [index, dependency] of missing.entries()) {
const archiveDirectory = join(
stagingRoot,
`package-${index}`
)
mkdirSync(archiveDirectory, { recursive: true })
const output = await runCapture(npm.command, [
...npm.prefixArgs,
'pack',
`${dependency.name}@${dependency.version}`,
'--ignore-scripts',
'--json',
'--pack-destination',
archiveDirectory
])
const metadata = parsePackedPackageMetadata(
output,
dependency
)
const archivePath = join(
archiveDirectory,
metadata.filename
)
verifyArchiveIntegrity(archivePath, dependency.integrity)
const destination = join(
root,
'node_modules',
...dependency.name.split('/')
)
if (existsSync(destination)) {
throw new Error(
`拒绝覆盖目标 Runtime 依赖目录:${destination}`
)
}
mkdirSync(destination, { recursive: true })
stagedDirectories.push(destination)
await run('tar', [
'-xzf',
archivePath,
'-C',
destination,
'--strip-components',
'1'
])
if (
!installedPackageMatches(
dependency.name,
dependency.version
)
) {
throw new Error(
`目标 Runtime 依赖暂存失败:${dependency.name}`
)
}
console.log(
`已暂存目标 Runtime 依赖:${dependency.name}@${dependency.version}`
)
}
return cleanup
} catch (error) {
cleanup()
throw error
}
}
function verifyHarnessPackage( function verifyHarnessPackage(
resources, resources,
options, options,
@@ -1278,6 +1529,7 @@ async function main(argv = process.argv.slice(2)) {
} }
rmSync(stagingDirectory, { recursive: true, force: true }) rmSync(stagingDirectory, { recursive: true, force: true })
let cleanupTargetDependencies = () => undefined
try { try {
if (!options.skipBuild) { if (!options.skipBuild) {
const npm = npmInvocation() const npm = npmInvocation()
@@ -1286,6 +1538,8 @@ async function main(argv = process.argv.slice(2)) {
[...npm.prefixArgs, 'run', 'build'] [...npm.prefixArgs, 'run', 'build']
) )
} }
cleanupTargetDependencies =
await stageTargetRuntimeDependencies(options)
await run( await run(
process.execPath, process.execPath,
builderArguments, builderArguments,
@@ -1322,6 +1576,7 @@ async function main(argv = process.argv.slice(2)) {
) )
} }
} finally { } finally {
cleanupTargetDependencies()
rmSync(stagingDirectory, { recursive: true, force: true }) rmSync(stagingDirectory, { recursive: true, force: true })
} }
} }
@@ -1333,9 +1588,13 @@ module.exports = {
detectBinaryArchitecture, detectBinaryArchitecture,
normalizePlatform, normalizePlatform,
parseArguments, parseArguments,
parsePackedPackageMetadata,
platformDefinitions, platformDefinitions,
replaceOutput, replaceOutput,
stageTargetRuntimeDependencies,
targetRuntimePackageNames,
verifyHarnessPackage, verifyHarnessPackage,
verifyArchiveIntegrity,
verifyUnpackedOutput, verifyUnpackedOutput,
verifyArtifacts, verifyArtifacts,
verifyArtifactSignature, verifyArtifactSignature,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.0", "version": "0.9.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.0", "version": "0.9.1",
"license": "0BSD", "license": "0BSD",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "0.25.1", "@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "goodbuddy", "name": "goodbuddy",
"version": "0.9.0", "version": "0.9.1",
"private": true, "private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes", "description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy", "desktopName": "GoodBuddy",
+32
View File
@@ -1,6 +1,38 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"releases": [ "releases": [
{
"version": "0.9.1",
"releasedAt": "2026-08-14",
"notes": {
"zh-CN": {
"features": [
"新增 DeepSeek Harness 开发者预览 Runtime,支持安全的 OpenAI 兼容 Chat Completions 连接、任意模型名称、Skills 与自定义 MCP,并保留 Ask 只读、Execute 授权、取消和工作区沙箱边界。",
"新增自然语言配置工具,助手可查看脱敏设置并规划应用偏好、Skill 与 MCP 变更;配置只在原生审批通过后应用,凭据不会暴露给模型。",
"GoodBuddy 原创源代码现以 0BSD 许可证开放,并补充英文项目总览以及自动化、会话监督、分区记忆、并行实验和持续学习等产品规划文档。"
],
"fixes": [
"修复模型工具调用期间流式推理内容可能折叠或不可见的问题,并让推理区域在生成时自动跟随最新内容。",
"修复从通道入口打开设置时未定位到所选企业微信、钉钉或微信页面的问题,并更正微信二维码扫码提示。",
"优化简体中文界面的系统字体、字号和行高,改善 Windows 与 macOS 上的小字号可读性和排版一致性。",
"修复 Windows arm64 发布构建缺少目标架构原生依赖、导致该平台安装包无法生成的问题。"
]
},
"en-US": {
"features": [
"Added the DeepSeek Harness preview Runtime with secure OpenAI-compatible Chat Completions, arbitrary model names, Skills, and custom MCP, while preserving read-only Ask, authorized Execute, cancellation, and workspace sandbox boundaries.",
"Added natural-language configuration tools that let the assistant inspect sanitized settings and plan changes to app preferences, Skills, and MCP; changes apply only after native approval without exposing credentials to models.",
"Released GoodBuddy's original source under the 0BSD License and expanded the English overview and product plans for automation, conversation supervision, partitioned memory, parallel experiments, and continuous learning."
],
"fixes": [
"Fixed streamed reasoning becoming hidden during model tool calls, and kept the reasoning panel following the latest content while generation is in progress.",
"Fixed channel shortcuts opening the wrong settings page for WeCom, DingTalk, or WeChat, and corrected the WeChat QR-code scan guidance.",
"Improved Simplified Chinese typography with platform-native UI fonts, refined sizes, and line heights for clearer, more consistent text on Windows and macOS.",
"Fixed missing target-architecture native dependencies in Windows arm64 release builds, which prevented installers for that platform from being produced."
]
}
}
},
{ {
"version": "0.9.0", "version": "0.9.0",
"releasedAt": "2026-08-14", "releasedAt": "2026-08-14",
+95
View File
@@ -6,6 +6,7 @@ import {
rmSync, rmSync,
writeFileSync writeFileSync
} from 'node:fs' } from 'node:fs'
import { createHash } from 'node:crypto'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
@@ -44,11 +45,27 @@ interface ReleaseBuilderModule {
arguments_: string[], arguments_: string[],
environment?: { platform: string; arch: string } environment?: { platform: string; arch: string }
) => ReleaseOptions ) => ReleaseOptions
parsePackedPackageMetadata: (
output: string,
expected: {
name: string
version: string
integrity: string
}
) => {
name: string
version: string
integrity: string
filename: string
}
replaceOutput: ( replaceOutput: (
stagingDirectory: string, stagingDirectory: string,
destination: string, destination: string,
options: ReleaseOptions options: ReleaseOptions
) => void ) => void
targetRuntimePackageNames: (
options: ReleaseOptions
) => string[]
verifyHarnessPackage: ( verifyHarnessPackage: (
resources: string, resources: string,
options: ReleaseOptions, options: ReleaseOptions,
@@ -73,6 +90,10 @@ interface ReleaseBuilderModule {
options: ReleaseOptions options: ReleaseOptions
) => void ) => void
verifyPortableZip: (filePath: string) => void verifyPortableZip: (filePath: string) => void
verifyArchiveIntegrity: (
filePath: string,
expectedIntegrity: string
) => void
writeManifest: ( writeManifest: (
directory: string, directory: string,
options: ReleaseOptions options: ReleaseOptions
@@ -296,6 +317,80 @@ describe('release build arguments', () => {
}) })
}) })
it('identifies the native packages required by each release target', () => {
expect(
releaseBuilder.targetRuntimePackageNames({
...windowsOptions,
arch: 'arm64'
})
).toEqual(['@koromix/koffi-win32-arm64'])
expect(
releaseBuilder.targetRuntimePackageNames({
...windowsOptions,
platform: 'linux',
formats: ['AppImage', 'deb']
})
).toEqual([
'@koromix/koffi-linux-x64',
'@deepseek-ai/node-addon-landlock-run-linux-x64'
])
})
it('validates packed target dependency metadata and archive integrity', () => {
const directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-release-dependency-test-')
)
const archive = join(directory, 'target.tgz')
const contents = Buffer.from('locked target dependency')
writeFileSync(archive, contents)
const integrity = `sha512-${createHash('sha512')
.update(contents)
.digest('base64')}`
const expected = {
name: '@koromix/koffi-win32-arm64',
version: '3.1.4',
integrity
}
try {
expect(
releaseBuilder.parsePackedPackageMetadata(
JSON.stringify([
{
...expected,
filename: 'koffi-win32-arm64.tgz'
}
]),
expected
)
).toMatchObject({
...expected,
filename: 'koffi-win32-arm64.tgz'
})
releaseBuilder.verifyArchiveIntegrity(archive, integrity)
expect(() =>
releaseBuilder.verifyArchiveIntegrity(
archive,
`sha512-${Buffer.alloc(64).toString('base64')}`
)
).toThrow('完整性校验失败')
expect(() =>
releaseBuilder.parsePackedPackageMetadata(
JSON.stringify([
{
...expected,
version: '3.1.5',
filename: 'koffi-win32-arm64.tgz'
}
]),
expected
)
).toThrow('元数据不匹配')
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
it('keeps Web3D test fixtures out of release resources', () => { it('keeps Web3D test fixtures out of release resources', () => {
const packageJson = require('../package.json') as { const packageJson = require('../package.json') as {
build: { build: {