Skip to content

Latest commit

 

History

History
429 lines (355 loc) · 32.4 KB

File metadata and controls

429 lines (355 loc) · 32.4 KB

リリースエンジニアリング

fail-open な工程 + 表示するだけの検証器 = 緑のまま欠陥出荷

事象: notarize スクリプトは「資格情報の無い協力者でもビルドできるように」 プロファイル疎通失敗時に警告を出して未 notarize のまま正常終了する設計 だった。Apple の開発者規約が更新されて疎通確認が失敗した日、リリースは そのまま進み、未 notarize の zip が全チェック緑で公開された。検証器 (verify-release)は Gatekeeper 判定(spctl)を表示していたが、 spctl … | head -2 の形だったためパイプ終端の exit code しか見えずrejected が出ても連鎖は止まらない。さらにリリース実行側が工程出力を tail -1 で圧縮していたため、表示されていた rejected の行も人間に 届かなかった。3 つの失敗が積み重なって初めて成立した事故である。

適用方法:

  1. fail-open を許した工程には、下流に決定論的なゲートを置く。 成功時に のみマーカーファイル(例: <zip>.notarizedstatus: Accepted を確認 してから touch)を書き、検証器はその存在を要求する。オンライン照会 (spctl はチケット伝播が遅れる)ではなくローカルの決定論で判定する。
  2. cmd | head / cmd | tail はパイプ終端の exit を返す — 判定に使う コマンドをパイプに入れるな。表示用と判定用を分ける。
  3. リリース工程の出力を圧縮するなら、成功トークンを grep せよ。 tail -1 は「最後の行がたまたま成功メッセージ」に依存しており、失敗時は ヒント行が最後に来て成功に見える。grep Accepted は失敗時に空になる。
  4. ゲートは両方向を実地で試す: マーカーありで通ること、無しで落ちること。 片方向だけの確認は「発火しないゲート」を検出できない。
  5. zip の notarize はバイトを変えない(staple 不可の CLI 配布物)。事後に 同一 zip を再提出すれば公開済みアセットも sha もそのまま正常化できる — 再アップロード不要かの判断材料として覚えておく。
  6. 2026-08 に全数展開済み(GUI 15 + CLI 60): canonical の notarize スクリプト 両変種がマーカーを書き(開始時に旧マーカーを rm — 再ビルドされた成果物の隣に 古い判定を残さない)、各リポの make verify-release が二重ゲートで判定する。 第二ゲートは配布形式で異なる: GUI はオフラインの stapler validate (staple 済み bundle はオンライン照会不要)、CLI は -nt の鮮度テスト (zip は staple 不可なので「マーカーが zip より新しい」ことを要求 — マーカー後に 再ビルドされた zip は再 notarize しない限り落ちる)。vendored コピーは check-org check10 が byte 比較で drift を検出する(slack-router の build-tools/ 配置も対象)。

macOS の署名・notarization、リリースアーカイブ、Homebrew tap 配布に関する知見集。 各項目は「事象 → なぜ → 適用方法」の構造で、実プロジェクトで踏んだ事例から抽出している。 規範的なルールは CONVENTIONS.md (§Release Archive Standard, §Code Signing and Notarization)を参照。本書はその背後にある 「なぜ」と、ルール化しきれない落とし穴を扱う。


tar.gz 配布でも darwin バイナリは notarize できる(temp-zip パターン)

事象: Apple の notary service(xcrun notarytool submit)は zip / dmg / pkg コンテナしか受け付けず、tar.gz は直接送れない。

なぜ有効か: notary ticket はバイナリの CDHash(中身のハッシュ)をキーに Apple の サーバに保存される。最終配布フォーマットが何であっても、実行時に macOS が CDHash で 問い合わせて ticket を見つければ notarized として扱われる。

適用方法: packaging の前に「temp で zip 化 → submit → zip 破棄」を挟む。

