Thiết lập CI/CD thực dụng với GitHub Actions
Nội dung bài viết

Một pipeline tốt nên nhàm chán. Nó chạy, nó pass, nó deploy, và không ai phải nghĩ về nó. Pipeline gây chú ý là pipeline đang có vấn đề.

Dưới đây là cấu trúc tôi tái sử dụng qua nhiều dự án, cùng lý do đằng sau từng lựa chọn.

Bốn phần cốt lõi

  1. Lint và kiểm tra kiểu trên mọi pull request
  2. Chạy test song song khi có thể
  3. Build một lần, rồi tái sử dụng kết quả để deploy
  4. Deploy chỉ từ nhánh chính

Nguyên tắc thứ ba là thứ nhiều người bỏ qua. Nếu bạn build ở bước kiểm thử rồi build lại lần nữa ở bước deploy, bạn đang deploy một artifact khác với artifact vừa được kiểm tra. Khác biệt thường nhỏ, cho tới ngày nó không nhỏ nữa.

Workflow kiểm thử

name: CI

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - run: pnpm lint
      - run: pnpm typecheck
      - run: pnpm test

Vài chi tiết đáng nói:

Khối concurrency hủy các lần chạy cũ khi bạn push liên tiếp vào cùng một nhánh. Không có nó, mỗi lần sửa nhỏ lại thêm một job xếp hàng, và bạn phải chờ kết quả của phiên bản code đã lỗi thời.

--frozen-lockfile bắt buộc cài đúng phiên bản trong file lock. Nếu package.json và lockfile không khớp, lệnh này thất bại thay vì âm thầm cài phiên bản khác. Đây chính là cái ngăn tình trạng “chạy trên máy tôi mà hỏng trên CI”.

cache: pnpm trong setup-node xử lý cache kho gói mà không cần cấu hình actions/cache thủ công.

Cache là thứ tạo ra khác biệt

Chênh lệch giữa một pipeline 30 giây và một pipeline 5 phút phần lớn nằm ở cache.

Có hai thứ đáng cache:

Kho gói. Đã được xử lý bởi cache: pnpm ở trên.

Kết quả build trung gian. Với dự án lớn, cache thư mục build giúp các lần chạy sau chỉ biên dịch phần thay đổi:

- uses: actions/cache@v4
  with:
    path: |
      .astro
      node_modules/.vite
    key: build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
    restore-keys: |
      build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-

restore-keys là phần quan trọng: khi không tìm thấy cache khớp chính xác, nó lấy cache gần nhất cùng tiền tố. Bạn vẫn được hưởng phần lớn lợi ích thay vì phải build lại từ con số không.

Chạy test song song

Khi bộ test đủ lớn, tách nó ra bằng matrix:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      # ... các bước cài đặt
      - run: pnpm test --shard=${{ matrix.shard }}/4

fail-fast: false đáng để bật. Mặc định GitHub hủy toàn bộ matrix ngay khi một nhánh thất bại, nghĩa là bạn chỉ thấy một lỗi mỗi lần chạy. Tắt nó đi thì bạn nhận được bức tranh đầy đủ và sửa hết trong một lượt.

Tách build và deploy

name: Deploy

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # ... cài đặt
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - name: Deploy lên Cloudflare Pages
        run: npx wrangler pages deploy dist --project-name=my-site
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

Job deploy không hề checkout mã nguồn. Nó chỉ tải về đúng artifact mà job build đã tạo ra, nên thứ được phát hành chắc chắn là thứ đã qua kiểm tra.

Dòng environment: production cho phép bạn thêm bước phê duyệt thủ công trong phần cài đặt repository, và giới hạn secret chỉ những job thuộc môi trường đó mới đọc được.

Bảo mật cho pipeline

Ghim action theo phiên bản. Dùng actions/checkout@v4 chứ đừng dùng @main. Với dự án nhạy cảm, ghim theo mã commit đầy đủ để một bản cập nhật của action không thể âm thầm đổi hành vi pipeline.

Giới hạn quyền mặc định. Token của GitHub Actions mặc định có nhiều quyền hơn mức cần thiết. Thu hẹp lại ở đầu file:

permissions:
  contents: read

Rồi cấp thêm quyền cụ thể cho job nào thực sự cần.

Cẩn thận với pull_request_target. Sự kiện này chạy với quyền ghi và có quyền đọc secret, kể cả khi pull request đến từ một fork. Chỉ dùng nó khi bạn hiểu rõ hệ quả, và tuyệt đối không checkout rồi chạy code từ fork trong ngữ cảnh đó.

Bắt đầu nhỏ

Đừng cố dựng pipeline hoàn hảo ngay lần đầu. Tôi thường bắt đầu chỉ với hai bước: cài dependency và build. Chỉ riêng việc đó đã bắt được phần lớn lỗi thực tế, kiểu quên commit một file hay import sai đường dẫn phân biệt hoa thường mà trên Windows không lộ ra.

Thêm lint, thêm test, thêm deploy tự động dần theo nhu cầu. Một pipeline đơn giản chạy đều đặn có giá trị hơn nhiều một pipeline phức tạp mà cả đội đã quen bấm bỏ qua mỗi khi nó đỏ.

Tự động hóa có giá trị nhất khi không ai còn phải bận tâm đến nó.