GitHub API Flow 介紹以及實作

身為前軟體工程師,雖然當了運維工程師,但還是會需要碰到 GitHub 以及開發工作,最近遇到一個情況是要使用 GitHub API 去實作出一套 CMS 系統,讓內部開發者可以透過介面自動提交對應的檔案,申請 AWS 資源。

本篇會介紹實作自動化 GitHub Flow 時候,看到 API 規格時仍舊一頭霧水,原本以為自己已經很熟 Git 了,真正要實作時才發現對其運作的原理還有很大的進步空間,所以查了很多資料整理出來這篇筆記。文章會介紹直接對 GitHub repo 操作的流程,就不用再把整包程式碼 clone 到本地端了。

環境以及使用版本

  • GitHub REST API Version:
    • GitHub Enterprise Server 3.5
    • Free, Pro, & Team

為什麼選擇 GitHub Flow

通常想到 GitHub 的 CICD,第一直覺會想到用 GitHub Actions 來實作,但是 GitHub Actions 還是要透過提交 PR 來觸發,假設你遇到的情況是使用者完全不清楚他要提交什麼內容的時候,就會需要連同 PR 觸發也一起搞定的流程。

GitHub Flow 有很多種,如果想要了解的話可以參考 Git上的三種工作流程,已經有人整理好可以對照來看,在我的應用場景會需要使用到的是把 Upstream - Forked Repo - Local 的這種作法,詳見下圖示意:

upload successful

這種作法會直接把 upstream repo 的 master(main) 分支拉到 local repo 的 feature branch,開發後推送到 fork repo 的對應分支進行測試環境的 CI 工作,也就是觸發各種 GitHub Actions/CI triggers,最後合併進 upstream 的 master(main) 分支,往復循環。

這種作法的優點有兩個:

  • 分支都開在自己 fork 的 repo 上,比較不會跟別人的分支名稱混在一起
  • 假設自己做的功能需要客製化的 CI 測試,可以用 GitHub Actions 做在自己的 Repo 上面,這樣 Upstream 就只要進行綜合性的整合測試即可。

對應 GitHub Flow 的 GitHub API 簡單介紹

從上面的示意圖可以看出如果要完成一次 PR 提交,主要有三個步驟:

  1. 從 upstream 拉程式碼到分支: git fetch upstream/master && git rebase upstream/master
  2. commit 並且推送到 fork repo: git add && git commiit && git push
  3. 向 upstream 的 master 開 PR

如果是指令的操作,相信各位都蠻熟悉的。但假設我們今天是要在一台自動化的跳板機上面做這件事情,我不會想要花時間把整包 repo fork 下來,所以現在需要透過 GitHub API 直接對我們 fork 的 repo 進行操作。把上面的三個步驟對應到 GitHub API,就會像下面這幾張圖,每張圖下面我會稍微講解一下這個步驟在做什麼,以及需要記下的資訊:

upload successful

  1. 會使用到 Merge Upstream ,這個 API 會幫你把 Upstream 的 master fetch and rebase 到 fork repo 的 master 分支,相當於這個按鈕的功能:

upload successful

  1. 使用到的是 refs,這支 API 會幫你把某個分支的最新的 commit 給抓出來,在這邊因為我需要從 master 分支開出新分支,所以實作的範例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def get_latest_commit(host_name, repo_name, git_token):
# https://${host_name}/api/v3/repos/${repo_name}/git/ref/heads/master
latest_commit_api = "{0}/api/v3/repos/{1}/git/refs/heads/master".format(
host_name,
repo_name)
headers = {
"Authorization": "token {0}".format(git_token),
"Accept": "application/vnd.github+json"}

r = requests.get(
latest_commit_api,
headers=headers)

latest_commit = r.json().get("object").get("sha")

if not r.ok:
print("Request Failed - latest commit: {0}".format(r.text))
else:
return latest_commit

這邊的 latest_commit 實作時可以存在變數裡,創建 commit 會需要用到

  1. 最後會需要開新的分支,使用到的是 Create a reference,這邊可能會有人覺得 API 的命名很奇特(例如我),為什麼會使用 reference 呢?基本上你可以視 reference 為 branch,它就像是一個標籤,你可以更新它的 head 到某個 commit,以下是實作的範例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def create_new_branch(host_name, repo_name, git_token, branch_name, latest_commit):
# https://${host_name}/api/v3/repos/${repo_name}/repos/${repo_name}/git/refs
new_branch_api = "{0}/api/v3/repos/{1}/git/refs".format(
host_name,
repo_name)
headers = {
"Authorization": "token {0}".format(git_token),
"Accept": "application/vnd.github+json"}

payload = {
"ref": "refs/heads/" + branch_name,
"sha": latest_commit
}

r = requests.post(
new_branch_api,
headers=headers,
data=json.dumps(payload))

if not r.ok:
print("Request Failed: {0}".format(r.text))
else:
return latest_commit

upload successful

接下來會需要執行第二區塊,把新增的檔案加入一個提交 commit,這邊會需要幫所有檔案創建出 blob 物件,並且拿到 GitHub Database 的 SHA256 code, GitHub Database 的 API 的使用方法以及圖解可以參考 Getting started with the Git Database API,裡面有講解 commit, tree 和 file 的關係。