dist-darwin: cross-build-darwin
	@for arch in arm64 amd64; do \
		cp dist/$(BINARY)-darwin-$$arch /tmp/$(BINARY) && \
		(cd /tmp && zip -j /tmp/$(BINARY)-notary.zip $(BINARY)) && \
		scripts/notarize-darwin.sh /tmp/$(BINARY)-notary.zip "$(NOTARY_PROFILE)"; \
		rc=$$?; \
		rm -f /tmp/$(BINARY) /tmp/$(BINARY)-notary.zip; \
		test $$rc -eq 0; \
	done
	# この後は無修正の tar.gz / .deb / .rpm packaging でよい — バイナリは既に notarized
  • rm -f で notarize 失敗時も temp を掃除し、test $$rc -eq 0 で失敗を伝播する。
  • ticket は CDHash 紐付けなので、submit 後にファイル名を変えても zip を作り直しても有効。

notarytool 403 "required agreement missing/expired" は契約失効であってキー失効ではない

事象: それまで通っていた notarization が突然一律失敗し、ラッパースクリプトは 「Keychain profile not found」と報告した。

なぜ: スクリプトが事前 probe(notarytool history)の終了コードだけを見て失敗理由を 決め打ちしていた。直接 xcrun notarytool history --keychain-profile <profile> を叩くと実際は 403 A required agreement is missing or has expired — Apple Developer Program のライセンス契約が 更新され、Account Holder が未同意だっただけで、キーもプロファイルも有効だった。

適用方法:

  • ラッパーの失敗表示を鵜呑みにせず、必ず notarytool history を直接実行して実エラーを見る。
  • 403 ... agreement ... なら developer.apple.com/account で更新契約に同意する (App Store Connect の Agreements も確認)。数分で反映され、キー再登録は不要。
  • ラッパースクリプトは probe の stderr を捕捉して実エラーを必ず表示し、 403/agreement を検出したら契約再同意を案内するよう修正した(親切な誤診断は誤診断より悪い)。
  • notarytool history の事前 probe は flaky(成功直後に別 arch で偽 fail することがある)。 スクリプト経由で落ちたら notarytool submit --wait を直接叩いて迂回する。
  • notarytool の keychain profile はマシンローカル(保存先は data-protection キーチェーン — 下の画面ロックの項を参照)。別マシンには同期されないので、 ビルドマシンごとに store-credentials が必要。

notarytool の資格情報は画面ロック中は読めない(消えたように見える)

事象: 同一マシン・同一プロファイルで 8 本連続で通っていた notarize が、無人実行の 途中から一律 Error: No Keychain password item found for profile: <name> で失敗し始めた (2026-08、GUI 10 本一括リリース中)。プロファイル消失・キーチェーン破損に見えるが、 実際は画面ロックがかかっただけだった。解除した瞬間に何の再登録もなく復旧した。

なぜ: notarytool store-credentials は資格情報を data-protection キーチェーンに 保存する(legacy な security find-generic-password ではロック解除中でも見えない — これで消失と誤診しやすい)。data-protection キーチェーンの項目はコンソールロック中は 読み出し不可になるため、画面がロックされると notarytool は「item が無い」と報告する。 security show-keychain-info login.keychain-db が正常応答するのも誤診を誘う (login.keychain-db 側は無関係)。

適用方法:

  • このエラーを見たらまず ioreg -n Root -d1 | grep IOConsoleLockedYes なら 資格情報は無事で、画面を解除するだけでよい。プロファイル再作成は不要。
  • 無人リリース(並列エージェント・長時間バッチ)は notarize が終わるまで画面ロックを 保留するか、途中でロックされたら止まった分だけ後で再開する設計にする (tag 済み・release 未作成の状態からの再開は版数再利用にならない)。
  • 消失と断定して store-credentials をやり直す前に必ずロック状態を確認する — 再登録は不要な API キー操作であり、原因も誤って学習される。

クラウド同期フォルダ配下のバイナリは SIGKILL され得る(provenance xattr)

事象: Dropbox 同期フォルダ配下の ~/bin に Go バイナリを cp すると、起動即 zsh: killed(SIGKILL)。同じバイナリが dist/ 直下(同期外)では動く。

