GitLab CI/CD 中实现前端增量 Lint(ESLint & Stylelint)实践

316 阅读6分钟

一、为什么要做增量 Lint?

在多数现代前端项目里,我们都会配上:husky + lint-staged + eslint + stylelint + prettier
理想情况下,一切顺畅。但在“历史欠账型”老项目中会出现典型痛点:

问题场景结果开发者常用“应对”
项目中途才引入校验规则旧文件从未整体修复新修改触发旧文件海量错误
改动一个上千行的 .vue文件改动文件旧代码大量报错错误淹没真正的业务修改
大量格式/顺序类问题(可自动修)提交被卡直接 --no-verify 跳过
团队成员习惯绕过钩子规范形同虚设代码质量继续滑坡

于是出现“伪规范”阶段:本地 git commit -m "xxx" --no-verify 司空见惯,导致编辑器可视化 git 提交、合并失败,还得回到命令行,体验愈发糟糕。

痛点归结:需要一个 “不依赖开发者自觉、能在合并关口统一兜底” 的机制。

解决思路:在 Merge Request(MR)阶段,通过 GitLab Runner 运行增量 Lint,只检查本次 MR 变动文件;不通过 → 阻止合并。
这样:

  • 不阻塞日常开发小步提交(不会全量炸出千行错误)。
  • 避免随手 --no-verify 破坏规范。
  • 逐步“只校验改动”→ 渐进提升整体质量。

二、核心概念快速梳理

1. GitLab CI/CD vs. Runner

  • GitLab CI/CD:仓库内的 .gitlab-ci.yml 定义流水线(Pipeline),包含多个阶段(stages)与任务(jobs)。
  • GitLab Runner:驻留在服务器上的执行器,真正拉取代码并执行脚本(可自建,也可共享)。
  • 你可以把 Runner 理解为:“代码发生特定事件(如 MR)时的一次‘远程命令执行容器’”。

2. 为什么不用 Jenkins?

  • Jenkins 也能完成,但如果项目已经托管在 GitLab 上,使用 GitLab 原生 CI 更轻量:配置即代码、快速绑定 MR 生命周期。
  • 复杂发布链路可以后续再衔接到 Jenkins。

3. 增量 Lint 的核心要素

要素说明
触发时机仅 MR(防止对普通 push 触发浪费资源)
文件范围通过 GitLab MR Changes API 拿到“新增/修改”文件
语言与类型区分属于 ESLint / Stylelint 的扩展名集合
分批执行防止一次性参数过长 / 内存峰值过高
退出码任一工具非 0 → 阻止合并
缓存使用 --cache 加速重复任务
可扩展性后续可插入单测、覆盖率、通知、邮件、AI Code Review

三、整体流程设计

  1. MR 创建或更新 → 触发流水线(merge_request_event)。
  2. CI Job(lint 阶段)运行脚本:
    • 调用 GitLab API:/projects/:id/merge_requests/:iid/changes
    • 过滤掉删除文件,仅保留“新增 / 修改”且扩展名匹配的文件。
    • 拆分:ESLint 文件 & Stylelint 文件。
    • 分批调用 pnpm exec eslint / pnpm exec stylelint
  3. 任一失败 → Job 返回非 0 → MR 状态标红 → 无法合并。
  4. 后续可并行扩展:单测、通知、部署、AI codereview、summarization 等。

四、从 0 到 1:配置 .gitlab-ci.yml

最简结构示例(阶段层次):

stages:
  - install
  - lint
  - test
  - notify

写 rules 条件时:

  • 同一条 rules 里 if 和 changes 是并且关系:两者都满足才算命中该 rule。

  • rules 从上到下匹配,命中第一条就停止,采用这条里的 when。

  • when 的含义是“这个 job 在整条流水线里何时被调度/是否被创建”,针对的是前面“已完成的所有上游 stage”的结果,而不是“同一 stage 的上一个 job”。

    • when: on_success(默认): 仅当所有上游阶段成功时创建/执行该 job。
    • when: on_failure: 仅当任一上游阶段有失败时才创建/执行该 job。
    • when: always: 无论上游成功或失败都创建/执行。
    • when: manual/delayed: 分别为手动触发/延迟执行。
  • 若没有任何 rule 命中,job 不会被创建。

run_install:
  stage: install
  timeout: 20m
  script:
    - pnpm install
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(dev_(210|215)|stage|master)$/'
      changes:  #只有依赖变化,才重新安装。
        - package.json
      when: on_success
    - when: never

run_lint:
  stage: lint
  interruptible: true
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      when: on_success
      changes:  #只有变动包含指定文件类型,才会创建执行对应 job。
        - "**/*.{js,cjs,mjs,ts,tsx,vue,css,scss,sass,less}"
  script:
    - node scripts/lint_changed.js
  allow_failure: false

注意执行 lint 阶段之前,如果每次都重新 install 依赖,会很慢,一般项目的依赖也不会常变。

gitlab 默认是会删除 node_modules 的,如果想缓存住,需要在 gitlab CICD 的 Variables 添加如下设置。

这样设置后,如果依赖有变化的话,需要在服务器上重新 install 一下。在 runner 服务器操作前,需要 sudo su 切一下用户。

variables:
  GIT_CLEAN_FLAGS: -fdx -e node_modules/

如何获取 MR 变动文件

