还在手动 SSH 连服务器拉代码?还在因为本地环境和线上不一致焦头烂额?是时候把这些重复劳动交给机器了。本文不整虚的,直接带你用 GitHub Actions 和 GitLab CI 把自动化流水线搭起来。
一、 别背定义了,看本质
1.1 到底什么是 CI/CD?
很多教程上来就甩一堆缩写,其实没那么复杂。
CI (Continuous Integration) 持续集成:
说白了就是**“别把烂代码合进去”**。你提交代码,机器自动跑测试、查格式。挂了?那就修好再合。
CD (Continuous Delivery/Deployment) 持续交付/部署:
说白了就是**“别让人工操作毁了发布”**。测试通过了?机器自动打包、自动扔到服务器上。
| 缩写 |
全称 |
核心目标 |
| CI |
Continuous Integration |
频繁合并,自动验证 |
| CD |
Continuous Delivery/Deployment |
自动发布,随时可用 |
CI(持续集成)
持续集成的核心思想是:开发者频繁地(每天多次)将代码集成到共享的主分支,每次集成都通过自动化构建和测试来验证,从而尽早发现问题。
1
| 开发者提交代码 → 自动触发构建 → 运行测试 → 代码检查 → 反馈结果
|
CD(持续交付/部署)
- 持续交付(Continuous Delivery):代码随时处于可发布状态,但需要手动触发部署
- 持续部署(Continuous Deployment):代码通过所有测试后,自动部署到生产环境
1
| CI 通过 → 构建产物 → 自动/手动部署 → 生产环境
|
1.2 为什么你需要它
想象一下两种场景:
- 传统模式:写完代码 -> 本地跑测试(可能忘了) -> 手动打包 -> FTP/SCP 传服务器 -> SSH 登录 -> 重启服务 -> 发现配置错了 -> 回滚 -> 崩溃。
- CI/CD 模式:写完代码 ->
git push -> 去倒杯咖啡 -> 回来发现新功能已经上线了。
这就叫解放生产力。
1.3 主流 CI/CD 平台
| 平台 |
特点 |
配置文件位置 |
| GitHub Actions |
GitHub 原生,生态丰富 |
.github/workflows/*.yml |
| GitLab CI/CD |
GitLab 内置,功能全面 |
.gitlab-ci.yml |
| Jenkins |
自托管,高度可定制 |
Jenkinsfile |
| CircleCI |
云原生,配置简洁 |
.circleci/config.yml |
本文重点讲解 GitHub Actions 和 GitLab CI/CD。
二、 为什么我劝你一定要上 CI/CD
2.1 专治各种“不服”
❌ 那些年我们踩过的坑
- “在我电脑上能跑啊!”
- 这是最经典的扯皮。CI 环境是干净且统一的,它说不行,那就是不行,别怪环境。
- “哎呀,忘了执行数据库迁移了”
- “谁把测试环境搞挂了?”
- 没有 CI 挡在前面,垃圾代码很容易混进主分支,导致大家一起停工修 bug。
✅ 躺平开发的快乐
- 快速反馈:提交代码几分钟后,钉钉/Slack 就弹消息告诉你挂了,趁老板没发现赶紧修好。
- 睡个好觉:自动化测试覆盖了核心逻辑,周五发布也不慌。
- 一键回滚:部署脚本里写好了回滚逻辑,出问题点一下按钮就回到上一版。
2.2 别心疼那点配置时间
很多人觉得写 YAML 麻烦,宁愿手动搞。但账不是这么算的:
- 投入:花半天写配置文件。
- 回报:以后每一天、每一次提交、每一次发布,都在省时间。
- 隐形收益:减少了 90% 的人为低级错误(比如传错文件、连错库)。
三、 核心概念对比
在深入配置之前,先理解两个平台的核心概念对应关系:
| 概念 |
GitHub Actions |
GitLab CI/CD |
说明 |
| 配置文件 |
.github/workflows/*.yml |
.gitlab-ci.yml |
定义自动化流程 |
| 流水线 |
Workflow |
Pipeline |
一次完整的执行流程 |
| 阶段 |
- (通过 needs 控制) |
Stage |
逻辑分组 |
| 作业 |
Job |
Job |
具体执行的任务 |
| 步骤 |
Step |
Script |
单个命令或动作 |
| 执行器 |
Runner |
Runner |
执行任务的服务器 |
| 可复用组件 |
Action |
- (用 extends/include) |
封装的功能模块 |
| 触发器 |
on |
rules/only/except |
何时触发 |
| 敏感信息 |
Secrets |
Variables (masked) |
存储密码等 |
四、 GitHub Actions 实战
4.1 目录结构
1 2 3 4 5 6 7
| your-repo/ ├── .github/ │ └── workflows/ │ ├── ci.yml # CI 工作流 │ └── cd.yml # CD 工作流 ├── src/ └── package.json
|
4.2 配置文件语法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| name: CI
on: push: branches: [main, develop] pull_request: branches: [main, develop]
jobs: job-name: runs-on: ubuntu-latest needs: [other-job] if: condition services: service-name: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: - 5432:5432 steps: - name: Step Name uses: action/name@v1 with: param: value - name: Run Command run: | echo "Hello" npm install env: NODE_ENV: production
|
4.3 触发器详解(on)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| on: push: branches: - main - develop - 'release/**' push: paths: - 'src/**' - '!src/**/*.md' pull_request: branches: [main] workflow_dispatch: inputs: environment: description: 'Deploy environment' required: true default: 'staging' type: choice options: - staging - production schedule: - cron: '0 2 * * *'
|
4.4 作业依赖与条件执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| jobs: lint: runs-on: ubuntu-latest steps: ... test: runs-on: ubuntu-latest needs: lint steps: ... build: runs-on: ubuntu-latest needs: [lint, test] steps: ... deploy: runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/main' steps: ...
|
条件表达式:
1 2 3 4 5 6
| if: github.ref == 'refs/heads/main' if: github.event_name == 'push' if: success() if: failure() if: always() if: contains(github.event.head_commit.message, '[skip ci]')
|
4.5 环境变量与 Secrets
1 2 3 4 5 6 7 8 9 10 11 12
| jobs: deploy: runs-on: ubuntu-latest env: NODE_ENV: production steps: - name: Deploy env: DATABASE_URL: ${{ secrets.DATABASE_URL }} COMMIT_SHA: ${{ github.sha }} run: echo "Deploying..."
|
设置 Secrets:GitHub 仓库 → Settings → Secrets and variables → Actions
4.6 服务容器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: test_db ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
4.7 常用 Actions
| Action |
用途 |
actions/checkout@v4 |
检出代码 |
actions/setup-node@v4 |
安装 Node.js |
actions/setup-python@v5 |
安装 Python |
astral-sh/setup-uv@v4 |
安装 uv(Python) |
pnpm/action-setup@v4 |
安装 pnpm |
docker/build-push-action@v5 |
构建推送 Docker 镜像 |
appleboy/ssh-action@v1 |
SSH 远程执行 |
4.8 完整 CI 示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
| name: CI
on: push: branches: [main, develop] pull_request: branches: [main, develop]
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest"
- name: Set up Python run: uv python install 3.12
- name: Install dependencies run: | cd backend uv sync --all-extras
- name: Run Ruff linter run: | cd backend uv run ruff check .
- name: Run Ruff formatter check run: | cd backend uv run ruff format --check .
test: runs-on: ubuntu-latest needs: lint
services: postgres: image: postgres:16-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: test_db ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
steps: - uses: actions/checkout@v4
- name: Install uv uses: astral-sh/setup-uv@v4
- name: Set up Python run: uv python install 3.12
- name: Install dependencies run: | cd backend uv sync --all-extras
- name: Run migrations env: DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_db run: | cd backend uv run alembic upgrade head
- name: Run tests env: DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/test_db run: | cd backend uv run pytest -v --tb=short
build: runs-on: ubuntu-latest needs: test if: github.ref == 'refs/heads/main'
steps: - uses: actions/checkout@v4
- name: Build Docker image run: docker build -t myapp:${{ github.sha }} .
frontend-check: runs-on: ubuntu-latest
steps: - uses: actions/checkout@v4
- name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9
- name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'pnpm' cache-dependency-path: frontend/pnpm-lock.yaml
- name: Install dependencies run: | cd frontend pnpm install
- name: Lint & Type Check run: | cd frontend pnpm lint npx vue-tsc --noEmit
- name: Build run: | cd frontend pnpm build
|
4.9 完整 CD 示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| name: CD - Deploy to Production
on: push: branches: [main] workflow_dispatch:
jobs: deploy: runs-on: ubuntu-latest steps: - name: Deploy to Server via SSH uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} port: ${{ secrets.SERVER_PORT || 22 }} script: | set -e cd ${{ secrets.PROJECT_PATH || '/home/user/project' }} echo "📥 Pulling latest code..." git fetch origin main git reset --hard origin/main echo "🔨 Building and restarting services..." docker compose build docker compose up -d --remove-orphans echo "🧹 Cleaning up..." docker image prune -f echo "✅ Deployment complete!" docker compose ps
|
五、 GitLab CI/CD 实战
5.1 配置文件位置
GitLab CI/CD 使用项目根目录下的 .gitlab-ci.yml 文件。
5.2 配置文件语法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| stages: - check - test - build - deploy
variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" POETRY_VIRTUALENVS_IN_PROJECT: "true"
cache: key: "$CI_COMMIT_REF_SLUG" paths: - .cache/pip - .venv
default: image: python:3.11 before_script: - pip install poetry - poetry install
lint: stage: check script: - poetry run ruff check . - poetry run ruff format --check . rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
5.3 阶段(Stages)
1 2 3 4 5
| stages: - check - test - build - deploy
|
- 同一阶段的作业并行执行
- 不同阶段按顺序执行
- 前一阶段全部成功后才执行下一阶段
5.4 规则(Rules)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| job: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG - if: $CI_COMMIT_BRANCH changes: - "backend/**/*" - "pyproject.toml" - if: $CI_COMMIT_BRANCH == "develop" when: manual - when: never
|
5.5 缓存与产物
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| cache: key: files: - poetry.lock paths: - .venv/ - .cache/
test: artifacts: reports: coverage_report: coverage_format: cobertura path: coverage.xml junit: junit.xml paths: - dist/ expire_in: 1 week when: always
|
5.6 作业依赖(needs)
1 2 3 4 5 6 7 8 9
| test: stage: test needs: ["lint"]
deploy: stage: deploy needs: - job: build artifacts: true
|
5.7 服务容器(Services)
1 2 3 4 5 6 7 8 9 10 11
| test: services: - name: postgres:14 alias: db - name: redis:7 alias: cache variables: POSTGRES_DB: test_db POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password DATABASE_URL: "postgresql://test_user:test_password@db:5432/test_db"
|
5.8 预定义变量
| 变量 |
说明 |
$CI_COMMIT_SHA |
完整的 commit SHA |
$CI_COMMIT_SHORT_SHA |
短 commit SHA |
$CI_COMMIT_BRANCH |
分支名 |
$CI_COMMIT_TAG |
标签名 |
$CI_DEFAULT_BRANCH |
默认分支名 |
$CI_PIPELINE_SOURCE |
触发源 |
$CI_MERGE_REQUEST_IID |
MR 编号 |
$CI_PROJECT_DIR |
项目目录 |
$CI_REGISTRY |
容器仓库地址 |
$CI_REGISTRY_IMAGE |
项目镜像地址 |
5.9 YAML 锚点复用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| .setup_python: &setup_python - pip install poetry - poetry install
.backend_template: before_script: - *setup_python rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
lint: stage: check extends: .backend_template script: - poetry run ruff check .
|
5.10 完整 CI 示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
| stages: - check - test - build - deploy
variables: PYTHON_VERSION: "3.11" POETRY_VIRTUALENVS_IN_PROJECT: "true" PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache: key: files: - poetry.lock paths: - .cache/pip - .venv
default: tags: - docker retry: max: 2 when: - runner_system_failure
.setup_python: &setup_python - pip install poetry - poetry install --no-interaction
.backend_template: image: python:3.11 before_script: - *setup_python rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: - "backend/**/*" - "pyproject.toml" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
lint: stage: check extends: .backend_template script: - poetry run ruff check backend/ - poetry run ruff format --check backend/
type-check: stage: check extends: .backend_template script: - poetry run mypy backend/ allow_failure: true
unit-test: stage: test extends: .backend_template services: - name: postgres:14 alias: postgres variables: POSTGRES_DB: test_db POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db" script: - poetry run pytest --cov=backend --cov-report=xml --cov-report=term-missing --cov-fail-under=85 coverage: '/TOTAL.*?(\d+%)/' artifacts: reports: coverage_report: coverage_format: cobertura path: coverage.xml when: always needs: ["lint"]
build-docker: stage: build image: docker:24 services: - docker:24-dind variables: DOCKER_TLS_CERTDIR: "/certs" script: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - | if [ "$CI_COMMIT_TAG" ]; then docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG fi rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_COMMIT_TAG needs: ["unit-test"]
deploy-staging: stage: deploy image: alpine:latest before_script: - apk add --no-cache openssh-client - eval $(ssh-agent -s) - echo "$SSH_PRIVATE_KEY" | ssh-add - script: - ssh -o StrictHostKeyChecking=no $SERVER_USER@$SERVER_HOST " cd /path/to/project && git pull origin main && docker compose up -d --build " environment: name: staging url: https://staging.example.com rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual needs: ["build-docker"]
deploy-production: stage: deploy extends: deploy-staging environment: name: production url: https://example.com rules: - if: $CI_COMMIT_TAG when: manual
|
六、 GitHub Actions vs GitLab CI:怎么选?
6.1 语法对比
| 功能 |
GitHub Actions |
GitLab CI |
| 触发条件 |
on: push/pull_request |
rules: if |
| 阶段定义 |
无(用 needs 控制) |
stages: [check, test] |
| 作业依赖 |
needs: [job-name] |
needs: [job-name] |
| 条件执行 |
if: expression |
rules: when: manual |
| 环境变量 |
env: |
variables: |
| 密钥 |
${{ secrets.NAME }} |
$NAME(在 Settings 配置) |
| 服务容器 |
services: |
services: |
| 缓存 |
Action 内置 |
cache: |
| 产物 |
actions/upload-artifact |
artifacts: |
| 复用 |
uses: action@v1 |
extends: .template |
6.2 同功能示例对比
触发条件:
1 2 3 4 5 6 7 8 9 10 11
| on: push: branches: [main] pull_request: branches: [main]
rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
依赖管理:
1 2 3 4 5 6 7 8 9 10
| jobs: test: needs: lint runs-on: ubuntu-latest
test: stage: test needs: ["lint"]
|
服务容器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: - 5432:5432
services: - name: postgres:16 alias: postgres variables: POSTGRES_PASSWORD: postgres
|
6.3 掏心窝子的建议
别看网上对比图一大堆,其实选择很简单:
代码在哪,CI 就用哪。
- 代码在 GitHub?无脑用 GitHub Actions。生态太好了,你想干的任何事(比如发版自动生成 Release Note、自动打 Tag、自动推 Docker Hub),基本都有现成的 Action,
uses 一下完事。
- 代码在 GitLab(通常是公司内网)?那就用 GitLab CI。它的 Runner 机制非常成熟,权限控制也更细致,适合企业级复杂的发布流程。
个人项目选 GitHub Actions。
- 免费额度够你用到天荒地老。而且它的 Marketplace 简直是宝库,能让你少写很多脚本。
复杂流水线 GitLab CI 略胜一筹。
- GitLab 的
stages 和 needs 配合起来,对于那种几十个微服务、互相依赖的构建流程,可视化效果和管理体验会更好一些。
七、 避坑指南与最佳实践
7.1 别把所有东西塞进一个文件
1 2 3 4 5 6 7 8 9
| # GitHub Actions .github/workflows/ ├── ci.yml # 持续集成 ├── cd.yml # 持续部署 ├── cd-staging.yml # 测试环境部署 └── release.yml # 版本发布
# GitLab CI(单文件,可用 include 拆分) .gitlab-ci.yml
|
7.2 性能优化
使用缓存:
1 2 3 4 5 6 7 8 9 10 11 12
| - uses: actions/setup-node@v4 with: cache: 'pnpm'
cache: key: files: - pnpm-lock.yaml paths: - node_modules/
|
并行执行:
7.3 安全建议
1 2 3 4 5 6 7 8 9 10 11 12
| env: DATABASE_URL: ${{ secrets.DATABASE_URL }}
permissions: contents: read packages: write
uses: actions/checkout@v4 image: python:3.11
|
7.4 调试技巧
1 2 3 4 5 6 7 8 9 10
| - run: | echo "Event: ${{ github.event_name }}" echo "Ref: ${{ github.ref }}" echo "SHA: ${{ github.sha }}"
script: - echo "Branch: $CI_COMMIT_BRANCH" - echo "Pipeline: $CI_PIPELINE_SOURCE"
|
本地测试:
1 2 3 4 5 6
| brew install act act push
gitlab-runner exec docker job-name
|
八、 抄作业:完整模板速查
8.1 GitHub Actions 通用模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| name: CI/CD
on: push: branches: [main, develop] pull_request: branches: [main] workflow_dispatch:
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v4 - run: uv sync --all-extras - run: uv run ruff check . test: runs-on: ubuntu-latest needs: lint steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v4 - run: uv sync --all-extras - run: uv run pytest deploy: runs-on: ubuntu-latest needs: test if: github.ref == 'refs/heads/main' steps: - uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /path/to/project git pull && docker compose up -d --build
|
8.2 GitLab CI 通用模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
| stages: - check - test - deploy
variables: POETRY_VIRTUALENVS_IN_PROJECT: "true"
cache: key: files: - poetry.lock paths: - .venv
default: image: python:3.11
.setup: before_script: - pip install poetry - poetry install
lint: stage: check extends: .setup script: - poetry run ruff check .
test: stage: test extends: .setup script: - poetry run pytest needs: ["lint"]
deploy: stage: deploy script: - echo "Deploying..." rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH when: manual needs: ["test"]
|
总结
CI/CD 不是什么高大上的黑科技,它就是程序员的**“外骨骼”**。它帮你扛住了重复、枯燥、易错的脏活累活,让你能专注于最有价值的代码逻辑。
别纠结是先学 GitHub Actions 还是 GitLab CI,先跑起来。哪怕只是加一个最简单的“提交代码自动跑 lint”,你的开发体验也会有质的飞跃。
记住:任何需要重复两次以上的操作,都应该被自动化。
相关文章