なぜ: Dropbox の FileProvider extension は、sync フォルダにファイルが landing した時点で com.apple.provenance 等の xattr を付与する(同一マシン内のローカル cp でも付く)。 com.apple.provenance は system-protected で xattr -d でも削除不可。弱い署名 (ad-hoc / linker-signed)と provenance の組合せを Gatekeeper が SIGKILL する。 この挙動は macOS のマイナーアップデートで強化され、「以前は動いていた構成」が突然壊れた。

適用方法:

  • 同期フォルダ配下に置いた後に codesign --force --sign - <binary> で再署名すると通る (provenance は残るが、署名が「このマシンが今 sign した」ものになる)。 同期先の各マシンで再署名が必要(署名は sync では再付与されない)。
  • cp は宛先に新規 inode を作るので xattr が新規付与されるが、Finder の同一ボリューム内 「移動」は rename(inode 保持)なので発火しない。ditto も可。素の cp を避ける。
  • spctl --add は最新 macOS で deprecated("This operation is no longer supported")。
  • これは環境/配置の問題でコードバグではない。子プロセスの即死 (read response: EOF / broken pipe)を見たら、まず <binary> --version の exit code(137 = SIGKILL)と配置場所(同期フォルダ配下か)を確認する。
  • プロジェクトの Makefile に個人環境向けの install: target(~/bin 決め打ち cp + codesign)を入れない。プロジェクトは make builddist/ まで。個人環境への配置は dotfiles 側のスクリプトで吸収する。

GUI .app の署名は CLI と別パイプライン

事象: CLI 用の署名/notarize スクリプトは単体 Mach-O 前提で、.app バンドルには流用できない。

要点(正規手順は CONVENTIONS.md §Code Signing → GUI app signing):

  • WebView 系(Wails / Tauri)は entitlements 最小 2 つが必須: com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory。 無いと WKWebView の JS が Hardened Runtime に殺され、フロントエンドが無言で真っ白になる。
  • ネイティブ Swift / AppKit は JIT entitlements 不要。Hardened Runtime のみで動く。 localhost への HTTP も Info.plist の NSAppTransportSecurity 側で対応でき、entitlement 不要。
  • cp -r は禁止、ditto を使う: バンドル署名は xattr に格納されるため、cp -r は署名を 壊し「Code Signature Invalid」で起動即死する。配布 zip 化も ditto -c -k --keepParent
  • 署名順序: build → deep 署名(--force --deep --options runtime --timestamp --entitlements)→ ditto → notarize(.app は temp zip 化して submit)→ stapler staple。バンドル形式は staple できるのでオフライン first-launch でもダイアログが 出ない。CLI 単体 Mach-O は staple 不可(オンライン check が走る)。
  • Tauri は tauri build が env var(APPLE_SIGNING_IDENTITY)を見て bundler 内で署名する。 Tauri 内蔵 notarize は独自の env(Apple ID + パスワード)を要求し notarytool の keychain profile を使えないため、無効のままにして自前で xcrun notarytool を回すのが楽。 DMG ファイル名は Rust triplet arch(aarch64)で uname -marm64)と一致しない。 package target を build 依存にすると再ビルドで staple が剥がれる。
  • Swift Package Manager ベースの GUI は .app バンドル構造(Contents/MacOS/ 等)を Makefile で手動組み立てし、最後に codesign する。

リリースアーカイブは「公開物を実ダウンロード」して検証する

事象: ビルドログとローカル dist が正しくても、公開資産の中身が意図通りとは限らない。 全 org 監査(52 リポ / 171 アーカイブ)で、ある zip の README/LICENSE 欠落を検出した。

