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",
"version": "1.3.2",
"version": "1.3.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.3.2",
"version": "1.3.6",
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",

View File

@@ -1,6 +1,6 @@
{
"name": "erpauto",
"version": "1.3.2",
"version": "1.3.6",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
@@ -21,6 +21,7 @@
"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",
"release:prepare": "node scripts/prepare-release.js",
"release:publish": "node scripts/publish-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){}})\"",
"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.
--source <dir> Local release root. Default: release-output
--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
`)
}
@@ -41,6 +43,10 @@ function assert(condition, message) {
}
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
}
function loadConfig(configPath) {
const raw = fs.readFileSync(configPath, 'utf-8')
const parsed = yaml.load(raw)
@@ -56,22 +62,6 @@ function loadConfig(configPath) {
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'
@@ -95,6 +85,69 @@ async function readRemoteText(client, bucket, key) {
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() {
const args = parseArgs(process.argv.slice(2))
if (args.help || args.h) {
@@ -108,9 +161,12 @@ async function main() {
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 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)
assert(fs.existsSync(uploadRoot), `Upload root not found: ${uploadRoot}`)
const channelRoot = path.join(sourceRoot, updateConfig.basePrefix, channel)
assert(fs.existsSync(channelRoot), `Upload root not found: ${channelRoot}`)
const client = new S3Client({
region: updateConfig.region || 'us-east-1',
@@ -122,27 +178,32 @@ async function main() {
forcePathStyle: true
})
const files = walkFiles(uploadRoot)
assert(files.length > 0, `No files found under ${uploadRoot}`)
const { files, indexKey } = collectUploadFiles(
sourceRoot,
channelRoot,
channel,
version,
Boolean(args['full-sync']),
updateConfig.basePrefix
)
assert(files.length > 0, `No files found under ${channelRoot}`)
for (const filePath of files) {
const relative = path.relative(sourceRoot, filePath).replace(/\\/g, '/')
const body = fs.readFileSync(filePath)
for (const file of files) {
const body = fs.readFileSync(file.absolutePath)
await client.send(
new PutObjectCommand({
Bucket: updateConfig.bucket,
Key: relative,
Key: file.relativeKey,
Body: body,
ContentType: getContentType(filePath)
ContentType: getContentType(file.absolutePath)
})
)
console.log(`Uploaded: ${relative}`)
console.log(`Uploaded: ${file.relativeKey}`)
}
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}`)