feat: add one-click release publishing

This commit is contained in:
Misaka
2026-03-20 22:56:31 +08:00
parent 6d4b5efc95
commit 9add23f6ed
4 changed files with 321 additions and 30 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.3.2", "version": "1.3.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erpauto", "name": "erpauto",
"version": "1.3.2", "version": "1.3.6",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.929.0", "@aws-sdk/client-s3": "^3.929.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.3.2", "version": "1.3.6",
"description": "An Electron application with React and TypeScript", "description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "example.com", "author": "example.com",
@@ -21,6 +21,7 @@
"build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac", "build:mac": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --mac",
"build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux", "build:linux": "chcp 65001 && npm run prebuild && electron-vite build && electron-builder --linux",
"release:prepare": "node scripts/prepare-release.js", "release:prepare": "node scripts/prepare-release.js",
"release:publish": "node scripts/publish-release.js",
"release:upload": "node scripts/upload-release.js", "release:upload": "node scripts/upload-release.js",
"prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"", "prebuild": "node -e \"const fs=require('fs');['dist','out'].forEach(d=>{try{fs.rmSync(d,{recursive:true})}catch(e){}})\"",
"test": "vitest", "test": "vitest",

229
scripts/publish-release.js Normal file
View File

@@ -0,0 +1,229 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const { spawnSync } = require('child_process')
function usage() {
console.log(`
Usage:
node scripts/publish-release.js --channel <stable|preview> [options]
Options:
--channel <stable|preview> Release channel. Required.
--changelog <file> Optional changelog file. Default: auto-resolve from version
--config <file> Config file path. Default: config.yaml
--help Show this help message
`)
}
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 readYaml(filePath) {
return yaml.load(fs.readFileSync(filePath, 'utf-8'))
}
function resolveChangelogPath(version, explicitPath) {
if (explicitPath) {
return path.resolve(process.cwd(), explicitPath)
}
const rebuildPath = path.resolve(process.cwd(), 'docs', 'releases', `${version}-rebuild.md`)
if (fs.existsSync(rebuildPath)) {
return rebuildPath
}
return path.resolve(process.cwd(), 'docs', 'releases', `${version}.md`)
}
function validateUpdateConfig(configPath) {
assert(fs.existsSync(configPath), `Config file not found: ${configPath}`)
const parsed = readYaml(configPath)
const update = parsed && parsed.update
assert(update, 'Missing update config in config file')
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')
assert(update.basePrefix, 'update.basePrefix is required')
return update
}
function hasConflictMarker(filePath) {
const content = fs.readFileSync(filePath, 'utf-8')
return (
content.includes('<<<<<<<') || content.includes('=======') || content.includes('>>>>>>>')
)
}
function validateVersionFiles(version) {
const packageJsonPath = path.resolve(process.cwd(), 'package.json')
const packageLockPath = path.resolve(process.cwd(), 'package-lock.json')
assert(fs.existsSync(packageLockPath), `package-lock.json not found: ${packageLockPath}`)
assert(!hasConflictMarker(packageJsonPath), 'package.json contains merge conflict markers')
assert(!hasConflictMarker(packageLockPath), 'package-lock.json contains merge conflict markers')
const packageLock = readJson(packageLockPath)
assert(packageLock.version === version, 'package-lock.json version does not match package.json')
const rootPackage = packageLock.packages && packageLock.packages['']
if (rootPackage && rootPackage.version) {
assert(rootPackage.version === version, 'package-lock root package version does not match package.json')
}
}
function runStep(name, command, args, envOverrides = {}) {
console.log('')
console.log(`==> ${name}`)
console.log(`$ ${command} ${args.join(' ')}`)
let result
if (process.platform === 'win32') {
const shellCommand = [command, ...args]
.map((arg) => (/\s|"/.test(arg) ? `"${String(arg).replace(/"/g, '\\"')}"` : arg))
.join(' ')
result = spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', shellCommand], {
cwd: process.cwd(),
env: { ...process.env, ...envOverrides },
stdio: 'inherit'
})
} else {
result = spawnSync(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...envOverrides },
stdio: 'inherit'
})
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(`${name} failed with exit code ${result.status}`)
}
}
function readPreparedIndex(channel, basePrefix) {
const indexPath = path.resolve(
process.cwd(),
'release-output',
...basePrefix.split('/'),
channel,
'index.json'
)
assert(fs.existsSync(indexPath), `Prepared index not found: ${indexPath}`)
const parsed = readJson(indexPath)
assert(parsed && Array.isArray(parsed.releases), `Invalid prepared index: ${indexPath}`)
return { indexPath, parsed }
}
function summarizeRelease(version, channel, releaseEntry, indexPath) {
console.log('')
console.log('Release published successfully.')
console.log(`Version: ${version}`)
console.log(`Channel: ${channel}`)
console.log(`Artifact: ${releaseEntry.artifactKey}`)
console.log(`SHA256: ${releaseEntry.sha256}`)
console.log(`Changelog: ${releaseEntry.changelogKey}`)
console.log(`Prepared Index: ${indexPath}`)
console.log(`Published At: ${releaseEntry.publishedAt}`)
}
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 packageJsonPath = path.resolve(process.cwd(), 'package.json')
assert(fs.existsSync(packageJsonPath), `package.json not found: ${packageJsonPath}`)
const packageJson = readJson(packageJsonPath)
const version = packageJson.version
assert(version, 'package.json version is required')
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
const updateConfig = validateUpdateConfig(configPath)
validateVersionFiles(version)
const changelogPath = resolveChangelogPath(version, args.changelog)
assert(fs.existsSync(changelogPath), `Changelog not found: ${changelogPath}`)
runStep('Build Windows package', 'npm', ['run', 'build:win'], {
APP_CHANNEL: channel
})
runStep('Prepare release package', 'npm', [
'run',
'release:prepare',
'--',
'--channel',
channel,
'--changelog',
changelogPath
])
const { indexPath, parsed } = readPreparedIndex(channel, updateConfig.basePrefix)
const latestRelease = parsed.releases[0]
assert(latestRelease, 'Prepared index does not contain any release entry')
assert(
latestRelease.version === version && latestRelease.channel === channel,
`Prepared index latest entry mismatch: expected ${version}/${channel}, got ${latestRelease.version}/${latestRelease.channel}`
)
runStep('Upload release package', 'npm', [
'run',
'release:upload',
'--',
'--channel',
channel,
'--verify'
])
summarizeRelease(version, channel, latestRelease, indexPath)
}
try {
main()
} catch (error) {
console.error(`publish-release failed: ${error.message}`)
process.exit(1)
}

