1 of 64

當 Release 不再靠人工

用Python生態打造自動化版本治理與 DevX 實戰

陳松林 | 小松XiaoSong

Security Focused Devops Engineer

1

2 of 64

Agenda · 今天要聊什麼

2

01

痛點回顧

手動 Release 的代價

02

Image Tag 設計

4-tag 命名策略

03

Dev → Staging

Pipeline 實作

04

Staging → Prod

自動 promote

05

CHANGELOG 自動化

PR 驅動,零人工介入

06

總結

收穫 + 問題討論

3 of 64

3

01

痛點回顧

手動 Release 的代價與原有流程

4 of 64

試想一下 - 當新版本Release時

開發了3個月後

有N個commit,N個PR

需要撰寫 CHANGELOG.md

方法有哪些?

4

5 of 64

5

6 of 64

6

不寫

7 of 64

7

不寫

git log

AI 整理

8 of 64

8

不寫

git log

AI 整理

請Intern

叫AI整理

9 of 64

每次上線都是一場未知的冒險

9

10 of 64

但為什麼要寫Changelog

10

11 of 64

11

可追溯性(Traceability)

每次變更都有據可查,出問題時能快速定位是哪個版本引入的

加速 Rollback 決策

知道每個版本改了什麼,才能判斷該回退到哪個點,而不是盲目猜測

團隊溝通與交接

跨團隊協作時,Changelog 是最直接的歷史文件,減少口頭說明成本

稽核與法規遵循

SOC 2、ISO 27001、PCI DSS 這些認證都要求有完整的變更管理紀錄

12 of 64

12

02

Image Tag 設計策略

讓每個 image 都有身份證

13 of 64

曾經用過哪些image Tag

13

14 of 64

14

version

15 of 64

15

version

git-sha

16 of 64

16

version

git-sha

latest

17 of 64

一次產生4種Tag

17

staging

永遠指向最新 Staging Image

git-sha

唯一 commit 識別碼,便於追蹤到原始碼

v1.2.0-staging

版本 + 環境組合,跨環境不混淆

1.2.0

語意化版本號(SemVer),Prod promote 來源

18 of 64

一次產生4種Tag

18

{env}

永遠指向最新 Staging Image

git-sha

唯一 commit 識別碼,便於追蹤到原始碼

{ver}-{env}

版本 + 環境組合,跨環境不混淆

{ver}

語意化版本號(SemVer),Prod promote 來源

19 of 64

19

03

Dev → Staging 部署

手動輸入版本號,確保每次部署可控

20 of 64

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 同步產生

21 of 64

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

22 of 64

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

再次讀取驗證版本一致

23 of 64

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 of 64

24

04

Staging → Prod 部署

自動抓版本、複製 Image,

確保 Prod 跑的就是 Staging 一模一樣的版本

25 of 64

不同分支的push 推對應的環境

25

dev

staging

prod

開發

測試

正式

26 of 64

有推上Prod的權限 代表什麼

26

27 of 64

如果 如果你不小心 真的很不小心

27

28 of 64

如果 如果你不小心 真的很不小心

28

寫了Backdoor

29 of 64

如果 如果你不小心 真的很不小心

29

寫了Backdoor

推上Prod

30 of 64

如果 如果你不小心 真的很不小心

30

寫了Backdoor

推上Prod

正好想離職

31 of 64

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

32 of 64

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 of 64

如何保障程式碼及環境安全性

33

QA驗證

34 of 64

如何保障程式碼及環境安全性

34

QA驗證

Sonar Qube

35 of 64

如何保障程式碼及環境安全性

35

QA驗證

Sonar Qube

Distroless image

36 of 64

萬一被惡意入侵 至少做到了

36

移動範圍

受限

權限

受限

Outbound受限

無法去別的pod

ReadOnly

出不去

37 of 64

Tag 自動化完成,還差...

37

38 of 64

38

05

CHANGELOG 自動化

PR Title + Label 驅動,零人工

39 of 64

首先我們需要

良好的commit規範

39

40 of 64

在開始之前

這些話,是不是很熟悉?

「這支 commit 到底改了什麼,標題寫不清楚…」

「CHANGELOG 是不是又忘記更新了?」

「版本號要跳 0.5.0 還是 0.5.1,每次都要吵一下」

如果有共鳴 — 今天這兩個工具,就是為了解決這件事

02

41 of 64

關係圖 1 / 3

Pre-commit:git hook 管理框架

pre-commit

中立的執行平台

負責在 commit / push 時自動觸發、

安裝並執行你掛載的各種檢查工具

本身不檢查任何特定規則,只負責「觸發」與「管理」

04

42 of 64

關係圖 2 / 3

Commitizen:掛在框架上的插件

pre-commit

中立的執行平台

負責觸發與管理各種檢查工具

commitizen

具體的 Conventional Commits 工具

提供 commit-msg 檢查器,

可以被 pre-commit 呼叫

pre-commit 負責觸發機制,commitizen 負責實際判斷邏輯

05

43 of 64

關係圖 3 / 3

完整流程:一次 git commit 發生了什麼

git commit

使用者輸入 commit message

pre-commit

偵測到 commit-msg

這個時機,觸發檢查

commitizen

檢查格式是否符合

Conventional Commits

✓ 通過

commit 正常寫入歷史紀錄

✕ 擋下

commit 被拒絕,不會進入歷史紀錄

06

44 of 64

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

45 of 64

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

46 of 64

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

47 of 64

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

48 of 64

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 格式的版本區塊

49 of 64

如何結構化解析 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...

50 of 64

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

51 of 64

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()

確保輸出穩定可重現

52 of 64

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 不被覆蓋

新版本插入其後

53 of 64

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

54 of 64

總結一下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

55 of 64

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

56 of 64

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 of 64

57

06

整體架構與總結

從痛點到解決,DevX 的完整旅程

58 of 64

整理一下 做了哪些事情

58

59 of 64

三階段部署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

60 of 64

自動Changelog

60

1

collect_prs_for_range() + classify_change()

把 PR 資料分類、排好順序

2

format_changelog_section()

將PR渲染成 Markdown 格式,有連結、有 ticket 編號

3

prepend_to_changelog()

冪等寫入檔案——就算 pipeline 重跑,也不會重複寫入

61 of 64

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 of 64

今日重點整理

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

63 of 64

About Me

63

陳松林 小松

Security-Focused

DevOps Engineer

AWS

CICD

DevX

Security

社群 & 演講 Community & Speaking

www.xsong.us | linkedin.com/in/songlinchen

64 of 64

當 Release 不再靠人工 自動化版本治理與 DevX 實戰

64

陳松林 小松

Security-Focused

DevOps Engineer

AWS

CICD

DevX

Security

xsong.us | linkedin.com/in/songlinchen

今日簡報

Linkedin

陳松林