diff --git a/examples/.cd.jobs.yml b/examples/.cd.jobs.yml new file mode 100644 index 0000000..1db0745 --- /dev/null +++ b/examples/.cd.jobs.yml @@ -0,0 +1,69 @@ +# Webhook-CD 配置文件示例 +# 已支持的特性: + +name: webhook-cd-deployment-jobs + +# 环境变量配置 +env: + # 全局环境变量 + DEPLOY_ENV: develop + +# 分支配置 - 监听这些分支的合并请求,非必须 +jobs: + - on: + # 多个 Job 的目标分支不能重复 + branches: + - features/some-change + target: develop + deploy: + steps: + - name: 代码检查 + run: + - echo "检查完成" + - name: 构建项目 + run: + - echo "构建完成" + scripts: + pre_deploy: + - echo "开始部署前检查..." + - docker --version + - node --version + - npm --version + + post_deploy: + - echo "部署完成,执行后续任务..." + - pm2 status + - df -h + + cleanup: + - echo "执行清理任务..." + # - docker system prune -f + # - npm cache clean --force + - on: + branches: + - features/some-other-change + target: master + deploy: + steps: + - name: 代码检查 + run: + - echo "检查完成" + - name: 构建项目 + run: + - echo "构建完成" + scripts: + pre_deploy: + - echo "开始部署前检查..." + - docker --version + - node --version + - npm --version + + post_deploy: + - echo "部署完成,执行后续任务..." + - pm2 status + - df -h + + cleanup: + - echo "执行清理任务..." + # - docker system prune -f + # - npm cache clean --force diff --git a/index.d.ts b/index.d.ts index 87ed137..0d67d98 100644 --- a/index.d.ts +++ b/index.d.ts @@ -4,6 +4,8 @@ export type WebhookEvent = 'merge_created'; export type TriggerTypes = 'cli' | 'webhook'; +export type Status = 'wait' | 'closed'; + export type TaskInfo = { platform: Platform, team: string, @@ -15,7 +17,85 @@ export type TaskInfo = { trigger: TriggerTypes, router: string, json?: Record, - status?: 'wait' | 'closed', + status?: Status, created_at?: Date, updated_at?: Date, }; + +export interface PlatformHandler { + getCloneLink(task: Task): string; + getDefaultBranch(task: Task): Promise; + getMergeRequest(task: Task): Promise>; +} + +export interface Task { + team: string; + project: string; + repo: string; + target: string; +} + +export interface DeployItem { + on: { + branches: string[]; + exclude_branches: string[]; + }; + deploy: { + steps: { + name: string; + run: string | string[]; + }[]; + }; + scripts: { + pre_deploy: string | string[]; + post_deploy: string | string[]; + cleanup: string | string[]; + }; +} + +export type DeployConfig = { + name: string; + env?: Record; + jobs?: DeployItem[]; +} & DeployItem; + +export interface DeployJob { + items: DeployItem[]; + deployConfig: DeployConfig; +} + +export type DeploymentOptions = { + workspace: string, + cwd: string, + repo: string, + platform: Platform, + task: Task, + status: Status, + deployConfig: DeployConfig +}; + +export class Deployment { + workspace: string; + cwd: string; + repo: string; + platform: Platform; + task: Task; + status: Status; + deployConfig: DeployConfig; + constructor(platformHandler: PlatformHandler, options: DeploymentOptions); + resolveJobs(): Promise; + execJobs(jobs: DeployJob[]): Promise; +} + + +export interface Context { + platform: Platform; + task: Task; + workspace: string; + cwd: string; + repo: string; + platform: Platform; + status: Status; + deployConfig: DeployConfig; + deploy: Deployment; +} diff --git a/src/deploy.js b/src/deploy.js new file mode 100644 index 0000000..bab00f9 --- /dev/null +++ b/src/deploy.js @@ -0,0 +1,205 @@ +'use strict'; + +const is = require('@axiosleo/cli-tool/src/helper/is'); +const { _matchesBranch, _yaml } = require('./utils'); +const { _foreach, _shell, _exec } = require('@axiosleo/cli-tool/src/helper/cmd'); +const git = require('./git.js'); +const { debug } = require('@axiosleo/cli-tool'); +const { printer } = require('@axiosleo/cli-tool'); +const { _exists } = require('@axiosleo/cli-tool/src/helper/fs.js'); +const path = require('path'); + +async function execSteps(label, scripts, cwd) { + if (!scripts) { + return true; + } + printer.yellow(label + ': ').println(); + try { + await _foreach(scripts, async (script) => { + if (is.string(script)) { + await _exec(script, cwd); + } else if (is.array(script)) { + await _foreach(script, async (line) => { + printer.yellow(line.name + ': ').println(); + await _exec(line.run, line.dir ? path.join(cwd, line.dir) : cwd); + }); + } else if (is.object(script) && !is.empty(script.run)) { + if (is.array(script.run)) { + await _foreach(script.run, async (line) => { + await _exec(line, script.dir ? path.join(cwd, script.dir) : cwd); + }); + } else { + await _exec(script.run, script.dir ? path.join(cwd, script.dir) : cwd); + } + } else { + debug.log({ script }); + printer.print('不支持的脚本类型: ').red(script).println(); + return false; + } + }); + return true; + } catch (err) { + printer.print('执行 ' + label + ' 脚本失败: ').red(err.message).println(); + return false; + } +} + +class Deployment { + /** + * @param {import("../index.d.ts").PlatformHandler} platformHandler + */ + constructor(platformHandler, options) { + this.platformHandler = platformHandler; + this.workspace = options.workspace; + this.cwd = options.cwd; + this.repo = options.repo; + this.platform = options.platform; + this.task = options.task; + this.status = options.status; + /** @type {import("../index.d.ts").DeployConfig} */ + this.deployConfig = options.deployConfig; + + /** @type {import("../index.d.ts").DeployJob} */ + this.jobs = []; + } + + async resolveJobs() { + const mergeList = (await this.platformHandler.getMergeRequest(this.task) || []).filter((i) => + !(i.source === i.target) + ); + if (!mergeList || !mergeList.length) { + throw new Error('没有需要部署的分支'); + } + if (this.deployConfig && is.array(this.deployConfig.jobs)) { + // 多个分支配置 + const jobs = this.deployConfig.jobs; + jobs.forEach((job) => { + const items = _matchesBranch(job.target, job, mergeList); + if (!items || !items.length) { + return; + } + this.jobs.push({ + target: job.target, + items, + deployConfig: { + env: this.deployConfig.env, + ...job + } + }); + }); + } else { + const items = _matchesBranch(this.task.target, this.deployConfig, mergeList); + if (items && items.length) { + this.jobs.push({ + target: this.task.target, + items, + deployConfig: this.deployConfig + }); + } + } + return this.jobs; + } + + async execJobs() { + if (!this.jobs || !this.jobs.length) { + throw new Error('没有需要部署的任务'); + } + // 检查多个 jobs 的 target 是否相同 + if (this.jobs.length > 1) { + const targets = this.jobs.map(job => job.target); + if (targets.length !== new Set(targets).size) { + throw new Error('多个 jobs 的 target 不能相同'); + } + } + let success = true; + await _foreach(this.jobs, async (job) => { + try { + // 基于 target 分支创建临时分支 + let { target, items, deployConfig } = job; + if (!target) { + target = this.task.target.indexOf('refs/heads/') === -1 ? this.task.target : this.task.target.replace('refs/heads/', ''); + } + let tmpBranch; + try { + await git.branch.checkout(target, true, this.cwd); + tmpBranch = `tmp/commit-${await git.commit.id(this.cwd)}`; + } catch (err) { + debug.log(err); + success = false; + return; + } + await git.branch.reset(target, this.cwd); + await git.branch.clear(this.cwd, false); + await _shell(`git checkout -b ${tmpBranch}`, this.cwd, false, false); + + // 合并代码 + items = items.sort((a, b) => { + if (a.source === b.source) { + return 0; + } + return a.source > b.source ? 1 : -1; + }); + printer.yellow('需要合并的分支: ').println(); + items.forEach((item) => { + printer.yellow(item.source).print(' -> ').green(item.target).println(); + }); + let curr = '', last = null; + await _foreach(items, async (item) => { + curr = item; + let source = item.source.indexOf('refs/heads/') === -1 ? item.source : item.source.replace('refs/heads/', ''); + await _exec(`git merge origin/${source} -m 'merge: ${source}'`, this.cwd); + last = curr; + if (!await git.branch.exist(source, this.cwd)) { + printer.yellow('分支不存在: ').red(source).println(); + printer.print('Merge ').yellow(`${items.map(i => i.source).join(' | ')}`).println(' branches failed. last branch: ' + last.source); + return; + } + }); + + // 合并代码后,再读一次 .cd.yml 文件,避免配置文件被修改,如果未修改,则与主分支形同 + const ymlConfigFile = path.join(this.cwd, '.cd.yml'); + if (!await _exists(ymlConfigFile)) { + printer.warning('没有找到 .cd.yml 文件,可能已被删除,请检查文件是否存在'); + return; + } + deployConfig = await _yaml(ymlConfigFile); + if (!deployConfig) { + printer.warning('读取 .cd.yml 文件失败'); + return; + } + + // 合并配置 + const env = deployConfig.env || {}; + Object.keys(env).forEach((key) => { + process.env[key] = env[key]; + }); + + const { pre_deploy, post_deploy, cleanup } = deployConfig.scripts || {}; + const deploy = deployConfig.deploy || []; + const steps = deploy.steps || []; + + // 执行部署操作 + if (!await execSteps('执行预部署脚本', pre_deploy, this.cwd)) { + throw new Error('执行预部署脚本失败'); + } + if (!await execSteps('执行部署脚本', steps, this.cwd)) { + throw new Error('执行部署脚本失败'); + } + if (!await execSteps('执行后部署脚本', post_deploy, this.cwd)) { + throw new Error('执行后部署脚本失败'); + } + if (!await execSteps('执行清理脚本', cleanup, this.cwd)) { + throw new Error('执行清理脚本失败'); + } + } catch (err) { + debug.log(err); + printer.print('执行 ').yellow(job.target).print(' 部署操作失败: ').red(err.message).println(); + success = false; + return; + } + }); + return success; + } +} + +module.exports = Deployment; diff --git a/src/flow.js b/src/flow.js index f108fac..9fcf9b7 100644 --- a/src/flow.js +++ b/src/flow.js @@ -2,87 +2,34 @@ const path = require('path'); const { debug } = require('@axiosleo/cli-tool'); -const { _exec, _shell, _foreach } = require('@axiosleo/cli-tool/src/helper/cmd'); +const { _exec } = require('@axiosleo/cli-tool/src/helper/cmd'); const git = require('./git'); const { printer } = require('@axiosleo/cli-tool'); const { _exists, _mkdir } = require('@axiosleo/cli-tool/src/helper/fs'); const { _yaml } = require('./utils'); -const is = require('@axiosleo/cli-tool/src/helper/is'); const config = require('../config'); - -/** - * 通配符匹配函数 - * @param {string} pattern - 模式字符串,支持 * 通配符 - * @param {string} str - 要匹配的字符串 - * @returns {boolean} 是否匹配 - */ -function wildcardMatch(pattern, str) { - // 将通配符模式转换为正则表达式 - const regexPattern = pattern - .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // 转义特殊字符 - .replace(/\\\*/g, '.*'); // 将 \* 替换为 .* - - const regex = new RegExp(`^${regexPattern}$`); - return regex.test(str); -} - -/** - * 判断分支是否匹配分支模式列表 - * @param {string} branchName - 分支名称 - * @param {string[]} patterns - 分支模式列表 - * @returns {boolean} 是否匹配 - */ -function matchesBranchPatterns(branchName, patterns) { - if (!patterns || !Array.isArray(patterns)) { - return false; - } - - // 移除 refs/heads/ 前缀(如果存在) - const cleanBranchName = branchName.replace(/^refs\/heads\//, ''); - - return patterns.some(pattern => wildcardMatch(pattern, cleanBranchName)); -} - -/** - * @typedef {Object} Task - * @property {string} team - 团队名称 - * @property {string} project - 项目名称 - * @property {string} repo - 仓库名称 - * @property {string} target - 目标分支 - */ - -/** - * @typedef {Object} PlatformHandler - * @property {function(Task): string} getCloneLink - 获取仓库克隆链接 - * @property {function(Task): Promise} getDefaultBranch - 获取默认分支 - * @property {function(Task): Promise>} getMergeRequest - 获取合并请求 - */ +const Deployment = require('./deploy'); /** * 检查是否有分支冲突的情况 - * @param {*} context + * @param {import('../index.d.ts').Context} context */ -async function reset(context) { +async function init(context) { printer.warning('-'.repeat(100)); let { platform, task } = context; if (!config.platforms.includes(platform)) { throw new Error('不支持的平台: ' + platform); } const PlatformHandlerClass = require(`./platform/${platform}`); - /** @type {PlatformHandler} */ + /** @type {import('../index.d.ts').PlatformHandler} */ const platformHandler = new PlatformHandlerClass(); - let defaultBranch = await platformHandler.getDefaultBranch(task); - if (task.target !== defaultBranch) { - printer.print('目标分支: ').yellow(task.target).println(' 不是默认分支: ' + defaultBranch); - return 'end'; - } + const cwd = path.join(config.workspace, `./${task.repo}`); - let repo = context.task.repo; - if (!await _exists(context.workspace)) { - await _mkdir(context.workspace); + if (!await _exists(config.workspace)) { + await _mkdir(config.workspace); } - context.cwd = path.join(context.workspace, `./${repo}`); - printer.print('CWD: ').yellow(context.cwd).println(); + printer.print('CWD: ').yellow(cwd).println(); + context.cwd = cwd; // 如果仓库目录不存在,则克隆仓库 if (!await _exists(context.cwd)) { @@ -91,186 +38,44 @@ async function reset(context) { } else { await git.branch.reset(task.target, context.cwd); } - - let tmpBranch, cwd = context.cwd; - let target = task.target.indexOf('refs/heads/') === -1 ? task.target : task.target.replace('refs/heads/', ''); - context.target = target; - try { - await git.branch.checkout(target, true, cwd); - tmpBranch = `tmp/commit-${await git.commit.id(cwd)}`; - } catch (err) { - debug.log(err); - return false; - } - await git.branch.reset(target, cwd); - await git.branch.clear(cwd, false); - await _shell(`git checkout -b ${tmpBranch}`, cwd, false, false); - context.items = await platformHandler.getMergeRequest(task); -} - -async function readConfig(context) { - printer.warning('-'.repeat(100)); - const ymlConfigFile = path.join(context.cwd, '.cd.yml'); - let items = []; + const ymlConfigFile = path.join(cwd, '.cd.yml'); if (!await _exists(ymlConfigFile)) { printer.warning('没有找到 .cd.yml 文件,请检查文件是否存在'); - context.success = false; return 'end'; } const ymlConfig = await _yaml(ymlConfigFile); - printer.print('读取到配置: ').green(ymlConfig.name || '未命名').println(); - context.deployConfig = ymlConfig; - items = context.items.filter(i => { - if (i.source === i.target) { - return false; - } - if (i.source === `${context.target}`) { - return false; - } - - // 检查分支是否匹配配置的分支模式 - if (context.deployConfig.on && context.deployConfig.on.branches) { - const isIncluded = matchesBranchPatterns(i.source, context.deployConfig.on.branches); - - // 检查是否在排除列表中 - if (context.deployConfig.on.exclude_branches) { - const isExcluded = matchesBranchPatterns(i.source, context.deployConfig.on.exclude_branches); - return isIncluded && !isExcluded; - } - - return isIncluded; - } - - // 如果没有配置分支规则,默认包含所有分支(除了目标分支) - return true; + context.deploy = new Deployment(platformHandler, { + workspace: config.workspace, + cwd, + repo: task.repo, + platform: platform, + task: task, + status: 'wait', + deployConfig: ymlConfig }); - context.items = items; - if (!items || !items.length) { - debug.log('error', '没有需要部署的分支'); - return 'end'; - } } -async function merge(context) { - printer.warning('-'.repeat(100)); - let curr = ''; - let { items, cwd } = context; - let last = null; +/** + * 执行部署 + * @param {import('../index.d.ts').Context} context + * @returns + */ +async function run(context) { try { - items = items.sort((a, b) => { - if (a.source === b.source) { - return 0; - } - return a.source > b.source ? 1 : -1; - }); - printer.yellow('需要合并的分支: ').println(); - items.forEach((item) => { - printer.yellow(item.source).print(' -> ').green(item.target).println(); - }); - await _foreach(items, async (item) => { - curr = item; - let source = item.source.indexOf('refs/heads/') === -1 ? item.source : item.source.replace('refs/heads/', ''); - await _exec(`git merge origin/${source} -m 'merge: ${source}'`, cwd); - last = curr; - if (!await git.branch.exist(source, cwd)) { - throw new Error('分支不存在: ' + source); - } - }); - context.success = true; + context.jobs = await context.deploy.resolveJobs(); + context.success = await context.deploy.execJobs(); } catch (err) { - if (last === null) { - last = items[0]; - } - printer.print('Merge ').yellow(`${items.map(i => i.source).join(' | ')}`).println(' branches failed. last branch: ' + last.source); debug.log(err); context.success = false; - } -} - -async function execSteps(label, scripts, context) { - if (!scripts) { - return true; - } - printer.yellow(label + ': ').println(); - try { - await _foreach(scripts, async (script) => { - if (is.string(script)) { - await _exec(script, context.cwd); - } else if (is.array(script)) { - await _foreach(script, async (line) => { - printer.yellow(line.name + ': ').println(); - await _exec(line.run, line.dir ? path.join(context.cwd, line.dir) : context.cwd); - }); - } else if (is.object(script) && !is.empty(script.run)) { - if (is.array(script.run)) { - await _foreach(script.run, async (line) => { - await _exec(line, script.dir ? path.join(context.cwd, script.dir) : context.cwd); - }); - } else { - await _exec(script.run, script.dir ? path.join(context.cwd, script.dir) : context.cwd); - } - } else { - debug.log({ script }); - printer.print('不支持的脚本类型: ').red(script).println(); - return false; - } - }); - return true; - } catch (err) { - printer.print('执行 ' + label + ' 脚本失败: ').red(err.message).println(); - return false; - } -} - -async function deploy(context) { - printer.warning('-'.repeat(100)); - printer.print('开始部署: ').green(context.task.repo).println(); - try { - // 合并代码后,再读一次 .cd.yml 文件,避免配置文件被修改 - const ymlConfigFile = path.join(context.cwd, '.cd.yml'); - if (!await _exists(ymlConfigFile)) { - printer.warning('没有找到 .cd.yml 文件,可能已被删除,请检查文件是否存在'); - return; - } - context.deployConfig = await _yaml(ymlConfigFile); - if (!context.deployConfig) { - printer.warning('读取 .cd.yml 文件失败'); - context.success = false; - return; - } - const deployConfig = context.deployConfig; - // 加载 env - const env = deployConfig.env || {}; - Object.keys(env).forEach((key) => { - process.env[key] = env[key]; - }); - const { pre_deploy, post_deploy, cleanup } = deployConfig.scripts || {}; - const deploy = deployConfig.deploy || []; - const steps = deploy.steps || []; - if (!await execSteps('执行预部署脚本', pre_deploy, context)) { - context.success = false; - return 'end'; - } - if (!await execSteps('执行部署脚本', steps, context)) { - context.success = false; - return 'end'; - } - if (!await execSteps('执行后部署脚本', post_deploy, context)) { - context.success = false; - return 'end'; - } - if (!await execSteps('执行清理脚本', cleanup, context)) { - context.success = false; - return 'end'; - } - } catch (error) { - debug.log(error); - context.success = false; - context.error = error; + context.error = err; return 'end'; } } +/** + * 结束部署 + * @param {import('../index.d.ts').Context} context + */ async function end(context) { printer.warning('-'.repeat(100)); if (context.success) { @@ -281,9 +86,7 @@ async function end(context) { } module.exports = { - reset, - readConfig, - merge, - deploy, - end + init, + run, + end, }; diff --git a/src/utils.js b/src/utils.js index 3696d74..ac3f030 100644 --- a/src/utils.js +++ b/src/utils.js @@ -72,9 +72,63 @@ async function _yaml(file) { return null; } +/** + * 通配符匹配函数 + * @param {string} pattern - 模式字符串,支持 * 通配符 + * @param {string} str - 要匹配的字符串 + * @returns {boolean} 是否匹配 + */ +function _wildcardMatch(pattern, str) { + // 将通配符模式转换为正则表达式 + const regexPattern = pattern + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // 转义特殊字符 + .replace(/\\\*/g, '.*'); // 将 \* 替换为 .* + + const regex = new RegExp(`^${regexPattern}$`); + return regex.test(str); +} + +/** + * 判断分支是否匹配分支模式列表 + * @param {string} branchName - 分支名称 + * @param {string[]} patterns - 分支模式列表 + * @returns {boolean} 是否匹配 + */ +function _matchesBranchPatterns(branchName, patterns) { + if (!patterns || !Array.isArray(patterns)) { + return false; + } + + // 移除 refs/heads/ 前缀(如果存在) + const cleanBranchName = branchName.replace(/^refs\/heads\//, ''); + + return patterns.some(pattern => _wildcardMatch(pattern, cleanBranchName)); +} + +function _matchesBranch(target, deployConfig, mergeList) { + return mergeList.filter((i) => { + if (i.source === target || i.target !== target) { + return false; + } + if (deployConfig.on && deployConfig.on.branches) { + const isIncluded = _matchesBranchPatterns(i.source, deployConfig.on.branches); + + // 检查是否在排除列表中 + if (deployConfig.on.exclude_branches) { + const isExcluded = _matchesBranchPatterns(i.source, deployConfig.on.exclude_branches); + return isIncluded && !isExcluded; + } + + return isIncluded; + } + return true; + }); +} + module.exports = { _db, _yaml, _merge, - _table + _table, + _matchesBranch };