BlogBlog · github-actions

CI/CD với GitHub Actions cho dự án Next.js: cache, test song song, preview deploy và bảo mật secrets

Dựng pipeline GitHub Actions cho Next.js từ đầu: trigger và concurrency, cache pnpm và .next/cache, job song song, Playwright sharding, preview deploy lên Vercel, OIDC, pin action theo SHA và workflow cron tự commit an toàn.

Part of Deep Dive series
July 20, 2025
30 min read
By Leo Pham
github-actionsci-cdnextjsdevopssecurity
Share this post:

Một dự án Next.js điển hình: CI chạy 12-15 phút cho mỗi PR, kể cả PR chỉ sửa README. Lint, typecheck, unit test và build chạy nối tiếp trong một job duy nhất. Mỗi lần push thêm commit, run cũ vẫn chạy tiếp và chiếm runner. Secret deploy được dùng ở mọi job, và không ai nhớ action bên thứ ba nào đang được tham chiếu bằng tag @v2.

Chính repo blog của mình cũng có các workflow cron sinh nội dung với đủ dấu hiệu quen thuộc: node-version: '18' (Node 18 đã hết hỗ trợ từ tháng 4/2025), không khai báo permissions:, input của workflow_dispatch được nhét thẳng vào script run:, và bước cuối là git add . rồi git push không xử lý race. Không cái nào gây sự cố ngay, nhưng cái nào cũng là một sự cố đang chờ đến lượt.

Bài này dựng lại pipeline cho một app Next.js dùng pnpm, từng bước: cấu trúc workflow, cache, job song song, Playwright có sharding, preview deploy, các lớp bảo mật, cách tái sử dụng, và cuối cùng là viết lại workflow cron tự commit cho đúng. Ví dụ dùng actions/checkout@v7, actions/setup-node@v7, actions/cache@v6, actions/upload-artifact@v7, actions/download-artifact@v8 và pnpm/action-setup@v6, là các major version mới nhất tại thời điểm viết (11/2026).

TL;DR

  • Dùng concurrency với cancel-in-progress cho PR, không cho main.
  • Cache pnpm store qua setup-node và cache .next/cache riêng. Cache tạo trên main thì mọi PR dùng được.
  • Tách lint, typecheck, unit test, build thành job song song. Một job ci-ok gom kết quả làm required check duy nhất.
  • Playwright: --shard, reporter blob, gộp report ở một job riêng, trace bật on-first-retry.
  • permissions: contents: read ở đầu file, OIDC thay cho access key, pin action bên thứ ba theo commit SHA, tránh pull_request_target khi phải checkout code của PR.
  • Workflow cron tự commit: git add đúng thư mục, concurrency không cancel, retry push bằng git pull --rebase.

Giải phẫu một workflow: trigger, paths filter và concurrency

Đây là phần khung của .github/workflows/ci.yml. Các job cụ thể sẽ được bổ sung ở các phần sau.

name: CI

on:
  pull_request:            # mặc định: opened, synchronize, reopened
  push:
    branches: [main]

concurrency:
  # PR: mỗi PR một group. Push main: mỗi ref một group.
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  # Hủy run cũ khi PR có commit mới, nhưng không bao giờ hủy run trên main
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
  contents: read           # mặc định tối thiểu cho mọi job, job nào cần thêm thì tự khai báo

