From 9add23f6edc5af39873ea14f298a1cbc4e7403bf Mon Sep 17 00:00:00 2001 From: Misaka Date: Fri, 20 Mar 2026 22:56:31 +0800 Subject: [PATCH] feat: add one-click release publishing --- package-lock.json | 4 +- package.json | 3 +- scripts/publish-release.js | 229 +++++++++++++++++++++++++++++++++++++ scripts/upload-release.js | 115 ++++++++++++++----- 4 files changed, 321 insertions(+), 30 deletions(-) create mode 100644 scripts/publish-release.js diff --git a/package-lock.json b/package-lock.json index f7ad7ca..64bdf87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a150559..8326b0d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/publish-release.js b/scripts/publish-release.js new file mode 100644 index 0000000..d00559f --- /dev/null +++ b/scripts/publish-release.js @@ -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 [options] + +Options: + --channel Release channel. Required. + --changelog Optional changelog file. Default: auto-resolve from version + --config 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) +} diff --git a/scripts/upload-release.js b/scripts/upload-release.js index 613ab25..627e750 100644 --- a/scripts/upload-release.js +++ b/scripts/upload-release.js @@ -14,6 +14,8 @@ Options: --channel Release channel. Required. --source Local release root. Default: release-output --config Config file path. Default: config.yaml + --version 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}`)