从零开始搭建自动部署流程(CI/CD)

3,190 阅读6分钟

前言

本文的应用场景主要以小程序为例,不过其他项目也可以参考前面ci/cd的配置自己定制。

一、为什么要做小程序的自动化?

1)主要目的有两个

  1. 去除开发和测试时的重复提交代码这个操作,提升开发体验
  2. 避免在小程序提审和上线时,由于人为操作的失误带来线上事故

2)在接入CI/CD前有遭遇哪些问题

  • 开发体验差。每次修复完bug,都需要把小程序重新提到体验版,会经历以下步骤:
    • 把自己的代码合并到test分支,拉取别人代码合并
    • 我们使用的是uni-app,需要执行run build的命令,再等待几十秒
    • 打开另一个开发工具,找到编译好的目录,打开,再等待十几秒,填写小程序上传时的相关备注,上传完成
    • 打开小程序后台管理的网页,将小程序体验版切换成自己的,再通知到测试
  • 人为操作失误导致的事故。比如忘记拉取其他人代码就直接上传了体验版,或者弄错开发/生产的环境变量了,又或者弄混了直销/分销的代码
  • 每个人电脑上node_modules的依赖可能不一致。目前使用的是npm,且存在公司提供一些工具包
  • 每当有新人入职后,还需要把这一套开发/上线的重复流程给新人培训,避免不熟悉流程的情况下容易造成线上事故
  • 我们产品分为直销/分销和第三方共8个小程序,这一套组合拳下来,直接占用我半天的时间

3)接入了之后改善了哪些问题

  • 开发体验。直接提交完代码就完事,自动编译直分销和第三方多个版本。平均每个人每次节省了5分钟的时间,一天下来就是半个钟了
  • 上线不背锅。无需再繁琐地检查上线流程,一键提审,再也不用担心把测试环境的代码提交到线上啦

二、创建一个简单的CI/CD流程

1)gitlab CI/CD的基本工作流程

  • 注册一台runner机子,填入项目地址和令牌,就可以关联到对应的仓库
  • 当你推送代码的时候,会检查项目下有没有.gitlab-ci.yml文件
  • 当存在.gitlab-ci.yml文件时,会触发hooks在你当前runner机所处的位置,执行yml文件中描述的任务

2)注册一个runner机子

这里分开windows和linux两种版本,实际业务中都是放在linux服务器,windows版可以自己用来熟悉一下yml的一些命令和ci的代码测试