適用方法:

  • gh release download公開資産を実 DL → 展開して検査する。検査項目: canonical バイナリ名 / file での arch 一致 / README.md + LICENSE 同梱 / darwin の署名。
  • .app の zip は ditto -x -k で展開する。unzip は禁止 — unzip は symlink/xattr を 壊し、codesign --verify --deep が「a sealed resource is missing or invalid」と 誤失敗する(元 zip は健全なのに)。
  • spctl -a -t exec は bare CLI Mach-O を仕様上 reject する("the code is valid but does not seem to be an app")— notarize の有無と無関係。CLI の確認は codesign --verify --strict + Authority=Developer ID Application + Identifier 一致で行う。 spctl で "accepted, source=Notarized Developer ID" と評価できるのは .app だけ。
  • bare CLI の notarize 有無そのものは codesign --test-requirement="=notarized" --verify で直接確認できる — 通れば explicit requirement satisfied を返す。上記の 「spctl が使えないので Authority と Identifier で代替する」より一段強い確認で、 「署名は正しいが notarize されていない」バイナリを弾ける。ローカル dist ではなく gh release download した公開資産に対して実行すること(2026-08-23, mcp-bridge v0.1.0 で実証)。
    • ただしこの検査は notarize 直後には落ちる。 bare Mach-O は staple できずチケットが オンライン参照になるため、notarytoolAccepted を返した直後でも数十秒〜数分は code failed to satisfy specified code requirement(s) を返す。これを notarize 失敗と 読み違えないこと — 権威は notarytoolAccepted であり、この検査が見ているのは チケットの配信状況にすぎない。アップロード後に公開資産へ対して実行すれば自然に待ちが入る。 それでも落ちるなら until ループで再試行する(2026-08-23, mcp-bridge v0.1.1 で遭遇)。
  • git tag(lightweight)は git describe(annotated 限定)から見えない。Makefile の 版数導出は git describe --tags(lightweight 込み)を使う。

リリース zip 内の実行ファイルは canonical 名にする

事象: zip -j は渡したファイルの basename をそのままエントリ名にするため、per-arch 成果物(tool-darwin-arm64)を直接固めると zip 内のバイナリに arch サフィックスが残り、 利用者が配備時に rename を強いられる。32 ツールで一括修正になった。

適用方法: stage-and-zip パターン — per-arch 成果物を canonical 名でステージコピー してから固める。

$(eval STAGE=dist/_pkg-$(GOOS)-$(GOARCH)) \
rm -rf $(STAGE) && mkdir -p $(STAGE) ; \
cp dist/$(BINARY)-$(GOOS)-$(GOARCH)$(EXT) $(STAGE)/$(BINARY)$(EXT) ; \
zip -j $(ARCHIVE) $(STAGE)/$(BINARY)$(EXT) LICENSE README.md ; \
rm -rf $(STAGE) ; \
  • 単一 Mach-O の cp は署名を保持する(.app バンドルと違い ditto 不要)。
  • notarization は CDHash 紐付けでファイル名に依存しないため、rename しても通る。

CLI は --version フラグに応答必須(brew test が叩く)

事象: version サブコマンドしか持たないツールは、共有 formula テンプレートの assert_match version.to_s, shell_output("#{bin}/<name> --version") により brew test が "unknown flag" で失敗する。brew install は成功するので tap 掲載後に初めて露見し、 4 本まとめてパッチ版を切ることになった。

適用方法:

  • scaffold 時点で --version を通す(cobra なら rootCmd.Version = Version)。 サブコマンドは互換のため残し、SetVersionTemplate で出力を一致させる。
  • 回帰テストでは rootCmd.Version をテスト内で代入しない — 代入するとフラグが生えて 壊れたバイナリでも通ってしまう。本番 init での結線(rootCmd.Version != "")を検査する。

アップロード前にビルド済みバイナリの --version を確認する

事象: VERSION ?= dev のハードコードのまま make build-all を実行し、全リリース バイナリのバージョンが dev になった状態でアップロードしてしまった。

適用方法:

  • Makefile は VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) を最初から使う(?= なので手動指定も可能なまま)。
  • make build-all 直後に dist/<binary>-darwin-arm64 --version を実行し、期待する バージョン文字列と一致してからアップロードする。リリースチェックリストに含める。

一度公開したバージョン番号を再利用しない(fix-forward)

事象: GitHub Release が存在するタグを削除して貼り直すと、リリースが Draft に戻り "Latest" 指定も失われ、手動復旧が必要になった。

なぜ: 利用者が既にダウンロードしたバイナリと同じバージョン番号の別バイナリが世に出ると、 バージョン番号が識別子として機能しなくなる。

