前回、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_admins を false にした。緊急時にオーナーが直接直せる逃げ道を残しておこう、という判断だった。前回の記事で「ここを外すと素通しできる」と自分で書いておきながら、である。
そして設定直後、動作確認のために空コミットを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}/"
ライセンスを決めるのは公開ボタンを押す前がいい。
まとめ
新規リポジトリを固める通し手順は、結局これだけである。
ci.ymlを書いてPRでマージする。ジョブのname:が後で使う識別子になるprotection.jsonを書いてgh api -X PUT .../branches/main/protection --inputで適用する。enforce_admins: true、required_approving_review_count: 0- 空コミットのpushで検証する。出力ではなく
origin/mainが動いていないかで判定する release.ymlを書き、-ldflags -Xでバージョンを焼き、タグを打つ- 成果物を落として
shasum -cと-versionまで確認する
一番言いたいのは3番である。保護の設定画面は、設定した瞬間に「守られている」という気分をくれる。だが enforce_admins ひとつで、その気分は嘘になる。警告文が出ていることと、pushが止まっていることは別物だ。 自分のmainに一度、空コミットを撃ち込んでみてほしい。


コメント