feat: rebuild portable auto-update flow

This commit is contained in:
Misaka
2026-03-20 21:20:28 +08:00
parent 5e50a8fbcf
commit 6ad9463e73
29 changed files with 2333 additions and 13 deletions

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const rootDir = process.cwd()
const sourcePath = path.join(rootDir, 'build', 'PortableUpdater.cs')
const outputDir = path.join(rootDir, 'build', 'bin')
const outputPath = path.join(outputDir, 'portable-updater.exe')
function findCompiler() {
const candidates = [
'C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe',
'C:\\Windows\\Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'
]
return candidates.find((candidate) => fs.existsSync(candidate)) || null
}
function main() {
if (process.platform !== 'win32') {
console.log('Skipping updater compilation on non-Windows platform.')
return
}
if (!fs.existsSync(sourcePath)) {
throw new Error(`Updater source not found: ${sourcePath}`)
}
const compiler = findCompiler()
if (!compiler) {
throw new Error('Unable to find csc.exe for compiling portable-updater.exe')
}
fs.mkdirSync(outputDir, { recursive: true })
const compileArgs = ['/nologo', '/target:exe', '/optimize+', `/out:${outputPath}`, sourcePath]
const result = spawnSync(compiler, compileArgs, {
cwd: rootDir,
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error(`csc.exe failed with exit code ${result.status}`)
}
console.log(`Portable updater compiled successfully: ${outputPath}`)
}
try {
main()
} catch (error) {
console.error(`compile-updater failed: ${error.message}`)
process.exit(1)
}

193
scripts/prepare-release.js Normal file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
function printUsage() {
console.log(`
Usage:
node scripts/prepare-release.js --channel <stable|preview> --changelog <file> [options]
Options:
--channel <stable|preview> Release channel. Required.
--changelog <file> Markdown changelog file. Required.
--artifact <file> Portable exe path. Default: dist/erpauto-portable.exe
--version <x.y.z> Release version. Default: package.json version
--summary <text> Optional short summary for notesSummary
--published-at <ISO date> Optional publish time. Default: current time
--base-prefix <prefix> Default: updates/win-portable
--output <dir> Default: release-output
--existing-index <file> Existing index.json to merge with
`)
}
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (!token.startsWith('--')) continue
const key = token.slice(2)
const value = argv[i + 1]
if (!value || value.startsWith('--')) {
args[key] = true
continue
}
args[key] = value
i += 1
}
return args
}
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true })
}
function sha256File(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256')
const stream = fs.createReadStream(filePath)
stream.on('data', (chunk) => hash.update(chunk))
stream.on('error', reject)
stream.on('end', () => resolve(hash.digest('hex')))
})
}
function loadExistingIndex(existingIndexPath, outputIndexPath) {
const candidate = existingIndexPath || (fs.existsSync(outputIndexPath) ? outputIndexPath : null)
if (!candidate) {
return { releases: [] }
}
const parsed = readJson(candidate)
if (!parsed || !Array.isArray(parsed.releases)) {
throw new Error(`Invalid index file: ${candidate}`)
}
return parsed
}
function sortReleases(releases) {
return [...releases].sort((left, right) => {
const leftParts = String(left.version)
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
const rightParts = String(right.version)
.split('.')
.map((part) => Number.parseInt(part, 10) || 0)
const length = Math.max(leftParts.length, rightParts.length)
for (let i = 0; i < length; i += 1) {
const l = leftParts[i] || 0
const r = rightParts[i] || 0
if (r !== l) {
return r - l
}
}
const leftTime = new Date(left.publishedAt).getTime()
const rightTime = new Date(right.publishedAt).getTime()
if (rightTime !== leftTime) {
return rightTime - leftTime
}
return 0
})
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help || args.h) {
printUsage()
process.exit(0)
}
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
const version = args.version || packageJson.version
const channel = args.channel
const changelogPath = args.changelog
const artifactPath = path.resolve(process.cwd(), args.artifact || 'dist/erpauto-portable.exe')
const basePrefix = args['base-prefix'] || 'updates/win-portable'
const outputRoot = path.resolve(process.cwd(), args.output || 'release-output')
const publishedAt = args['published-at'] || new Date().toISOString()
const summary = args.summary
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
assert(changelogPath, 'Missing --changelog')
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
const resolvedChangelogPath = path.resolve(process.cwd(), changelogPath)
assert(fs.existsSync(resolvedChangelogPath), `Changelog not found: ${resolvedChangelogPath}`)
const artifactStat = fs.statSync(artifactPath)
const sha256 = await sha256File(artifactPath)
const fileName = `erpauto-${version}-${channel}-portable.exe`
const changelogFileName = `${version}.md`
const channelDir = path.join(outputRoot, basePrefix, channel)
const artifactsDir = path.join(channelDir, 'artifacts')
const changelogDir = path.join(channelDir, 'changelogs')
const outputIndexPath = path.join(channelDir, 'index.json')
ensureDir(artifactsDir)
ensureDir(changelogDir)
const targetArtifactPath = path.join(artifactsDir, fileName)
const targetChangelogPath = path.join(changelogDir, changelogFileName)
fs.copyFileSync(artifactPath, targetArtifactPath)
fs.copyFileSync(resolvedChangelogPath, targetChangelogPath)
const releaseEntry = {
version,
channel,
artifactKey: `${basePrefix}/${channel}/artifacts/${fileName}`,
sha256,
size: artifactStat.size,
publishedAt,
changelogKey: `${basePrefix}/${channel}/changelogs/${changelogFileName}`,
...(summary ? { notesSummary: summary } : {})
}
const existingIndex = loadExistingIndex(
args['existing-index'] ? path.resolve(process.cwd(), args['existing-index']) : null,
outputIndexPath
)
const mergedReleases = sortReleases([
releaseEntry,
...existingIndex.releases.filter(
(item) => !(item.version === version && item.channel === channel)
)
])
fs.writeFileSync(
outputIndexPath,
`${JSON.stringify({ releases: mergedReleases }, null, 2)}\n`,
'utf-8'
)
console.log('Release package prepared successfully.')
console.log(`Channel: ${channel}`)
console.log(`Version: ${version}`)
console.log(`Artifact: ${targetArtifactPath}`)
console.log(`Changelog: ${targetChangelogPath}`)
console.log(`Index: ${outputIndexPath}`)
console.log(`SHA256: ${sha256}`)
}
main().catch((error) => {
console.error(`prepare-release failed: ${error.message}`)
process.exit(1)
})