jobs:
  changes:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      code: ${{ steps.diff.outputs.code }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - id: diff
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
        run: |
          # code=false nếu chỉ thay đổi tài liệu
          if git diff --name-only "$BASE_SHA" "$GITHUB_SHA" | grep -qvE '^(docs/|.*\.md$)'; then
            echo "code=true" >> "$GITHUB_OUTPUT"
          else
            echo "code=false" >> "$GITHUB_OUTPUT"
          fi

pull_request khác push ở đâu. Với pull_request, GitHub không chạy trên commit cuối của nhánh mà trên một merge commit ảo (refs/pull/<số>/merge) giữa nhánh PR và nhánh base. Nghĩa là bạn test đúng thứ sẽ có sau khi merge. Hệ quả: nếu PR đang conflict với base, workflow pull_request sẽ không chạy. Còn push chạy trên đúng commit được push, dùng cho main sau khi merge và cho deploy production.

Đừng bật cả push cho mọi nhánh lẫn pull_request: mỗi commit trên nhánh PR sẽ chạy CI hai lần. Giới hạn push vào main (và tag release nếu cần) là đủ.

Paths filter và cái bẫy required check. Cách nhanh nhất để bỏ qua PR chỉ sửa tài liệu là khai báo ở cấp workflow:

on:
  pull_request:
    paths-ignore: ['docs/**', '**/*.md']

Cách này ổn cho workflow không bắt buộc. Nhưng nếu workflow đó là required status check trong branch protection, PR chỉ sửa README sẽ bị treo ở trạng thái "Expected — Waiting for status to be reported" mãi mãi, vì workflow bị skip thì không bao giờ báo kết quả. Đó là lý do mình lọc ở cấp job bằng job changes ở trên: workflow luôn chạy, các job nặng tự skip, và một job tổng hợp (phần sau) vẫn báo kết quả.

Concurrency. Biểu thức cancel-in-progress ở trên hủy run cũ của cùng một PR khi bạn push commit mới, tiết kiệm runner rất đáng kể với team hay push liên tục. Trên main thì không hủy: nếu hai PR merge liền nhau, bạn muốn cả hai run đều hoàn tất để biết commit nào làm hỏng build. Với job deploy production, đặt một concurrency group riêng (group: deploy-production, cancel-in-progress: false) để hai lần deploy không chạy chồng lên nhau.

Cài dependency nhanh: pnpm, setup-node cache và .next/cache

Mỗi job cần checkout, cài pnpm, cài Node, cài dependency. Mình gom vào một composite action trong repo để mọi job gọi một dòng:

# .github/actions/setup/action.yml
name: Setup pnpm and Node
description: Cài pnpm, Node theo .nvmrc và dependencies, có cache
inputs:
  node-version:
    description: Ghi đè version trong .nvmrc (dùng cho matrix)
    required: false
    default: ''
runs:
  using: composite
  steps:
    - uses: pnpm/action-setup@v6          # version lấy từ "packageManager" trong package.json
    - uses: actions/setup-node@v7
      with:
        node-version: ${{ inputs.node-version }}
        node-version-file: .nvmrc
        cache: pnpm                       # cache pnpm store, key theo pnpm-lock.yaml
    - shell: bash
      run: pnpm install --frozen-lockfile

Vài điểm quan trọng:

  • Thứ tự bước. pnpm/action-setup phải chạy trước setup-node, vì cache: pnpm cần gọi pnpm store path để biết thư mục cần cache. Đảo thứ tự là lỗi "Unable to locate executable file: pnpm".
  • Không có cache tự động cho pnpm. Từ setup-node v6, cache tự động chỉ bật cho npm (khi packageManager trong package.json là npm). Với pnpm và Yarn, bạn phải khai báo cache: pnpm như trên.
  • Cache cái gì. setup-node cache pnpm store (các file package đã tải), không cache node_modules. pnpm install vẫn chạy nhưng chỉ cần link từ store, không tải lại từ registry. Cache node_modules trực tiếp nghe hấp dẫn hơn nhưng dễ vỡ khi version Node hoặc OS thay đổi, vì native module được build cho môi trường cụ thể.
  • node-version và node-version-file cùng lúc. setup-node ưu tiên node-version nếu nó khác rỗng. Input rỗng thì dùng .nvmrc, nên một action dùng được cho cả job thường lẫn matrix.
  • --frozen-lockfile làm install fail nếu package.json và lockfile lệch nhau. Đây là điều bạn muốn trong CI. Thực ra pnpm đã bật mặc định khi phát hiện môi trường CI, nhưng ghi rõ ra thì người đọc workflow không phải đoán.

Cache build của Next.js. next build lưu kết quả compile vào .next/cache. Không có cache này, mỗi lần build trên CI đều là build lạnh và Next.js sẽ in cảnh báo không tìm thấy build cache. Thêm vào job build:

- uses: actions/cache@v6
  with:
    path: .next/cache
    # Key đổi khi lockfile hoặc source đổi; restore-keys cho phép dùng lại cache gần nhất
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-

Hai cái bẫy ở đây. Thứ nhất, ví dụ phổ biến dùng hashFiles('**/*.ts'): nếu bước cache đặt sau pnpm install, pattern đó băm cả node_modules, vừa chậm vừa làm key thay đổi khi không cần. Giới hạn pattern vào thư mục source (src/, app/ tùy cấu trúc). Thứ hai, phạm vi cache: cache tạo trong một PR chỉ PR đó (và các run sau của nó) dùng được, còn cache tạo trên nhánh default thì mọi nhánh đều restore được. Vì vậy workflow phải chạy trên push vào main để làm ấm cache cho các PR mới.

GitHub giới hạn tổng dung lượng cache mỗi repo (mặc định 10 GB) và xóa entry không được truy cập sau 7 ngày. Vượt giới hạn thì entry cũ bị đẩy ra. Repo nhiều PR song song có thể tự đẩy cache của nhau ra, nên xem tab Actions > Caches nếu thấy cache hit rate thấp bất thường.

Chạy song song lint, typecheck, unit test và matrix

Gộp tất cả vào một job thì tổng thời gian bằng tổng các bước. Tách thành job song song thì tổng thời gian bằng job chậm nhất cộng thời gian setup. Đánh đổi: mỗi job phải tự checkout và install, nên với repo nhỏ mà install chiếm phần lớn thời gian, tách quá nhiều job có thể chậm hơn. Quy tắc của mình: tách khi từng bước mất hơn khoảng một phút, gộp các bước nhanh (ví dụ lint + format check) vào cùng job.

  lint:
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup
      - run: pnpm lint
      - run: pnpm prettier --check .

  typecheck:
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup
      - run: pnpm next typegen      # sinh route types (Next.js 15.5+), bỏ bước này nếu dùng bản cũ hơn
      - run: pnpm tsc --noEmit

  unit:
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false            # một version fail không hủy version còn lại
      matrix:
        node-version: ['22', '24']
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup
        with:
          node-version: ${{ matrix.node-version }}
      - run: pnpm vitest run --reporter=default --reporter=junit --outputFile.junit=reports/junit-${{ matrix.node-version }}.xml
      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}
        with:
          name: junit-node-${{ matrix.node-version }}
          path: reports/
          retention-days: 7

  build:
    needs: changes
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup
      - uses: actions/cache@v6
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      - run: pnpm build
        env:
          NEXT_TELEMETRY_DISABLED: '1'
      - uses: actions/upload-artifact@v7
        with:
          name: next-build
          path: |
            .next
            !.next/cache
          include-hidden-files: true   # .next bắt đầu bằng dấu chấm, mặc định bị bỏ qua
          retention-days: 3