1. windows版(docs.gitlab.com/runner/inst…
  • 从刚刚设置的界面点到windows的安装
  • 有几步需要注意的,我简单说下(其实文档里面都有描述就不再赘述了)
    • 下载完之后,把那个.exe文件重命名为,gitlab-runner.exe方便后面跟着步骤操作
    • 完成下载之后要注册才能开始使用
  • 开始注册
    • 这里就是gitlab项目中cicd的一些配置,主要是令牌和url,后面注册的时候需要复制
    • 注册流程(docs.gitlab.com/runner/regi…
      • 执行命令 ./gitlab-runner.exe register
      • 填入复制的url和令牌
      • 填入描述(备注一下机器的用途就行)
      • 填入runner的tags,后续执行ci操作的时候会根据这个匹配
      • 选择执行脚本的语言,这里选shell,后续有些shell命令相关操作
      • 完成注册。这时候目录下会多一个config.toml文件。刷新gitlab后台会看到一台新的注册机子
  • 启动runner
    • .\gitlab-runner.exe run,执行完后,刷新gitlab后台可以看到机器的小点变绿色了,代表机器在运行。
    • 这时候只要配置了正确的yml文件,后续推送代码的时候,就会触发ci
2. linux版(docs.gitlab.com/runner/regi…
  • 如果是Ubuntu系统dpkg -i gitlab-runner_<arch>.deb,如果是CentOS执行rpm -i gitlab-runner_<arch>.rpm
  • 开始注册,sudo gitlab-runner register
  • 后面的填信息的步骤和windows的是一致的

3)创建一个.gitlab-ci.yml文件

1. windows版

一些步骤直接写在代码的注释中。如果想运行简单的示例,直接把涉及到的业务分支名称和脚本删除即可

# 小程序ci配置,后续上线流程有改动的话,注意重新检查这里的流程
image: node:latest
# 这里是步骤流程,可以自定义顺序
stages:
  - build
  - test
  
cache:
  paths:
    - node_modules/
# 执行安装依赖的任务
install_job:
  # 这里是第一个流程build,目前只有一个并行任务
  stage: build
  script:
    - npm --registry https://registry.npm.taobao.org install
  # 这个对应的是刚刚注册的runner的名字,这个非常重要,决定了你是否能启用某个runner机子
  tags:
    - test_ci
  # 这里是触发的限制
  only:
    # 这个是限制的分支,这里表示只有在这三个分支推送时,才会触发cicd
    refs:
      - master
      - pre-production
      - production
    # 这个是触发的变量,gitlab的默认变量可以去gitlab-cicd的文档中去找
    variables:
      # 这里代表commit的备注中,存在cicd这几个关键词时,才会触发
      - $CI_COMMIT_TITLE =~ /cicd/
# master分支
# 执行编译和上传的任务
master_no_oem_job:
  # 这里是第二个流程build,相同的流程可以并行执行。可并行的任务数量需要设置
  stage: test
  # 项目中小程序编译和上传代码的相关命令,这些就是之前重复的步骤,现在全部在脚本中自动实现
  script:
    # 编译直销/分销环境 
    - npm run copy_diffModule_d    
    # 编译打包小程序代码
    - npm run build:directsale
    # 将打包好的代码上传到对应的直销/分销小程序,这里可以接小程序官方文档提供的api
    - npm run upload:devd
    # 将同样的代码改变环境变量,再上传一份到对应的第三方小程序
    - cross-env NODE_ENV=development isOem=false isThird=true node ./script/ci/uploadCode.js
  tags:
    - test_ci
  only:
    refs:
      - master
    variables:
      - $CI_COMMIT_TITLE =~ /cicd/
# 执行编译和上传的任务,和上一个任务基本一致,只是小程序不一样
master_oem_job:
  stage: test
  script:
    - npm run copy_diffModule_nd    
    - npm run build:no-directsale
    - npm run upload:devnd
    - cross-env NODE_ENV=development isOem=true isThird=true node ./script/ci/uploadCode.js
  tags:
    - test_ci
  only:
    refs:
      - master
    variables:
      - $CI_COMMIT_TITLE =~ /cicd/
2. linux版

和windows版基本一致,有些点需注意

  • 如果项目中是通过软链的方式连接到其他地方的,依赖的安装可能要更换下时机
  • 系统的变量可能在软链的地方拿不到,需要提前输出在目录中
# 小程序ci配置,后续上线流程有改动的话,注意重新检查这里的流程
# 目前仅针对master,pre,pro三个分支进行处理
image: node:latest

stages:
  - build
  - test
before_script:
  # 这里因为业务的原因需要链接到另一台服务器,不需要可以去掉
  - ssh -p 10086 faier@**.**.cc << ssh
  - node -v
  - pwd
  - ls
  - echo $CI_COMMIT_REF_NAME
  - echo $CI_COMMIT_BRANCH
after_script:
  # 这里因为业务的原因需要链接到另一台服务器,不需要可以去掉
  - ssh
# 定义一些通用变量,方便后面引用
variables:
  LIB_DIR: "~/gitlab-cicd/ts-app/libs/yx-miniapp/node_modules/"
  MATER_NO_OEM_DIR: "~/gitlab-cicd/ts-app/master/no_oem/yx-miniapp"
  MATER_OEM_DIR: "~/gitlab-cicd/ts-app/master/oem/yx-miniapp"
  PRE_NO_OEM_DIR: "~/gitlab-cicd/ts-app/pre-production/no_oem/yx-miniapp"
  PRE_OEM_DIR: "~/gitlab-cicd/ts-app/pre-production/oem/yx-miniapp"
  PRO_NO_OEM_DIR: "~/gitlab-cicd/ts-app/production/no_oem/yx-miniapp"
  PRO_OEM_DIR: "~/gitlab-cicd/ts-app/production/oem/yx-miniapp"
# 有包需要更新的时候才执行,相当于自己做一步缓存
# 这里如果不是软链的方式可以按照正常的npm install的流程
install_job:
  stage: build
  script:
    - rm -rf $MATER_NO_OEM_DIR/node_modules/ $MATER_OEM_DIR/node_modules/ $PRE_NO_OEM_DIR/node_modules/ $PRE_OEM_DIR/node_modules/ $PRO_NO_OEM_DIR/node_modules/ $PRO_OEM_DIR/node_modules/
    - cp -r $LIB_DIR $MATER_NO_OEM_DIR
  tags:
    - test_ci
  only:
    refs:
      - master
      - pre-production
      - production
      - waldon_ci_feat
    # git的提交备注中含有reInstall关键词才触发重新安装依赖
    variables:
      - $CI_COMMIT_TITLE =~ /reInstall/

# pro分支,包括第三方直分销
pro_no_oem_job:
  stage: test
  script:
    - cd ~/gitlab-cicd/ts-app/production/no_oem/yx-miniapp
    # 因为链接到了另一台服务器,拿不到系统默认变量,需要手动输出然后自己再引用
    - echo "CI_COMMIT_TITLE"=$CI_COMMIT_TITLE >> ".env.ci"
    - pwd
    # 正常情况是会默认自动更新代码的,这里也是软链的原因
    - git pull origin production
    - npm run copy_diffModule_d
    - npm run build:directsale-pro
    - npm run upload:prod
    - cross-env NODE_ENV=production isOem=false isThird=true node ./script/ci/uploadCode.js
    - cp -r $PRO_NO_OEM_DIR/dist/build/mp-weixin/ ~/gitlab-cicd/ts-app_build/production/no_oem/
  tags:
    - test_ci
  only:
    refs:
      - production
    variables:
      - $CI_COMMIT_TITLE =~ /cicd/

4)触发了ci后的效果

  • 管道这里会跑对应的任务
  • 机子所在位置的目录下会生成对应的文件
  • 如果执行失败了,可以点进对应的任务里面看报错提示。

三、在小程序中实现CI自动上传/预览代码

1)密钥及 IP 白名单配置