適用方法:

  • 既公開資産に影響する修正でも再リリースせず、次のパッチ/マイナーリリースで自然に直す (fix-forward)。
  • タグを消す前に必ず gh release list でリリースの有無を確認する。リリースが無いタグに 限り貼り直してよい。

prebuilt-binary Homebrew tap の作法と検証

事象: ソースビルドすると Developer ID 署名が剥がれる。tap は署名+notarize 済み リリース zip をそのまま入れる prebuilt 方式にする。

適用方法:

  • formula(Go CLI): depends_on arch: :arm64 + depends_on :macosbin.install "<name>"。資産名にバージョンが中間に入る形式では brew の自動 version 検出が 効かないため url ハードコード + version 明示。brew style の ComponentsOrder は urlversion より前を要求するので、url#{version} 補間は使えない。
  • 新しい macOS を要求するアプリは cask の下限を明示的に設定する。 共有テンプレートの 既定 :big_sur のままだと、動作しない OS に対応していると広告することになり、しかも 何も失敗しない——アプリが動かない環境に cask が入ってしまう。シンボルはマーケティング名 ではないので Homebrew 自身の表から読む(brew ruby -e 'puts MacOSVersion::RELEASES' または Library/Homebrew/macos_version.rb。26 が :tahoe、27 が :golden_gate)。
  • zap がアプリの保存状態を消すべきかは、チェック項目ではなく判断である。 システム側の 設定を変更し、変更前の値の記録を保持するツールでは、その記録を zap で消すと 「変更されたまま戻す記録がない」状態を作る——zap はシステム設定自体を戻せない。 この種の記録は残し、アンインストール前にアプリ内で取り消すよう利用者に伝える。
  • cask(GUI .app): depends_on macos: :big_sur(文字列形は style が symbol 形に矯正)、 zap trash: [...]
  • cask が notarization の真テスト: formula は brew が quarantine xattr を剥がすが、 cask は保持する → spctl -a -t exec がフル Gatekeeper 判定を行う。 クリーンマシンで "accepted, source=Notarized Developer ID" になれば署名保持の決定的証明。
  • クリーン VM が無いときの代替検証: 公開 Release から zip を DL → 自分で com.apple.quarantine xattr を付与 → codesign --verify --strict + quarantine 付きで実行。 quarantined な notarized バイナリが実行できれば Gatekeeper のオンライン判定を通過した証拠。
  • 公開前検証は local tap で GitHub 不要: tap リポの working copy を $(brew --repository)/Library/Taps/<org>/homebrew-tap に clone すれば brew install <org>/tap/<name> が動く(url は公開 release 資産を指すので DL 可能)。 brew audit --tap は Casks/ ディレクトリが無いと cask を検査しない。
  • ssh 越しの brew は PATH に無い(/opt/homebrew/bin.zprofile 経由)→ eval "$(/opt/homebrew/bin/brew shellenv)" を先頭に。cask install は --appdir="$HOME/Applications" で sudo プロンプト(非対話 ssh でハング)を回避できるが、 --appdir は uninstall には渡せない(receipt から解決される)。
  • 後片付けは uninstall → untap の順(installed 成果物が残っていると untap が拒否される)。

dist/ のバイナリ再署名は、そこから起動中の常駐プロセスを殺す

事象: make builddist/<binary> を上書き + codesign --force で再署名した結果、 そのバイナリから起動していた常駐 daemon が macOS に kill され、データ記録が 30 分以上 無言で欠落した。

なぜ: 実行中バイナリのコード署名が変わると macOS がプロセスを落とす。LaunchAgent 未登録 だと KeepAlive による自動復帰も効かない。

適用方法:

  • 常駐 daemon は dist/ からではなく .app 同梱パスContents/Resources/<binary>)から 起動させる。make build の影響を受けず、LaunchAgent の KeepAlive で復帰する。
  • ビルドを伴う作業の前後で daemon の status(最終サンプル時刻等)を確認する。 長い欠落は自分のビルドが落とした可能性を疑う。

同梱物のあるパッケージは「サイズ」で真っ先に嘘がばれる