Matrix dùng khi nào. Với app (không phải thư viện), bạn deploy đúng một version Node, nên matrix Node chỉ đáng tiền trong giai đoạn nâng cấp: chạy song song version cũ và mới vài tuần, rồi bỏ version cũ. Thư viện publish lên npm thì nên test mọi version Node đang được hỗ trợ mà bạn cam kết. Matrix còn hỗ trợ include (thêm tổ hợp riêng, ví dụ chỉ chạy Windows cho một version) và exclude, cùng max-parallel nếu muốn giới hạn số runner dùng đồng thời.

timeout-minutes ở mọi job. Mặc định một job được chạy tới 6 tiếng. Một test bị treo vì chờ network sẽ đốt cả buổi phút runner trước khi bạn để ý. Đặt timeout gấp khoảng 2-3 lần thời gian bình thường.

Job tổng hợp làm required check. Thay vì đưa từng job vào branch protection (và phải sửa protection mỗi lần đổi tên job), mình dùng một job duy nhất:

  ci-ok:
    if: always()
    needs: [changes, lint, typecheck, unit, build, e2e-report]
    runs-on: ubuntu-latest
    steps:
      - name: Kiểm tra kết quả các job
        env:
          RESULTS: ${{ join(needs.*.result, ' ') }}
        run: |
          echo "Kết quả: $RESULTS"
          for r in $RESULTS; do
            if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
              exit 1
            fi
          done

if: always() bảo đảm job này luôn chạy kể cả khi job khác fail hay bị skip. Job bị skip (PR chỉ sửa tài liệu) có kết quả skipped, được coi là đạt. Trong branch protection hoặc ruleset, bạn chỉ cần required check ci-ok.

Playwright trong CI: sharding, trace và report

E2E thường là phần chậm nhất. Playwright chia test ra nhiều máy bằng --shard=<index>/<total>. Cấu hình trước:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

const isCI = Boolean(process.env.CI)

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,               // chia theo từng test, shard cân bằng hơn chia theo file
  forbidOnly: isCI,                  // quên test.only thì CI fail
  retries: isCI ? 2 : 0,
  workers: isCI ? 2 : undefined,
  reporter: isCI ? [['blob'], ['github']] : 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',         // chỉ ghi trace khi test fail và được retry
    screenshot: 'only-on-failure',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: {
    command: 'pnpm start',
    url: 'http://localhost:3000',
    reuseExistingServer: !isCI,
    timeout: 120_000,
  },
})

Workflow dùng lại bản build từ job build, nên mỗi shard không phải build lại:

  e2e:
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        shardIndex: [1, 2, 3, 4]
        shardTotal: [4]
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup
      - uses: actions/download-artifact@v8
        with:
          name: next-build
          path: .next
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
      - uses: actions/upload-artifact@v7
        if: ${{ !cancelled() }}
        with:
          name: blob-report-${{ matrix.shardIndex }}
          path: blob-report
          retention-days: 7

  e2e-report:
    if: ${{ !cancelled() && needs.build.result == 'success' }}
    needs: [build, e2e]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup
      - uses: actions/download-artifact@v8
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true
      - run: pnpm exec playwright merge-reports --reporter html ./all-blob-reports
      - uses: actions/upload-artifact@v7
        with:
          name: playwright-report
          path: playwright-report
          retention-days: 14
      - name: Fail nếu e2e fail
        if: needs.e2e.result != 'success'
        run: exit 1

