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.
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
concurrencyvớicancel-in-progresscho PR, không chomain. - Cache pnpm store qua
setup-nodevà cache.next/cacheriêng. Cache tạo trênmainthì mọi PR dùng được. - Tách lint, typecheck, unit test, build thành job song song. Một job
ci-okgom kết quả làm required check duy nhất. - Playwright:
--shard, reporterblob, gộp report ở một job riêng, trace bậton-first-retry. permissions: contents: readở đầu file, OIDC thay cho access key, pin action bên thứ ba theo commit SHA, tránhpull_request_targetkhi phải checkout code của PR.- Workflow cron tự commit:
git addđúng thư mục,concurrencykhông cancel, retry push bằnggit 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-setupphải chạy trướcsetup-node, vìcache: pnpmcần gọipnpm 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-nodev6, cache tự động chỉ bật cho npm (khipackageManagertrongpackage.jsonlà npm). Với pnpm và Yarn, bạn phải khai báocache: pnpmnhư trên. - Cache cái gì.
setup-nodecache pnpm store (các file package đã tải), không cachenode_modules.pnpm installvẫn chạy nhưng chỉ cần link từ store, không tải lại từ registry. Cachenode_modulestrự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-versionvànode-version-filecùng lúc.setup-nodeưu tiênnode-versionnế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-lockfilelàm install fail nếupackage.jsonvà 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
bloblà định dạng trung gian để gộp. Mỗi shard tạo một blob, jobe2e-reportgộ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-artifactv4 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-retrychỉ ghi trace cho test fail ở lần đầu, nên chi phí thấp khi mọi thứ xanh. Tải artifactplaywright-report, giải nén, chạypnpm 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ưuVERCEL_TOKENcủa production làm environment secret. Secret của environment chỉ được cấp cho job khai báoenvironment: 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 chogit add .Script sinh nội dung có thể để lại file tạm, log,package-lock.jsonbị sửa donpm install, hay output debug chứa response của API.git add .commit hết những thứ đó lênmain. 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 --quiettrá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ênmain. 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. concurrencykhô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_TOKENvớicontents: writelà đủ để 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ư
17và đừ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_TOKENkhông kích hoạt workflow khác (trừworkflow_dispatchvà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
concurrencyvớicancel-in-progresschỉ chopull_request. - Khai báo
cache: pnpmchosetup-nodevà cache.next/cachevớ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-minutescho từng job, và dùngci-oklàm required check duy nhất. - Cấu hình Playwright với
fullyParallel,--shard, reporterblob,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
subcụ thể đến environment. - Pin mọi action bên thứ ba theo SHA (
pinact run) và bật Dependabot chogithub-actionsvớicooldown. - Tìm trong repo mọi
${{ github.event.nằm trongrun:và chuyển sang biến môi trường; chạyzizmormột lần. - Trong workflow tự commit: thay
git add .bằng path cụ thể, thêm retry push vớigit pull --rebase.
Đọc thêm
- GitHub Docs: Security hardening for GitHub Actions
- GitHub Docs: Events that trigger workflows
- Playwright: Sharding
- Next.js: CI build caching
- actions/setup-node
- Supply chain attack trên npm: bài học và cách tự bảo vệ dự án JavaScript
- Chiến lược testing cho web app: unit, integration và E2E
- Docker và Kubernetes cho JavaScript developer