讓流程替你記住規則:用 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 的資源用量是關鍵。但自架代表三件事要自己扛,決定自架前先想清楚誰負責:

  1. 備份:repo、資料庫、設定檔都要有異地備份,而且要實際還原過一次。
  2. 升級:固定大版本,看 release notes 再升;不要用 latest。
  3. 對外防護:關掉不用的入口(公開註冊、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 改了沒生效)

三個原則:

  1. 「為什麼」比「怎麼做」重要。只寫「不要用 X」,下一個人會覺得是過度小心而把它改掉;寫了「用 X 時發生過什麼」,就沒人會去碰。
  2. 踩到坑的當下就寫進去,不要等「之後整理」。之後永遠不會來。
  3. 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

再加兩條實務規則:

  1. 檢查腳本放在 repo 裡,workflow 只負責呼叫它。本機用同一個映像、同一個指令就能跑出一樣的結果,CI 失敗時不用猜。
  2. 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。

使用原則

  1. 只給需要的那一步:把 secret 放在 step 的 env,不要放在整個 job 或 workflow 的 env。其他步驟(尤其是第三方 action)拿不到,就不會外洩。
  2. 遮罩只認得原始值。base64、URL 編碼、拆成好幾段印出來,日誌裡都會是明文。不要 echo secret,也不要用 set -x 執行用到它的指令。
  3. 不要寫進 artifact 或快取:產生的設定檔裡有 secret,就不要上傳。
  4. 最小權限、有到期日:只讀就不給寫,token 設到期日,到期前換掉;有人離開團隊時,把他建立的 token 都換掉。
  5. 在文件裡記名字與用途,不記值。例如在 AGENTS.md 寫「TOOLS_READ_TOKEN:組織層級,機器帳號 ci-bot 的唯讀 token,讀 org/tools,2027-06 到期」。下一個人才知道它是做什麼的、壞了要找誰、多久要換一次。
  6. 部署用的金鑰不要放進 CI,理由同第五節:放了,就等於任何能改 workflow 的人都能部署。

六、不讓事情出錯的原則

這是我現在每次動手前都會過一遍的清單:

  1. 先看數據再改。每個結論都要能指出是哪個數字、跟哪次的紀錄比。數據不夠就先收集,不要憑印象加資源。

  2. 影響正式環境的操作先確認、先備份、想好怎麼退回。重啟資料庫、reload 網頁伺服器、改正式站的檔案,都算。設定檔用 nginx -t 這類檢查通過才 reload,不用 restart。

  3. 機密不進 git,也不貼進對話、issue 或日誌。token 放權限 600 的檔案,設定檔用 token_url: file:... 讀;需要使用者輸入時用 read -s。

  4. 改了設定要確認真的生效。單一檔案的 bind mount,編輯器存檔時會換掉檔案,執行中的容器看到的還是舊內容,要 --force-recreate。

  5. 版本固定。映像寫完整版本號,不用 latest;大版本升級先看 release notes 的設定變更。

  6. 清理指令一定要帶保留條件。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
  7. 日誌設上限。容器的 json-file 日誌預設不限大小,給 max-size 與 max-file。

  8. 輪詢頻率合理。runner 每 5 秒問一次,一天就是 1.7 萬筆請求;改成 15 秒,job 最多晚 15 秒開始,對小團隊沒差。

  9. 只在被要求時 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

小團隊沒有餘裕讓同一個坑被踩兩次。把規則交給流程記住,人就可以專心在真正需要判斷的地方。