Giải thích các lựa chọn:

  • Reporter blob là định dạng trung gian để gộp. Mỗi shard tạo một blob, job e2e-report gộp lại thành một HTML report duy nhất. Nếu mỗi shard tự xuất HTML, bạn sẽ có 4 report rời rạc. Tên artifact phải khác nhau (blob-report-1, -2...) vì artifact từ upload-artifact v4 trở đi là bất biến, hai job không thể ghi vào cùng một artifact.
  • if: ${{ !cancelled() }} cho bước upload: mặc định step bị bỏ qua khi step trước fail, mà khi test fail mới là lúc bạn cần report nhất.
  • Trace. on-first-retry chỉ ghi trace cho test fail ở lần đầu, nên chi phí thấp khi mọi thứ xanh. Tải artifact playwright-report, giải nén, chạy pnpm exec playwright show-report playwright-report, bấm vào test fail để xem trace: DOM snapshot từng bước, network, console. Đây là cách debug test chỉ fail trên CI nhanh nhất mình biết.
  • Không cache browser. Tài liệu Playwright khuyên không cache browser binary: thời gian restore cache gần bằng thời gian tải, còn system dependencies (--with-deps) thì không cache được.
  • Chọn số shard. Bắt đầu sao cho mỗi shard chạy khoảng 3-5 phút. Mỗi shard tốn thêm phần setup cố định (checkout, install, cài browser), nên tăng shard quá nhiều thì tổng phút runner tăng mà thời gian chờ không giảm thêm.

Preview deploy và environments có người duyệt

Có hai cách tạo preview deployment cho mỗi PR trên Vercel.

Cách 1: Vercel Git integration. Kết nối repo trong dashboard Vercel, mỗi push lên nhánh PR sẽ tự build và comment URL preview vào PR. Không cần workflow nào, build chạy trên hạ tầng Vercel. Đây là lựa chọn mặc định đúng cho đa số team.

Cách 2: Vercel CLI trong GitHub Actions. Dùng khi bạn muốn deploy chỉ xảy ra sau khi CI xanh, cần chạy bước riêng trước build (sinh code, kéo nội dung), hoặc muốn production deploy phải có người duyệt. Khi đó tắt build tự động của Git integration ("git": { "deploymentEnabled": false } trong vercel.json) để không bị deploy hai lần.

  deploy-preview:
    needs: ci-ok
    # PR từ fork không có secrets, bỏ qua thay vì fail
    if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment:
      name: preview
      url: ${{ steps.deploy.outputs.url }}
    env:
      VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
      VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup        # vercel nằm trong devDependencies, version khóa bởi lockfile
      - run: pnpm exec vercel pull --yes --environment=preview --token="$VERCEL_TOKEN"
      - run: pnpm exec vercel build --token="$VERCEL_TOKEN"
      - id: deploy
        run: |
          url=$(pnpm exec vercel deploy --prebuilt --token="$VERCEL_TOKEN")
          echo "url=$url" >> "$GITHUB_OUTPUT"

  deploy-production:
    needs: ci-ok
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    environment:
      name: production
      url: https://shop.acme.vn
    env:
      VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
      VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup
      - run: pnpm exec vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
      - run: pnpm exec vercel build --prod --token="$VERCEL_TOKEN"
      - run: pnpm exec vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN"

Mình dùng pnpm exec vercel thay vì npx vercel@latest như nhiều hướng dẫn: @latest nghĩa là mỗi lần deploy tải một version mới nhất chưa được ai review, chạy với token deploy của bạn. Đưa vercel vào devDependencies thì version bị khóa trong lockfile và được cập nhật qua PR như mọi dependency khác.

Environments. Trong Settings > Environments, tạo preview và production:

  • production: bật Required reviewers (chọn người hoặc team phải bấm Approve trước khi job chạy), giới hạn Deployment branches chỉ main, và lưu VERCEL_TOKEN của production làm environment secret. Secret của environment chỉ được cấp cho job khai báo environment: production, nên một job lint bị chèn code độc cũng không đọc được nó.
  • preview: không cần reviewer, nhưng nên dùng token riêng nếu provider cho phép phân quyền.

Một số protection rule của environment phụ thuộc gói GitHub và việc repo là public hay private, nên kiểm tra trang docs Environments cho đúng gói của bạn. Lưu ý thêm: biến NEXT_PUBLIC_* được nhúng thẳng vào bundle phía client lúc build, nên tuyệt đối không đặt secret nào với prefix đó, kể cả ở preview.

Bảo mật: permissions, OIDC, pin SHA, pull_request_target và secrets

