Quy trình CI/CD (CI/CD pipeline) là con đường tự động mà một thay đổi đi từ lúc git push cho đến khi lên production. Tích hợp liên tục (continuous integration, CI) dựng (build) và kiểm thử mọi lần push trên một máy sạch, để lỗi lộ ra chỉ vài phút sau khi mắc phải. Còn chuyển giao hoặc triển khai liên tục (continuous delivery hoặc deployment, CD) thì phát hành những gì đã qua kiểm thử bằng một bước duy nhất, lặp lại được: nếu vẫn có người bấm nút thì gọi là chuyển giao (delivery), còn nếu không ai bấm gì cả thì là triển khai (deployment). Trên GitHub Actions, quy trình này là một tệp YAML nằm trong .github/workflows/, ghi rõ khi nào chạy và chạy gì. Các quy trình của tôi được cố ý làm mỏng: phần kiểm tra chỉ chạy đúng những lệnh tôi vẫn gõ trên laptop, trên cùng image Postgres và Valkey mà tệp Docker Compose của tôi khởi động, còn mọi khâu triển khai đều là script hoặc CLI, không có GitHub thì vẫn chạy y như vậy.
Năm 2024 tôi có viết bài Dùng Docker Compose cho CI/CD thay vì YAML riêng của từng nền tảng, với ý chính là để logic dựng trong Compose, còn công cụ CI chỉ việc gọi nó. Hai năm sau, tôi xem lại toàn bộ các kho mã mà tôi đang dùng để phát hành. Có bốn kho có workflow, kho nào cũng chỉ có một người commit là tôi, và trong số các workflow đó không còn cái nào gọi docker compose nữa. Nguyên tắc thì vẫn đứng vững, còn cách làm thì không. Dưới đây là những thứ đã thay thế nó: phiên bản ví dụ đã cắt gọn của các tệp, thời gian từng bước mà GitHub ghi lại, và cả những phần ít tự động hơn so với mong đợi của mấy sơ đồ CI/CD.
Mọi quy trình tôi đang chạy, gói trong một bảng#
| Quy trình | Bắt đầu bởi | CI kiểm tra gì | Đi đến đâu | Ai phát hành |
|---|---|---|---|---|
| Blog này và trang Hugo của một khách hàng | Mọi lần push, trên bất kỳ nhánh nào | hugo --gc dựng được hay không | Cloudflare Pages: main là production, các nhánh khác có URL xem trước | Không ai cả; push chính là phát hành |
| API của ứng dụng chợ mà tôi đang làm | Một script triển khai, chạy tay | Lint, kiểm tra kiểu và bộ kiểm thử Django, mỗi lần push lên main | Fly.io, triển khai cuốn chiếu (rolling deploy) | Tôi |
| Giao diện của chính ứng dụng đó: web, cập nhật OTA, bản dựng cho store | Các script phát hành và bản dựng store trên máy, chạy tay | TypeScript và Jest, mỗi lần push lên main | Kho lưu trữ đối tượng (object storage), một dịch vụ cập nhật OTA và hai kho ứng dụng | Tôi |
| pyhtml2text, gói Python mã nguồn mở của tôi | Mọi lần push, pull request và tag | Wheel cho 15 nền tảng đích, kiểm thử trên phần lớn trong số đó | PyPI, khi gắn tag | Cái tag |
Nói theo sách vở thì hai trang Hugo là triển khai liên tục, còn ứng dụng kia (một ứng dụng chợ kiêm cộng đồng mà tôi đang làm) là tích hợp liên tục cộng với phát hành bằng đúng một lệnh, tức là chuyển giao liên tục: phần kiểm tra báo cho tôi biết khi có gì sai, nhưng không chặn tôi triển khai. Tôi cố ý chia như vậy. Một bản cập nhật OTA sẽ đến mọi điện thoại đang dùng phiên bản ứng dụng mà nó nhắm tới, nên việc phát hành phải là một quyết định, chứ không phải tác dụng phụ của một lần merge. Còn API là nơi chạy migration cơ sở dữ liệu. Hai ứng dụng Django còn lại của tôi thì hoàn toàn không có workflow nào.
Phần CI: một workflow, hai job#
Toàn bộ CI của ứng dụng nằm trong một tệp, .github/workflows/checks.yml. Trước ngày 11 tháng 10, ngay cả tệp này cũng chưa có. Đây là một ví dụ đã cắt gọn cho thấy hình dạng của nó:
# Lint, types and tests on every push and pull request to main.
# Nothing here deploys.
name: Checks
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: true
jobs:
python:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: myapp
POSTGRES_DB: myapp
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U myapp"
--health-interval 5s --health-timeout 5s --health-retries 20
valkey:
image: valkey/valkey:8-alpine
ports: ["6379:6379"]
env:
DEBUG: "True"
DATABASE_URL: postgresql://myapp:myapp@localhost:5432/myapp
REDIS_URL: redis://localhost:6379
PYTHON_JIT: "0" # match production
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run mypy myapp/
- run: uv run python manage.py test --parallel 4 --noinput
app:
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
# Expo writes this on `expo start` and it is gitignored,
# so a fresh checkout doesn't have it.
- run: printf '/// <reference types="expo/types" />\n' > expo-env.d.ts
- run: npm run typecheck
- run: npx jest --ciHai job chạy song song. Trong 35 lần chạy thành công tính đến giờ, trung vị của job ứng dụng là khoảng bảy phút, còn job Python là khoảng mười hai phút rưỡi.
Khối services: chính là chỗ tệp Compose đã chuyển tới, phần sau sẽ nói rõ. Môi trường chỉ có vài biến và một cờ. Mọi thứ còn lại đều rơi về giá trị mặc định cho môi trường phát triển mà lập trình viên có được khi chạy docker compose up, nên bộ kiểm thử không cần bí mật (secret) nào. Để chắc chắn, tôi đã chạy thử một phần bộ kiểm thử với đúng môi trường đó và không có gì khác.
CI chạy cùng trình thông dịch với laptop của tôi và với image production, tức là bản Python 3.15.0 do chính uv dựng, vì uv sync đọc .python-version và tải về đúng bản đó. (Tệp thật của tôi còn truyền cho setup-uv một tham số python-version-file, thật ra chẳng làm gì cả: setup-uv v6 không có tham số nào như vậy, và lần chạy nào cũng kèm một cảnh báo nói đúng điều đó. Viết bài này tôi mới để ý.) Việc dùng chung trình thông dịch đã chứng minh giá trị chỉ tám ngày sau khi workflow ra đời. Ngày 19 tháng 10, tôi bật trình biên dịch JIT của 3.15, và ngay lần chạy đầu tiên có JIT, một bài kiểm thử đã thất bại: một câu yield from rất bình thường, nằm trên đoạn code mà lần lưu dữ liệu nào của ứng dụng cũng đi qua, lại ném ra AttributeError: 'list' object has no attribute 'send'. Cùng commit đó mà tắt JIT thì qua. Máy Mac của tôi là arm64 nên chưa bao giờ gặp lỗi này; còn máy chạy CI (runner) là x86_64, giống các máy production. JIT giờ đã được tắt lại, cả trong CI lẫn trong Dockerfile, và lỗi này chưa bao giờ lọt lên production.
Thật ra ngay lần chạy đầu tiên cũng đã thất bại, nhưng vì một lý do tẻ nhạt hơn. Expo tạo tệp expo-env.d.ts khi bạn chạy expo start, mà tệp này lại nằm trong gitignore. Vì vậy bản làm việc nào tôi từng dùng cũng có nó, còn một bản checkout mới tinh thì không. TypeScript báo năm lỗi chưa từng xuất hiện trên máy tôi. Bước tạo tệp đó chính là cách sửa.
concurrency sẽ hủy lần chạy cũ khi có một lần push mới hơn lên cùng nhánh. Cả mười hai lần chạy bị hủy tính đến giờ đều kết thúc theo cách này. Một hook trước commit (pre-commit) chạy công cụ lint, và chạy thêm bộ kiểm tra kiểu khi có tệp Python hoặc TypeScript được stage, nên phần lớn lỗi không đi được tới lúc push. Nếu bạn chép tệp này về dùng, hãy nâng các action lên phiên bản chính mới hơn: lần chạy nào cũng cảnh báo rằng actions/checkout@v4, actions/setup-node@v4 và astral-sh/setup-uv@v6 nhắm tới Node.js 20, phiên bản đã bị ngừng hỗ trợ.
Tệp Docker Compose đã đi đâu#
Đây là phần trong tệp docker-compose.yml của ứng dụng mà CI quan tâm, viết lại dưới dạng ví dụ. Tệp thật còn nhiều dịch vụ hơn và nhiều biến môi trường hơn hẳn.
services:
valkey:
image: valkey/valkey:8-alpine
ports:
- "6379:6379"
# Host port 55432, not 5432, so it can't collide with a local Postgres.
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=myapp
- POSTGRES_PASSWORD=myapp
- POSTGRES_DB=myapp
ports:
- "55432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp -d myapp"]
interval: 5s
retries: 10
api:
build: .
command: python manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp
- REDIS_URL=redis://valkey:6379
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_started
volumes:
postgres_data:Đặt nó cạnh khối services: trong checks.yml thì thấy đó là cùng hai image: Postgres 17 (ở đây là bản Alpine, trong CI là bản Debian) và valkey/valkey:8-alpine. Quan hệ giữa hai bên bây giờ là vậy. Tệp Compose là thứ lập trình viên chạy; còn CI khai báo cùng các image đó dưới dạng service container, rồi chạy thẳng trên runner đúng những lệnh mà lập trình viên vẫn chạy, uv run … và npm run ….
So với bài năm 2024, phần tôi quan tâm nhất vẫn đứng vững: công cụ CI vẫn mỏng, và các bước kiểm tra lẫn triển khai đều là những lệnh tôi tự chạy được. Thứ không trụ lại được là để CI gọi Compose. CI của ứng dụng hoàn toàn không dựng Docker image. Image được dựng đúng một lần, lúc triển khai, trên máy dựng (builder) của Fly, còn CI chỉ cần trả lời code có ổn không, và khi bộ công cụ đã có sẵn trong bộ nhớ đệm (cache) thì việc đó chỉ tốn sáu giây cài đặt. Ngoài ra, tệp Compose còn bind-mount bản làm việc và nạp dữ liệu mẫu, những việc một lần chạy kiểm thử không nên làm. Vậy nên CI sẽ cần một tệp Compose riêng, tức là một bản mô tả thứ hai cho cùng các dịch vụ, phải giữ cho khớp nhau bằng tay.
Thêm nữa, ví dụ năm 2024 có một lỗi mà lúc đó tôi không để ý. docker compose build chỉ dựng image, chứ không bao giờ chạy command của dịch vụ. Thế nên lệnh npm test trong tệp đó chưa từng chạy trong CI, và workflow vẫn xanh dù kiểm thử có qua hay không.
Rủi ro mà bài đó lo nhất, tức là đổi nhà cung cấp CI, rốt cuộc chưa bao giờ xảy ra. Cuộc chuyển dịch thật sự lại diễn ra ở đầu bên kia: blog này và trang của khách hàng kia đã đổi nơi lưu trữ sang Cloudflare Pages, và với blog thì chỉ cần viết mới một workflow dài 45 dòng, vì khâu dựng vốn đã chỉ là một lệnh. Còn cái workflow ba trăm dòng mà tôi từng cảnh báo? Tôi có đúng một cái, của pyhtml2text. Nó dài vì wheel phải được dựng trên runner Linux, macOS và Windows, mỗi loại cho nhiều kiến trúc, và cũng vì vậy mà đây là việc duy nhất mà YAML riêng của nền tảng là công cụ đúng.
Bộ nhớ đệm: đọc thời gian từng bước trước đã#
GitHub ghi lại thời gian của từng bước, và gh api repos/<owner>/<repo>/actions/runs/<run-id>/jobs sẽ trả về cho bạn. Đây là số liệu từ lần chạy thành công gần nhất của ứng dụng, ngày 20 tháng 10:
| Job | Bước | Giây |
|---|---|---|
| Python | Khởi động Postgres và Valkey | 18 |
| Python | uv sync --locked, đã có cache | 6 |
| Python | mypy | 27 |
| Python | Kiểm thử Django | 761 |
| App | npm ci, đã có cache | 60 |
| App | Jest | 333 |
Cả hai bộ nhớ đệm cho thư viện phụ thuộc đều đang bật, và cả hai đều là phần rẻ. Bộ nhớ đệm uv mà GitHub giữ cho kho này chỉ khoảng 200 KB, vì setup-uv dọn đi những gì uv chỉ cần tải lại là xong; còn của npm thì gần 400 MB. Thời gian nằm ở bộ kiểm thử, mà bộ kiểm thử thì đã chạy song song bốn luồng rồi. Muốn chạy nhanh hơn, tôi sẽ sửa bộ kiểm thử chứ không phải bộ nhớ đệm.
Blog thì ngược lại. Ảnh đã được Hugo xử lý nằm trong thư mục resources/_gen vốn bị gitignore, nên runner mới nào cũng phải mã hóa lại toàn bộ ảnh bìa, ảnh trong bài và ảnh giữ chỗ. Workflow ở phía dưới khôi phục chúng bằng actions/cache, với khóa là mã băm của chính các bức ảnh; nếu khóa không khớp thì dùng bản cache cũ mới nhất, vì Hugo chỉ xử lý phần còn thiếu. Trong tám lần chạy cuối cùng trước khi có cache, bước dựng mất từ 33 đến 76 giây; trong 26 lần kể từ đó là 6 đến 20 giây, chỉ một lần ngoại lệ là 94 giây, dù chính commit đó còn thêm ảnh giữ chỗ và một cỡ thẻ thứ hai.
Image của ứng dụng được dựng trên builder của Fly: các tệp khai báo thư viện phụ thuộc được chép vào trước code, còn bộ nhớ đệm tải về của uv nằm trong một cache mount của BuildKit (RUN --mount=type=cache,target=/root/.cache/uv). Nhưng cải thiện lớn nhất ở đây lại không phải là bộ nhớ đệm. Tệp .dockerignore cũ là danh sách chặn (blocklist), và nó bỏ sót một thư mục chứa các git worktree phụ, mỗi worktree lại có thư viện phụ thuộc và sản phẩm dựng của riêng nó. Kết quả là 7,7 GB với 277.000 tệp bị đẩy lên builder mỗi lần triển khai. Viết lại thành danh sách cho phép (allowlist), ngữ cảnh dựng giờ chỉ còn khoảng 40 MB. Một ví dụ cho cách làm này:
# Exclude everything, then let back in only what the Dockerfile COPYs.
*
!pyproject.toml
!uv.lock
!manage.py
!myapp
!static
# Rules match the whole relative path and the last match wins,
# so these re-excludes apply inside the allowed directories too.
**/__pycache__/
**/*.pyc
**/.DS_Store
**/*.logBí mật, đặt ở càng ít chỗ càng tốt#
Bí mật nằm ở ba nơi. Bí mật của GitHub Actions dành cho những việc workflow làm, và hai kho Hugo mỗi kho chỉ có đúng một cái, CLOUDFLARE_API_TOKEN. Bí mật của Fly là cấu hình lúc chạy của ứng dụng, được đặt bằng flyctl secrets set, và không bao giờ đi qua Actions. Cuối cùng là tệp .env bị gitignore trên laptop của tôi, chứa token mà script triển khai dùng để xóa cache của Cloudflare.
PyPI thì hoàn toàn không cần bí mật. pyhtml2text dùng cơ chế trusted publishing: PyPI chấp nhận token OIDC ngắn hạn mà GitHub cấp cho workflow, nên job phát hành chỉ xin quyền id-token: write, và trang bí mật của kho thì trống trơn. Không có gì để rò rỉ, cũng không có gì phải xoay vòng.
Token chỉ được cấp đúng quyền cần thiết. Token Fly của ứng dụng trên GitHub phục vụ một bước kiểm tra định kỳ chỉ liệt kê máy, nên nó được tạo bằng fly tokens create readonly. Các lần triển khai lên Pages chỉ xin contents: read và deployments: write, không hơn.
Dữ liệu đầu vào luôn đi qua env:, không bao giờ nhét thẳng vào script. Workflow OTA nhận ghi chú phát hành dạng văn bản tự do. Nếu viết ${{ inputs.notes }} ngay trong run:, đoạn văn bản sẽ được dán thẳng vào script trước khi bash đọc, nên một ghi chú có dấu nháy và dấu chấm phẩy sẽ biến thành code. Truyền qua biến môi trường thì nó vẫn chỉ là dữ liệu. Một ví dụ cho cách làm này:
- name: Publish
env:
OTA_API_TOKEN: ${{ secrets.OTA_API_TOKEN }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ROLLOUT: ${{ inputs.rollout }}
INPUT_NOTES: ${{ inputs.notes }}
run: |
args=("$INPUT_PLATFORM")
[ -n "$INPUT_ROLLOUT" ] && args+=(--rollout "$INPUT_ROLLOUT")
[ -n "$INPUT_NOTES" ] && args+=(--notes "$INPUT_NOTES")
./scripts/publish-ota.sh "${args[@]}"Có những khóa tôi để hẳn bên ngoài GitHub. Một bản báo cáo định kỳ cần một khóa có quyền quản trị phần đăng nhập của ứng dụng, nên nó đã chuyển từ một workflow định kỳ sang một tác vụ định kỳ trên máy Mac của tôi. Workflow vẫn còn đó làm phương án dự phòng chạy tay, và bỏ qua những mục cần đến khóa đó.
Triển khai: mỗi lần push, bằng script, và theo tag#
Các trang Hugo được triển khai mỗi lần push#
Đây là phiên bản ví dụ của workflow cho blog, đã lược bỏ bước tạo dự án Pages ở lần chạy đầu tiên:
name: Deploy to Cloudflare Pages
on:
push:
workflow_dispatch:
env:
HUGO_VERSION: "0.165.0"
PAGES_PROJECT: my-site
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: ${{ env.HUGO_VERSION }}
extended: true
# Processed images are gitignored; restore them so Hugo only
# re-encodes what is new. An older cache still helps.
- uses: actions/cache@v4
with:
path: resources/_gen
key: hugo-resources-${{ hashFiles('content/**/*.jpg', 'content/**/*.png', 'assets/img/**') }}
restore-keys: |
hugo-resources-
# A multilingual site has no root 404.html; without one, Pages
# answers unknown paths with index.html and a 200.
- name: Build
run: |
hugo --gc
cp public/en/404.html public/404.html
# ...
- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}--branch=${{ github.ref_name }} làm cho main thành production và cho mọi nhánh khác một URL xem trước. Workflow cho trang của khách hàng có cùng cấu trúc, chỉ thêm một dòng rm -rf public trước khi dựng, vì bên đó thư mục public/ được commit, nếu không thì các trang đã xóa sẽ cứ nằm lại trong bản tải lên.
Phiên bản Hugo chỉ được ghim ở đây, không ở đâu khác. Tháng 10, một lần cập nhật của trình quản lý gói đã nâng Hugo trên laptop của tôi lên 0.168, cao hơn một bậc so với bản mới nhất mà theme hỗ trợ. Các lần triển khai vẫn ở 0.165 và chẳng hề hấn gì.
Và bước dựng chính là bài kiểm thử. Ngày 14 tháng 9, bản triển khai xem trước của một nhánh thất bại ba lần liên tiếp: một bài viết trên nhánh đó dùng ref của Hugo để liên kết tới một bài vẫn còn là bản nháp. CI dựng đúng như production dựng, tức là bỏ bản nháp ra, nên liên kết trỏ vào hư không và Hugo dừng lại với lỗi REF_NOT_FOUND. Trong lúc đó, main vẫn triển khai bình thường.
API của ứng dụng được triển khai bằng script#
Phần cốt lõi của script triển khai, viết dưới dạng ví dụ:
IMAGE_LABEL="deploy-$(git rev-parse --short HEAD)-$(date +%Y%m%d%H%M%S)"
# Build first, then wake a spare machine, then roll out exactly that image.
flyctl deploy --config fly.toml --build-only --push --image-label "$IMAGE_LABEL"
curl -fsS --retry 5 https://myapp.fly.dev/health > /dev/null
flyctl deploy --config fly.toml --image "registry.fly.io/myapp:$IMAGE_LABEL"Và các dòng tương ứng trong fly.toml:
[deploy]
# max_unavailable only counts machines that are running,
# which is why the script wakes one first.
strategy = "rolling"
max_unavailable = 1Image được dựng đúng một lần, đẩy lên registry của Fly với một nhãn ghép từ commit và thời điểm, rồi được triển khai theo đúng nhãn đó, nên thứ được tung ra chính xác là thứ đã được dựng.
Thứ giúp triển khai không gián đoạn (zero-downtime) là việc đánh thức một máy dự phòng trước. max_unavailable = 1 chỉ đếm những máy đang chạy, mà ứng dụng lại không giữ máy nào chạy khi rảnh (một máy đang tạm dừng chỉ mất khoảng một giây để tỉnh lại). Vì vậy, trước đây cái máy duy nhất đang thức bị thay cùng đợt với các máy đang ngủ, và đằng sau nó không còn máy nào sẵn sàng. Ngày 28 tháng 9, tôi cho một đầu dò gửi yêu cầu liên tục tới origin: khi chạy flyctl deploy trơn thì có 7 lần hết thời gian chờ và một khoảng trống 7,3 giây, còn với script triển khai, vốn dựng trước và có sẵn một máy đang thức trước khi bắt đầu tung ra, thì không có lỗi nào trong 2.514 yêu cầu.
Phần còn lại của script đánh dấu lần triển khai trên kênh chat của đội, nơi các cảnh báo lỗi gửi về, để khi lỗi dồn dập thì có thể đối chiếu với thứ vừa thay đổi, rồi xóa cache Cloudflare và làm nóng các máy. Giao diện web thì hoàn toàn không đi qua image; chuyện đó tôi đã viết trong Phát hành giao diện web mà không cần triển khai lại API.
Migration không phải là một bước trong quy trình. release_command của Fly bị treo trong trường hợp của tôi, nên máy đầu tiên khởi động với image mới sẽ giữ khóa và chạy migration, trong khi các máy khác chờ. Vì thế, migration nào cũng phải tương thích với code vẫn đang chạy: thêm cột ở lần triển khai này, ngừng dùng nó ở lần sau, và xóa nó ở lần thứ ba.
Script này không có chỗ nào bắt buộc phải là laptop của tôi, nhưng có vài tệp chỉ nằm trên máy đang giữ nó ở đó: thông tin đăng nhập Cloudflare, và một bản ghi về thứ đã được triển khai lần gần nhất, thứ mà một runner mới tinh sẽ không có. Nó còn triển khai cả những thay đổi chưa commit, và có báo điều đó trên kênh chat, trong khi script phát hành web thì từ chối một cây làm việc chưa sạch. Trong bài Mỗi nhánh một môi trường, tôi viết rằng làm những việc này bằng tay thì ổn khi chỉ có một mình bạn làm, nhưng từ lập trình viên thứ hai trở đi thì nó phải xảy ra vì có gì đó xảy ra trong git. Ứng dụng vẫn chỉ có một lập trình viên. Các workflow môi trường theo nhánh trong bài đó vẫn nằm trên chính nhánh mà tôi đã dựng chúng.
Workflow OTA chỉ chạy tay, vì việc phát hành phải là một quyết định, và vì dịch vụ cập nhật từ chối bản phát hành mới khi đang có một đợt tung ra từng phần (partial rollout) còn mở, nên nếu kích hoạt theo push thì đợt canary nào cũng sẽ vướng. Workflow này chưa từng chạy lần nào. Mọi bản cập nhật OTA cho đến nay đều được đẩy đi từ laptop của tôi, qua chính script mà workflow gọi. Lý do nên giữ workflow mỏng gói gọn trong một câu: phát hành bắt đầu bằng cách nào thì cũng là cùng một script.
pyhtml2text phát hành theo tag#
Job phát hành, với tên gói đã được đổi thành tên chung chung:
release:
runs-on: ubuntu-latest
if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }}
needs: [linux, macos, windows, sdist]
environment:
name: pypi
url: https://pypi.org/p/mypackage
permissions:
id-token: write # trusted publishing and signing
contents: write
attestations: write
steps:
- uses: actions/download-artifact@v7
- uses: actions/attest@v4
with:
subject-path: 'wheels-*/*'
- uses: astral-sh/setup-uv@v7
if: ${{ startsWith(github.ref, 'refs/tags/') }}
- name: Publish to PyPI
if: ${{ startsWith(github.ref, 'refs/tags/') }}
run: uv publish --trusted-publishing always 'wheels-*/*'Job phát hành chờ mọi job dựng xong, và chỉ khi có tag mới thật sự xuất bản; chạy tay thì chỉ dựng và tạo chứng thực (attestation) chứ không tải gì lên. actions/attest ghi lại workflow nào đã dựng từng wheel, còn uv publish --trusted-publishing always tải chúng lên bằng token OIDC thay vì mật khẩu. Bản thân gói thư viện này tôi đã viết trong Một pull request bị đóng, một bản fork và một gói PyPI.
Workflow định kỳ chỉ là “cố gắng hết sức”#
Ứng dụng có một workflow chạy định kỳ, kiểm tra xem Fly có bật nhiều máy hơn mức cơ bản hay không, chuyện mà nếu không kiểm tra thì sẽ chỉ lộ ra trên hóa đơn. Nó cảnh báo bằng cách cố ý thất bại, vì GitHub sẽ gửi email cho chủ kho khi một lần chạy định kỳ thất bại. Lịch được đặt là cron: '*/15 * * * *', tức 96 lần một ngày. Vậy mà trong hai tuần qua, mỗi ngày nó chỉ chạy từ ba đến tám lần, với khoảng cách trung vị giữa các lần chạy khoảng ba tiếng bốn mươi phút. Lời giải thích duy nhất trong tài liệu của GitHub là tải: lần chạy định kỳ có thể bị trễ khi Actions bận, và bị bỏ qua khi bận đến một mức nào đó. Với một đợt mở rộng quy mô kéo dài, vài tiếng là đủ. Còn với thứ gì cần báo trong vòng vài phút, cron của GitHub không phải là công cụ giám sát.
Những câu tôi sẽ hỏi về một quy trình CI/CD#
Dù bạn đang dựng một quy trình mới hay xem lại quy trình đang có:
- Nếu GitHub Actions ngừng hoạt động cả một ngày, bạn có còn phát hành được không? Nếu không, logic phát hành của bạn đang nằm trong YAML, và bài năm 2024 vẫn còn đúng.
- CI có chạy cùng trình thông dịch, cùng kiến trúc CPU và cùng phiên bản chính của cơ sở dữ liệu với production không? CI của tôi bắt được một lỗi trình biên dịch chỉ vì runner và production đều là x86_64, còn laptop của tôi thì không.
- Một bản checkout mới tinh có qua được không? Nếu không, thủ phạm thường là các tệp được sinh tự động và bị gitignore.
- Thời gian của một lần chạy thật ra tiêu vào đâu? Hãy đọc thời gian từng bước trước khi thêm bộ nhớ đệm.
- Mỗi bí mật đang nằm ở bao nhiêu chỗ, và nó có thật sự cần nằm trên GitHub không?
