ghコマンドでGoリポジトリを固める通し手順 — GitHub Actions CI・mainブランチ保護・v*タグでバイナリ自動リリース

DevOps

前回、AIエージェントとの開発でmainを守るで「なぜmainを保護するのか」を書いた。規約で守らせるのではなく、構造で不可能にする、という話だ。

今回はその続きで、手順に徹する。概念は分かっても、いざ自分のリポジトリでやろうとすると手が止まる。どのAPIを、どの順番で叩くのか。題材は、この数日で書いていたGo製のデーモン wsslirp を公開するにあたって実際にやった作業そのものである。

出発点は、CIもブランチ保護もライセンスも無い、ソースだけが載った公開リポジトリ。ゴールは次の3つだ。

  • 誰も(オーナーである自分も)mainに直接pushできない
  • PRはCIが通らないとマージできない
  • git tag v0.1.0 && git push --tags だけで4プラットフォームのバイナリが公開される

そして最後に、この手順を実行した私が実際に踏み抜いた罠を書く。前回の記事に「これをやると素通しになる」と自分で書いておきながら、その通りに素通しさせてしまった話である。

順番が大事: CI → 保護 → リリース

先に結論を書くと、この順番以外でやると詰む。

手順の依存関係

理由はブランチ保護の「必須チェック」がチェック名の文字列を指定する仕組みだからだ。CIが存在しない状態で "contexts": ["build and test"] と書いて保護を掛けると、GitHubは「build and test の報告を待つ」状態でPRを止める。報告する者がいないので、永遠にマージできないPRができあがる。

だからCIを先に作り、1度でも走らせてチェック名を確定させてから保護を掛ける。

Step 1: CIを作る

.github/workflows/ci.yml を置く。全文はこれだけである。

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: build and test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true

      - name: gofmt
        run: |
          unformatted=$(gofmt -l .)
          if [ -n "$unformatted" ]; then
            echo "these files need gofmt:"
            echo "$unformatted"
            exit 1
          fi

      - name: go vet
        run: go vet ./...

      - name: build
        run: go build ./...

      - name: test
        run: go test ./... -timeout 5m

      - name: test (race)
        run: go test -race ./... -timeout 10m

      - name: benchmarks compile and run once
        run: go test ./... -run '^$' -bench . -benchtime 1x -timeout 5m

ポイントを4つ。

name: build and test が、後で保護側に書くチェック名になる。 ここが両者の接続点である。ジョブに name: を付けなければジョブID(test)が使われる。保護のJSONに書く文字列と食い違うと前述の「永遠に待つPR」になるので、ここは意識して命名する。

go-version-file: go.mod を使う。 Goのバージョンをワークフローにベタ書きすると、go.mod を上げたときに二重管理になって片方が腐る。ファイルを参照させれば嘘をつかない。

-race を常設する。 wsslirpはフレームをgoroutine間で受け渡す構造で、しかも受信ループのパケット解析を NoCopy(パース結果がバッファの実体を指す)にしている。この手のコードは、通常のテストが全部通ってもデータ競合が残る。CIで毎回踏ませる価値がある。

ベンチは -benchtime 1x で1回だけ回す。 目的は計測ではなく、ベンチマークコードが腐ってコンパイルすら通らなくなるのを防ぐこと。CIランナーの時間は共有環境なので、そこで採った数字は性能の証拠にならない。数字が欲しければ手元で測る。

これをブランチに置いてPRを作ると、CIが走ってチェック名が確定する。マージする。

Step 2: mainに保護を掛ける

保護はWeb UIでも設定できるが、API経由だと設定が全部テキストで残るので、こちらを勧める。他のリポジトリに同じ設定を配るのも、差分を見るのもコピペで済む。

JSONをファイルに書く。

{
  "required_status_checks": {
    "strict": true,
    "contexts": ["build and test"]
  },
  "enforce_admins": true,
  "required_pull_request_reviews": {
    "required_approving_review_count": 0,
    "dismiss_stale_reviews": true,
    "require_last_push_approval": false
  },
  "restrictions": null,
  "required_linear_history": false,
  "allow_force_pushes": false,
  "allow_deletions": false,
  "block_creations": false,
  "required_conversation_resolution": true
}

適用する。

gh api -X PUT repos/<owner>/<repo>/branches/main/protection --input protection.json

restrictions は必須キーで、省略するとエラーになる。使わないなら明示的に null を置く。

各項目の意図を書いておく。

項目 なぜ
contexts ["build and test"] Step 1で決めたジョブ名。ここが命綱
strict true mainが進んだら、PRを最新に追従させてから再テストを要求する
required_approving_review_count 0 個人開発では0にする。 1にすると自分のPRを自分で承認できず詰む
enforce_admins true オーナーにも適用する。ここが今日の主役
allow_force_pushes false 履歴の書き換えを禁止
allow_deletions false mainブランチごと消されるのを禁止
required_conversation_resolution true レビューコメントを未解決のままマージさせない