Đây là phần quan trọng nhất của bài. CI có quyền đọc code, đọc secrets và thường có quyền ghi vào repo, nên nó là mục tiêu tấn công hấp dẫn.

Quyền tối thiểu cho GITHUB_TOKEN

Mỗi job nhận một GITHUB_TOKEN tự động. Quyền mặc định phụ thuộc cài đặt của repo/org: repo và org tạo gần đây mặc định chỉ đọc, nhưng repo cũ có thể vẫn đang để read/write cho mọi thứ. Đừng phụ thuộc vào setting đó, hãy khai báo trong file:

permissions:
  contents: read         # đầu file: mặc định cho mọi job

jobs:
  label-pr:
    permissions:
      contents: read
      pull-requests: write   # chỉ job này được ghi vào PR

Khi khai báo permissions:, mọi quyền không liệt kê đều thành none.

OIDC thay cho access key dài hạn

Nếu pipeline cần đẩy image lên AWS ECR hay upload asset lên S3, cách cũ là lưu AWS_ACCESS_KEY_ID và AWS_SECRET_ACCESS_KEY vào secrets. Key đó sống mãi cho tới khi ai đó nhớ rotate. Với OIDC, mỗi job xin một token ngắn hạn từ GitHub, AWS kiểm tra token đó đến từ đúng repo, đúng nhánh hoặc environment, rồi cấp credential tạm thời.

Trust policy của IAM role (tạo OIDC provider token.actions.githubusercontent.com trong IAM trước):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:acme/shop-web:environment:production"
        }
      }
    }
  ]
}
  push-image:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write        # bắt buộc để xin OIDC token
      contents: read
    env:
      AWS_REGION: ap-southeast-1
      ECR_REGISTRY: 123456789012.dkr.ecr.ap-southeast-1.amazonaws.com
    steps:
      - uses: actions/checkout@v7
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-shop-web-deploy
          aws-region: ap-southeast-1
      - run: |
          aws ecr get-login-password --region "$AWS_REGION" \
            | docker login --username AWS --password-stdin "$ECR_REGISTRY"
          docker build -t "$ECR_REGISTRY/shop-web:$GITHUB_SHA" .
          docker push "$ECR_REGISTRY/shop-web:$GITHUB_SHA"

