讓流程替你記住規則:用 Forgejo 建立小團隊的開發流程
小團隊的問題通常不是技術不夠,而是沒有人專門負責記住規則。三、五個人(再加上幾個 AI 代理)同時改同一批設定,誰動了正式站、為什麼這樣改、上次踩過什麼坑,全都只存在某個人的腦袋裡,或是某段已經捲走的聊天紀錄裡。
我的結論是:規則不要靠人記,要寫進流程。issue 記為什麼、PR 記怎麼做與怎麼驗證、CI 擋掉低級錯誤、repo 裡的文件記住踩過的坑。這篇整理用 Forgejo 與 Forgejo Actions runner 建立這套流程的做法與原則(issue、PR 這些名詞不熟的話,第二節開頭有說明)。
一、為什麼自架 Forgejo
Forgejo 是社群治理的 Git 服務(Gitea 的分支),一個執行檔或一個容器就能提供 repo、issue、PR、套件庫與 CI(Forgejo Actions,語法和 GitHub Actions 幾乎相同)。
| GitHub/GitLab.com | 自架 GitLab | 自架 Forgejo | |
|---|---|---|---|
| 資源 | 不用管 | 4 GB 記憶體起跳 | 一個容器 + 資料庫,閒置數百 MB |
| 私有 repo、CI | 依方案計費、限額 | 有 | 有 |
| 資料在哪 | 別人的機房 | 自己 | 自己 |
| 要自己負責 | 幾乎沒有 | 很多 | 備份、升級、對外防護 |
對一台同時跑網站的小主機來說,Forgejo 的資源用量是關鍵。但自架代表三件事要自己扛,決定自架前先想清楚誰負責:
- 備份:repo、資料庫、設定檔都要有異地備份,而且要實際還原過一次。
- 升級:固定大版本,看 release notes 再升;不要用
latest。 - 對外防護:關掉不用的入口(公開註冊、OpenID 登入)、SSH 只用金鑰、管理頁面不要直接暴露在網路上。
二、流程:issue → 分支 → PR → 合併 → 部署
先用一份文件來比喻
想像一個團隊一起維護一份重要的文件,例如公司的作業手冊:
- 有人發現某一段要改,先開一張待辦單,寫下要改什麼、為什麼要改。這是 issue。
- 負責的人不直接改正式版,而是複製一份副本在上面修改。這是分支。
- 改好之後送審:把副本和正式版的差異交給其他人看,請大家確認。這是 PR(pull request,「請把我的修改拉進正式版」)。
- 送審時會先跑一遍自動檢查,像拼字檢查、格式檢查,明顯的錯誤不用等人看就會被擋下來。這是 CI。
- 審過之後把副本的修改收進正式版。這是合併(merge)。
- 最後把新版本印出來發給大家。這是部署(deploy),讓修改真正生效。
正式版永遠是審過、檢查過的版本;每一次修改都留下「為什麼改、誰看過、檢查結果」的紀錄。這就是整套流程的核心。
| 名詞 | 一句話說明 |
|---|---|
| repo(repository) | 一個專案的所有檔案與完整的修改歷史 |
| commit | 一次存檔:改了哪些檔案、誰改的、為什麼 |
| 分支(branch) | 從正式版分出來的副本,在上面改不會影響正式版;正式版通常叫 main 或 master |
| issue | 待辦單或問題單:要做什麼、為什麼、討論與決定 |
| PR(pull request) | 送審:請求把一個分支的修改合併進正式版,附上說明,大家在上面討論 |
| review | 審查:其他人看過 PR 的修改,提出意見或同意 |
| CI(持續整合) | 每次推上修改就自動執行的檢查與測試,結果顯示在 PR 上 |
| runner | 實際執行 CI 的程式,從 Forgejo 領工作、跑完回報結果 |
| 合併(merge) | 把審過的分支收進正式版 |
| 部署(deploy) | 把正式版放到伺服器上,讓使用者真正用到 |
GitHub、GitLab、Forgejo 都是同一套觀念,只是名稱與介面略有不同(GitLab 把 PR 叫 merge request)。
實際的流程
flowchart LR
I["Issue<br/>為什麼要改"] --> B["分支<br/>一個變更一個分支"]
B --> P["PR<br/>怎麼做、怎麼驗證"]
P --> C{"CI 檢查"}
C -- "失敗" --> B
C -- "通過" --> R["人看過<br/>同意合併"]
R --> M["合併"]
M --> D["部署<br/>由有權限的人執行"]
D --> L["記錄<br/>CHANGELOG、文件"]
每一步都有它的用途:
- Issue 寫「為什麼」。問題、數據、選了哪個方案、為什麼不選其他的。半年後回頭看,最有價值的是這一段。
- 分支一個變更一個。混在一起的 PR 沒辦法只退其中一半。
- PR 寫「怎麼做」與「怎麼驗證」。測了什麼、部署指令是什麼、預期看到什麼輸出。reviewer 不用猜。
Closes #12與Refs #12要分清楚。用來追蹤後續事項的 issue(「一週後再看數據」)要用Refs,否則 PR 一合併,它就被關掉,追蹤也跟著消失。- 合併(merge)和部署(deploy)分開。合併只代表程式碼同意了;部署到正式環境是另一個需要確認的動作,特別是需要 root 的操作,由有權限的人執行,並且先備份。
- 最後一定要記錄。部署後的實際數據(記憶體、回應時間、錯誤次數)寫回 CHANGELOG,下次才有比較基準。
三、把規則寫下來,讓人與 AI 代理都看得到
現在團隊裡常常有 AI 代理在改程式。它們每次都從零開始,沒寫下來的規則對它們來說就不存在。對新進的同事也一樣。
我的做法是每個重要的目錄放一份 AGENTS.md(或 README),內容固定幾個部分:
# forgejo-runner
| 位置 | 內容 |
|---|---|
| docker-compose.yml | DinD 與 runner |
| config.yml | 一次一個 job、30 分鐘逾時 |
| data/token | runner 的 token,機密,不進 git |
## 隔離方式與為什麼
- job 不掛主機的 docker.sock:掛了等於給 job 主機 root
## 不要做的事
- 不要把部署放進 workflow
- 不要把 valid_volumes 改成 '**'
## 已知的坑
- 改了 config.yml 一定要 --force-recreate(2026-11 改了沒生效)
三個原則:
- 「為什麼」比「怎麼做」重要。只寫「不要用 X」,下一個人會覺得是過度小心而把它改掉;寫了「用 X 時發生過什麼」,就沒人會去碰。
- 踩到坑的當下就寫進去,不要等「之後整理」。之後永遠不會來。
- CHANGELOG 記數據與決策,不只記改了什麼。「PHP worker 從 8 改成 12」沒有用;「一週內 worker 用滿幾十次、來源幾乎都是爬蟲,所以不加 worker、改在入口限速」才能讓下次的人判斷這個決定還成不成立。
四、Runner 的架構與隔離
CI runner 會執行 repo 裡任何人寫的 workflow,它是整套系統裡最容易被忽略的安全邊界。
不要掛主機的 docker.sock
最常見的教學是把 /var/run/docker.sock 掛給 runner。這等於讓每一個 job 都拿到主機的 root:job 可以開一個 privileged 容器、把主機的 / 掛進去。
比較安全的做法是 Docker-in-Docker(DinD):runner 連的是一個獨立的 Docker daemon,job 容器都在它裡面跑,碰不到主機的檔案與金鑰。
flowchart TB
subgraph Host["主機"]
F["Forgejo"]
subgraph Net["runner 網路"]
R["forgejo-runner<br/>輪詢 Forgejo、派工"]
subgraph D["DinD(privileged、1 核、1 GB)"]
J1["job 容器<br/>非 privileged、不掛 volume"]
end
end
end
R -- "unix socket<br/>(共用 volume)" --> D
R -- "HTTPS" --> F
J1 -- "checkout(HTTPS)" --> F
重點:
- DinD 只開 unix socket,不開 TCP 2375。很多範例用
DOCKER_HOST=tcp://docker:2375,但這樣 job 容器可以從 DinD 的閘道連到 Docker API,再開 privileged 容器逃出去。用共用 volume 傳 socket 給 runner,job 就碰不到。 - DinD 的資源上限包含所有 job。給 DinD
mem_limit與cpus,CI 再怎麼跑,正式服務至少保有剩下的資源。小主機就把capacity設成 1。 - job 容器的限制寫在 runner 設定:不給 privileged、不允許掛 volume、不給 Docker socket。
# docker-compose.yml(摘要)
services:
dind:
image: docker:29.9.2-dind # 固定版本
privileged: true # DinD 的必要條件
command: ["dockerd", "--host=unix:///run/dind/docker.sock"]
environment:
- DOCKER_TLS_CERTDIR=
volumes:
- dind-socket:/run/dind
- dind-data:/var/lib/docker
healthcheck: # socket 出現才算就緒
test: ["CMD", "test", "-S", "/run/dind/docker.sock"]
interval: 2s
retries: 30
mem_limit: 1g
cpus: 1
logging:
driver: json-file
options: { max-size: 10m, max-file: "3" }
runner:
image: data.forgejo.org/forgejo/runner:13.2.1
depends_on:
dind: { condition: service_healthy }
command: ["forgejo-runner", "daemon", "--config", "/config.yml"]
environment:
- DOCKER_HOST=unix:///run/dind/docker.sock
volumes:
- dind-socket:/run/dind
- ./config.yml:/config.yml:ro
- ./data:/data # token 放這裡(權限 600,不進 git)
mem_limit: 128m
volumes:
dind-socket:
dind-data:
# config.yml(摘要)
runner:
capacity: 1
timeout: 30m
fetch_interval: 15s
container:
privileged: false
valid_volumes: []
docker_host: "-" # 不把 Docker socket 給 job
options: "--memory=768m --cpus=1"
server:
connections:
main:
url: https://git.example.com/
uuid: <在 Forgejo 建立 runner 時取得>
token_url: file:/data/token # token 不寫進設定檔
網址要一致
actions/checkout 用的是 runner 連 Forgejo 的那個網址。如果 runner 用 http://forgejo:3000(容器名稱)連線,job 在 DinD 裡解析不到這個名稱,checkout 會一直重試卡住。
正確做法是 runner 與 job 都用正式的 HTTPS 網址,再用 extra_hosts(runner)與 --add-host(job,寫在 container.options)把它指到內部的閘道,不必繞到外部的 CDN 再回來。
全域 runner 還是個別 runner
Forgejo 可以在站台、組織、使用者三個層級建立 runner。只在自己帳號下建立的 runner,不會接組織底下 repo 的工作,而且不會有任何錯誤訊息,只是 job 一直排隊。小團隊通常一台主機一個全域 runner 最單純。
五、CI 該做什麼、不該做什麼
小主機上的 CI,目標是擋掉低級錯誤,不是取代建置伺服器。
適合放進 CI 的:
shellcheck、bash -n、php -l、Python 語法檢查- 設定檔的語法檢查,例如在容器裡跑
nginx -t(憑證換成自簽、只檢查語法) - 文件或內容的格式檢查(front matter、圖片是不是真的圖片)
- 跑得快的單元測試
不適合的:
- 動輒 0.5–1 GB 記憶體的大型建置(前端打包、
composer install),會把主機推進 swap - 部署。部署需要權限,放進 workflow 就要把金鑰交給 runner
再加兩條實務規則:
- 檢查腳本放在 repo 裡,workflow 只負責呼叫它。本機用同一個映像、同一個指令就能跑出一樣的結果,CI 失敗時不用猜。
- job 映像選小的(例如 alpine),需要什麼工具在第一步
apk add。
# .forgejo/workflows/check.yml
on:
pull_request: {}
push:
branches: [main]
jobs:
check:
runs-on: docker
container:
image: data.forgejo.org/oci/node:24-alpine3.23
steps:
- shell: sh
run: apk add -q --no-cache bash git shellcheck python3
- uses: actions/checkout@v4
- run: bash .forgejo/check.sh
情境一:PR 跑測試,搭配一個測試用的資料庫
應用程式的測試常常需要資料庫。用 services 起一個用完即丟的 MariaDB,job 用服務名稱 db 就連得到,測完整個環境一起刪掉,不會碰到正式資料庫。
# .forgejo/workflows/test.yml
on:
pull_request: {}
push:
branches: [main]
jobs:
test:
runs-on: docker
container:
image: data.forgejo.org/oci/node:24-alpine3.23
services:
db:
image: mariadb:11.8
env:
MARIADB_DATABASE: app_test
MARIADB_USER: app
MARIADB_PASSWORD: test-only # 只存在這個 job 裡的測試密碼
MARIADB_RANDOM_ROOT_PASSWORD: "1"
options: --memory=256m
env:
DB_HOST: db
DB_DATABASE: app_test
DB_USERNAME: app
DB_PASSWORD: test-only
steps:
- shell: sh
run: >-
apk add -q --no-cache bash git composer
php84-pdo_mysql php84-mbstring php84-dom php84-xml php84-xmlwriter php84-tokenizer
- uses: actions/checkout@v4
- name: 等資料庫就緒
run: |
for i in $(seq 30); do
php -r 'new PDO("mysql:host=db;dbname=app_test", "app", "test-only");' 2>/dev/null && exit 0
sleep 2
done
echo "資料庫 60 秒內沒有就緒"; exit 1
- run: composer install --no-interaction --no-progress --prefer-dist
- run: php artisan migrate --force
- run: vendor/bin/phpunit
幾個要注意的地方:
- 服務容器也算在 DinD 的資源上限裡。job 768 MB 加上資料庫 256 MB 已經超過 1 GB 的 DinD,要嘛調低 job 的上限,要嘛調高 DinD;先量一次記憶體高峰再決定。
- 一定要等資料庫就緒。服務容器啟動了不代表資料庫可以連,沒有等待的步驟,第一個測試會隨機失敗。
- 測試密碼可以寫在 workflow 裡,真的機密不行。需要外部 API 金鑰時放進 repo 或組織的 secrets,用
${{ secrets.NAME }}取用;從 fork 開的 PR 拿不到 secrets,這是刻意的。 composer install有多重要看專案大小。小專案一兩百 MB 沒問題;套件多的專案先量過,太重就只在main跑,PR 只跑語法檢查。套件名稱依 Alpine 版本調整。
情境二:排程檢查,不靠人記得去看
有些事情不是改程式時才會壞,而是時間到了就壞:憑證到期、網域到期、相依套件出現新漏洞。用 schedule 讓 CI 定期檢查,失敗時 Actions 頁面會是紅的,比靠人記得每個月去看可靠。
# .forgejo/workflows/certs.yml
on:
schedule:
- cron: "0 1 * * 1" # UTC,等於台北時間每週一 09:00
workflow_dispatch: {} # 也可以在網頁上手動執行
jobs:
certs:
runs-on: docker
container:
image: data.forgejo.org/oci/node:24-alpine3.23
steps:
- shell: sh
run: apk add -q --no-cache openssl
- name: 14 天內到期的憑證
shell: sh
run: |
fail=0
for host in example.com www.example.com shop.example.com; do
if echo | openssl s_client -connect "$host:443" -servername "$host" 2>/dev/null \
| openssl x509 -noout -checkend 1209600 >/dev/null; then
echo "ok $host"
else
echo "FAIL $host:14 天內到期,或連不上"; fail=1
fi
done
exit $fail
cron是 UTC,寫上換算後的當地時間,免得下一個人算錯。- 排程只在預設分支上的 workflow 生效,在其他分支改了不會跑。
- 一定加上
workflow_dispatch,改完可以立刻手動跑一次確認,不用等到下週一。 - 檢查的目標要是外部看得到的狀態:這裡直接連 443,不讀主機上的憑證檔,所以 CDN、反向代理、憑證沒有 reload 這些問題都抓得到。
- 同樣的寫法可以用在
composer audit、npm audit這類漏洞檢查:每週跑一次,比每次 PR 都跑省資源。
Secrets 與變數:什麼放哪裡
workflow 寫在 git 裡,誰都看得到;有些值不該、或不方便寫在裡面。Forgejo Actions 提供兩種存放處,都可以設在 repo、組織、使用者三個層級(「設定 → Actions」):
| Secrets | 變數(Variables) | |
|---|---|---|
| 取用 | ${{ secrets.NAME }} |
${{ vars.NAME }} |
| 存進去之後 | 網頁上看不到值,只能覆寫 | 看得到、可以直接改 |
| 日誌 | 自動遮成 *** |
原樣顯示 |
| 從 fork 開的 PR | 拿不到 | 拿得到 |
| 適合 | token、密碼、webhook 網址、金鑰 | 主機清單、版本號、門檻值這類不機密的設定 |
另外每個 job 都會自動拿到一個 token(${{ secrets.GITHUB_TOKEN }},Forgejo 也可以寫 FORGEJO_TOKEN),actions/checkout 就是用它 clone。它只對這個 repo 有效,碰不到其他私有 repo,job 結束就失效,不需要自己管。
情境 A:讀另一個私有 repo
共用的測試工具、設定或子模組放在另一個私有 repo 時,自動 token 讀不到,要另外給一個:
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
repository: org/tools
token: ${{ secrets.TOOLS_READ_TOKEN }}
path: tools
- token 用專用的機器帳號建立,只給它那個 repo 的讀取權限、scope 只勾
read:repository。不要用自己的個人 token:它能讀你所有的 repo,還可能可以寫。 - 很多 repo 都要用時,設成組織層級的 secret,換 token 只要改一處。
情境 B:排程失敗時通知
前面的排程檢查失敗時,只有打開網頁才會看到。把聊天工具的 webhook 網址存成 secret,失敗才送:
- name: 失敗時通知
if: failure()
shell: sh
env:
WEBHOOK_URL: ${{ secrets.ALERT_WEBHOOK_URL }}
run: |
wget -q -O /dev/null --header 'Content-Type: application/json' \
--post-data '{"text":"${{ github.repository }}:排程檢查失敗"}' "$WEBHOOK_URL"
webhook 網址本身就是憑證:拿到的人就能往你的頻道發訊息,所以要放 secret,不是變數。
情境 C:不機密、但會常改的設定
要檢查的網域清單會隨著網站增減,每次都改 workflow、開 PR 有點小題大作。放進變數:
- name: 14 天內到期的憑證
shell: sh
env:
HOSTS: ${{ vars.CERT_HOSTS }} # 例如 "example.com www.example.com"
DAYS: ${{ vars.CERT_DAYS || '14' }} # 沒設定時用 14 天
run: |
for host in $HOSTS; do
echo | openssl s_client -connect "$host:443" -servername "$host" 2>/dev/null \
| openssl x509 -noout -checkend $((DAYS * 86400)) >/dev/null \
|| { echo "FAIL $host"; fail=1; }
done
exit ${fail:-0}
變數的代價是沒有版本紀錄:誰在什麼時候改成什麼,git 裡查不到。所以只放「改了也不用審查」的值;會影響行為、需要討論的設定,還是寫進 repo 走 PR。
使用原則
- 只給需要的那一步:把 secret 放在 step 的
env,不要放在整個 job 或 workflow 的env。其他步驟(尤其是第三方 action)拿不到,就不會外洩。 - 遮罩只認得原始值。base64、URL 編碼、拆成好幾段印出來,日誌裡都會是明文。不要
echosecret,也不要用set -x執行用到它的指令。 - 不要寫進 artifact 或快取:產生的設定檔裡有 secret,就不要上傳。
- 最小權限、有到期日:只讀就不給寫,token 設到期日,到期前換掉;有人離開團隊時,把他建立的 token 都換掉。
- 在文件裡記名字與用途,不記值。例如在 AGENTS.md 寫「
TOOLS_READ_TOKEN:組織層級,機器帳號ci-bot的唯讀 token,讀org/tools,2027-06 到期」。下一個人才知道它是做什麼的、壞了要找誰、多久要換一次。 - 部署用的金鑰不要放進 CI,理由同第五節:放了,就等於任何能改 workflow 的人都能部署。
六、不讓事情出錯的原則
這是我現在每次動手前都會過一遍的清單:
-
先看數據再改。每個結論都要能指出是哪個數字、跟哪次的紀錄比。數據不夠就先收集,不要憑印象加資源。
-
影響正式環境的操作先確認、先備份、想好怎麼退回。重啟資料庫、reload 網頁伺服器、改正式站的檔案,都算。設定檔用
nginx -t這類檢查通過才 reload,不用 restart。 -
機密不進 git,也不貼進對話、issue 或日誌。token 放權限 600 的檔案,設定檔用
token_url: file:...讀;需要使用者輸入時用read -s。 -
改了設定要確認真的生效。單一檔案的 bind mount,編輯器存檔時會換掉檔案,執行中的容器看到的還是舊內容,要
--force-recreate。 -
版本固定。映像寫完整版本號,不用
latest;大版本升級先看 release notes 的設定變更。 -
清理指令一定要帶保留條件。
docker system prune -a會刪掉當下沒在用的映像,包括手動載入、拉不回來的那些。手動載入的映像加 label,清理時排除:docker build --label ci.keep=1 -t my-ci-image . docker system prune -af --filter until=168h --filter label!=ci.keep -
日誌設上限。容器的
json-file日誌預設不限大小,給max-size與max-file。 -
輪詢頻率合理。runner 每 5 秒問一次,一天就是 1.7 萬筆請求;改成 15 秒,job 最多晚 15 秒開始,對小團隊沒差。
-
只在被要求時 commit、push、merge。尤其是 AI 代理,「做好了」和「推上去了」要分開確認。
七、常見的坑
| 症狀 | 原因 | 解法 |
|---|---|---|
| job 卡在 checkout,一直重試 | runner 用容器名稱連 Forgejo,job 在 DinD 裡解析不到 | runner 與 job 都用正式網址,extra_hosts/--add-host 指到內部 |
| runner 第一次啟動連不上 Docker、重啟一次 | runner 比 DinD 先起來 | DinD 加 healthcheck,runner depends_on: condition: service_healthy |
runner registration token not found |
新版 runner 淘汰了 register,建立 runner 時給的 token 和舊式的註冊 token 不同 |
改用 server.connections(UUID + token 檔案) |
| 某個 repo 的 job 永遠在排隊 | runner 建在使用者層級,不接組織的 repo | 改成站台層級的全域 runner |
CI 突然 No such image、pull access denied |
清理時刪掉了手動載入的映像,CI 不會自己重建 | 重新載入,以後清理帶 label filter |
| PR 一直停在「檢查中」 | 兩個分支從同一個起點各自轉換了整批檔案的編碼,數千個檔案衝突,衝突清單超過資料庫欄位上限 | 確認其中一邊已經涵蓋另一邊後,用 git merge -s ours 記錄為已合併,再開 PR |
最後一個值得多說一句:大量檔案的編碼轉換、格式化、搬目錄,只在一條分支上做一次,做完立刻併回主線。兩條分支各做一次,Git 沒辦法幫你合併,因為每一行都不一樣。
結語
這套流程看起來步驟很多,但每一步都在回答同一個問題:下一個人(或下一個 AI 代理)接手時,需要知道什麼?
- 為什麼這樣做 → issue
- 怎麼驗證的 → PR
- 哪些錯誤不能再犯 → CI
- 哪些坑踩過 → AGENTS.md
- 改了之後效果如何 → CHANGELOG
小團隊沒有餘裕讓同一個坑被踩兩次。把規則交給流程記住,人就可以專心在真正需要判斷的地方。