const path = require('path'); const fs = require('fs/promises'); const exec = require('util').promisify(require('child_process').exec); const readline = require('readline/promises'); const semver = require('semver'); // 用于语义化版本号处理 // 映射包名到defaults.json中的配置键名(用于版本同步) const CONFIG_KEYS = { '@tryghost/portal': 'portal', '@tryghost/sodo-search': 'sodoSearch', '@tryghost/comments-ui': 'comments', '@tryghost/announcement-bar': 'announcementBar', '@tryghost/signup-form': 'signupForm' }; const CURRENT_DIR = process.cwd(); // 当前工作目录 // 读取当前项目的package.json路径和内容 const packageJsonPath = path.join(CURRENT_DIR, 'package.json'); const packageJson = require(packageJsonPath); // 从package.json中提取应用名称和当前版本 const APP_NAME = packageJson.name; const APP_VERSION = packageJson.version; /** * 安全执行命令的工具函数 * 捕获错误并返回包含stdout和stderr的对象(避免进程直接崩溃) */ async function safeExec(command) { try { return await exec(command); } catch (err) { return { stdout: err.stdout, stderr: err.stderr }; } } /** * 确保当前应用在允许发布的列表中 * 检查APP_NAME是否存在于CONFIG_KEYS的键名中 */ async function ensureEnabledApp() { const ENABLED_APPS = Object.keys(CONFIG_KEYS); if (!ENABLED_APPS.includes(APP_NAME)) { console.error(`${APP_NAME} is not enabled, please modify ${__filename}`); process.exit(1); // 非允许的应用,退出进程 } } /** * 确保不在main分支上执行发布 * 防止直接在主分支上修改代码 */ async function ensureNotOnMain() { const currentGitBranch = await safeExec(`git branch --show-current`); if (currentGitBranch.stderr) { console.error(`There was an error checking the current git branch`) console.error(`${currentGitBranch.stderr}`); process.exit(1); // 分支检查失败,退出进程 } if (currentGitBranch.stdout.trim() === 'main') { console.error(`The release can not be done on the "main" branch`) process.exit(1); // 在main分支上,退出进程 } } /** * 确保当前git工作区干净(无未提交的更改) * 避免发布时包含未跟踪的修改 */ async function ensureCleanGit() { const localGitChanges = await safeExec(`git status --porcelain`); if (localGitChanges.stderr) { console.error(`There was an error checking the local git status`) console.error(`${localGitChanges.stderr}`); process.exit(1); // 状态检查失败,退出进程 } if (localGitChanges.stdout) { console.error(`You have local git changes - are you sure you're ready to release?`) console.error(`${localGitChanges.stdout}`); process.exit(1); // 有未提交的更改,退出进程 } } /** * 获取用户输入的版本更新类型(patch/minor/major)并计算新版本号 * 默认使用patch更新 */ async function getNewVersion() { const rl = readline.createInterface({input: process.stdin, output: process.stdout}); const bumpTypeInput = await rl.question('Is this a patch, minor or major (patch)? '); rl.close(); const bumpType = bumpTypeInput.trim().toLowerCase() || 'patch'; if (!['patch', 'minor', 'major'].includes(bumpType)) { console.error(`Unknown bump type ${bumpTypeInput} - expected one of "patch", "minor, "major"`) process.exit(1); // 无效的更新类型,退出进程 } return semver.inc(APP_VERSION, bumpType); // 使用semver计算新版本号 } /** * 更新全局配置文件中的应用版本 * 只保留主版本号和次版本号(如1.2.3 → 1.2) */ async function updateConfig(newVersion) { // 定位全局配置文件defaults.json const defaultConfigPath = path.resolve(__dirname, '../../ghost/core/core/shared/config/defaults.json'); const defaultConfig = require(defaultConfigPath); // 获取当前应用在配置文件中的键名 const configKey = CONFIG_KEYS[APP_NAME]; // 更新配置中的版本(只保留major和minor) defaultConfig[configKey].version = `${semver.major(newVersion)}.${semver.minor(newVersion)}`; // 写回配置文件(保留4空格缩进) await fs.writeFile(defaultConfigPath, JSON.stringify(defaultConfig, null, 4) + '\n'); } /** * 更新当前应用的package.json版本号 */ async function updatePackageJson(newVersion) { const newPackageJson = Object.assign({}, packageJson, { version: newVersion }); // 写回package.json(保留2空格缩进) await fs.writeFile(packageJsonPath, JSON.stringify(newPackageJson, null, 2) + '\n'); } /** * 生成更新日志(Changelog) * 包含i18n更新信息和相关的提交记录 */ async function getChangelog(newVersion) { const rl = readline.createInterface({input: process.stdin, output: process.stdout}); const i18nChangesInput = await rl.question('Does this release contain i18n updates (Y/n)? '); rl.close(); // 判断是否包含国际化更新 const i18nChanges = i18nChangesInput.trim().toLowerCase() !== 'n'; let changelogItems = []; // 如果有i18n更新,添加到更新日志 if (i18nChanges) { changelogItems.push('Updated i18n translations'); } // 获取当前应用目录下最近50条提交记录(限制在当前目录) const lastFiftyCommits = await safeExec(`git log -n 50 --oneline -- .`); if (lastFiftyCommits.stderr) { console.error(`There was an error getting the last 50 commits`); process.exit(1); // 获取提交记录失败,退出进程 } // 解析提交记录,查找上一次发布的提交 const lastFiftyCommitsList = lastFiftyCommits.stdout.split('\n'); const releaseRegex = new RegExp(`Released ${APP_NAME} v${APP_VERSION}`); const indexOfLastRelease = lastFiftyCommitsList.findIndex((commitLine) => { const commitMessage = commitLine.slice(11); // 移除哈希前缀 return releaseRegex.test(commitMessage); }); if (indexOfLastRelease === -1) { // 未找到上一次发布记录,使用最近20条相关提交作为 fallback console.warn(`Could not find commit for previous release. Will include recent commits affecting this app.`); const recentCommits = await safeExec(`git log -n 20 --pretty=format:"%h%n%B__SPLIT__" -- .`); if (recentCommits.stderr) { console.error(`There was an error getting recent commits`); process.exit(1); } const recentCommitsList = recentCommits.stdout.split('__SPLIT__'); // 筛选包含Linear链接的提交(通常是已处理的任务) const recentCommitsWhichMentionLinear = recentCommitsList.filter((commitBlock) => { return commitBlock.includes('https://linear.app/ghost'); }); // 生成提交记录的GitHub链接 const commitChangelogItems = recentCommitsWhichMentionLinear.map((commitBlock) => { const lines = commitBlock.split('\n'); if (!lines.length || !lines[0].trim()) { return null; // 跳过无效条目 } const hash = lines[0].trim(); return `https://github.com/TryGhost/Ghost/commit/${hash}`; }).filter(Boolean); // 过滤空值 changelogItems.push(...commitChangelogItems); } else { // 找到上一次发布记录,获取两次发布之间的提交 const lastReleaseCommit = lastFiftyCommitsList[indexOfLastRelease]; const lastReleaseCommitHash = lastReleaseCommit.slice(0, 10); // 提取提交哈希 // 获取从上次发布到现在的提交(限制在当前目录) const commitsSinceLastRelease = await safeExec(`git log ${lastReleaseCommitHash}..HEAD --pretty=format:"%h%n%B__SPLIT__" -- .`); if (commitsSinceLastRelease.stderr) { console.error(`There was an error getting commits since the last release`); process.exit(1); } const commitsSinceLastReleaseList = commitsSinceLastRelease.stdout.split('__SPLIT__'); // 筛选包含Linear链接的提交 const commitsSinceLastReleaseWhichMentionLinear = commitsSinceLastReleaseList.filter((commitBlock) => { return commitBlock.includes('https://linear.app/ghost'); }); // 生成提交记录的GitHub链接 const commitChangelogItems = commitsSinceLastReleaseWhichMentionLinear.map((commitBlock) => { const lines = commitBlock.split('\n'); if (!lines.length || !lines[0].trim()) { return null; // 跳过无效条目 } const hash = lines[0].trim(); return `https://github.com/TryGhost/Ghost/commit/${hash}`; }).filter(Boolean); // 过滤空值 changelogItems.push(...commitChangelogItems); } // 格式化更新日志列表 const changelogList = changelogItems.map(item => ` - ${item}`).join('\n'); return `Changelog for v${APP_VERSION} -> ${newVersion}: \n${changelogList}`; } /** * 主函数:执行发布流程 */ async function main() { // 前置检查 await ensureEnabledApp(); // 检查应用是否允许发布 await ensureNotOnMain(); // 检查是否不在main分支 await ensureCleanGit(); // 检查工作区是否干净 console.log(`Running release for ${APP_NAME}`); console.log(`Current version is ${APP_VERSION}`); // 获取新版本号 const newVersion = await getNewVersion(); console.log(`Bumping to version ${newVersion}`); // 生成更新日志 const changelog = await getChangelog(newVersion); // 更新package.json并提交 await updatePackageJson(newVersion); await exec(`git add package.json`); // 更新全局配置并提交 await updateConfig(newVersion); await exec(`git add ../../ghost/core/core/shared/config/defaults.json`); // 创建发布提交 await exec(`git commit -m 'Released ${APP_NAME} v${newVersion}\n\n${changelog}'`); // 提示用户检查并提交PR console.log(`Release commit created - please double check it and use "git commit --amend" to make any changes before opening a PR to merge into main`) } // 执行主函数 main();