async function fetchChangedFiles() {
 // 拼接下面的url,Api_Base 是git地址域名后面拼接一个/api/v4,接下来是项目id,在仓库能看到,这俩是需要硬编码的,最后面的 merge id 在cicd流程中能够获取到
  const url = `${API_BASE}/projects/${PROJECT_ID}/merge_requests/${MR_IID}/changes`;
 // 调用 GitLab MR changes API
  const res = await fetch(url, {
    headers: { "PRIVATE-TOKEN": process.env.GitLabToken }, // 这个token不建议硬编码,最好在git lab 上新建一个token,并且加到变量里面
  });

  if (!res.ok) {
    // HTTP 非 2xx
    throw new Error(`API 返回 ${res.status} ${res.statusText}`);
  }

  const data = await res.json();
  if (!data.changes) {
    throw new Error("响应缺少 changes 字段");
  }

  // data.changes: [{ old_path, new_path, new_file, renamed_file, deleted_file, ... }]
  const list = [];
  for (const ch of data.changes) {
    if (ch.deleted_file) continue; // 删除文件不 lint
    const p = ch.new_path || ch.old_path; // 正常情况使用 new_path;兜底 old_path
    if (p && (matchEslintExt(p) || (ENABLE_STYLELINT && matchStyleExt(p))))
      list.push(p); // 匹配扩展则加入
  }
  return [...new Set(list)];
}

增量执行 ESLint(分批控制 & 退出码)

核心思路:将变动文件按批次切片,逐批运行,记录最终退出码。

function runEslintOn(files) {
  if (!files.length) {
    log("ESLint 无匹配文件,跳过。");
    return 0;
  }
  // 分批执行 ESLint
  log(`准备 ESLint 校验文件数: ${files.length}`);
  const baseArgs = [
    // eslint 参数基底
    "exec",
    "eslint",
    "--color",
    "--cache",
    "--cache-location",
    ".eslintcache", // 缓存提升速度
  ];

  let exitCode = 0; // 累积最终退出码

  for (let i = 0; i < files.length; i += BATCH_SIZE) {
    // 分批循环
    const batch = files.slice(i, i + BATCH_SIZE); // 当前批次文件
    log(
      `批次 ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(
        files.length / BATCH_SIZE
      )} (${batch.length} 文件)`
    );
    const res = spawnSync("pnpm", [...baseArgs, ...batch], {
      stdio: "inherit",
    }); 

    if (res.error) {
      error(`ESLint 执行失败: ${res.error.message}`);
      exitCode = 1;
    } else if (res.status !== 0) {
      exitCode = res.status;
    }
  }
  return exitCode; // 返回最终状态
}

Stylelint 的逻辑基本一致,只需要换 baseArgs、匹配扩展集。

主执行流程(调度 & 汇总)

/* ===================== 主执行流程(IIFE) ===================== */

(async () => {
  let files;
  try {
    files = await fetchChangedFiles(); // 调用 API 获取文件
  } catch (e) {
    error(`获取改动文件失败: ${e.message}`);
    process.exit(1);
  }

  log(`API 返回改动文件数: ${files.length}`); // 输出文件数量

  if (files.length === 0) {
    log("无需要 lint 的文件,结束。");
    process.exit(0);
  }

  const eslintFiles = files.filter(matchEslintExt);
  const styleFiles = ENABLE_STYLELINT ? files.filter(matchStyleExt) : [];

  const eslintCode = runEslintOn(eslintFiles);
  const styleCode = runStylelintOn(styleFiles);

  const final = eslintCode || styleCode;

  if (final === 0) {
    log("增量 Lint 全部通过 ✅");
  } else {
    error("增量 Lint 失败 ❌");
  }
  process.exit(final);
})().catch((e) => {
  // 捕获顶层异常
  error(`脚本异常: ${e.stack || e.message}`);
  process.exit(1);
})

建议将完整脚本放在 scripts/ 目录下,并通过 node scripts/lint_changed.js 调用。

pipeline 失败,可强制合并,提示如下:

在gitlab仓库 merge request 可勾选强制要求必须通过:

image.png

特别注意

  1. gitlab 支持使用 workflow - when: never 让这次 MR 根本不生成 pipeline。按照预期,rules 不符合时应该是直接跳过流水线的,但实际上 GitLab 的 MR 页面 UI 一直保持 “Checking…” 状态——应该 GitLab 的已知体验缺陷(gitlab.com/gitlab-org/…
  • 不知道最新版是否可以,所以 rules 条件还是需要写在 stage 里面。都不符合时可以直接跳过!
    #workflow:
    #  rules:
    #    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^(master|stage)$/'
    #      when: always
    #    - when: never  # 其它情况整个 pipeline skipped
  1. 在 GitLab 中,创建一个 “把分支 a 合并到分支 b 的 Merge Request”,MR 页面上看到的那条(或多条)预合并流水线,默认使用的是“源分支 a 当前提交里的 .gitlab-ci.yml”配置,而不是目标分支 b 的。

总结

通过 GitLab Runner + MR 增量 Lint 脚本,我们达到了:

  • 避免开发者随意 --no-verify
  • 不把历史债务一次性压给当前修改者。
  • 让“质量门”前移到代码合并前,而不是线上事故后。

这套方案很容易落地:一个脚本 + 一段配置 → 即刻把“软规范”变为“硬门槛”。

同理单元测试和钉钉通知等功能实现也是类似,但没这么复杂,只需逐步引入。

另外如果想接入AI codereview ,可以像上面那样,先把变更文件拿到,处理成 json,然后结合 Prompt 一起发给大模型,接下来获取结果就可以。

可以根据实际需要,发挥一下想象,等配置稳定了,再补充一下!