Điều kiện sub là phần quan trọng nhất. repo:acme/shop-web:environment:production nghĩa là chỉ job chạy trong environment production của đúng repo đó mới assume được role. Dùng wildcard kiểu repo:acme/* là mở cửa cho mọi repo trong org, kể cả repo thử nghiệm của thực tập sinh. Google Cloud (Workload Identity Federation) và Azure (federated credentials) có cơ chế tương tự. Vercel CLI thì vẫn cần token, nên giữ nó trong environment secret như phần trên.

Pin action bên thứ ba theo commit SHA

Tháng 3/2025, action tj-actions/changed-files (CVE-2025-30066) bị chiếm quyền. Kẻ tấn công sửa các version tag có sẵn, trỏ chúng sang một commit độc hại. Mọi workflow dùng tj-actions/changed-files@v35, @v44... chạy trong khoảng 14-15/3/2025 đều chạy code đó: nó đọc secrets từ bộ nhớ của runner và in ra log của workflow. Với repo public, log ai cũng xem được, nên secrets coi như đã lộ. Hướng dẫn sau sự cố là rotate mọi secret đã dùng trong các run thời gian đó.

Bài học: tag trong Git có thể bị dời, commit SHA thì không. Pin như sau:

# Comment giữ version để người đọc và Dependabot/Renovate biết đang ở bản nào
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Lấy SHA của một tag bằng gh api repos/actions/checkout/commits/v7.0.1 --jq .sha, hoặc chạy pinact run (công cụ mã nguồn mở suzuki-shunsuke/pinact) để pin toàn bộ workflow một lần. Trong bài này mình viết tag cho dễ đọc, còn trong repo thật thì pin hết, ưu tiên tuyệt đối với action bên thứ ba như pnpm/action-setup hay aws-actions/*. Pin SHA mà không cập nhật thì bạn bị kẹt với bản cũ có lỗ hổng, nên nó phải đi kèm Dependabot hoặc Renovate ở phần sau.

Cái bẫy pull_request_target

pull_request_target chạy trong ngữ cảnh của nhánh base: có secrets, GITHUB_TOKEN có thể có quyền ghi, kể cả khi PR đến từ fork. Nó được thiết kế để làm việc như gắn label hay comment vào PR từ fork. Thảm họa xảy ra khi bạn checkout code của PR và chạy nó:

# NGUY HIỂM: đừng làm thế này
on: pull_request_target
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.pull_request.head.sha }}   # code do người lạ viết
      - run: pnpm install && pnpm test                       # postinstall script, test file... chạy với secrets
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}

Người gửi PR chỉ cần thêm một postinstall script vào package.json là lấy được token. Quy tắc: chạy code của PR thì dùng pull_request (không có secrets với fork, token chỉ đọc). Chỉ dùng pull_request_target cho tác vụ không chạy code của PR. workflow_run có rủi ro tương tự nếu nó tải artifact do workflow của PR tạo ra rồi thực thi.

Script injection và chuyện in secrets ra log

${{ ... }} được thay bằng giá trị thô trước khi shell chạy script. Tiêu đề PR là dữ liệu do người dùng nhập, và một tiêu đề chứa $(...) sẽ trở thành lệnh shell:

# Sai: tiêu đề PR được chèn thẳng vào script
- run: echo "Deploy preview cho: ${{ github.event.pull_request.title }}"

# Đúng: đưa qua biến môi trường, shell coi nó là dữ liệu
- env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  run: echo "Deploy preview cho: $PR_TITLE"

Input của workflow_dispatch (như TOPIC="${{ github.event.inputs.topic }}" trong workflow cũ của mình) ít rủi ro hơn vì chỉ người có quyền ghi mới bấm chạy được, nhưng dùng biến môi trường thì không mất gì, và còn tránh lỗi khi input có dấu ngoặc kép.

Về secrets trong log: GitHub tự che giá trị secret khi nó xuất hiện nguyên văn, nhưng không che giá trị đã biến đổi (base64, cắt chuỗi, JSON có ký tự escape). Không echo secret để debug, không bật set -x trong bước có secret, và nếu bạn tự tạo ra giá trị nhạy cảm (ví dụ token đổi từ API) thì che nó bằng echo "::add-mask::$DERIVED_TOKEN" trước khi dùng. Artifact không được che gì cả: đừng upload thư mục có .env.

Hai công cụ đáng chạy định kỳ: actionlint bắt lỗi cú pháp và biểu thức sai, zizmor chuyên tìm các vấn đề bảo mật như injection, pull_request_target nguy hiểm, action chưa pin.

Tái sử dụng: reusable workflows và composite actions

Composite action ở phần cache là một cách tái sử dụng: gom nhiều step lại, chạy bên trong job của nơi gọi. Reusable workflow gom nhiều job lại, được gọi như một job:

# .github/workflows/deploy-vercel.yml
name: Deploy to Vercel
on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true
      production:
        type: boolean
        default: false
    secrets:
      VERCEL_TOKEN:
        required: true
    outputs:
      url:
        value: ${{ jobs.deploy.outputs.url }}

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    env:
      VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
      VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
      PROD_FLAG: ${{ inputs.production && '--prod' || '' }}
      TARGET_ENV: ${{ inputs.production && 'production' || 'preview' }}
    steps:
      - uses: actions/checkout@v7
      - uses: ./.github/actions/setup
      - run: pnpm exec vercel pull --yes --environment="$TARGET_ENV" --token="$VERCEL_TOKEN"
      - run: pnpm exec vercel build $PROD_FLAG --token="$VERCEL_TOKEN"
      - id: deploy
        run: echo "url=$(pnpm exec vercel deploy --prebuilt $PROD_FLAG --token="$VERCEL_TOKEN")" >> "$GITHUB_OUTPUT"

Nơi gọi:

jobs:
  deploy-preview:
    needs: ci-ok
    uses: ./.github/workflows/deploy-vercel.yml
    with:
      environment: preview
    secrets:
      VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
Tiêu chí Composite action Reusable workflow
Đơn vị tái sử dụng Nhóm step Một hoặc nhiều job
Chạy ở đâu Trong job của nơi gọi, cùng runner Job riêng, runner riêng
Secrets Truyền qua inputs hoặc env Khai báo secrets: hoặc secrets: inherit
Environment, runs-on, matrix Không tự khai báo được Có, như workflow thường
Log trên giao diện Gộp vào một step Hiển thị như job riêng
Phù hợp cho Setup lặp lại (pnpm, Node, cache) Quy trình hoàn chỉnh (deploy, release) dùng chung nhiều repo

Ba lưu ý: local action (./.github/actions/setup) chỉ dùng được sau bước checkout, vì file của nó nằm trong repo. Mọi bước run trong composite action phải khai báo shell:. Và reusable workflow chỉ có thể giữ nguyên hoặc hạ quyền GITHUB_TOKEN so với workflow gọi nó, không thể nâng lên. Ưu tiên truyền secrets tường minh thay vì secrets: inherit, để workflow được gọi chỉ nhận đúng thứ nó cần.

Giữ actions luôn mới: Dependabot hoặc Renovate

Pin SHA mà không có ai cập nhật thì sau một năm workflow của bạn chạy toàn bản cũ. Dependabot hiểu cả SHA lẫn comment version và mở PR cập nhật cả hai:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directories: ['/', '/.github/actions/*']   # workflows + composite actions
    schedule:
      interval: weekly
    cooldown:
      default-days: 7        # đợi bản phát hành "nguội" một tuần rồi mới đề xuất
    groups:
      github-actions:
        patterns: ['*']      # gộp thành một PR mỗi tuần

  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 3
    groups:
      dev-dependencies:
        dependency-type: development

cooldown là lớp phòng thủ đáng giá: các vụ chiếm quyền như tj-actions/changed-files ở trên thường bị phát hiện và xử lý trong vòng một hai ngày, nên chờ vài ngày trước khi nâng version giúp bạn tránh kéo về đúng bản độc hại trong khoảng thời gian nguy hiểm nhất. Cooldown chỉ áp dụng cho version update, không làm chậm security update.

Nếu dùng Renovate, cấu hình tương đương:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended", "helpers:pinGitHubActionDigests"],
  "minimumReleaseAge": "7 days",
  "packageRules": [
    { "matchManagers": ["github-actions"], "groupName": "github actions" }
  ]
}

helpers:pinGitHubActionDigests tự chuyển tag thành SHA kèm comment version. Chọn cái nào? Dependabot có sẵn trong GitHub, không cần cài gì. Renovate linh hoạt hơn nhiều (lịch chạy, automerge theo loại update, hỗ trợ monorepo phức tạp) nhưng cần cài GitHub App hoặc tự host.

Workflow chạy theo lịch và tự commit vào repo

Quay lại workflow cron sinh nội dung của blog. Đây là phiên bản mình viết lại:

# .github/workflows/content-cron.yml
name: Content cron

on:
  schedule:
    - cron: '17 1 * * *'     # 01:17 UTC = 08:17 giờ Việt Nam; tránh phút 00
  workflow_dispatch:
    inputs:
      topic:
        description: Chủ đề (để trống thì lấy từ backlog)
        type: string
        required: false

concurrency:
  group: content-cron
  cancel-in-progress: false  # không hủy run đang commit dở

permissions:
  contents: write            # cần để push, và chỉ thế thôi

jobs:
  generate:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc
      - run: npm ci
      - name: Generate post
        env:
          TOPIC: ${{ inputs.topic }}
          CONTENT_API_KEY: ${{ secrets.CONTENT_API_KEY }}
        run: node scripts/blog/generate.mjs --topic "$TOPIC"

      - name: Commit and push
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

          # Chỉ add đúng thư mục nội dung, không bao giờ "git add ."
          git add content/blog/
          if git diff --staged --quiet; then
            echo "Không có bài mới, bỏ qua"
            exit 0
          fi
          git commit -m "content: scheduled post $(date -u +%F)"

          for attempt in 1 2 3; do
            if git push; then
              exit 0
            fi
            echo "Push bị từ chối (lần $attempt), rebase lên remote rồi thử lại"
            git pull --rebase
            sleep $((attempt * 5))
          done
          echo "Push thất bại sau 3 lần" >&2
          exit 1

Từng thay đổi so với bản cũ, và lý do:

  • git add content/blog/ thay cho git add . Script sinh nội dung có thể để lại file tạm, log, package-lock.json bị sửa do npm install, hay output debug chứa response của API. git add . commit hết những thứ đó lên main. Liệt kê đúng path bạn muốn commit, và nếu script tạo file ở chỗ khác, coi đó là bug của script.
  • git diff --staged --quiet tránh tạo commit rỗng hoặc fail khi không có gì mới.
  • Retry với git pull --rebase. Giữa lúc checkout và lúc push, người khác (hoặc một workflow cron khác) có thể đã push lên main. Push sẽ bị từ chối với "non-fast-forward". Rebase commit của bot lên trên rồi push lại là an toàn, vì bot chỉ đụng vào thư mục nội dung của nó. Nếu rebase gặp conflict, script dừng với lỗi, và đó là lúc con người cần xem.
  • concurrency không cancel. Ngăn hai run (cron và bấm tay) cùng commit một lúc.
  • Biến môi trường cho input, như đã nói ở phần script injection.
  • Không dùng PAT khi không cần. Workflow cũ checkout bằng một personal access token lưu trong secret. GITHUB_TOKEN với contents: write là đủ để push, hết hạn khi job kết thúc và chỉ có quyền trên đúng repo này.

Những hành vi của schedule cần biết:

  • Cron tính theo UTC và chỉ chạy trên nhánh default. Khoảng cách ngắn nhất là 5 phút.
  • Run có thể bị trễ khi GitHub tải cao, đặc biệt đầu mỗi giờ, và khi tải rất cao một số run có thể bị bỏ. Vì vậy dùng phút lẻ như 17 và đừng thiết kế logic phụ thuộc vào giờ chạy chính xác.
  • Với repo public, workflow theo lịch bị tự tắt sau 60 ngày repo không có hoạt động.
  • Commit được push bằng GITHUB_TOKEN không kích hoạt workflow khác (trừ workflow_dispatch và repository_dispatch), để tránh vòng lặp vô hạn. Nếu bạn cần CI chạy trên commit của bot, dùng token của một GitHub App (actions/create-github-app-token). Deploy qua Vercel Git integration thì vẫn chạy bình thường, vì Vercel nhận webhook push từ GitHub chứ không phụ thuộc vào Actions.

Bảng tóm tắt các trigger hay dùng:

Trigger Khi nào chạy Secrets Code được chạy Dùng cho
push Push lên nhánh hoặc tag khớp filter Có Commit được push CI trên main, deploy production, release
pull_request PR được mở hoặc cập nhật Có với PR cùng repo, không có với PR từ fork Merge commit ảo của PR CI cho PR, preview deploy
pull_request_target Sự kiện PR, kể cả từ fork Có Code của nhánh base Gắn label, comment. Không checkout code PR
workflow_dispatch Bấm tay hoặc gọi API Có Nhánh được chọn Job có tham số, chạy lại thủ công
schedule Theo cron (UTC) Có Nhánh default Cron nội dung, dọn dẹp, quét bảo mật
workflow_call Được workflow khác gọi Do nơi gọi truyền vào Theo nơi gọi Reusable workflow
workflow_run Sau khi workflow khác kết thúc Có Nhánh default Xử lý kết quả CI của PR từ fork, cẩn thận với artifact
merge_group PR vào merge queue Có Commit tạm của queue Required checks khi bật merge queue

Những lỗi thường gặp

1. Cache key băm cả node_modules.

# Sai: đặt sau pnpm install, băm hàng chục nghìn file trong node_modules
key: nextjs-${{ hashFiles('**/*.ts') }}