每次我們使用 git add 指令加入檔案的時候,都會生成一個 blob 物件儲存在本地端的 git,然後在提交 commit 的時候,會把這些 blob 物件的 SHA256 key 集合起來,生成一個 tree (有點像是演算法的子樹的概念),最後推送到遠端的分支時,會更新遠端分支最新 commit 的整個 root tree,並且生成新的 root tree。

第四步開始,我們需要把所有想提交的檔案都呼叫遠端的 API 拿到 blob 的 SHA256 key。原本使用指令的方式可以直接在本地的 git database 生成,但這次的目標是本地不要有任何 Git Repo,只保留呼叫 API 的邏輯,所以有多少檔案就要呼叫多少次 API,因為 GitHub 在檔案上傳這塊沒有提供一次上傳多個或是上傳一個資料夾的 API。

這步驟使用的 API 是 Create a blob,這邊需要注意的點有兩個:

  • 上傳前需要寫個把檔案 encode 成 uft-8 或是 base64 的邏輯 (GitHub API 的規格),再把產出物填到呼叫的 payload 裡
  • 檔案的路徑和之後你要拿的 root tree 從哪邊開始有關,我這邊是直接填寫 repo 根目錄到這個檔案的整條 filepath

這邊直接展示範例程式碼比較清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Blob:
def __init__(self, path, mode, type, sha):
self.path = path
self.mode = mode
self.type = type
self.sha = sha


def encoding_py(file):
f = io.open(file, mode="r", encoding="utf-8")
return f.read()


def upload_blob(host_name, repo_name, git_token, encoding_content):
# https://${host_name}/api/v3/repos/${repo_name}/repos/${repo_name}/git/blobs
git_sync_api = "{0}/api/v3/repos/{1}/git/blobs".format(
host_name,
repo_name)
headers = {
"Authorization": "token {0}".format(git_token),
"Accept": "application/vnd.github+json"}

payload = {
"content": encoding_content,
"encoding": "utf-8"
}
r = requests.post(
git_sync_api,
headers=headers,
data=json.dumps(payload))

if not r.ok:
print("Request Failed: {0}".format(r.text))
else:
return r.json().get("sha")


def interate_input_files(input_dir):
for file in os.listdir(input_dir):
local_filepath = input_dir + os.fsdecode(file)
filename = os.fsdecode(file)

encoding_content = encoding_py(local_filepath)
sha_code = upload_blob(
GITHUB_HOST_NAME, # github_host
TEST_INFRA_REPO, # repo_name
GITHUB_ACCESS_TOKEN, # git_token
encoding_content # encoding_content
)
blob = Blob(remote_filepath + filename, "100644", "blob", sha_code)

# print(json.dumps(blob, default=lambda obj: obj.__dict__,sort_keys=True))
git_tree.append(json.dumps(blob, default=lambda obj: obj.__dict__,
sort_keys=True))


if __name__ == "__main__":
interate_input_files(sys.argv[1])
print(str(git_tree).replace("\'", ""))

第五步驟是要拿到現在這個分支最新 commit 的 tree,呼叫 get tree API 之後會吐回來這個tree 的 SHA256 key,這邊一樣實作時先存在變數裡,後續會需要用到。以下是範例程式碼,因為看 GitHub 文件的範例可能會有點疑惑,我這邊是直接拿 fork repo 的 master 分支的 tree,開出來的分支一樣是指到 master branch 的最新 commit,只要 commit 一致,tree 的 SHA256 key 就會一樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def get_master_branch_tree(host_name, repo_name, git_token):
# https://${host_name}/api/v3/repos/${repo_name}/repos/${repo_name}/git/trees/master
master_branch_tree_api = "{0}/api/v3/repos/{1}/git/trees/master".format(
host_name,
repo_name)
headers = {
"Authorization": "token {0}".format(git_token),
"Accept": "application/vnd.github+json"}

r = requests.get(
master_branch_tree_api,
headers=headers)

master_tree_sha = r.json().get("sha")

if not r.ok:
print("Request Failed: {0}".format(r.text))
else:
return master_tree_sha

第六步驟是要更新第五步驟拿到的整棵 root tree,這邊我是拿整個 repo 的 root tree,而不是更新某顆 sub tree (就是其中的某個資料夾的意思),如果有特定的 sub tree 需要更新的話,從第四步驟上傳 blob 開始,就要注意填寫上傳檔案的相對路徑,不然會導致更新到錯誤的資料夾,甚至把資料刪掉這種慘事。有錯誤的話,在開 PR 的時候都會看到,所以不需要太擔心。

這個步驟算是比較難懂一點,而且跟你上傳檔案時填寫的路徑有關,所以我這邊畫了一張示意圖,會比較好懂點:

upload successful

這個步驟會用到 Create a tree,範例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def create_new_tree_by_folder(host_name, repo_name, git_token, base_tree):
# https://${host_name}/api/v3/repos/${repo_name}/repos/${repo_name}/git/trees
new_tree_api = "{0}/api/v3/repos/{1}/git/trees".format(
host_name,
repo_name)
headers = {
"Authorization": "token {0}".format(git_token),
"Accept": "application/vnd.github+json"}

