Claude Codeでelectron-updaterを実装し、ローカル更新サーバーで検証する
electron-updaterの自動更新をClaude Codeで実装し、latest.ymlのハッシュ・段階配信・署名をローカルの更新フィードで確かめる手順。Stop hookで検証を自動化します。
electron-updaterは、Electronアプリに自動更新を足すためのパッケージです。更新の配信元に置くのは、ビルド成果物と latest.yml というメタデータのファイル。クライアントは latest.yml を読んで新しい版があるかを判断し、成果物のハッシュを突き合わせてから入れ替えます。
Claude Codeに実装を任せるときの難所は、コードを書くことではありません。更新が本当に届くかを、実機のインストーラーなしで確かめる手段をどう用意するかです。この記事では、ローカルのHTTPサーバーを更新フィードに見立て、latest.yml と成果物の整合を検証するスクリプトを作ります。それをStop hookにつなぎ、Claudeが「できました」と言う前に必ず通る関門にします。
先に決める: どのバージョンのドキュメントを読むか
electron-builderの公式ドキュメントは、読み始める前に版を確かめる必要があります。自動更新のページには「next (v27)」の表示があり、リリース前の内容だと明記されています。安定版はv26のドキュメントです。
npmのdist-tagでは、electron-updaterの latest が6.8.11、next が7.0.0-alpha.12でした。electron-builderは latest が26.18.0、next が27.0.0-alpha.13です。
| 機能 | 載っているドキュメント | 備考 |
|---|---|---|
latest.yml の生成・汎用サーバー配信 | 載っているドキュメントnext (v27)のページに記載 | 備考仕組み自体は以前からある |
stagingPercentage による段階配信 | 載っているドキュメント同上 | 備考latest.yml を手で編集する方式 |
| チャンネル(alpha / beta / latest) | 載っているドキュメント同上 | 備考generateUpdatesFilesForAllChannels |
| 署名付きマニフェスト | 載っているドキュメントSigned Update Manifestsページ | 備考v27の機能として説明されている |
autoInstallEvent: "onNextLaunch" | 載っているドキュメント同ページ内 | 備考electron-updater 7.0以降と記載 |
署名付きマニフェストと autoInstallEvent は、v27の新機能として書かれています。安定版のv26に同じ挙動を期待しないでください。依頼文には、使う版を先に書いておきます。
線引きをもう少し具体的にすると、次のとおりです。v26で使えるのは、generic プロバイダーでの配信、latest.yml の sha512 と size の照合、stagingPercentage の手編集、チャンネルの切り替えです。この記事の verify-feed.mjs とStop hookは、ここまでで完結します。一方、署名付きマニフェスト、autoInstallEvent: "onNextLaunch"、disableWebInstaller の既定値変更は、v27のドキュメントに書かれた挙動です。v26のプロジェクトで「署名の検証で守られている」と前提を置くと、実際には何も検証されていない状態になります。
依頼文の例を挙げます。「electron-builder 26の範囲で実装する。署名付きマニフェストと autoInstallEvent は使わない。v27へ上げるときは別の作業として扱う」。こう書いておけば、Claudeがv27の節を読んで気を利かせ、存在しない設定キーを electron-builder.yml に足す事態を避けられます。
Claude Codeに渡す前提をCLAUDE.mdに置く
最初にCLAUDE.mdで前提を固定します。Claudeは、electron-updaterの古い記事にある setFeedURL を呼ぶコードを書きがちです。公式ドキュメントは「setFeedURL は呼ばない」としています。electron-builderがビルド時に app-update.yml を resources に作るからです。
## 自動更新(electron-updater)
- バージョンは electron-builder 26 / electron-updater 6 系。v27 の機能は使わない
- `autoUpdater` は `electron-updater` から import する(electron 組み込みは使わない)
- `setFeedURL` は呼ばない。配信先は electron-builder の `publish` 設定が決める
- 更新フィードの確認は `node scripts/verify-feed.mjs <URL>` で行う
- 更新に関わる変更を終えたら、必ずローカルの `feed/` を作り直して上のコマンドを通すElectronアプリ自体の土台は、Claude CodeでElectronアプリを開発する手順で作った構成を前提にします。
実装を3段階に分けて依頼する
一度に頼むと、設定とコードとテストが混ざって切り分けられません。次の順に、段階ごとに確認を挟みます。
electron-updaterの実装を頼む順序
- 1
publish設定とフィードの生成
汎用サーバー向けの
publish設定を足し、ビルドでlatest.ymlが出ることを確かめます。 - 2
メインプロセスの更新処理
autoUpdaterのイベントを受けてログを出すコードを書きます。 - 3
ローカルフィードの検証
feed/を配信し、latest.ymlと成果物の整合を検証スクリプトで通します。
publish設定: 汎用サーバーを指す
配信先を自前のHTTPサーバーにするには、generic プロバイダーを指定します。公式の説明では、汎用サーバーに限り、成果物とメタデータを自分でアップロードする必要があります。ローカル検証と相性がよい理由は、ここにあります。
# electron-builder.yml(抜粋)
publish:
provider: generic
url: http://127.0.0.1:8123ビルド後の出力ディレクトリに、成果物と並んで latest.yml が出ているかを確認させます。macOSは latest-mac.yml、Linuxは latest-linux.yml です。macOSでは、latest-mac.yml を作るために zip ターゲットが要ります。既定のターゲットは dmg と zip なので、明示しなければ問題になりません。
メインプロセス: イベントにログを付ける
デバッグはロガーを設定するだけで足ります。公式ドキュメントは electron-log を勧めています。次のような形が出発点です(公式の例に沿った構成)。
import { app } from "electron"
import { autoUpdater } from "electron-updater"
import log from "electron-log"
export function setupAutoUpdate(): void {
autoUpdater.logger = log
log.transports.file.level = "info"
autoUpdater.on("update-available", (info) => log.info("available", info.version))
autoUpdater.on("download-progress", (p) => log.info("progress", Math.round(p.percent)))
autoUpdater.on("update-downloaded", (info) => log.info("downloaded", info.version))
autoUpdater.on("error", (err) => log.error("updater error", err))
if (app.isPackaged) {
autoUpdater.checkForUpdatesAndNotify()
}
}autoUpdater.on("error", ...) は必ず頼みます。署名の検証失敗もハッシュの不一致も、このイベントで届くからです。
コード例で受けているのは4つだけです。autoUpdaterは、ほかにも次のイベントを出します。
checking-for-update: 更新の確認が始まったとき。ログにこれが出ていなければ、checkForUpdatesAndNotify()がそもそも呼ばれていません。app.isPackagedの条件で弾かれている開発中は、ここで気づけますupdate-not-available: 確認は済み、新しい版がなかったとき。フィードのversionが現在の版以下だと、ここに来ます。checking-for-updateの次にこれが出るなら、通信は成功していて、版の比較で止まっていますupdate-cancelled: ユーザーの操作などでダウンロードが中断されたとき。errorとは別に届きますappimage-filename-updated: AppImageの更新に成功し、ファイルが最終的な場所へ移された後に出ます。Linuxで、AppImageとして配布している場合に限ります
checking-for-update から update-available、download-progress、update-downloaded までの順にログが並べば、フィードの読み取りからダウンロードまでが通ったと判断できます。途中で止まった箇所が、切り分けの起点です。Claudeに追加を頼むときは、これらを log.info で1行ずつ出すようにすれば足ります。
パッケージ化していない開発中に更新のUIを試すには、プロジェクトのルートに dev-app-update.yml を置きます。中身は publish 設定をYAMLで書いたものです。そのうえで、autoUpdater.forceDevUpdateConfig = true を設定します。これも公式ドキュメントの記載です。
更新フィードを検証するスクリプトを作る
latest.yml の sha512 と size は、成果物と一致していなければなりません。electron-updaterはダウンロードした成果物のsha512を必ず照合するので、フィード側がずれた時点で更新は失敗します。この照合を、Electronを起動せずに手元で再現するのが検証スクリプトの役目です。
次の verify-feed.mjs は、依存パッケージなしで動きます。latest.yml を取得し、files[] の各エントリを実際にダウンロードして、サイズとsha512を比べます。stagingPercentage があれば、0〜100の範囲かも見ます。
// scripts/verify-feed.mjs
import { createHash } from "node:crypto";
const base = process.argv[2];
const manifestName = process.argv[3] ?? "latest.yml";
const errors = [];
const res = await fetch(new URL(manifestName, base + "/"));
if (!res.ok) { console.error(`${manifestName}: HTTP ${res.status}`); process.exit(2); }
const text = await res.text();
const version = text.match(/^version:\s*(.+)$/m)?.[1]?.trim();
const staging = text.match(/^stagingPercentage:\s*(\d+)/m)?.[1];
const files = [...text.matchAll(/-\s+url:\s*(.+)\n\s+sha512:\s*(\S+)\n\s+size:\s*(\d+)/g)]
.map((m) => ({ url: m[1].trim(), sha512: m[2], size: Number(m[3]) }));
if (!version) errors.push("version がありません");
if (files.length === 0) errors.push("files[] がありません");
if (staging !== undefined && !(Number(staging) >= 0 && Number(staging) <= 100))
errors.push(`stagingPercentage が範囲外: ${staging}`);
for (const f of files) {
const r = await fetch(new URL(encodeURI(f.url), base + "/"));
if (!r.ok) { errors.push(`${f.url}: HTTP ${r.status}`); continue; }
const buf = Buffer.from(await r.arrayBuffer());
const actual = createHash("sha512").update(buf).digest("base64");
if (buf.length !== f.size) errors.push(`${f.url}: size ${buf.length} != ${f.size}`);
if (actual !== f.sha512) errors.push(`${f.url}: sha512 不一致`);
}
if (errors.length) { console.error(errors.join("\n")); process.exit(2); }
console.log(`OK version=${version} files=${files.length} staging=${staging ?? "なし(全員)"}`);macOSとLinuxのフィードは、ファイル名が違います。第2引数にマニフェスト名を渡すと、同じスクリプトでそのまま検証できます。
node scripts/verify-feed.mjs http://127.0.0.1:8123 latest-mac.yml
node scripts/verify-feed.mjs http://127.0.0.1:8123 latest-linux.yml複数のOS向けに配信するなら、Stop hookのスクリプトにもこの2行を足しておきます。feed/ にあるマニフェストのうち、1つだけ壊れていても見逃さないためです。
簡易パーサーなので、読めるのは files: の下に url / sha512 / size が並ぶ標準的な形だけです。実際の latest.yml で空振りしたら、YAMLライブラリに置き換えます。
手元での実行結果
Node.js 20で、64KBの乱数ファイルを成果物に見立てたダミーのフィードを作り、ローカルのHTTPサーバーで配信して流しました。electron-builderが出力した本物の latest.yml ではなく、同じ項目を持つ手書きのものです。
node scripts/verify-feed.mjs http://127.0.0.1:8123
# OK version=1.1.0 files=1 staging=10
# 成果物の1バイトを書き換えたあと
node scripts/verify-feed.mjs http://127.0.0.1:8123
# TestApp Setup 1.1.0.exe: sha512 不一致 (終了コード 2)
# latest.yml を stagingPercentage: 150 に書き換えたあと
node scripts/verify-feed.mjs http://127.0.0.1:8123
# stagingPercentage が範囲外: 150
# TestApp Setup 1.1.0.exe: sha512 不一致 (終了コード 2)このスクリプトが保証するのは、フィードの整合だけです。署名の検証、インストーラーの起動、OSごとの入れ替えは確認できません。そこは、パッケージ化した実アプリで試す領域です。
Stop hookで検証を毎回通す
検証コマンドをCLAUDE.mdに書いても、Claudeが飛ばす場合があります。確実に通すには、Stop hookにします。Stopは「Claudeが応答を終えるとき」に発火し、終了コード2を返すとClaudeは止まらず作業を続けます。公式の一覧でも、Stopの終了コード2は「Claudeが止まるのを防ぎ、会話を続けさせる」と説明されています。
#!/bin/bash
# .claude/hooks/verify-update-feed.sh
INPUT=$(cat)
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
exit 0
fi
cd "$CLAUDE_PROJECT_DIR" || exit 0
[ -f feed/latest.yml ] || exit 0 # フィードが無い間は何もしない
python3 -m http.server 8123 --directory feed --bind 127.0.0.1 >/dev/null 2>&1 &
SERVER=$!
trap 'kill $SERVER 2>/dev/null' EXIT
sleep 1
if ! node scripts/verify-feed.mjs http://127.0.0.1:8123 >&2; then
echo "更新フィードの検証に失敗しました。latest.yml と成果物を直してください。" >&2
exit 2
fi冒頭の stop_hook_active の確認は省けません。Claude Codeは、Stop hookが続けてブロックを8回繰り返し、その間にClaudeのツール呼び出しがなければ、フックを上書きして止めます。確認なしで書くと、直せない不一致のたびに8回空回りします。8回で足りない場合の上限は、環境変数 CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で上げられます。
設定は .claude/settings.json に書きます。Stopにはmatcherが使えないので、hooks だけを書きます。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-update-feed.sh"
}
]
}
]
}
}このフックを前述のダミーのフィードで流すと、整合しているときは終了コード0、成果物を壊すと終了コード2とメッセージが出ました。stop_hook_active が true の入力では、フィードが壊れていても0で抜けます。
フックが失敗したときの扱いを詳しく決めたい場合は、Claude Code hooksのonFailureで失敗時に操作を止める設定が参考になります。
段階配信とチャンネルを検証に載せる
stagingPercentageは手で編集する
段階配信は、latest.yml に stagingPercentage: 10 を書くと有効になります。公式ドキュメントによると、更新はユーザーの10%に配られます。配信を巻き戻したいときの注意が、ひとつあります。壊れた版を取り下げるには、壊れた版より大きいバージョン番号を出さなければなりません。同じ番号で出し直しても、すでに壊れた版を入れた人はそのままだからです。
割合を変える作業は、成果物の sha512 と size には影響しません。Claudeには「stagingPercentage を変えるときは、成果物に触らない」と指示しておきます。
チャンネル: betaはlatestも受け取る
package.json の version に -beta を付けると、betaチャンネルの成果物になります。generateUpdatesFilesForAllChannels を true にしておくと、各チャンネルの更新ファイルが生成されます。
クライアント側は autoUpdater.channel = "beta" で受け取る系統を選びます。betaを選んだユーザーはbetaとlatestの両方を、alphaを選んだユーザーはalpha・beta・latestを受け取ります。公式ドキュメントには、GitHub Releasesを配信先にした場合は version のタグが効かず、channel を明示する必要がある、という注意もあります。
署名付きマニフェスト: v27で変わること
v27の説明では、latest.yml 自体にEd25519の署名が付きます。鍵ペアは次のコマンドで作ります。
npx electron-builder create-update-key秘密鍵は、CIシークレットにbase64で入れて ELECTRON_BUILDER_UPDATE_SIGN_KEY で渡す形が基本です。公開鍵はアプリの app-update.yml に自動で埋め込まれます。公開鍵を持つ版のアプリは、署名のないマニフェストを取り込みません。ERR_UPDATER_MANIFEST_NOT_SIGNED(署名なし)か ERR_UPDATER_MANIFEST_SIGNATURE_INVALID(改ざん・信頼しない鍵)で、ダウンロード前に止まります。
Claude Codeに作業を頼むときは、次の点を前提にしてください。
- 秘密鍵は、リポジトリにもClaudeの読める範囲にも置かない。
.gitignoreと権限設定で守る - 署名の対象には
stagingPercentageが入っている。したがって署名後にlatest.ymlの段階配信の割合を手で書き換えると、署名の検証に通らなくなります - 署名後に変えても検証に影響しないのは、
releaseName、releaseNotes、releaseDateの3つ
署名後に割合だけを変える手順は、ドキュメントに記載がありません。段階配信を使うなら、割合を変えるたびに再署名か再公開が要るものとして、運用を組む必要があります。
署名の検証は、先ほどのスクリプトでは再現しません。electron-builderには electron-builder verify コマンドがあり、各マニフェストを、パッケージ済みアプリが埋め込む信頼鍵と照合します。リリース前の検証には、こちらを使います。
更新のセキュリティ設定: v27で変わる2つの既定
署名付きマニフェストとは別に、v27ではAppUpdaterの設定が2つ、変更または追加されています。
disableWebInstaller: 既定がtrueになりました。NSISのWebインストーラーは、インストール時にマニフェストが示すURLから本体を取得します。この取得物は署名の検証を通らない場合があるためです。trueのままだと、Webインストーラーの更新はERR_UPDATER_WEB_INSTALLER_DISABLEDで拒否されます。nsis-webを意図して配布するときだけ、アプリの準備が整う前にupdater.disableWebInstaller = falseを設定します。v27で作ったnsis-webのインストーラーは目印のファイルを持ち、NsisUpdaterが自動でfalseにします。なお、nsis-webのアプリではupdateManifest: falseにしないよう書かれています。マニフェストがWebパッケージのパス・SHA-512・サイズを保証しているからですallowUnverifiedLinuxPackages: v27で追加された設定で、既定はtrueです。.debと.rpmの自動更新を入れるとき、GPG署名を強制するかどうかを決めます。electron-builder自身はLinuxパッケージに署名しないため、既定は緩い側です。falseにすると、パッケージマネージャーが対応している範囲で署名の検査が強制されます
どちらも、フィードの整合を見る verify-feed.mjs では確かめられません。更新が拒否されるのはクライアント側の判断だからです。CLAUDE.mdには「Webインストーラーを使うか」「Linuxパッケージの署名を強制するか」を書いておき、Claudeが黙って disableWebInstaller = false を足すことを防ぎます。
旧クライアントへの互換: electronUpdaterCompatibility
v27の latest*.yml は、新しい files[] 形式だけで出力されます。electron-updater 1.x〜2.15.0だけが必要とする、先頭階層の path と sha512 は含まれません。古いクライアントがまだ現場に残っているなら、electronUpdaterCompatibility で互換の範囲を宣言します。既定は >=2.16 です。OSごとに指定でき、たとえば win.electronUpdaterCompatibility と書けます。
範囲が2.16.0未満にかかると、先頭階層の path・sha512 と、Windows向けのSHA-256のチェックサム sha2 も出力されます。2.0.0未満にかかるときは、古い latest-mac.json も作られます。ただし、sha2 だけで検証するメタデータは非推奨です。v27は警告を出し、v28は受け付けません。1.x〜2.15のクライアントを実際に配っていないのに、互換の範囲を下げる理由はありません。
この記事の検証スクリプトは files[] だけを見ます。互換の範囲を下げたフィードでは、先頭階層の値も出るので、スクリプトを広げるかどうかは配るクライアントの版で決めます。
更新を入れるタイミングと、独自の検証フック
終了時のインストールが壊れる場合に備える
既定では、ダウンロード済みの更新はアプリの終了時にインストールされます。設定項目は autoUpdater.autoInstallEvent で、値は "manual"、"onQuit"、"onNextLaunch" のどれかです。既定は "onQuit" です。
WindowsでOSのシャットダウンや再起動と重なると、インストーラーが途中で強制終了され、アプリが中途半端にアンインストールされた状態になるおそれがあります。electron-updater 7.0以降(electron-builder v27)では、セッション終了を検知して終了時のインストールを見送る保護が常に働きます。さらに "onNextLaunch" を選ぶと、更新を保留しておき、次の起動時にインストールします。ドキュメントでは、v28で "onNextLaunch" が既定になる予定だと書かれています。
保留したインストールを自動で実行できないターゲットもあります。deb・rpm・pacmanはpkexecやsudoで昇格するため、またマシン単位のNSISはUACが出るため、起動時の自動インストールは見送られます。この場合は installPendingUpdateIfAvailable() を、アプリが好きなタイミングで呼びます。Claudeには、対象ターゲットを先に伝えてから実装を頼みます。
ダウンロード後の検証を自前で足す
すべてのアップデーターが持つ verifyUpdateFile に関数を代入すると、インストール可能になる前に独自の検証を差し込めます。失敗を返すと更新は中止され、ファイルは削除され、ERR_UPDATER_INVALID_UPDATE_FILE が発火します。失敗には、理由を示す message が必須です。
import { NsisUpdater } from "electron-updater"
const updater = new NsisUpdater()
updater.verifyUpdateFile = async ({ updateFilePath, originalUpdateFileName }) => {
try {
checkSignature(updateFilePath, getCorrespondingSignatureFile(originalUpdateFileName))
return { response: "success" }
} catch (err) {
return { response: "failure", message: `${err}` }
}
}checkSignature は例のための仮の関数で、検証の中身は自分で書きます。このフックは、ダウンロード直後だけでなく、前回の起動で保存したキャッシュの再利用時や、次回起動時のインストール直前にも呼ばれます。検証を通っていないファイルが、本来の名前のままインストーラーに渡ることはありません。
認証ヘッダーと配信元のオリジン
非公開のフィードに Authorization ヘッダーを付ける場合、ヘッダーとフィードURLのクエリ文字列が送られるのは、フィードと同じオリジン(スキーム・ホスト・ポート)に対してだけです。latest.yml の files[].url が別のオリジンを指していると、そのダウンロードにはヘッダーが付きません。ドキュメントは、更新ファイルをフィードと同じオリジンから相対パスで配るか、署名付きURLを使うよう案内しています。ローカルの検証でヘッダーなしのまま通っても、本番で認証付きフィードに載せ替えると挙動が変わります。
よくあるつまずき
| 症状 | 見る場所 |
|---|---|
| macOSで更新が走らない | 見る場所アプリにコード署名が要ります。署名なしでは自動更新は動かないと公式に書かれています |
| Windowsで更新が走らない | 見る場所対象はNSISだけです。Squirrel.Windowsは対象外です |
APPIMAGE env is not defined | 見る場所AppImageとして起動していません。インストール済みのアプリで試すのが推奨です |
| 更新後に署名エラー | 見る場所公開鍵を埋め込んだ版を出した後に、署名なしのマニフェストを公開していないか |
| 割合を戻しても配信が変わらない | 見る場所取り下げは、より大きいバージョン番号の公開で行います |
自動更新の対象は、macOSがDMG、LinuxがAppImage・DEB・Pacman・RPM、WindowsがNSISです。それ以外のターゲットで配信を計画している場合は、Claudeに実装を頼む前に、この対応表を見直してください。
なお、Claude Code自体の更新方法やチャンネルの選び方は別の話です。そちらはClaude CodeのautoUpdatesChannelでstableとlatestを選ぶにまとめています。
まとめ
electron-updaterの検証で一番手間がかかるのは、実機のインストーラーを毎回用意することです。フィードの整合だけなら、ローカルのHTTPサーバーと30行ほどのスクリプトで足ります。それをStop hookにつなぐと、Claudeは latest.yml と成果物がずれた状態では作業を終えられません。
そのうえで、署名と実インストールの確認は残ります。v27の署名付きマニフェストを使う場合は、段階配信の割合も署名の対象だという点を、運用の設計に入れておく必要があります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。