# Đúng
key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}

2. Upload report chỉ khi thành công.

# Sai: test fail thì step này bị skip, đúng lúc cần report nhất
- uses: actions/upload-artifact@v7
  with: { name: blob-report-1, path: blob-report }

# Đúng
- uses: actions/upload-artifact@v7
  if: ${{ !cancelled() }}
  with: { name: blob-report-1, path: blob-report }

3. cancel-in-progress: true cho mọi thứ. Hủy run trên main hay job deploy production giữa chừng có thể để lại deployment dở dang. Chỉ cancel cho PR, như biểu thức ở đầu bài.

4. Paths filter cấp workflow cho required check. PR chỉ sửa tài liệu bị treo mãi ở "Waiting for status". Lọc ở cấp job và dùng job ci-ok.

5. Tag thay vì SHA cho action bên thứ ba.

# Sai: ai kiểm soát repo action (hoặc dời được tag) thì kiểm soát pipeline của bạn
- uses: pnpm/action-setup@v6

# Đúng: pin SHA, để Dependabot/Renovate cập nhật
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0

6. Secret ở cấp workflow. Khai báo env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} ở đầu file nghĩa là mọi job, mọi step (kể cả pnpm install chạy postinstall của hàng trăm package) đều thấy token. Đưa secret xuống đúng job, tốt nhất là environment secret.