payload = {
"base_tree": base_tree,
"tree": git_tree # 第四步驟拿到的 Array 填在這裡
}

r = requests.post(
new_tree_api,
headers=headers,
data=json.dumps(payload))

new_tree_sha = r.json().get("sha")

if not r.ok:
print("Request Failed: {0}".format(r.text))
else:
return new_tree_sha

第七步驟,透過 Create a commit API 提交 commit,這邊回傳的 commit SHA256 記下來,下步驟會用到,範例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def create_new_commit(host_name, repo_name, git_token, latest_master_commit, new_tree_sha):
# https://${host_name}/api/v3/repos/${repo_name}/repos/${repo_name}/git/commits
new_commit_api = "{0}/api/v3/repos/{1}/git/commits".format(
host_name,
repo_name)
headers = {
"Authorization": "token {0}".format(git_token),
"Accept": "application/vnd.github+json"}

payload = {
"message": "test commit message",
"author": {"name": "${your github username}", "email": "${your GitHub email}"},
"parents": [latest_master_commit], # 這邊填入第二步驟拿到的最新 commit
"tree": new_tree_sha # 這邊填入第六步驟拿到的 Tree SHA256 key
}

r = requests.post(
new_commit_api,
headers=headers,
data=json.dumps(payload))

new_commit_sha = r.json().get("sha") # 拿到新的 commit SHA256,記下來待會會用到

if not r.ok:
print("Request Failed: {0}".format(r.text))
else:
return new_commit_sha

第八步驟,透過 Update a reference API 把新建的 branch (reference) 指到剛剛提交的 commit,這樣才算是完整更新 branch,範例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def bind_commit_to_branch(host_name, repo_name, git_token, new_branch, new_commit_sha):
# https://${host_name}/api/v3/repos/${repo_name}/repos/${repo_name}/git/refs/heads/test-demo
bind_commit_to_branch_api = "{0}/api/v3/repos/{1}/git/refs/heads/{2}".format(
host_name,
repo_name,
new_branch)

headers = {
"Authorization": "token {0}".format(git_token),
"Accept": "application/vnd.github+json"}

payload = {
"sha": new_commit_sha, # 剛剛提交的 commit 的 SHA256 key
"force": True
}

r = requests.patch(
bind_commit_to_branch_api,
headers=headers,
data=json.dumps(payload))

print(r.json())

if not r.ok:
print("Request Failed: {0}".format(r.text))
else:
return r.json()

upload successful

到這邊已經克服了最難的魔王了。第九步驟超簡單的,就是呼叫 開 PR 的 API,對 upstream 提交分支合併的請求。

結語

這樣的工作流程算是步驟蠻多的,而且參數的傳遞需要稍微整理一下才不會搞混,但是還是有兩大優點:

  • 第一是不需要花額外的儲存空間去存 clone 下來的 Repo,這樣的特性讓這個自動化程式可以運行在各種 Infrastructure 架構,比方說 Kubernetes 上,因為開發者是用自己的 repo 在提交 PR,所以不會存在分支名稱衝突,就算檔案有衝突,也會在開 PR 的時候看到衝突結果。

  • 第二是統一幫開發者開 PR,因為不能確保每個開發者提交 PR 的習慣都照著 fetch & rebase 的流程來,而且有些開發者會提交很多次的 commits,這樣 PR 不夠簡潔,這個流程可以確保檔案的新增都在一個 PR 內。

或許有些用過 GitHub API 的人會提出為什麼不使用 Create or update file contents API 針對要新增的檔案做循序新增?

因為這個 API 做的事情是拿到 blob sha + 直接把檔案 commit,假設要提交的檔案很多,最後開出來的 PR 一樣會有很多 commits 紀錄,這樣 PR 會很醜,身為工程師還是要有美感。

老實說我在第二步驟的 tree 的更新這邊卡超久,大概試了三個小時才終於開出正確的 PR,而且因為步驟太多,所以中間一度要靠著不斷嘗試才知道錯誤出在哪個步驟。不過理清了順序和原理之後,這套流程就可以改成任何想要的自動化提交 PR 內部系統的功能,也算是花時間徹底了解整個 GitHub Flow 的原理和複習下 GitHub Database。

Reference

🍀 Git上的三種工作流程
🍀 GitHub API - Create or update file contents
🍀 GitHub API - Sync a fork branch with the upstream repository
🍀 GitHub API - Get a reference
🍀 GitHub API - Create a reference
🍀 GitHub Docs - Getting started with the Git Database API
🍀 GitHub API - Create a pull request
🍀 GitHub API - Create a blob
🍀 GitHub API - Get a tree
🍀 GitHub API - Create a tree
🍀 GitHub API - Create a commit
🍀 GitHub API - Update a reference
🍀 GitHub API - Create or update file contents
🍀 Create a folder and push multiple files under a single commit through GitHub API
🍀 StackOverflow - How to create a commit and push into repo with GitHub API v3?