事象: GUI アプリに CLI バイナリを同梱するリリースで、make clean 直後に make package を実行したところ、同梱が黙ってスキップされたまま notarize まで通過した。 zip が 1.2MB(正常なら約 10MB)だったことで、アップロード前の検証で気づいた。

なぜ: 同梱処理が [ -x "$CLI_BIN" ] のような存在チェックで、無ければ警告を出して 続行する作りだった。make clean は素の名前のバイナリを消し、package が依存する build-all はプラットフォーム接尾辞付きの名前しか作らないため、参照先が存在しなかった。 署名も notarize も「中身が欠けていること」は検出しない。

適用方法:

  • アップロード前に必ずアーカイブを展開し、中身を数える。同梱物の有無、実行ファイルの --version、署名(spctl --assess)を実物に対して確認する。
  • 同梱物のサイズは最も安価な健全性指標。桁が変わったら疑う。
  • 依存ステップが「無ければ警告して続行」なら、リリース経路では失敗させる方が安全。 開発時の利便性のための緩さが、そのままリリース事故になる。

同梱するバイナリの版数文字列は明示的に固定する

事象: タグ後に Makefile の1行だけ直したところ、git describev0.1.0-1-g<sha> を返し、同梱バイナリの版数表示がリリース版でない文字列になった。

なぜ: 版数を git describe --tags から取る構成では、タグ以降に何かコミットすると (それがコンパイル対象でなくても)版数文字列が変わる。

適用方法: 同梱物をビルドするときは make build VERSION=vX.Y.Z のように明示指定する。 コンパイル対象に差分が無いことを git diff --stat <tag>..HEAD で確認したうえで、 リリース版の文字列を名乗らせる。

scaffold 時に書いた状態記述は、リリースが更新してくれない

事象: 「プレリリース」「未リリース」と書かれた README を持つツールが 3 本見つかった。 いずれも実際にはリリース済みで、うち 1 本は 4 回リリースし、Homebrew tap にも載り、 その brew install コマンドを README 自身が 2 行下に載せていた。もう 1 本は 「未リリース。公開後は以下で入ります」と書いた直後に、既に動く brew コマンドを 置いていた。

なぜ: リリースチェックリストが見るのは CHANGELOG・バージョン・署名・アーカイブで、 scaffold 時に書いた散文は誰も見ない。しかも「まだ出ていない」は書いた瞬間は正しく、 最初のリリースで初めて誤りになる — 書いた本人がその瞬間に立ち会わない種類の腐り方をする。 同種のものとして、README の機能説明が後で実測して否定された挙動を宣伝し続けていた例も あった(別ツールの org profile 行)。

適用方法:

  • 状態を散文で書かない。 リリース済みかどうかはインストール手順の有無で伝わる。 版数も書かない(git tag が唯一の情報源)
  • どうしても書くなら、リリース手順が触るファイルにだけ書く(CHANGELOG 等)。 README の冒頭バナーは誰の手順にも含まれない
  • 機械的に検出できる。 「リリースが 1 件以上あるのに README に 未リリース|プレリリース|not yet released が含まれる」は grep + gh release list で 判定でき、組織横断チェックに足せる

LICENSE への追記は分類を壊し、通知の分離は配布物への同梱と対で行う

事象: org の 5 リポが GitHub 上で「Other」ライセンスと表示されていた。中身は 5 つとも MIT で、LICENSE の末尾に第三者帰属が追記されていたためテンプレート一致が壊れていただけ だった。通知を NOTICE.md に分離したところ、2 リポで別の欠陥が露見した — 同梱している コード(Chart.js、highlight.js + Mermaid.js)の帰属が、リリースアーカイブに一度も入って いなかった。しかも分離の前例として参照したリポ自身が同じ穴を持っていた。さらに 1 リポの 通知は他プロジェクトからの貼り付け誤りで、無関係な由来を宣言していた。

なぜ: GitHub の判定は LICENSE がテンプレートと一致するかだけで決まり、1 行の追記で 「Other」に落ちる。一方、帰属が果たされているかは「リポジトリに置いてあるか」ではなく **「配布物に入っているか」**で決まる。MIT / BSD は通知の同梱を配布の条件にしているので、 リポジトリに置いただけでは条件を満たさない。この 2 つは逆向きに効く: 分類を直すために 通知を LICENSE から出すと、配布物からも消える。