7. git add . trong workflow tự commit. Commit luôn file tạm, lockfile bị sửa, output debug. Add đúng thư mục.

8. Không có timeout-minutes. Một job treo chạy tới giới hạn 6 tiếng mặc định trước khi bị kill.

Checklist

  • Thêm permissions: contents: read ở đầu mọi workflow, cấp thêm quyền ở cấp job khi cần.
  • Thêm concurrency với cancel-in-progress chỉ cho pull_request.
  • Khai báo cache: pnpm cho setup-node và cache .next/cache với key giới hạn trong thư mục source.
  • Tách lint, typecheck, unit test, build thành job song song, đặt timeout-minutes cho từng job, và dùng ci-ok làm required check duy nhất.
  • Cấu hình Playwright với fullyParallel, --shard, reporter blob, trace: 'on-first-retry' và job gộp report.
  • Đưa token deploy vào environment secret; bật required reviewers cho production.
  • Chuyển access key cloud dài hạn sang OIDC với điều kiện sub cụ thể đến environment.
  • Pin mọi action bên thứ ba theo SHA (pinact run) và bật Dependabot cho github-actions với cooldown.
  • Tìm trong repo mọi ${{ github.event. nằm trong run: và chuyển sang biến môi trường; chạy zizmor một lần.
  • Trong workflow tự commit: thay git add . bằng path cụ thể, thêm retry push với git pull --rebase.

Đọc thêm

Last updated: July 20, 2025

Comments