156
scripts/upload-release.js Normal file
View File

@@ -0,0 +1,156 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const { GetObjectCommand, PutObjectCommand, S3Client } = require('@aws-sdk/client-s3')
function usage() {
console.log(`
Usage:
node scripts/upload-release.js --channel <stable|preview> [options]
Options:
--channel <stable|preview> Release channel. Required.
--source <dir> Local release root. Default: release-output
--config <file> Config file path. Default: config.yaml
--verify Read back remote index.json after upload
`)
}
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (!token.startsWith('--')) continue
const key = token.slice(2)
const value = argv[i + 1]
if (!value || value.startsWith('--')) {
args[key] = true
continue
}
args[key] = value
i += 1
}
return args
}
function assert(condition, message) {
if (!condition) {
throw new Error(message)
}
}
function loadConfig(configPath) {
const raw = fs.readFileSync(configPath, 'utf-8')
const parsed = yaml.load(raw)
const update = parsed && parsed.update
assert(update, 'Missing update config in config.yaml')
assert(update.enabled, 'update.enabled is false')
assert(update.endpoint, 'update.endpoint is required')
assert(update.accessKey, 'update.accessKey is required')
assert(update.secretKey, 'update.secretKey is required')
assert(update.bucket, 'update.bucket is required')
return update
}
function walkFiles(dirPath) {
const results = []
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name)
if (entry.isDirectory()) {
results.push(...walkFiles(fullPath))
} else {
results.push(fullPath)
}
}
return results
}
function getContentType(filePath) {
const ext = path.extname(filePath).toLowerCase()
if (ext === '.json') return 'application/json; charset=utf-8'
if (ext === '.md') return 'text/markdown; charset=utf-8'
if (ext === '.exe') return 'application/vnd.microsoft.portable-executable'
return 'application/octet-stream'
}
async function readRemoteText(client, bucket, key) {
const response = await client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key
})
)
const chunks = []
for await (const chunk of response.Body) {
chunks.push(Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf-8')
}
async function main() {
const args = parseArgs(process.argv.slice(2))
if (args.help || args.h) {
usage()
process.exit(0)
}
const channel = args.channel
assert(channel === 'stable' || channel === 'preview', 'Missing or invalid --channel')
const sourceRoot = path.resolve(process.cwd(), args.source || 'release-output')
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
const updateConfig = loadConfig(configPath)
const uploadRoot = path.join(sourceRoot, updateConfig.basePrefix, channel)
assert(fs.existsSync(uploadRoot), `Upload root not found: ${uploadRoot}`)
const client = new S3Client({
region: updateConfig.region || 'us-east-1',
endpoint: updateConfig.endpoint,
credentials: {
accessKeyId: updateConfig.accessKey,
secretAccessKey: updateConfig.secretKey
},
forcePathStyle: true
})
const files = walkFiles(uploadRoot)
assert(files.length > 0, `No files found under ${uploadRoot}`)
for (const filePath of files) {
const relative = path.relative(sourceRoot, filePath).replace(/\\/g, '/')
const body = fs.readFileSync(filePath)
await client.send(
new PutObjectCommand({
Bucket: updateConfig.bucket,
Key: relative,
Body: body,
ContentType: getContentType(filePath)
})
)
console.log(`Uploaded: ${relative}`)
}
if (args.verify) {
const indexKey = `${updateConfig.basePrefix}/${channel}/index.json`
const remoteIndex = await readRemoteText(client, updateConfig.bucket, indexKey)
console.log('')
console.log(`Verified remote index: ${indexKey}`)
console.log(remoteIndex)
}
}
main().catch((error) => {
console.error(`upload-release failed: ${error.message}`)
process.exit(1)
})