required_approving_review_count: 0 は妥協に見えるかもしれないが、そうではない。承認は0でも「PRを経由すること」と「CIが通ること」は強制される。 個人開発で欲しいのはまさにそれで、承認者を1人要求すると単に運用が止まる。

罠: 保護が「効いている」ように見えて素通しになる

さて、事故の話である。

私は最初、enforce_adminsfalse にした。緊急時にオーナーが直接直せる逃げ道を残しておこう、という判断だった。前回の記事で「ここを外すと素通しできる」と自分で書いておきながら、である。

そして設定直後、動作確認のために空コミットをmainへpushしてみた。出力がこれだ。

remote: - Changes must be made through a pull request.
remote:
remote: - Required status check "build and test" is expected.
To https://github.com/yoshiharu-ishii/wsslirp.git
   e1aa96d..d85fecc  main -> main

「PRを通せ」と怒られている。 一見、保護が効いて弾かれたように読める。私も一瞬そう読んだ。

だが最終行をよく見ると e1aa96d..d85fecc main -> main と書いてある。これはpushが成功したときの表示である。拒否されたなら、こうなるはずだ。

 ! [remote rejected] main -> main (protected branch hook declined)

つまり enforce_admins: false のとき、GitHubはルール違反を列挙したうえで、バイパスして通す。警告文は出るが、止まらない。「pushは失敗するはず」という間抜けなメッセージの空コミットが、公開したばかりのmainに載った。

しかも始末が悪いことに、消せないallow_force_pushes: false は管理者バイパスの対象外で、force pushは本当に拒否される。

 ! [remote rejected] e1aa96d -> main (protected branch hook declined)

消すには、いま張ったばかりの保護を自分で緩めるしかない。空コミット1個のためにやる取引ではないので、墓標として残すことにした。

教訓: 検証は「拒否されたか」ではなく「リモートが動いていないか」で見る

この事故から、確認手順を次のように改めた。pushの出力を読むのではなく、リモートのHEADが動いていないことを確認する。

# 空コミットで叩いてみる
git commit --allow-empty -m "probe: 保護の動作確認"
git push origin main
echo "exit=$?"

# 本当の答えはこっち
git fetch -q origin
git log --oneline -1 origin/main   # 動いていなければ守れている

# 後片付け
git reset --hard HEAD~1

enforce_admins: true にしてから同じことをやると、今度は正しく落ちる。

remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: - Changes must be made through a pull request.
 ! [remote rejected] main -> main (protected branch hook declined)

exit=1 で、origin/main は動かない。これが「効いている」状態である。

ついでにもう1つ、私が踏んだ小さな罠。

git push origin main 2>&1 | tail -6; echo "exit=$?"

これは常に0を返す$? はパイプの最後(tail)の終了ステータスだからだ。pushの成否を見たいならパイプせずに実行するか、PIPESTATUS を見る。私はこれで「exit 0だから成功?」と一瞬混乱した。

後から enforce_admins だけ切り替えるならこれで済む。

# 有効化
gh api -X POST repos/<owner>/<repo>/branches/main/protection/enforce_admins
# 無効化(緊急時)
gh api -X DELETE repos/<owner>/<repo>/branches/main/protection/enforce_admins

Step 3: タグでリリースを自動化する

保護が済んだら、リリースも手動作業から追い出す。.github/workflows/release.yml を置く。

name: Release

on:
  push:
    tags: ['v*']

permissions:
  contents: write