使用 miniprogram-ci前应访问"微信公众平台-开发-开发设置"后下载代码上传密钥,并配置 IP 白名单 开发者可选择打开 IP 白名单,打开后只有白名单中的 IP 才能调用相关接口

2)安装小程序ci的依赖包,npm install miniprogram-ci --save

3)将下载好的秘钥放到安全的目录

4)编写小程序ci的脚本

这里就直接贴出我们项目里面的实现,仅供参考。
如果涉及到第三方小程序,需注意把extEnable这个字段设为false,这个官方文档中无提及,这个坑我踩了一波

const ci = require("miniprogram-ci");
const fs = require("fs-extra");
const path = require("path");
const dotenv = require("dotenv");

const isOem = process.env.isOem === "true";
const isThird = process.env.isThird === "true";
const isPreview = process.env.isPreview === "true";
const mode = process.env.NODE_ENV;

const filePath = `../../.env.${mode}`;
const filePath_ci = `../../.env.ci`; // 处理上传时候的参数
const devInfoPath = path.resolve(__dirname, "../../src/devInfo.json");

let ciPreviewNumb = 18; // 预览机器人序号

const readFileInfo = filePath => {
  let fileName = path.resolve(__dirname, filePath);
  let data = fs.readFileSync(fileName, { encoding: "utf8" });
  const obj = dotenv.parse(data);
  return obj;
};
let desc = "";
const fileInfo = readFileInfo(filePath);
if (isPreview) {
  try {
    const devInfo = fs.readJsonSync(devInfoPath);
    const { ci_number, ci_desc } = devInfo;
    desc = ci_desc;
    ciPreviewNumb = ci_number;
  } catch (error) {
    console.info(`读取用户信息文件失败`, error);
  }
} else {
  const fileInfo_ci = readFileInfo(filePath_ci);
  desc = `${fileInfo.VUE_APP_ENV}${isOem ? "分销" : "直销"}${
    !!isThird ? "第三方" : ""
  },${fileInfo_ci.CI_COMMIT_TITLE},${new Date().toLocaleString()}`;
}
const projectPath = path.resolve(__dirname, "../../dist/build/mp-weixin");
let appid = "";

if (!!isThird) {
  // 第三方暂时只提交pro分支
  appid = isOem
    ? fileInfo.VUE_APP_NO_DIRECTSALE_THIRD_APPID
    : fileInfo.VUE_APP_DIRECTSALE_THIRD_APPID;
  const extPath = path.resolve(
    __dirname,
    "../../dist/build/mp-weixin/ext.json",
  );
  let extData = fs.readJSONSync(extPath);
  // 文档和社区都没有关于extEnable这个字段会影响ci的说明,官方人员自己都不知道,绝了
  extData.extEnable = false;
  extData.extAppid = appid;
  extData.ext.extAppid = appid;
  fs.writeJSONSync(extPath, extData);
} else {
  appid = isOem
    ? fileInfo.VUE_APP_NO_DIRECTSALE_APPID
    : fileInfo.VUE_APP_DIRECTSALE_APPID;
}
const privateKey = `./privateKey/private.${appid}.key`; // 在不同的环境拿对应的秘钥
const privateKeyPath = path.resolve(__dirname, privateKey);
(async () => {
  const project = new ci.Project({
    appid,
    type: "miniProgram",
    projectPath,
    privateKeyPath,
    ignores: ["node_modules/**/*"],
  });
  if (isPreview) {
    const previewResult = await ci.preview({
      project,
      desc, // 此备注将显示在“小程序助手”开发版列表中
      setting: {
        autoPrefixWXSS: true, // 样式补全
      },
      robot: ciPreviewNumb,
      pagePath: "pages/loading/main", // 预览页面
      onProgressUpdate: () => {},
    });
    console.info(`previewResult`, previewResult);
  } else {
    const uploadResult = await ci.upload({
      project,
      version: "2.2.12", // 版本上线或重新提交,改这里的版本即可-
      desc,
      setting: {
        autoPrefixWXSS: true, // 样式补全
      },
      onProgressUpdate: () => {},
    });
    console.info(`uploadResult:`, uploadResult);
  }
})();

5)在package.json中定义脚本入口

这里就是对应之前在gitlab-cicd.yml中定义的script

"upload:devd": "cross-env NODE_ENV=development isOem=false node ./script/ci/uploadCode.js",
"upload:devnd": "cross-env NODE_ENV=development isOem=true node ./script/ci/uploadCode.js",

参考