適用方法:

  • LICENSE はテンプレートのまま置き、第三者帰属は NOTICE.md 等の別ファイルにする。 LICENSE から NOTICE への参照も書かない — それ自体が追記になって分類を壊す。誘導は README に置く。
  • 分離と package への同梱は同じコミットで行う。順序を分けると、その間のリリースが 帰属を落とす。
  • 検証を構造読みで済ませない。staging は 1 行の長いシェルループで、cp が失敗しても 後続の tar/zip は走り、ファイルが入らないまま静かにアーカイブができる。実際に 作って中身を一覧する。
  • 署名済み .app を配る GUI は別扱い。アーカイブは .app のみで、通知を並べられない (署名バンドルを乱す)。同梱コードを持つなら .app の Resources に入れる。
  • 「前例」のリポが正しいとは限らない。 参照した先が同じ穴を持っていた。前例は書式の 参考にし、正しさは条件(配布物に入るか)で判定する。
  • 帰属文は貼り付けで増える。本文に他プロジェクト名が残っていないかを確認する。

リリース中に別エージェントがビルドした: まず凍結し、公開物の出所を証明する — 比較のための再ビルドはしない

事象: あるセッションが gem-agent をリリースしている最中(2026-09-13)、別のエージェントが 同じ作業コピー(と同居リポジトリ)で make build を実行し、ad-hoc 署名の dist/<name> を 書き、その後「自分の出力が公開アーカイブに混入したかもしれない」と申告した。dist/ は 共有の出力先で、数分の間に 2 つの書き手がそこにいた。

なぜ重要か: 構造上は混入しにくい — make builddist/<name> に書き、make packagedist/<name>-darwin-arm64 を自分でビルドして zip にし、もう一方のファイルは読まない。 リリース冒頭の make clean はそこにあった物を消す — が、「しにくい」は証拠ではなく、 最初に浮かぶ手(再ビルドして比べる)は残っている証拠を壊す。決着は公開物そのものと、 誰も編集しない記録からつける。

適用方法:

  • まず凍結する。 監査が終わるまで make clean・再ビルド・再署名はしない。以下は全て 読み取りのみ。
  • 出所は公開物自身から。 gh release download で zip をファイルに落とし、ditto -x -k で展開し、go version -m <binary> を見る: vcs.revision がリリースコミットで vcs.modified=false であること。別コミットや未コミット変更のあるツリーから作った バイナリはこれを偽装できない。今回の紛れ込んだビルドは v0.77.1-2-g93b02e6 を 埋め込んでおり、リリースコミットより古いコミット — それだけで、リリースの make clean より前と確定した。
  • 同一性は Apple から。 xcrun notarytool history --output-format json で提出 id を得て、 xcrun notarytool log <id>sha256 を読む。これが Apple が実際に審査した物の ハッシュで、公開資産の shasum -a 256(パイプせずファイルに保存してから)・ローカル zip・tap の formula と一致しなければならない。4 つのハッシュが揃えば決着。zip 内 バイナリと dist/<name>-darwin-arm64cmp が 5 つ目。
  • 時系列は誰も編集しない物から。 git reflog --date=iso(commit・amend の時刻)、 ls -lT dist/(バイナリ → zip → .notarized マーカーの順)、gh release view --json assetscreatedAt、紛れ込んだ成果物の mtime。1 つの表に並べる: そのビルドは make clean より前(消えて、パッケージされていない)か upload より後(無関係)のどちらかで、 バイナリと zip の間の数秒に入っても別のファイル名に書くだけ。
  • 運用上、リリースは作業コピーを占有する。 そのリポジトリのビルドが要る別エージェントは 自分の clone で作業するか待つ。共有 dist/ は 2 つの書き手が出会う場所。リリース手順 そのものが防壁(make clean && make packageverify-release のマーカー鮮度ゲート)で、 go version -m と notary ログが事後にそれを証明する監査。