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

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

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

upload successful

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

這種作法的優點有兩個:

  • 分支都開在自己 forkrepo 上,比較不會跟別人的分支名稱混在一起
  • 假設自己做的功能需要客製化的 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 repomaster 分支,相當於這個按鈕的功能:

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 呢?基本上你可以視 referencebranch,它就像是一個標籤,你可以更新它的 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 DatabaseSHA256 code, GitHub Database 的 API 的使用方法以及圖解可以參考 Getting started with the Git Database API,裡面有講解 commit, treefile 的關係。

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

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

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

  • 上傳前需要寫個把檔案 encodeuft-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("\'", ""))

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