jobs:
  release:
    name: build and publish
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true

      - name: test
        run: go test ./... -timeout 5m

      - name: build binaries
        env:
          VERSION: ${{ github.ref_name }}
        run: |
          mkdir -p dist
          for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
            os=${target%/*}
            arch=${target#*/}
            name="wsslirpd-${VERSION}-${os}-${arch}"
            CGO_ENABLED=0 GOOS=$os GOARCH=$arch go build \
              -trimpath \
              -ldflags "-s -w -X main.version=${VERSION}" \
              -o "dist/${name}/wsslirpd" ./cmd/wsslirpd
            cp README.md "dist/${name}/"
            tar -C dist -czf "dist/${name}.tar.gz" "${name}"
            rm -rf "dist/${name}"
          done
          cd dist && sha256sum *.tar.gz > checksums.txt

      - name: publish release
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh release create "${{ github.ref_name }}" \
            --title "${{ github.ref_name }}" \
            --generate-notes \
            dist/*.tar.gz dist/checksums.txt

こちらもポイントを4つ。

permissions: contents: write が要る。 デフォルトのトークンは読み取りのみなので、これが無いと gh release create が403で落ちる。CI側は contents: read のままでいい。権限は必要なジョブにだけ与える。

CGO_ENABLED=0 でクロスコンパイルする。 Goの標準ライブラリだけで済むなら、これで4プラットフォームがランナー1台から出る。

-trimpath-ldflags "-s -w" 前者はビルドマシンの絶対パスをバイナリから消す(ビルドの再現性とプライバシーの両方)。後者はシンボルとDWARFを落としてサイズを削る。

-X main.version=${VERSION} で、タグ名をバイナリに焼き込む。次節の話につながる。

--generate-notes はコミットとPRからリリースノートを自動生成してくれるので、最初はこれで十分だ。

バイナリに自分の素性を名乗らせる

-X main.version=... を受ける側をGoで書く。

// version is stamped by the release build:
// -ldflags "-X main.version=v0.1.0". Unstamped builds fall back to the
// VCS revision the toolchain records.
var version = ""

func buildVersion() string {
    if version != "" {
        return version
    }
    info, ok := debug.ReadBuildInfo()
    if !ok {
        return "dev"
    }
    rev, dirty := "", ""
    for _, s := range info.Settings {
        switch s.Key {
        case "vcs.revision":
            if len(s.Value) > 7 {
                rev = s.Value[:7]
            } else {
                rev = s.Value
            }
        case "vcs.modified":
            if s.Value == "true" {
                dirty = "-dirty"
            }
        }
    }
    if rev == "" {
        return "dev"
    }
    return "dev-" + rev + dirty
}

肝は debug.ReadBuildInfo のフォールバックである。Goツールチェインはビルド時のgitリビジョンと、作業ツリーが汚れていたかどうかを勝手にバイナリへ埋め込んでくれる。だから開発中のビルドは、何もしなくても自分の素性を言える。

$ go build -o wsslirpd ./cmd/wsslirpd && ./wsslirpd -version
wsslirpd dev-f9bbc5d-dirty

$ go build -ldflags "-X main.version=v0.1.0" -o wsslirpd ./cmd/wsslirpd && ./wsslirpd -version
wsslirpd v0.1.0

そして、これを起動ログの1行目に出す

log.Printf("wsslirpd %s listening on %s (endpoint /net)", buildVersion(), *listen)
2026/08/15 09:00:00 wsslirpd v0.1.0 listening on 127.0.0.1:8087 (endpoint /net)

これは飾りではない。以前、止め忘れた古いデーモンが同じポートに居座っていて、新しく実装した機能が動かないと数十分悩んだことがある。テストが繋いでいたのは先週のバイナリだった。起動行にバージョンが出ていれば一発で分かる。デーモンを書くなら、バージョンは黙って名乗らせておくのがいい。

リリースして、落として、確かめる

タグを打つ。

git tag -a v0.1.0 -m "wsslirp v0.1.0"
git push origin v0.1.0

ワークフローが走り、リリースができる。作りっぱなしにせず、成果物を落として検証する。

$ gh release download v0.1.0 -R yoshiharu-ishii/wsslirp -p '*darwin-arm64*' -p 'checksums.txt'
$ shasum -a 256 -c checksums.txt --ignore-missing
wsslirpd-v0.1.0-darwin-arm64.tar.gz: OK
$ tar xzf wsslirpd-v0.1.0-darwin-arm64.tar.gz
$ ./wsslirpd-v0.1.0-darwin-arm64/wsslirpd -version
wsslirpd v0.1.0

チェックサムが合い、バイナリが起動し、自分をv0.1.0だと名乗った。ここまで確認して初めて「リリースした」と言える。

最後に: LICENSEを忘れない

これは私が最後まで忘れていた項目である。publicにしただけのリポジトリは、法的には全権留保になる。ライセンスファイルが無いと、第三者はコードをimportすることも、バイナリを再配布することもできない。「公開した」つもりでも、実際には見せているだけだ。

しかも厄介なのは、リリースを切った後に気づくと配布物にも入っていないこと。v0.1.0のtar.gzにはLICENSEが無かったので、MITを追加して v0.1.1 を出し直し、v0.1.0のリリースノートには「こちらを使え」と追記した。

リリースワークフロー側は、同梱を1行で保証しておく。

cp README.md LICENSE "dist/${name}/"

ライセンスを決めるのは公開ボタンを押す前がいい。

まとめ

新規リポジトリを固める通し手順は、結局これだけである。

  1. ci.yml を書いてPRでマージする。ジョブの name: が後で使う識別子になる
  2. protection.json を書いて gh api -X PUT .../branches/main/protection --input で適用する。enforce_admins: truerequired_approving_review_count: 0
  3. 空コミットのpushで検証する。出力ではなく origin/main が動いていないかで判定する
  4. release.yml を書き、-ldflags -X でバージョンを焼き、タグを打つ
  5. 成果物を落として shasum -c-version まで確認する

一番言いたいのは3番である。保護の設定画面は、設定した瞬間に「守られている」という気分をくれる。だが enforce_admins ひとつで、その気分は嘘になる。警告文が出ていることと、pushが止まっていることは別物だ。 自分のmainに一度、空コミットを撃ち込んでみてほしい。

コメント

タイトルとURLをコピーしました