Skip to content
69 changes: 69 additions & 0 deletions examples/.cd.jobs.yml
Original file line number Diff line number Diff line change
@@ -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
82 changes: 81 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,7 +17,85 @@ export type TaskInfo = {
trigger: TriggerTypes,
router: string,
json?: Record<string, any>,
status?: 'wait' | 'closed',
status?: Status,
created_at?: Date,
updated_at?: Date,
};

export interface PlatformHandler {
getCloneLink(task: Task): string;
getDefaultBranch(task: Task): Promise<string>;
getMergeRequest(task: Task): Promise<Array<{ source: string, target: string, title: string }>>;
}

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<string, string>;
jobs?: DeployItem[];
} & DeployItem;

export interface DeployJob {
items: DeployItem[];
deployConfig: DeployConfig;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeployJob type missing target field used at runtime

Medium Severity

The DeployJob interface lacks a target property, but the runtime code in resolveJobs pushes objects with target: job.target, and execJobs destructures target from each job. Any consumer relying on this type definition would not know target exists, leading to incorrect type-checking and missing IDE support for a critical field.

Fix in Cursor Fix in Web


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<DeployJob[]>;
execJobs(jobs: DeployJob[]): Promise<boolean>;
}


export interface Context {
platform: Platform;
task: Task;
workspace: string;
cwd: string;
repo: string;
platform: Platform;
status: Status;
deployConfig: DeployConfig;
deploy: Deployment;
}
205 changes: 205 additions & 0 deletions src/deploy.js
Original file line number Diff line number Diff line change
@@ -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)
);
Comment thread
cursor[bot] marked this conversation as resolved.
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,
Comment thread
cursor[bot] marked this conversation as resolved.
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reading YAML overwrites per-job config with top-level config

High Severity

When using multi-job configs, deployConfig = await _yaml(ymlConfigFile) replaces the per-job deployConfig (which contains that job's deploy and scripts) with the entire YAML file contents. For jobs-based configs, the top level has only name, env, and jobs — no scripts or deploy. So deployConfig.scripts and deployConfig.deploy resolve to undefined, and all deployment steps and scripts are silently skipped while reporting success.

Additional Locations (1)
Fix in Cursor Fix in Web

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;
Loading