feat: add one-click release publishing
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
229
scripts/publish-release.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user