View File

@@ -14,6 +14,8 @@ Options:
--channel <stable|preview> Release channel. Required. --channel <stable|preview> Release channel. Required.
--source <dir> Local release root. Default: release-output --source <dir> Local release root. Default: release-output
--config <file> Config file path. Default: config.yaml --config <file> Config file path. Default: config.yaml
--version <x.y.z> Release version. Default: package.json version
--full-sync Upload the whole channel directory instead of current release only
--verify Read back remote index.json after upload --verify Read back remote index.json after upload
`) `)
} }
@@ -41,6 +43,10 @@ function assert(condition, message) {
} }
} }
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
function loadConfig(configPath) { function loadConfig(configPath) {
const raw = fs.readFileSync(configPath, 'utf-8') const raw = fs.readFileSync(configPath, 'utf-8')
const parsed = yaml.load(raw) const parsed = yaml.load(raw)
@@ -56,22 +62,6 @@ function loadConfig(configPath) {
return update 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) { function getContentType(filePath) {
const ext = path.extname(filePath).toLowerCase() const ext = path.extname(filePath).toLowerCase()
if (ext === '.json') return 'application/json; charset=utf-8' if (ext === '.json') return 'application/json; charset=utf-8'
@@ -95,6 +85,69 @@ async function readRemoteText(client, bucket, key) {
return Buffer.concat(chunks).toString('utf-8') return Buffer.concat(chunks).toString('utf-8')
} }
function buildFileDescriptor(sourceRoot, absolutePath) {
return {
absolutePath,
relativeKey: path.relative(sourceRoot, absolutePath).replace(/\\/g, '/')
}
}
function collectUploadFiles(sourceRoot, channelRoot, channel, version, fullSync, basePrefix) {
const indexPath = path.join(channelRoot, 'index.json')
assert(fs.existsSync(indexPath), `Index not found: ${indexPath}`)
if (fullSync) {
const results = []
const entries = fs.readdirSync(channelRoot, { withFileTypes: true })
function walk(dirPath) {
const dirEntries = fs.readdirSync(dirPath, { withFileTypes: true })
for (const entry of dirEntries) {
const fullPath = path.join(dirPath, entry.name)
if (entry.isDirectory()) {
walk(fullPath)
} else {
results.push(buildFileDescriptor(sourceRoot, fullPath))
}
}
}
for (const entry of entries) {
const fullPath = path.join(channelRoot, entry.name)
if (entry.isDirectory()) {
walk(fullPath)
} else {
results.push(buildFileDescriptor(sourceRoot, fullPath))
}
}
return { files: results, indexKey: `${basePrefix}/${channel}/index.json` }
}
const parsedIndex = readJson(indexPath)
assert(parsedIndex && Array.isArray(parsedIndex.releases), `Invalid index file: ${indexPath}`)
const releaseEntry = parsedIndex.releases.find(
(release) => release.version === version && release.channel === channel
)
assert(releaseEntry, `Release entry not found in index for ${channel}/${version}`)
const artifactPath = path.resolve(sourceRoot, releaseEntry.artifactKey)
const changelogPath = path.resolve(sourceRoot, releaseEntry.changelogKey)
assert(fs.existsSync(artifactPath), `Artifact not found: ${artifactPath}`)
assert(fs.existsSync(changelogPath), `Changelog not found: ${changelogPath}`)
return {
files: [
buildFileDescriptor(sourceRoot, artifactPath),
buildFileDescriptor(sourceRoot, changelogPath),
buildFileDescriptor(sourceRoot, indexPath)
],
indexKey: `${basePrefix}/${channel}/index.json`
}
}
async function main() { async function main() {
const args = parseArgs(process.argv.slice(2)) const args = parseArgs(process.argv.slice(2))
if (args.help || args.h) { if (args.help || args.h) {
@@ -108,9 +161,12 @@ async function main() {
const sourceRoot = path.resolve(process.cwd(), args.source || 'release-output') const sourceRoot = path.resolve(process.cwd(), args.source || 'release-output')
const configPath = path.resolve(process.cwd(), args.config || 'config.yaml') const configPath = path.resolve(process.cwd(), args.config || 'config.yaml')
const updateConfig = loadConfig(configPath) const updateConfig = loadConfig(configPath)
const packageJson = readJson(path.resolve(process.cwd(), 'package.json'))
const version = args.version || packageJson.version
assert(version, 'Missing release version')
const uploadRoot = path.join(sourceRoot, updateConfig.basePrefix, channel) const channelRoot = path.join(sourceRoot, updateConfig.basePrefix, channel)
assert(fs.existsSync(uploadRoot), `Upload root not found: ${uploadRoot}`) assert(fs.existsSync(channelRoot), `Upload root not found: ${channelRoot}`)
const client = new S3Client({ const client = new S3Client({
region: updateConfig.region || 'us-east-1', region: updateConfig.region || 'us-east-1',
@@ -122,27 +178,32 @@ async function main() {
forcePathStyle: true forcePathStyle: true
}) })
const files = walkFiles(uploadRoot) const { files, indexKey } = collectUploadFiles(
assert(files.length > 0, `No files found under ${uploadRoot}`) sourceRoot,
channelRoot,
channel,
version,
Boolean(args['full-sync']),
updateConfig.basePrefix
)
assert(files.length > 0, `No files found under ${channelRoot}`)
for (const filePath of files) { for (const file of files) {
const relative = path.relative(sourceRoot, filePath).replace(/\\/g, '/') const body = fs.readFileSync(file.absolutePath)
const body = fs.readFileSync(filePath)
await client.send( await client.send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: updateConfig.bucket, Bucket: updateConfig.bucket,
Key: relative, Key: file.relativeKey,
Body: body, Body: body,
ContentType: getContentType(filePath) ContentType: getContentType(file.absolutePath)
}) })
) )
console.log(`Uploaded: ${relative}`) console.log(`Uploaded: ${file.relativeKey}`)
} }
if (args.verify) { if (args.verify) {
const indexKey = `${updateConfig.basePrefix}/${channel}/index.json`
const remoteIndex = await readRemoteText(client, updateConfig.bucket, indexKey) const remoteIndex = await readRemoteText(client, updateConfig.bucket, indexKey)
console.log('') console.log('')
console.log(`Verified remote index: ${indexKey}`) console.log(`Verified remote index: ${indexKey}`)