當 Release 不再靠人工
用Python生態打造自動化版本治理與 DevX 實戰
陳松林 | 小松XiaoSong
Security Focused Devops Engineer
1
Agenda · 今天要聊什麼
2
01
痛點回顧
手動 Release 的代價
02
Image Tag 設計
4-tag 命名策略
03
Dev → Staging
Pipeline 實作
04
Staging → Prod
自動 promote
05
CHANGELOG 自動化
PR 驅動,零人工介入
06
總結
收穫 + 問題討論
3
01
痛點回顧
手動 Release 的代價與原有流程
試想一下 - 當新版本Release時
開發了3個月後
有N個commit,N個PR
需要撰寫 CHANGELOG.md
方法有哪些?
4
5
6
不寫
7
不寫
git log
AI 整理
8
不寫
git log
AI 整理
請Intern
叫AI整理
每次上線都是一場未知的冒險
9
但為什麼要寫Changelog
10
11
可追溯性(Traceability)
每次變更都有據可查,出問題時能快速定位是哪個版本引入的
加速 Rollback 決策
知道每個版本改了什麼,才能判斷該回退到哪個點,而不是盲目猜測
團隊溝通與交接
跨團隊協作時,Changelog 是最直接的歷史文件,減少口頭說明成本
稽核與法規遵循
SOC 2、ISO 27001、PCI DSS 這些認證都要求有完整的變更管理紀錄
12
02
Image Tag 設計策略
讓每個 image 都有身份證
曾經用過哪些image Tag
13
14
version
15
version
git-sha
16
version
git-sha
latest
一次產生4種Tag
17
staging
永遠指向最新 Staging Image
git-sha
唯一 commit 識別碼,便於追蹤到原始碼
v1.2.0-staging
版本 + 環境組合,跨環境不混淆
1.2.0
語意化版本號(SemVer),Prod promote 來源
一次產生4種Tag
18
{env}
永遠指向最新 Staging Image
git-sha
唯一 commit 識別碼,便於追蹤到原始碼
{ver}-{env}
版本 + 環境組合,跨環境不混淆
{ver}
語意化版本號(SemVer),Prod promote 來源
19
03
Dev → Staging 部署
手動輸入版本號,確保每次部署可控
VERSION 輸入方式
20
方式 A — 手動輸入 VERSION
1. Run Pipeline → Branch: staging
2. Pipeline: custom:deploy-to-staging
3. 設定 Variable: VERSION = 1.2.0
4. Pipeline 自動套用 git tag 在該 commit
方式 B — git tag 觸發
git tag v0.3.0�git push origin v0.3.0
Pipeline 自動偵測 tag
→ VERSION = tag(去除 v 前綴)
→ 觸發 deploy_staging_from_tag step
兩種方式最終效果相同:自動套用 git tag 並觸發 4-tag ECR 打標流程,CHANGELOG 同步產生
Dev → Staging 後續自動化部署流程
21
1
update_version_in_pyproject
更新 pyproject.toml 版本號為 vX.Y.Z,commit & push 回 staging branch
2
deploy_staging_from_version
Build Docker image,打上 4 個 tag,push 到 Staging ECR
3
generate_changelog_from_prs
呼叫 Bitbucket API 收集 PRs,產生 CHANGELOG.md,commit & push
update_version.py — pyproject.toml 版本更新
22
def update_pyproject_version(version, path):� # 正規化:確保以 v 開頭� if not version.startswith("v"):� version = f"v{version}"�� # 驗證 SemVer 格式� if not re.match(r"^v\d+\.\d+\.\d+$", version):� raise ValueError(...)�� # Regex 替換版本行� pattern = r'^(\s*version\s*=\s*["\'])([^"\']*)(["\'])'� new_content = re.sub(� pattern, lambda m:
f'{m.group(1)}
{version}{m.group(3)}',� content, flags=re.MULTILINE� )�� path.write_text(new_content)� print(f"✅ Updated to {version}")
版本正規化
輸入 1.2.0 或 v1.2.0 均可
輸出統一為 v1.2.0
格式驗證
不符合 vX.Y.Z 立即 raise
避免寫入非法版本
Regex 精準替換
支援 ' 與 " 兩種引號
re.MULTILINE 逐行匹配
回寫 & 驗證
寫回後透過 tomllib
再次讀取驗證版本一致
deploy_staging_from_version — pipeline
23
4 個 tag 命名、build、push、App Runner 觸發
# 版本格式驗證�: "${VERSION:?VERSION is required}"��# 組建 4 個 tag�IMAGE_VERSION_STAGING="${REGISTRY}/${REPO}:v${VERSION}-staging"�IMAGE_STAGING_ALIAS="${REGISTRY}/${REPO}:staging"�IMAGE_SHA="${REGISTRY}/${REPO}:${GIT_SHA}"�IMAGE_PLAIN_VERSION="${REGISTRY}/${REPO}:${VERSION}"��# Build�docker build -t "$IMAGE_VERSION_STAGING" \� -t "$IMAGE_STAGING_ALIAS" \� -t "$IMAGE_SHA" \� -t "$IMAGE_PLAIN_VERSION" .��# Push�docker push "$IMAGE_VERSION_STAGING"�docker push "$IMAGE_STAGING_ALIAS"�docker push "$IMAGE_SHA"�docker push "$IMAGE_PLAIN_VERSION"�
4 個 tag 變數
統一命名格式
方便後續查找
單次 build
同一 image 多 tag
只 build 一次,節省時間
24
04
Staging → Prod 部署
自動抓版本、複製 Image,
確保 Prod 跑的就是 Staging 一模一樣的版本
不同分支的push 推對應的環境
25
dev
staging
prod
開發
測試
正式
有推上Prod的權限 代表什麼
26
如果 如果你不小心 真的很不小心
27
如果 如果你不小心 真的很不小心
28
寫了Backdoor
如果 如果你不小心 真的很不小心
29
寫了Backdoor
推上Prod
如果 如果你不小心 真的很不小心
30
寫了Backdoor
推上Prod
正好想離職
promote_staging_to_prod — 流程
31
Staging ECR
v0.7.0-staging
v0.7.0
git-sha
staging
promote pipeline
Copy image
No Build
手動觸發
Prod ECR
v0.7.0-prod
v0.7.0
git-sha
Prod pipeline 只做 docker pull + docker tag + docker push,不重新 build image,確保 binary 完全一致
prod
promote_staging_to_prod — pipeline
# 1. 找最新 vX.Y.Z-staging tag�LATEST_STAGING_TAG=$(aws ecr describe-images \� --repository-name "$REPO_STAGING" \� | jq -r '� .imageDetails | sort_by(.imagePushedAt)� | reverse | .[] | (.imageTags // []) | .[]� | select(test("^v[0-9]+-staging$"))� ' | head -n1)��# 2. 解析版本號�VERSION="${LATEST_STAGING_TAG#v}"�VERSION="${VERSION%-staging}"��# 3. Pull & Re-tag�docker pull "${REGISTRY}/${REPO_STAGING}:${LATEST_STAGING_TAG}"�docker tag SOURCE "${REGISTRY}/${REPO_PROD}:v${VERSION}-prod"�docker tag SOURCE "${REGISTRY}/${REPO_PROD}:prod"�docker tag SOURCE "${REGISTRY}/${REPO_PROD}:${VERSION}"��# 4. Push & Trigger�aws apprunner start-deployment \
--service-arn
"$SERVICE_ARN"
jq 排序策略
sort_by(.imagePushedAt)
→ 取最新 push 的 staging 版本
Shell 字串操作
${VAR#v} 移除前綴 v
${VAR%-staging} 移除後綴
Re-tag 邏輯
同一個 image digest
打 4 個不同 prod tag
如何保障程式碼及環境安全性
33
QA驗證
如何保障程式碼及環境安全性
34
QA驗證
Sonar Qube
如何保障程式碼及環境安全性
35
QA驗證
Sonar Qube
Distroless image
萬一被惡意入侵 至少做到了
36
移動範圍
受限
權限
受限
Outbound受限
無法去別的pod
ReadOnly
出不去
Tag 自動化完成,還差...
37
38
05
CHANGELOG 自動化
PR Title + Label 驅動,零人工
首先我們需要
良好的commit規範
39
在開始之前
這些話,是不是很熟悉?
✕
「這支 commit 到底改了什麼,標題寫不清楚…」
✕
「CHANGELOG 是不是又忘記更新了?」
✕
「版本號要跳 0.5.0 還是 0.5.1,每次都要吵一下」
如果有共鳴 — 今天這兩個工具,就是為了解決這件事
02
關係圖 1 / 3
Pre-commit:git hook 管理框架
pre-commit
中立的執行平台
負責在 commit / push 時自動觸發、
安裝並執行你掛載的各種檢查工具
本身不檢查任何特定規則,只負責「觸發」與「管理」
04
關係圖 2 / 3
Commitizen:掛在框架上的插件
pre-commit
中立的執行平台
負責觸發與管理各種檢查工具
commitizen
具體的 Conventional Commits 工具
提供 commit-msg 檢查器,
可以被 pre-commit 呼叫
pre-commit 負責觸發機制,commitizen 負責實際判斷邏輯
05
關係圖 3 / 3
完整流程:一次 git commit 發生了什麼
git commit
使用者輸入 commit message
pre-commit
偵測到 commit-msg
這個時機,觸發檢查
commitizen
檢查格式是否符合
Conventional Commits
✓ 通過
commit 正常寫入歷史紀錄
✕ 擋下
commit 被拒絕,不會進入歷史紀錄
06
STEP 1
安裝:兩個套件一次裝好
$ python -m venv .venv
$ source .venv/bin/activate
(.venv) $ pip install commitizen
(.venv) $ pip install pre-commit
(.venv) $ cz version
» Installed Commitizen Version: 4.16.4
(.venv) $ pre-commit --version
» pre-commit 3.7.1
1
建立虛擬環境
隔離套件,不污染全域環境
2
啟用虛擬環境
之後的 cz / pre-commit 指令才找得到
3
安裝 Commitizen
負責 commit 規範檢查、版本號、changelog
4
安裝 Pre-commit
負責在對的時機自動觸發檢查
09
STEP 2 · DEMO
實際範例:用 cz commit 寫一次
$ git add payment_service.py
$ cz commit
? Select the type of change you are committing
» feat: A new feature
? What is the scope of this change? (press [enter] to skip)
» payment
? Write a short and imperative summary of the code changes:
» add retry logic for timeout errors
feat(payment): add retry logic for timeout errors
[main a1b2c3d] feat(payment): add retry logic for timeout errors
1 file changed, 12 insertions(+)
10
STEP 3 · DEMO · 關鍵指令
cz bump — 一個指令搞定版本號、Changelog、Tag
$ git log --oneline -3
a1b2c3d feat(payment): add retry logic
9d8e7f6 fix(payment): correct retry count
3c2b1a0 (tag: v0.4.0) bump: 0.3.0 → 0.4.0
$ cz bump
bump: version 0.4.0 → 0.5.0
tag to create: v0.5.0
increment detected: MINOR
## v0.5.0 (2026-07-28)
### Features
- add retry logic for timeout errors
### Bug Fixes
- correct retry count
[main 7f6e5d4] bump: version 0.4.0 → 0.5.0
1
自動判斷版本號
掃描 commit 歷史,決定 major/minor/patch
2
自動產生 Changelog
依 feat/fix 自動分類,寫入 CHANGELOG.md
3
自動打 Tag
打上 v0.5.0,git log 上清楚可查
4
一個指令完成
不用再手動算版本號、手動寫 changelog
11
CHANGELOG 生成機制概覽
47
PR Title
PR Labels
PR URL / ID
Ticket (regex)
# Changelog��## [1.2.0] - 2025-06-26��### Features�- Add login page (#42)��### Bug Fixes�- Fix token expire (#43)��### Breaking Changes�- Revamp auth API (#44)��### Others�- Update deps (#45)
generate_changelog.py
generate_changelog.py — 模組架構
48
run_git()
執行 git 指令,回傳 stdout
detect_previous_version_tag()
從 git tags 找前一個 SemVer 版本
get_commits_between()
git rev-list,取版本區間 commits
bitbucket_request()
Bitbucket REST API 呼叫封裝
get_prs_for_commit()
/commit/{sha}/pullrequests API
extract_labels_from_pr()
從 PR dict 提取 labels(含 fallback)
detect_ticket_from_title()
Regex 擷取 Jira Ticket ID
classify_change()
Label → 分類,title prefix → fallback
collect_prs_for_range()
整合以上函式,收集並去重所有 PR
format_changelog_section()
渲染 Markdown 格式的版本區塊
如何結構化解析 PR 資訊
49
from dataclasses import dataclass��@dataclass�class PullRequestInfo:� id: int� title: str� url: str� labels: list[str]� ticket: Optional[str]
id
PR 數字 ID,用於去重 & 生成 URL
title
PR 標題,分類邏輯的主要來源
url
Bitbucket PR 頁面連結
labels
PR labels list(可空),分類優先來源
ticket
Regex 擷取的 Ticket ID(如 PROJ-123)
Ticket Regex
m = re.search(� r"\b[A-Z][A-Z0-9]+-\d+\b",� title
)
匹配格式:PROJ-123, FH-42...
bitbucket_request() — API 封裝-自動化腳本的網路層
50
BITBUCKET_API_BASE = "https://api.bitbucket.org/2.0"��def bitbucket_request(method, path, token, params):� url = f"{BITBUCKET_API_BASE}{path}"� headers = {"Authorization": f"Bearer {token}"}� resp = requests.request(� method, url, headers=headers,
params=params, timeout=30� )� resp.raise_for_status()� return resp.json()
def get_prs_for_commit(workspace, repo, sha, token):� path = f"/repositories/{workspace}/{repo}/� commit/{sha}/pullrequests"� data = bitbucket_request("GET", path, token)� return data.get("values", [])
Bearer Token
Pipeline 自動注入
BITBUCKET_ACCESS_TOKEN
raise_for_status
4xx/5xx 立即拋出
HTTPError,上層 catch
API Endpoint
/commit/{sha}/pullrequests
查詢 commit 所屬 PRs
回傳格式
data["values"] 為
PR dict list
collect_prs_for_range() — 收集與去重
51
遍歷 commits → 查詢 API → 去重 → 排序
def collect_prs_for_range(workspace, repo, start, end, token):� commits = get_commits_between(start, end)� seen_pr_ids: Dict[int, PullRequestInfo] = {}�� for sha in commits:� try:� prs = get_prs_for_commit(..., sha)� except requests.HTTPError:� continue�� for pr in prs:� pr_id = pr.get("id")� if pr_id in seen_pr_ids: continue�� seen_pr_ids[pr_id] = PullRequestInfo(� id=pr_id, title=pr.get("title"),� url=..., labels=extract_labels_from_pr(pr),� ticket=detect_ticket_from_title(title)� )�� return [seen_pr_ids[k] for k in sorted(seen_pr_ids)]
git rev-list
取區間 commits
HTTPError 逐條處理
不因單一失敗中斷
seen_pr_ids 去重
同 PR 多 commit 只計一次
sorted()
確保輸出穩定可重現
Markdown 渲染 & 冪等寫入
52
def format_changelog_section(version, date, prs):� header = f"## [{display_version}] - {date}\n\n"� categories_order = [� "Breaking Changes", "Features", "Bug Fixes",� "Documentation", "Refactor", "Chore", "Others"� ]� for cat in categories_order:� items = categorized.get(cat) or []
if
not items: continue� lines.append(f"### {cat}")� for pr in items:� lines.append(f"- {ticket_prefix}{pr.title} ({pr_link})")�
def prepend_to_changelog(section, version, path):� # 冪等:版本已存在則跳過� if version_header_pattern.search(existing):� print("CHANGELOG already contains...; skipping")� return� # 插入到 # Changelog header 之後� new_content = header + section + rest�
分類順序定義
Breaking → Features → Fixes
→ Docs → Refactor → Chore → Others
PR Link 格式
[#42](https://bitbucket.org/...)
ticket prefix 自動附加
Idempotent 冪等設計
同版本不重複寫入
安全的 retry 行為
Header 保留
# Changelog header 不被覆蓋
新版本插入其後
Conventional Commits 慣例式提交
53
Type
用途
範例
Feat
新功能
feat(auth): add JWT refresh token support
Fix
修bug
fix(ecs): correct task role ARN for secrets manager access
Chore
雜務(更新套件等)
chore(deps): bump boto3 to 1.34.0
Ci
CICD相關
ci(bitbucket): add ecr image scan step to pipeline
總結一下Commitizen — 本機端就先擋下不合規的 Commit
54
指令
用途
範例
cz commit
互動式引導,
自動組出規範 commit
feat(vault): add AppRole auth
commit-msg
不符合規範直接擋下
❌ update stuff → 被拒絕
cz bump
掃描 history 自動判斷版本號
0.5.2 → 0.6.0
cz changelog
自動產生 / 更新 CHANGELOG.md
依 type 分類產出 Markdown
classify_change() — 完整分類邏輯
55
def classify_change(title, labels):� title_low = title.lower().strip()� label_set = {lbl.lower() for lbl in labels}�� def has_label(*candidates):� return any(c in label_set for c in candidates)�� # ── Label 優先 ──────────────────� if has_label("breaking","breaking-change"): return "Breaking Changes"� if has_label("feat","feature","enhancement"): return "Features"� if has_label("fix","bug","bugfix","bug-fix"): return "Bug Fixes"� if has_label("docs","documentation"): return "Documentation"�� # ── Title Prefix Fallback ───────� if title_low.startswith(("feat:","feature:","feat(")): return "Features"� if title_low.startswith(("fix:","bug:","fix(")): return "Bug Fixes"� if title_low.startswith(("chore:","chore(")): return "Chore"�� return "Others" # 預設分類�
has_label() closure
any() + 集合查找 O(1)
多個候選一次檢查
Label 優先
breaking > feat > fix > docs
清楚的優先順序
Title Prefix Fallback
Conventional Commits 格式
feat:/fix:/chore:...
Others 例外類型
所有未知類型統一歸類
不遺漏任何 PR
main() - CLI 入口:token 解析 → 版本偵測 → 收集 PR → 寫入 CHANGELOG
56
def main():� args = parse_args() # --version, --from-version�� # Token 優先順序� token = (� os.environ.get("BITBUCKET_STEP_OAUTH_ACCESS_TOKEN")� or os.environ.get("BITBUCKET_TOKEN")� )�� # 自動偵測前一版本� prev = detect_previous_version_tag(args.version)�� # 收集 PR� if token and token != "dummy":� prs = collect_prs_for_range(..., token)� else: # Fallback� commits = get_commits_between(prev, "HEAD")� prs = parse_from_commit_messages(commits)�� # 產生 CHANGELOG� section = format_changelog_section(version, today, prs)� prepend_to_changelog(section, version)�� print(f"✅ CHANGELOG updated: {len(prs)} PRs")
1
Token 優先
OAuth > PAT > dummy
2
自動偵測
前一個 SemVer tag
3
API 或 commit
message fallback
4
格式化輸出
Markdown section
57
06
整體架構與總結
從痛點到解決,DevX 的完整旅程
整理一下 做了哪些事情
58
三階段部署Tag
59
Staging
v0.7.0-staging
v0.7.0
git-sha
staging
Dev
v0.7.0-dev
v0.7.0
git-sha
Prod
v0.7.0-prod
v0.7.0
git-sha
prod
dev
Retag
自動Changelog
60
1
collect_prs_for_range() + classify_change()
把 PR 資料分類、排好順序
2
format_changelog_section()
將PR渲染成 Markdown 格式,有連結、有 ticket 編號
3
prepend_to_changelog()
冪等寫入檔案——就算 pipeline 重跑,也不會重複寫入
Before vs After — 改善效果對比
61
主題
Before(原本)
After(現在)
版本追蹤
靠記憶,容易打錯,前後不一致
語義化版本號 自動 tag,4-tag 策略
Changelog
手動從 commit log 複製貼上
PR Title + Label 驅動,自動產生
Image 管理
latest 覆蓋,不知 Prod 跑哪版
環境 × 版本 4-tag,環境隔離清晰
Prod 部署
手動選版本,易出錯,版本飄移
從 Staging ECR promote,binary 完全一致
Rollback
不確定回到哪個 git-sha
版本 tag 明確,docker pull 直接回滾
今日重點整理
62
Image Tag = 環境 × 版本
4-tag 策略讓每個 image 都有身份證,知道在跑哪個Image
01
Pipeline 取代人工決策
VERSION 輸入一次,Staging、Prod全自動處理
02
PR 即文件
PR Title + Label 規範好,CHANGELOG 自動生成,寫文件零成本
03
冪等腳本設計
同版本重複執行不重複寫入,安全Rerun,CI 錯誤不再是惡夢
04
Promote,不重新 Build
Prod 跑的 Image 與 Staging 測試的完全相同,避免資安風險與版本漂移
05
About Me
63
陳松林 小松
Security-Focused
DevOps Engineer
AWS
CICD
DevX
Security
社群 & 演講 Community & Speaking
www.xsong.us | linkedin.com/in/songlinchen
當 Release 不再靠人工 自動化版本治理與 DevX 實戰
64
陳松林 小松
Security-Focused
DevOps Engineer
AWS
CICD
DevX
Security
xsong.us | linkedin.com/in/songlinchen
今日簡報
陳松林