<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">

 <title>Web Scratch</title>
 <link href="https://efcl.info/feed/" rel="self"/>
 <link href="https://efcl.info"/>
 <updated>2026-11-09T09:21:08+09:00</updated>
 <id>https://efcl.info/</id>
 
 <author>
   <name>azu</name>
   
 </author>
 

 
 <entry>
   <title>LDRライクなRSSリーダのIrodrをReact 19 + Vite+で書き直して、iPhone Duoにも対応した</title>
   <link href="https://efcl.info/2026/11/07/irodr-2/"/>
   <updated>2026-11-07T15:00:00+09:00</updated>
   <id>https://efcl.info/2026/11/07/irodr-2</id>
   <content type="html"><![CDATA[ <p><a href="https://www.inoreader.com/">Inoreader</a>のフロントエンドとして動くLDRライクなRSSリーダの<a href="https://irodr.netlify.app/">Irodr</a>を、React 19 + <a href="https://viteplus.dev/">Vite+</a>で書き直しました。</p>
<ul>
<li><a href="https://github.com/azu/irodr/pull/127">Rebuild irodr as a React 19 SPA on Vite+ by azu · Pull Request #127 · azu/irodr</a></li>
</ul>
<p>Irodrは2017年にLDRが終了するときに作ったもので、そこから9年ぐらい毎日使っています。
今回の書き直しでも見た目とキー操作（<code>j</code>/<code>k</code>/<code>s</code>/<code>a</code>など）は変えていません。
中身は全部書き直していて、InoreaderとGitHub Notificationsを同じ「ソース」として扱う作りになっています。
JSのサイズはgzip後で397kBから150kBになりました。</p>
<p>Irodrについては次の記事で書いています。</p>
<ul>
<li><a href="https://efcl.info/2017/08/31/ldr-to-irodr/">LDRがサービス終了のため、Inoreader/Feedlyをバックエンドに動くRSSリーダを書いている | Web Scratch</a></li>
<li><a href="https://efcl.info/2018/09/30/irodr-1.0.0/">LDRライクなRSSリーダのIrodr 1.0.0をリリースした | Web Scratch</a></li>
</ul>
<h2>きっかけ</h2>
<p>きっかけは、<a href="https://www.inoreader.com/">Inoreader</a>のProプランのフィード数の上限です。
Proプランは<a href="https://www.inoreader.com/pricing">RSSフィードの購読数が2500まで</a>に制限されていて、自分の購読数がこれを超えそうになっていました。</p>
<p>GitHubでWatchしているリポジトリのリリースは、<a href="https://github.com/azu/watch-rss">watch-rss</a>を使ってRSSフィードとしてInoreaderに購読しています。
（<a href="https://efcl.info/2024/02/08/javascript-info/">私のJavaScriptの情報収集法 2024年版</a>でも書いています）
この仕組みで購読しているGitHubリポジトリのフィードが2000以上あって、購読数の大半はこれでした。</p>
<p>GitHubリポジトリのリリースは、GitHubのWatch（Releasesのみ）でも受け取れます。
これをGitHub Notificationsに逃がせば、Inoreaderのフィード数を2000ほど減らせます。
ただ、GitHub Notificationsの画面はRSSリーダのようには読めないので、IrodrでGitHub Notificationsをリポジトリ単位のフィードとして読めるようにしました。</p>
<ul>
<li><a href="https://github.com/azu/irodr/pull/123">feat: support GitHub Notifications as repository feeds by azu · Pull Request #123 · azu/irodr</a></li>
</ul>
<p>これで、InoreaderのフィードとGitHub Notificationsを同じ画面、同じキー操作で読めるようになりました。
そのため、GitHubのフィードはInoreaderから消せました。</p>
<p>ただ、このGitHub Notifications対応は、Inoreader向けだったIrodrに後から足したものだったので、結構無理やりな作りになっていました。
Irodr 1.xは、状態管理に<a href="https://almin.js.org/">Almin</a>、UIにOffice UI Fabricを使っています（<a href="https://efcl.info/2018/09/30/irodr-1.0.0/">1.0.0の記事</a>参照）。
ビルドは、1.0の頃はcreate-react-app-typescriptで、その後react-scripts 5に移行していました。
<a href="https://create-react-app.dev/">Create React App</a>はすでに非推奨になっています。
Office UI Fabric（今の<a href="https://developer.microsoft.com/en-us/fluentui">Fluent UI</a>）も、使っていた7系は古いままでした。</p>
<p>そのため、InoreaderとGitHubを同じ扱いにするのも兼ねて、全部書き直すことにしました。</p>
<h2>書き直しの方針</h2>
<p>書き直しは<a href="https://claude.com/claude-code">Claude Code</a>でやりました。
最初に、音声入力で次のような方針を伝えています。</p>
<ul>
<li>技術スタックを最新にして、依存はできるだけ減らす</li>
<li>React Hooksに移行して、クライアントサイドのSPAのままにする</li>
<li>LDRのように、キーを押したらすぐ反応する作りにする</li>
<li>GitHub NotificationsもInoreaderと同じ扱いにして、ソースを増やしてもUI側は何もしなくていい作りにする</li>
<li>Vite+を使って、LintやフォーマッターはできるだけStrictにして、AIが書けるようにする</li>
<li>外部サービスのAPIを再現したFake Serverを用意して、それに対してインテグレーションテストを書く</li>
<li>UIフレームワークは使わず、StyleXでスタイルを書く</li>
</ul>
<p>インテグレーションテストはPlaywrightかVitestのBrowser Modeか迷っていましたが、OAuthのリダイレクトでページ遷移が必要だったのでPlaywrightになりました。</p>
<p>最初の実装は1時間ぐらいで出てきて、そこから半日ぐらいレビューと修正を繰り返して、PRをマージしています。
この辺の試行錯誤は後述します。</p>
<h2>技術スタック</h2>
<p>1.xと2.0の主な違いは次のとおりです。</p>
<table>
<thead>
<tr>
<th></th>
<th>1.x</th>
<th>2.0</th>
</tr>
</thead>
<tbody>
<tr>
<td>ビルド・開発</td>
<td>Create React App (react-scripts 5)</td>
<td><a href="https://viteplus.dev/">Vite+</a> (<code>vp dev</code> / <code>vp build</code>)</td>
</tr>
<tr>
<td>UI</td>
<td>React 18 + Almin + Office UI Fabric 7</td>
<td>React 19（関数コンポーネントとhooks）+ <a href="https://react.dev/learn/react-compiler">React Compiler</a></td>
</tr>
<tr>
<td>スタイル</td>
<td>CSS + Office UI Fabric</td>
<td><a href="https://stylexjs.com/">StyleX</a>。UIフレームワークなし</td>
</tr>
<tr>
<td>Lint・整形</td>
<td>整形のみ（Prettier + lint-staged）</td>
<td>Oxlint + Oxfmt + 型チェック（<code>vp check</code>）</td>
</tr>
<tr>
<td>テスト</td>
<td>Jest</td>
<td>Vitest + Playwright</td>
</tr>
<tr>
<td>ブラウザで動く依存の数</td>
<td>48</td>
<td>4 (<code>react</code>, <code>react-dom</code>, <code>@stylexjs/stylex</code>, <code>markdown-it</code>)</td>
</tr>
</tbody>
</table>
<h3>Vite+</h3>
<p><a href="https://viteplus.dev/">Vite+</a>は、Vite、Rolldown、Vitest、tsdown、Oxlint、Oxfmtなどを<code>vp</code>という1つのCLIにまとめたツールチェーンです。
Irodrでは開発サーバー、ビルド、テスト、Lint、フォーマット、pre-commitフックまでを<code>vp</code>だけでやっています。</p>
<ul>
<li><code>vp check</code>: 整形・Lint・型チェック</li>
<li><code>vp test</code>: 単体テスト（Vitest）</li>
<li><code>vp build</code>: 本番ビルド</li>
</ul>
<p>設定ファイルをツールごとに持たなくていいのと、Lintとフォーマットが速いので、ローカルでもCIでも待ち時間がほとんどありません。
本番ビルドの時間は1.xの27秒から0.5秒ぐらいになりました。</p>
<p>パッケージのバージョンは、pnpm workspaceの<code>catalog:</code>に全部固定で書いています。
<code>catalogMode: strict</code>にしてるので、catalogにないバージョンは入りません。</p>
<h3>React 19 + React Compiler（Oxc版）</h3>
<p>UIはReact 19の関数コンポーネントとhooksだけで書いて、メモ化は<a href="https://react.dev/learn/react-compiler">React Compiler</a>に任せています。</p>
<p>最初はBabel版のReact Compilerを使っていました。
ただ、OxlintのルールにReact Compiler向けのものが入っていたので、OxcがReact Compilerを再実装しているのではないかと思って聞いてみました。
すると、<a href="https://oxc.rs/docs/guide/usage/transformer/react-compiler.html">OxcによるRust移植版</a>（<code>oxc-transform-react</code>）を教えてもらいました。
<code>@vitejs/plugin-react</code>の<code>react({ compiler: true })</code>で有効にできます。
Babel版からOxc版に切り替えたら、ビルドの出力はモジュールの順番以外は同じで、ビルド時間は1.7秒から0.7秒になりました。
Babel関係の依存も消せています。
Oxcのドキュメントではまだexperimentalという扱いです。</p>
<p>React Compilerで気をつけないといけないのは、コンパイラが扱えないコンポーネントは黙ってスキップされることです。
レンダリング中の変更やrefの読み取りなどがあると、そのコンポーネントだけメモ化されません。
Irodrでは、Oxlintに入っているReact Compilerのルール（<code>react/purity</code>、<code>react/refs</code>など）を有効にしています。
コンパイラの扱えないコードがあると<code>vp check</code>は失敗します。</p>
<h3>StyleX</h3>
<p>スタイルは<a href="https://stylexjs.com/">StyleX</a>で書いています。
<code>stylex.create</code>でスタイルを定義してコンポーネントに渡すだけの使い方で、UIフレームワークは使っていません。
1.xではOffice UI Fabricの<code>List</code>コンポーネントなどを使っていましたが、2.0ではリストやフォーカス管理は自前です。
LDR風のUIはそもそも装飾がほとんどないので、UIフレームワークがなくても特に困りませんでした。</p>
<p>ユーザースクリプトから参照される<code>.SubscriptionContentsContainer-*</code>のようなクラス名は、StyleXが生成するクラス名とは別に残しています。</p>
<h2>Sourceインターフェース</h2>
<p>2.0で一番大きな変更は、InoreaderとGitHub Notificationsをどちらも同じ<code>Source</code>インターフェースを実装する「ソース」にしたことです。</p>
<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="text">src/
  sources/   ソース。source.ts が共通のインターフェース (Source, Feed, Item, capabilities, settings)
    inoreader/  OAuth 2.0、購読一覧、記事の読み込み、mark-all-as-read
    github/     Notifications API、既読にする処理、IndexedDB キャッシュ
  app/       Reader（React に依存しない）
    reader-model.ts  immutable な状態と、その遷移の関数
    reader-view.ts   画面に渡す形への変換
    reader.ts        createReader: 通信・先読み・既読・自動更新などの副作用
  ui/        React コンポーネント、キーボードショートカット、User Script API
  lib/       小さな共通処理（store、IndexedDB、サニタイザー、キーバインド）
  main.tsx   ソース・Reader・UI をつなぐ唯一の場所
</code></pre></div></div>
<p>依存の向きは<code>ui → app → sources → lib</code>の一方向だけです。
UIとReader（状態管理の中心）は、どのソースのフィードかを知らずに動きます。
<code>src/main.tsx</code>だけがどのソースがあるかを知っていて、新しいソースを足すときも<code>src/app</code>と<code>src/ui</code>を変える必要はありません。</p>
<p>ソースごとの違いは、各ソースの中に閉じています。
たとえば、Inoreaderはフィードごとに記事を取得しますが、GitHubは未読の通知を全部IndexedDBへキャッシュします。
既読にするAPIもソースごとに違います。
画面側には、<code>capabilities</code>（<code>loadMore</code> / <code>unreadFilter</code> / <code>liveItems</code>）と、データで書いた設定フォーム（<code>SourceSettings</code>）だけを見せています。</p>
<p>この辺の詳細は<a href="https://github.com/azu/irodr/blob/master/docs/architecture.md">docs/architecture.md</a>に書いています。</p>
<h2>immutableな書き方をLintで強制する</h2>
<p>最初に出てきた実装は、Readerなどがクラスで書かれていました。
自分はimmutableな書き方の方が好みなので、まず<code>let</code>、引数への再代入、<code>Object.assign</code>を禁止するLintを入れてもらいました。
Oxlintには<code>no-restricted-syntax</code>がないので、<code>let</code>を禁止するルールはOxlintのJavaScriptプラグインとして書いています。
このプラグインは<code>lib/oxlint-plugin-immutable</code>というpnpm workspaceのパッケージにしています。
<code>immutable/no-let</code>に加えて、<code>Error</code>を継承する場合以外のクラスを禁止する<code>immutable/no-class</code>も入っています。</p>
<ul>
<li><a href="https://oxc.rs/docs/guide/usage/linter/js-plugins.html">Oxlint JS Plugins</a></li>
</ul>
<p>その後、Reader自体もクラスではなくfunctionalにして、他のコードも同じにしてもらいました。
今のルールは次のような感じです。</p>
<ul>
<li>クラスは使わない。<code>extends Error</code>だけは<code>instanceof</code>のために残す</li>
<li>何か処理を持つモジュールは<code>createX(options)</code>という関数で作る。返すのはアロー関数を並べたオブジェクトなので<code>this</code>はない</li>
<li>状態はstoreに置いて、変えるときは新しい値を作って丸ごと差し替える</li>
<li>状態の遷移は<code>(state, input) =&gt; state</code>の形の関数にする。ソースやタイマーなしで単体テストできる</li>
<li>通信やタイマーなどの副作用は、<code>createX</code>のクロージャの中だけで扱う</li>
</ul>
<p>クラスからfunctionalへの書き換えでは、古いReaderと新しいReaderに同じ操作を流して結果を比べる形でチェックしています。
書き換えのときは、記録した30個のシナリオで差分が0であることを確認しました。
マージ前の最終レビューでは、さらにランダムに生成した8800回ぐらいの操作で比べて、差分が0であることを確認しています。</p>
<p>1.xではDDD的なレイヤードアーキテクチャで、ドメインモデルをクラスで書いていました。
2.0では状態の遷移がただの関数になっているので、Readerの処理（フィード移動、先読み、既読にする処理）のテストは、ソースやタイマーなしで書けます。
書き方のルールがLintでチェックされるので、AIに書かせたコードでも、レビューで書き方の指摘をする必要がなくなります。</p>
<h3>対応ブラウザはBaseline Widely available</h3>
<p>対応ブラウザについては、ちょっと行ったり来たりしました。
レビューで<code>Map.groupBy</code>や<code>toSorted</code>などES2024の機能が古いSafariで動かないと指摘して、一度ES2022で書き直してもらいました。
ただ、考え直すとモダンブラウザだけで良かったので、最終的には<a href="https://developer.mozilla.org/en-US/docs/Glossary/Baseline/Compatibility">Baseline Widely available</a>（主要ブラウザ全部で2.5年以上使える機能）を基準にしました。
<code>Map.groupBy</code>や<code>toSorted</code>はWidely availableなので、そのまま使っています。</p>
<p>ビルドはViteのデフォルトの<code>baseline-widely-available</code>を対象にしています。
ただし、足りない組み込み関数を補うpolyfillは入れていないので、<a href="https://github.com/3ru/eslint-plugin-baseline-js">eslint-plugin-baseline-js</a>で組み込み関数とWeb APIをチェックしています。
例外は後述する<a href="https://developer.chrome.com/docs/ai/translator-api">Translator API</a>だけで、あるときだけ使う機能なので許可しています。</p>
<h2>Fake Serverに対するテスト</h2>
<p>テストは、Inoreader / GitHub APIのFake Server（<code>e2e/fake-api/</code>）に対して動かしています。
単体テスト（Vitest）とインテグレーションテスト（Playwright）のどちらも同じFake Serverを使います。
Fake Serverは実際のAPIの動作に合わせて作っていて、リクエストを記録して検証もできます。
OAuth、購読一覧、続きの読み込み、<code>mark-all-as-read</code>、GitHubの<code>Link</code>ヘッダーでのページ送りや既読にしたときのレスポンスなどを実装しています。</p>
<p>インテグレーションテストは本番ビルド（<code>vp build --mode e2e</code>）を<code>vp preview</code>で配信して、Playwrightでキー操作をして確認しています。
フィード移動、既読にする処理、先読み、エラー時の動作、ユーザースクリプトAPIなどが対象です。
本物のAPIに依存しないので、CIで安定して動きます。</p>
<p>CIのインテグレーションテストは、<a href="https://tech.newmo.me/entry/playwright-ci-optimization">PlaywrightのDockerイメージ</a>を使う形も試しました。
Irodrぐらいの規模だとイメージの取得の方が<code>playwright install</code>より遅くて、ジョブは62秒から85秒ぐらいになりました。
それでも、ブラウザとシステムの依存がイメージに入っていて、毎回<code>playwright install --with-deps</code>しなくて済むので、今もDockerイメージを使っています。</p>
<h2>1.xから引き継いだもの</h2>
<p>書き直しではありますが、次のものは1.xと互換性があります。</p>
<ul>
<li>Inoreaderのログイン状態と、GitHubのトークンとキャッシュ（IndexedDBの形式は1.xと同じ）</li>
<li>User Script API（<code>window.userScript</code>、<code>userscript-init</code>イベント、<code>SubscriptionContent::*</code>イベント）</li>
<li>ユーザースクリプトが使うクラス名（<code>.SubscriptionContentsContainer-*</code>、<code>.SubscriptionListContainer-item</code>、<code>.ng-content</code>）</li>
<li>1.xで保存した設定（初回起動時に引き継ぐ）</li>
<li>Netlify Edge FunctionのCORSプロキシ</li>
</ul>
<p>そのため、既存のユーザースクリプトはそのまま動くはずです。</p>
<h2>使ってみて見つけた問題</h2>
<p>実装が出てきたあとは、実際に使ってみて気になったところをスクショや画面収録と一緒に伝えて直してもらう、というのを繰り返しました。</p>
<ul>
<li><code>s</code>で移動したときに、今いるフィードがカテゴリの見出しの下に隠れる
<ul>
<li>stickyな見出しの高さの分だけ<code>scroll-padding-top</code>を入れて直した</li>
</ul>
</li>
<li>同じフィードが2つ表示されて、<code>s</code>が効かなくなる
<ul>
<li>Inoreaderが同じ購読を2回返すことがあって、フィードIDが重複していた。1.xはMapで持っていたので気づかなかった</li>
</ul>
</li>
<li>blockquoteの見た目が1.xと違う
<ul>
<li>新しいCSSに独自のスタイルが入っていた。1.xと計算後のスタイルを比べて、ブラウザのデフォルトに戻した</li>
</ul>
</li>
<li>InoreaderのClient IDとSecretの欄が、何も入れてないと未入力に見える
<ul>
<li>「Use your own Inoreader app」として折りたたんで、値があるときだけ開くようにした</li>
</ul>
</li>
</ul>
<p>この辺は、自動テストでは見つけにくいので、実際に使ってみるのが一番早かったです。</p>
<h2>パフォーマンス</h2>
<p>1.xと2.0のビルドを、Fake ServerのInoreader APIに40フィード × 20記事を入れた同じ条件で、Playwright（Chromium）から比べました。
CIのクラウド環境での相対的な比較なので、手元のマシンの数値ではありません。
詳しい条件は<a href="https://github.com/azu/irodr/pull/127">PR #127</a>に書いています。</p>
<table>
<thead>
<tr>
<th>項目</th>
<th>1.x → 2.0（CPU等速）</th>
<th>1.x → 2.0（CPU 4倍遅い）</th>
</tr>
</thead>
<tbody>
<tr>
<td>フィード一覧が出るまで</td>
<td>376 → 228 ms</td>
<td>1,188 → 570 ms</td>
</tr>
<tr>
<td>FCP（最初の描画）</td>
<td>268 → 116 ms</td>
<td>1,016 → 384 ms</td>
</tr>
<tr>
<td><code>s</code>で次のフィードの記事が出るまで（先読み済み）</td>
<td>66 → 23 ms</td>
<td>215 → 111 ms</td>
</tr>
<tr>
<td>30フィード読んだあとのJSメモリ</td>
<td>9.7 → 5.3 MB</td>
<td>9.8 → 5.2 MB</td>
</tr>
</tbody>
</table>
<table>
<thead>
<tr>
<th></th>
<th>1.x</th>
<th>2.0</th>
</tr>
</thead>
<tbody>
<tr>
<td>JS（gzip後）</td>
<td>397 kB</td>
<td>150 kB</td>
</tr>
<tr>
<td>CSS（gzip後）</td>
<td>30.9 kB</td>
<td>4.2 kB</td>
</tr>
<tr>
<td>本番ビルドの時間</td>
<td>27秒</td>
<td>0.5秒</td>
</tr>
</tbody>
</table>
<p>この表のサイズとビルド時間は、2026年11月7日時点のmasterで測り直した数値です。
書き直した直後はJSが136kBでしたが、後述する「あとで読む」やタッチ画面への対応を足したので少し増えています。</p>
<p><code>j</code>で次の記事へスクロールする時間は元々10msぐらいで、どちらも同じぐらいです。
LDRライクなリーダとして一番大事なのは、<code>s</code>を押したら待たずに次のフィードへ切り替わることです。
これは先読みの仕組みで1.xの頃からやっていました。
2.0では、<code>s</code>を連打したときに読み込み中のフィードからさらに先へ進めるようになっています。
読み込み中のフィードは点線の枠で表示されます。</p>
<h2>書き直した後の変更</h2>
<p>書き直しをマージした後も、使っていて気になったところを直しています。</p>
<ul>
<li><a href="https://github.com/azu/irodr/pull/128">feat(reader): keep read items viewable while their feed stays listed · Pull Request #128</a>
<ul>
<li>GitHub Notificationsは未読の通知しか返さないので、既読にしたリポジトリへ戻ると空になっていた</li>
<li>Readerが既読にした記事を覚えておいて、フィードが一覧に残っている間は表示するようにした</li>
</ul>
</li>
<li><a href="https://github.com/azu/irodr/pull/129">fix(ui): make the feed list as light as irodr 1.x · Pull Request #129</a>
<ul>
<li>フィード一覧の見た目を1.xと同じ軽さにした</li>
</ul>
</li>
<li><a href="https://github.com/azu/irodr/pull/130">fix(github): mark each notification read instead of the whole repository · Pull Request #130</a>
<ul>
<li>GitHubの既読にする処理をリポジトリ単位の<code>PUT</code>からスレッド単位の<code>PATCH /notifications/threads/{id}</code>に変更した</li>
<li>リポジトリ単位だと未読のまま残るケースがあった</li>
</ul>
</li>
<li><a href="https://github.com/azu/irodr/pull/131">Add translation mode with smart batching and link preservation · Pull Request #131</a>
<ul>
<li><code>Shift+T</code>で記事を英語から日本語へ翻訳するモード</li>
<li>見えている段落を優先して翻訳し、リンクは壊さずにラベルだけ翻訳する</li>
</ul>
</li>
<li><a href="https://github.com/azu/irodr/pull/137">feat: あとで読む（Inoreader の Saved web pages に保存）を追加 · Pull Request #137</a>
<ul>
<li><code>p</code>で今の記事を「Read later」（Inoreader）へ保存できるようにした。もう一度押すと外れる（<a href="https://github.com/azu/irodr/pull/139">#139</a>、<a href="https://github.com/azu/irodr/pull/140">#140</a>）</li>
<li>GitHub Notificationsの記事も、Inoreaderの「Read later」に保存できる</li>
<li>「Read later」も1つのフィードとして一覧に出る</li>
</ul>
</li>
<li><a href="https://github.com/azu/irodr/pull/138">feat(ui): support iPhone Duo and touch screens (sidebar drawer, tap controls for j/k/s) · Pull Request #138</a>
<ul>
<li>タッチ画面では、記事の右下に<code>p</code>/<code>k</code>/<code>j</code>/<code>s</code>と同じ動きをするボタンを出すようにした</li>
<li>幅が600pxより狭い画面では、フィード一覧を記事の上に引き出す形で表示する</li>
<li>iPhone Duoの折りたたみ表示への対応は次の節で書きます（<a href="https://github.com/azu/irodr/pull/146">#146</a>）</li>
<li>iOSのSafariやホーム画面に追加したWebアプリで、ヘッダーが上のバーに隠れる問題などを直した（<a href="https://github.com/azu/irodr/pull/142">#142</a>、<a href="https://github.com/azu/irodr/pull/143">#143</a>、<a href="https://github.com/azu/irodr/pull/145">#145</a>、<a href="https://github.com/azu/irodr/pull/147">#147</a>）</li>
</ul>
</li>
</ul>
<p>「あとで読む」は、<code>Source</code>インターフェースに<code>saveForLater</code>と<code>removeFromLater</code>を任意のメソッドとして足しただけで、Readerは対応しているソースに保存を頼むだけです。
そのため、GitHub Notificationsの記事をInoreaderに保存するような、ソースをまたぐ操作もUI側の分岐なしで書けました。</p>
<h2>iPhone Duoの折りたたみ表示</h2>
<p>iPhone DuoでもIrodrを読めるように、折りたたみ表示に対応しました。</p>
<ul>
<li><a href="https://github.com/azu/irodr/pull/146">feat(ui): use the iPhone Duo fold for a thin sidebar and bottom tap controls · Pull Request #146</a></li>
</ul>
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">irodrでiPhone Duoの縦の折りたたみ表示に対応した
Viewport Segments APIを使ってます。 https://t.co/ZFPPSzDUXP</p>&mdash; azu (@azu_re) <a href="https://x.com/azu_re/status/2114277593105277405?ref_src=twsrc%5Etfw">October 25, 2026</a></blockquote>
<script async src="https://platform.x.com/widgets.js" charset="utf-8"></script>
<p>画面がどう分かれているかは、<a href="https://developer.mozilla.org/en-US/docs/Web/API/Viewport_segments_API">Viewport Segments API</a>で取得しています。
Safari 27.1では、このAPIはiPhone Duo向けの開発者プレビューという扱いです。
Safariの設定にある機能フラグで「Device Posture API」と「Viewport Segments API」を有効にすると使えます。</p>
<ul>
<li><a href="https://webkit.org/blog/18486/webkit-features-for-safari-27-1/">WebKit Features for Safari 27.1 | WebKit</a></li>
</ul>
<p>APIがないブラウザでは、今までどおりのレイアウトになります。</p>
<p>ヒンジの角度を取るAPIはありません。
ただ、折りたたみの状態かどうかや、画面が左右と上下のどちらに分かれているかは判定できます。
Irodrでは、この「どちらに分かれているか」を見てレイアウトを変えています。</p>
<p>実装では、JavaScriptの<code>window.viewport.segments</code>は使っていません。
CSSのメディア特性である<code>horizontal-viewport-segments</code>と<code>vertical-viewport-segments</code>だけを使っています。
StyleXの<code>defineConsts</code>でメディアクエリに名前を付けて、各コンポーネントのスタイルから参照しています。</p>
<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="ts"><span class="k">export</span> <span class="kd">const</span> <span class="nx">media</span> <span class="o">=</span> <span class="nx">stylex</span><span class="p">.</span><span class="nf">defineConsts</span><span class="p">({</span>
    <span class="c1">// 本のように左右に開いたとき: フィード一覧を細くする</span>
    <span class="na">sideBySide</span><span class="p">:</span> <span class="dl">"</span><span class="s2">@media (horizontal-viewport-segments: 2) and (min-width: 600px)</span><span class="dl">"</span><span class="p">,</span>
    <span class="c1">// ノートPCのように上下に開いたとき: フィード一覧はアイコンだけ、下の画面にボタン</span>
    <span class="na">stacked</span><span class="p">:</span> <span class="dl">"</span><span class="s2">@media (vertical-viewport-segments: 2) and (pointer: coarse) and (min-width: 600px)</span><span class="dl">"</span>
<span class="p">});</span>
</code></pre></div></div>
<p><code>min-width: 600px</code>を付けているのは、600pxより狭いときはフィード一覧を引き出す形で表示していて、そちらを優先するためです。
ボタンの並べ方を変えるところだけは、同じメディアクエリを<code>matchMedia</code>で見ています。</p>
<p>最初は、本のように左右に開いたら、左の画面をフィード一覧、右の画面を記事にしていました。
ただ、半分に分けると記事が狭すぎたので、次のような形に変えています。</p>
<ul>
<li>左右に開いたとき（本のような形）: フィード一覧を160pxの細い表示にする</li>
<li>上下に開いたとき（ノートPCのように置いた形）: フィード一覧はアイコンだけにして、下の画面の下半分に<code>k</code>/<code>j</code>/<code>s</code>などのボタンを置く</li>
</ul>
<p>上下に開いたときのボタンは記事のスクロール領域の中に置いているので、ボタンの上をスワイプしても記事をスクロールできます。</p>
<p>Safari 27.1では、<code>env(viewport-segment-height 0 1)</code>が実際の画面よりかなり大きな値を返して、ボタンが画面を覆ってしまいました。
iPhone Duoは真ん中で折れるので、ボタンの高さはビューポートの高さの4分の1で決めています。</p>
<h2>翻訳の試行錯誤</h2>
<p><code>Shift+T</code>の翻訳はブラウザの<a href="https://developer.chrome.com/docs/ai/translator-api">Translator API</a>を使っていますが、これはChromeとEdgeにしかありません。
FirefoxでもIrodrを使いたいので、どのブラウザでも翻訳できるようにする方法を色々試しました。</p>
<p>最初に試したのは、ローカルサーバーとして動くシングルバイナリ版のIrodrです。
Macなら<a href="https://developer.apple.com/documentation/translation">Apple Translation framework</a>で翻訳ができます。
<a href="https://github.com/hotchpotch/trn">hotchpotch/trn</a>のようにSwiftで小さな翻訳コマンドを書いて、ビルド済みのIrodrと一緒に<a href="https://viteplus.dev/guide/pack"><code>vp pack</code></a>で1ファイルにするという作りです。
<code>vp pack</code>はtsdownの<code>exe</code>オプションを使っていて、中身はNodeのSingle Executable Applicationsです。
ローカルサーバーがあれば<code>Shift+T</code>はそれを使い、なければ今までどおりTranslator APIを使います。</p>
<p>これは実際に動きました。
また、Appleの翻訳はAttributedStringをそのまま翻訳して、リンクや強調の位置も翻訳後の文に合わせてくれます。
そのため、リンクを含む段落もそのまま翻訳できました。
ただ、実行ファイルがNode本体を含むので150MBぐらいになります。
色々試した結果、ローカルサーバーを立ち上げてから使うという形は、ウェブアプリとして使っているIrodrとはちょっと違う感じがして、一旦やめました。
コードは残してあるので、ローカルのLLMと連携するようなことをやるときには、この仕組みを使う可能性があります。</p>
<ul>
<li><a href="https://github.com/azu/irodr/tree/d826b767f8506c35c9e9a8a3fdae44ead4929cf2">azu/irodr at d826b767f8506c35c9e9a8a3fdae44ead4929cf2</a></li>
</ul>
<p>この試行錯誤から本体に取り込んだのは、見えている範囲の段落を優先して翻訳する仕組みです（<a href="https://github.com/azu/irodr/pull/131">PR #131</a>）。</p>
<h2>AIで書き直してみて</h2>
<p>1万4千行ぐらいの追加になる書き直しを自分で一から書くのは大変なので、今回は<a href="https://claude.com/claude-code">Claude Code</a>に書いてもらいました。
既存の動作（キー操作、既読にするタイミング、User Script API）が仕様で、Fake Serverに対するテストで確認しながら進める形です。</p>
<p>やってみて良かったのは、コードの書き方（functional、immutable）をLintで強制したことと、<a href="https://github.com/azu/irodr/blob/master/AGENTS.md">AGENTS.md</a>にコマンドとルールをまとめたことです。
人とAIが同じ手順（<code>vp check</code>と<code>vp test</code>）で確認するので、レビューは動作の話に集中できます。</p>
<p>一方で、実際に使ってみないとわからない問題（見出しに隠れる、スタイルが違う）は結構ありました。</p>
<h2>おわりに</h2>
<p>Inoreaderのフィード数制限をきっかけに、Irodrを作ってから9年経ってようやくコードベースを書き直しました。
使い方は変わっていないので、これまで使っていた人はそのまま<a href="https://irodr.netlify.app/">https://irodr.netlify.app/</a>を開けば使えます。</p>
<ul>
<li><a href="https://github.com/azu/irodr">azu/irodr: RSS reader client like LDR for Inoreader.</a></li>
</ul>
<p>何かおかしなところがあれば、Issueを立ててください。</p>
<ul>
<li><a href="https://github.com/azu/irodr/issues">Issues · azu/irodr</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>Chizu 🧀: リンクをルールに応じてブラウザとプロファイルへ振り分けるmacOSアプリ</title>
   <link href="https://efcl.info/2026/10/28/chizu/"/>
   <updated>2026-10-28T10:00:00+09:00</updated>
   <id>https://efcl.info/2026/10/28/chizu</id>
   <content type="html"><![CDATA[ <p>macOSの既定のブラウザとして登録しておき、開いたリンクを設定ファイルのルールに応じて、指定したブラウザとプロファイルへ振り分けるアプリ <a href="https://github.com/azu/chizu">Chizu 🧀</a> を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/azu/chizu">azu/chizu</a></li>
</ul>
<p>Chizuには、ブラウザを選ぶ画面や設定画面はありません。
振り分けのルールは<code>~/.config/chizu/config.jsonc</code>というJSONCファイルに書き、リンクを開くたびにこのファイルを読み直します。
行き先にはブラウザのプロファイルも指定でき、現時点では<a href="https://www.diabrowser.com/">Dia</a>、Google Chrome、Firefoxのプロファイル指定に対応しています。</p>
<h2>インストール</h2>
<p>動作環境は次のとおりです。</p>
<ul>
<li>Apple SiliconのMac + macOS 14以降</li>
</ul>
<p>次のコマンドでインストールし、ChizuをmacOSの既定のブラウザに設定できます。
いつも通りnotarizationはしてないので、自己判断でご自由にどうぞ。</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="sh">curl <span class="nt">-fL</span> https://github.com/azu/chizu/releases/latest/download/chizu-macos-arm64.zip <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$TMPDIR</span><span class="s2">/chizu-macos-arm64.zip"</span> <span class="o">&amp;&amp;</span>
curl <span class="nt">-fL</span> https://github.com/azu/chizu/releases/latest/download/chizu-macos-arm64.zip.sha256 <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$TMPDIR</span><span class="s2">/chizu-macos-arm64.zip.sha256"</span> <span class="o">&amp;&amp;</span>
<span class="o">(</span><span class="nb">cd</span> <span class="s2">"</span><span class="nv">$TMPDIR</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> shasum <span class="nt">-a</span> 256 <span class="nt">-c</span> chizu-macos-arm64.zip.sha256<span class="o">)</span> <span class="o">&amp;&amp;</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Applications"</span> <span class="o">&amp;&amp;</span>
ditto <span class="nt">-x</span> <span class="nt">-k</span> <span class="s2">"</span><span class="nv">$TMPDIR</span><span class="s2">/chizu-macos-arm64.zip"</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Applications"</span> <span class="o">&amp;&amp;</span>
xattr <span class="nt">-dr</span> com.apple.quarantine <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Applications/Chizu.app"</span> <span class="o">&amp;&amp;</span>
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Applications/Chizu.app"</span> <span class="o">&amp;&amp;</span>
<span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Applications/Chizu.app/Contents/MacOS/chizu"</span> default <span class="nb">set</span>
</code></pre></div></div>
<p>ダウンロードしたZIPは、一緒に公開しているSHA-256のチェックサムと照合してから展開しています。
アプリはバージョン間で同じ自己署名の証明書で署名していて、Appleの公証（notarization）はしていないため、<code>xattr</code>でquarantine属性を解除しています。</p>
<h2>なぜ作ったか</h2>
<p>これまでは、リンクをURLやリンク元のアプリに応じて振り分けるmacOSアプリの<a href="https://choosy.app/">Choosy</a>を使っていました。
Choosyには、URLやリンク元のアプリを条件にした<a href="https://choosy.app/help/settings/rules">ルール</a>を設定できます。</p>
<p>Chizuを作った主な理由は2つあります。</p>
<p>1つ目は、ChoosyがDiaのプロファイル指定に対応していなかったことです。
Diaは、Arcを作ったThe Browser Companyによるブラウザです。
ChoosyからDiaでリンクを開くことはできますが、プロファイルを指定して開くことはできませんでした。</p>
<p>2つ目は、設定を複数のMacで共有したかったことです。
Choosyには、設定を複数の端末で共有する標準的な方法が用意されておらず、plistファイルを扱う形になっていました。
振り分けのルールは普通のファイルとしてGitで管理し、差分を確認しながら複数のMacで同期したいと考えていました。</p>
<p>そこで、設定ファイルでルールを管理でき、ブラウザのプロファイルまで指定できる既定のブラウザとしてChizuを作りました。
設定ファイルはシンボリックリンクでも読めるので、dotfilesのリポジトリでGit管理できます。</p>
<h2>仕組み</h2>
<p>Chizu自体はWebページを表示しません。
ChizuをmacOSの既定のブラウザとして登録しておき、リンクを受け取ったら設定ファイルのルールで行き先を決めて、実際のブラウザで開きます。</p>
<p>処理の流れは次のとおりです。</p>
<ol>
<li>macOSが、他のアプリで開いたリンクを既定のブラウザであるChizuへ渡す</li>
<li>Chizuが設定ファイルを読み込んで検証する</li>
<li>上から順にルールを評価し、最初に一致したルールの行き先を決める</li>
<li>行き先のブラウザでURLを開く（プロファイルの指定があれば、そのプロファイルで開く）</li>
</ol>
<p>詳しい仕組みは、Chizuの<a href="https://github.com/azu/chizu/blob/main/docs/products/chizu/features/routing/design.ja.md">リンクの振り分けの設計</a>に書いています。
リンク元のアプリは、macOSから受け取ったApple Eventの送信元から判別できた場合だけ、ルールの条件に使います。</p>
<h2>使い方</h2>
<p>Chizuは、<code>chizu</code>コマンドで設定ファイルを作って確認してから、既定のブラウザに設定して使います。</p>
<ol>
<li><code>chizu config init</code>で設定ファイルを作る</li>
<li><code>~/.config/chizu/config.jsonc</code>にルールを書く</li>
<li><code>chizu config check</code>と<code>chizu check URL</code>で、ルールの結果を確認する</li>
<li><code>chizu default set</code>でmacOSの既定のブラウザをChizuにする</li>
</ol>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="sh"><span class="c"># 設定ファイルを作る（既にファイルやシンボリックリンクがある場合は上書きしない）</span>
~/Applications/Chizu.app/Contents/MacOS/chizu config init
<span class="c"># 設定ファイルにエラーがないか検査する</span>
~/Applications/Chizu.app/Contents/MacOS/chizu config check
<span class="c"># リンクを開かずに、一致するルール・ブラウザ・プロファイルを表示する</span>
~/Applications/Chizu.app/Contents/MacOS/chizu check <span class="s1">'https://team.example.com/projects/123'</span>
<span class="c"># Mailから開いた場合を想定して確認する</span>
~/Applications/Chizu.app/Contents/MacOS/chizu check <span class="s1">'https://calendar.example.com/'</span> <span class="nt">--source</span> com.apple.mail
<span class="c"># macOSの既定のブラウザをChizuにして、状態を確認する</span>
~/Applications/Chizu.app/Contents/MacOS/chizu default <span class="nb">set</span>
~/Applications/Chizu.app/Contents/MacOS/chizu default status
</code></pre></div></div>
<p><code>chizu check URL</code>はリンクを開かないので、既定のブラウザを切り替える前にルールの結果を確かめられます。
設定ファイルが壊れている場合や、指定したブラウザやプロファイルが見つからない場合はエラーとして報告します。
別のブラウザやプロファイルで黙って開くことはしないので、意図しないアカウントでリンクを開いてしまうことを避けられます。</p>
<p>設定ファイルはリンクを開くたびに読み直すため、設定を変更してもアプリの再起動は不要です。</p>
<h3>設定ファイルの書き方</h3>
<p>設定ファイルは、<code>version</code>、ルールに一致しなかったときの行き先である<code>default</code>、上から順に評価する<code>rules</code>の配列で構成されています。
各ルールは<code>name</code>、条件を書く<code>when</code>、行き先を書く<code>destination</code>を持ち、最初に一致したルールの行き先でリンクを開きます。</p>
<div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="jsonc"><span class="p">{</span><span class="w">
  </span><span class="nl">"version"</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w">
  </span><span class="c1">// どのルールにも一致しなかったときの行き先</span><span class="w">
  </span><span class="nl">"default"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"bundleID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"com.apple.Safari"</span><span class="w"> </span><span class="p">},</span><span class="w">
  </span><span class="c1">// 上から順に評価して、最初に一致したルールを使う</span><span class="w">
  </span><span class="nl">"rules"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Docs in Chrome"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"when"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"host"</span><span class="p">:</span><span class="w"> </span><span class="s2">"docs.example.com"</span><span class="w"> </span><span class="p">},</span><span class="w">
      </span><span class="c1">// Chromeに表示されているプロファイル名を指定する</span><span class="w">
      </span><span class="nl">"destination"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"bundleID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"com.google.Chrome"</span><span class="p">,</span><span class="w"> </span><span class="nl">"profile"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Work"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Team links"</span><span class="p">,</span><span class="w">
      </span><span class="c1">// ホストとパスの前方一致の両方を満たすときだけ一致する</span><span class="w">
      </span><span class="nl">"when"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"host"</span><span class="p">:</span><span class="w"> </span><span class="s2">"team.example.com"</span><span class="p">,</span><span class="w"> </span><span class="nl">"pathPrefix"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/projects"</span><span class="w"> </span><span class="p">},</span><span class="w">
      </span><span class="nl">"destination"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"bundleID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"company.thebrowser.dia"</span><span class="p">,</span><span class="w"> </span><span class="nl">"profile"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Team"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Calendar links from Mail"</span><span class="p">,</span><span class="w">
      </span><span class="c1">// Mailから開いたリンクのときだけ一致する</span><span class="w">
      </span><span class="nl">"when"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"urlContains"</span><span class="p">:</span><span class="w"> </span><span class="s2">"calendar"</span><span class="p">,</span><span class="w"> </span><span class="nl">"sourceBundleID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"com.apple.mail"</span><span class="w"> </span><span class="p">},</span><span class="w">
      </span><span class="nl">"destination"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"bundleID"</span><span class="p">:</span><span class="w"> </span><span class="s2">"com.google.Chrome"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">},</span><span class="w">
  </span><span class="p">],</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p><code>when</code>には、次の条件を書けます。
1つの<code>when</code>に複数の条件を書いた場合は、すべてを満たしたときだけ一致します。</p>
<table>
<thead>
<tr>
<th><code>when</code>の項目</th>
<th>一致する条件</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>urlContains</code></td>
<td>URL全体のどこかに文字列を含む（大文字・小文字は区別しない）</td>
</tr>
<tr>
<td><code>urlRegex</code></td>
<td>URL全体がICU形式の正規表現に一致する（<code>(?i)</code>で大文字・小文字を無視できる）</td>
</tr>
<tr>
<td><code>host</code></td>
<td>ホストが完全一致する（大文字・小文字は区別しない）</td>
</tr>
<tr>
<td><code>pathPrefix</code></td>
<td>パスが指定した文字列から始まる</td>
</tr>
<tr>
<td><code>sourceBundleID</code></td>
<td>リンクを開いたアプリのBundle IDが一致する</td>
</tr>
</tbody>
</table>
<p>設定ファイルはJSONCなので、<code>//</code>や<code>/* ... */</code>のコメントと末尾のカンマを書けます。
末尾のカンマは、<a href="https://jsonc.org/">JSONCの仕様</a>ではパーサーが任意で対応してよい拡張として扱われていて、Chizuはこれを受け付けています。</p>
<h3>AIに設定してもらう</h3>
<p><code>chizu --help</code>には、コマンドの書式に加えて、設定ファイルの項目や評価の順序、コピーして使える例を表示しています。
そのため、AIエージェントに<code>--help</code>を読ませれば、設定ファイルを書いてもらえます。</p>
<p>たとえば、次のようなプロンプトの末尾に振り分けの希望を書いて、AIエージェントに渡します。</p>
<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="text">~/Applications/Chizu.app/Contents/MacOS/chizu --help を見て、Chizuを設定して。
~/.config/chizu/config.jsonc がなければ chizu config init で作って、次の希望に合わせてルールを書いて。
書いたら chizu config check と chizu check URL で、各ルールとどのルールにも一致しないURLの結果を確認して見せて。
問題がなければ chizu default set で既定のブラウザにして。

振り分けの希望:
- GitHubのリンクは、Chromeの「Work」プロファイルで開く
- それ以外は、Safariで開く
</code></pre></div></div>
<p>READMEにも、英語版のプロンプトを載せています。</p>
<ul>
<li><a href="https://github.com/azu/chizu#configure-with-an-ai">Configure with an AI</a></li>
</ul>
<h2>ブラウザのプロファイルを指定する</h2>
<p><code>destination</code>の<code>profile</code>には、ブラウザに表示されているプロファイル名を指定します。
現時点でプロファイルの指定に対応しているのは、次のブラウザです。</p>
<ul>
<li>Dia（<code>company.thebrowser.dia</code>）</li>
<li>Google Chrome（<code>com.google.Chrome</code>）</li>
<li>Firefox（<code>org.mozilla.firefox</code>）</li>
</ul>
<p>他のブラウザのプロファイル指定を追加するのは難しくないので、必要なブラウザがあれば追加できます。</p>
<p>Diaのプロファイル指定には、Diaが提供しているAppleScriptの機能を使っています。
指定したプロファイルに新しいタブを作り、そのタブを選択してからDiaを前面に出します。
初めてDiaへ振り分けるときに、macOSからオートメーションの許可を求められるので、ChizuがDiaを操作することを許可します。</p>
<p>Diaのプロファイルを切り替える方法としては、<a href="https://github.com/jdsimcoe/dia-router">Dia Router</a>のように、プロファイル切り替えのキーボードショートカットを送る方法もあります。
Chizuでは、ショートカットの設定や切り替えの待ち時間に依存しないように、AppleScriptで操作する方法を選んでいます。</p>
<p>Chromeのプロファイル指定では、まずChromeの<code>Local State</code>ファイルから、プロファイル名とディレクトリの対応を読み取ります。
そのディレクトリを<code>--profile-directory</code>オプションで指定して、Chromeを起動します。
同じ名前のプロファイルが複数ある場合や見つからない場合は、リンクを開かずにエラーとします。
<code>Local State</code>は他のアプリのデータなので、初回はChizuからGoogle Chromeのデータへのアクセスを許可します。
許可はシステム設定 → プライバシーとセキュリティ → ファイルとフォルダから行えます。</p>
<h2>ブラウザ内のリンクの振り分け</h2>
<p>macOSの既定のブラウザを経由するのは、メールやチャットなど、ブラウザ以外のアプリから開いたリンクです。
ブラウザの中で開いたリンクは、そのブラウザ自身が開くため、Chizuには届きません。</p>
<p>そこで、Chizuは<code>chizu-http:</code>と<code>chizu-https:</code>というURLスキームも登録しています。
この仕組みは、<a href="https://github.com/delphinus">@delphinus</a>さんが<a href="https://github.com/azu/chizu/pull/3">#3</a>で追加してくれました。
ユーザースクリプトやブラウザ拡張で、リンクの<code>https:</code>を<code>chizu-https:</code>に置き換えて開くと、Chizuが先頭の<code>chizu-</code>を取り除いてから、通常のリンクと同じルールで振り分けます。</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="sh"><span class="c"># chizu-https: のリンクも同じルールで確認できる</span>
~/Applications/Chizu.app/Contents/MacOS/chizu check <span class="s1">'chizu-https://team.example.com/projects/123'</span>
</code></pre></div></div>
<p><code>chizu://open?url=...</code>のようにURLをクエリパラメーターで渡す方法では、元のURLをエンコードしなければなりません。
スキームだけを置き換える方法なら、エンコードせずに元のURLをそのまま渡せます。</p>
<p>どのリンクをChizuへ渡すかは、ブラウザ側のユーザースクリプトや拡張で決めます。
渡したリンクの行き先が送り元のブラウザだった場合はそのブラウザで開くので、設定ファイルで別のブラウザへ振り分けるリンクだけを渡すようにします。</p>
<h2>sdevで仕様駆動開発</h2>
<p>コントリビュートした人は気づいた通り、Chizuは<a href="https://github.com/azu/sdev">sdev</a>という開発ツールを使って開発しています。
sdevは、人とAIで仕様（Specification）を書いてからソフトウェアを作るための、仕様駆動開発のツールです。</p>
<p>Chizuのリポジトリでは、<code>docs/products/</code>以下に、目的と対象利用者を書いたProduct、機能ごとのRequirementsとDesignを置いています。
変更するときは、先に仕様を更新してから、実装とテストを変更する流れで開発しています。
仕様は英語を正本として、日本語版も同じ変更で更新しています。</p>
<ul>
<li><a href="https://github.com/azu/chizu/blob/main/docs/README.ja.md">Chizu の仕様</a></li>
</ul>
<p>この記事で書いた「なぜプロファイルが見つからないときはエラーとするのか」や「なぜスキームを置き換える方式にしたのか」といった判断も、仕様のドキュメントで説明しています。</p>
<h2>まとめ</h2>
<p>Chizuは、開いたリンクを設定ファイルのルールに応じて、ブラウザとプロファイルへ振り分けるmacOSアプリです。
設定はJSONCファイルなので、dotfilesに入れてGitで管理できます。</p>
<p>凝った機能はなく、ChoosyでDiaのプロファイルを指定できなかったので作ったアイデアアプリです。</p>
<hr />
<ul>
<li>GitHub: <a href="https://github.com/azu/chizu">azu/chizu</a></li>
<li>仕様: <a href="https://github.com/azu/chizu/blob/main/docs/README.ja.md">Chizu の仕様</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>Quick Input Translator: 書いた文章を入力欄中でインライン翻訳するmacOSアプリ</title>
   <link href="https://efcl.info/2026/10/23/quick-input-translator/"/>
   <updated>2026-10-23T10:00:00+09:00</updated>
   <id>https://efcl.info/2026/10/23/quick-input-translator</id>
   <content type="html"><![CDATA[ <p>入力欄に日本語で文章を書いてから、末尾に<code>...</code>を入力すると、その場で英語に翻訳して置き換えるmacOSアプリ <a href="https://github.com/azu/quick-input-translator">Quick Input Translator</a> を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/azu/quick-input-translator">azu/quick-input-translator</a></li>
</ul>
<p><video src="/wp-content/uploads/2026/10/quick-input-translator.mp4" controls muted loop playsinline width="100%"></video></p>
<p>翻訳にはmacOSの<a href="https://developer.apple.com/documentation/translation">Translation framework</a>を使っていて、翻訳はMac上（オンデバイス）で行われます。
外部の翻訳APIを呼び出さないので、APIキーは不要で、言語モデルを一度ダウンロードしておけばオフラインでも素早く翻訳できます。</p>
<h2>インストール</h2>
<p>動作環境</p>
<ul>
<li>Apple SiliconのMac + macOS 26以降</li>
</ul>
<p>次のコマンドでインストールできます。notarizationはしてないので自己判断でお願いします。</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="sh">curl <span class="nt">-fL</span> https://github.com/azu/quick-input-translator/releases/latest/download/quick-input-translator-macos-arm64.zip <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$TMPDIR</span><span class="s2">/quick-input-translator.zip"</span> <span class="o">&amp;&amp;</span>
ditto <span class="nt">-x</span> <span class="nt">-k</span> <span class="s2">"</span><span class="nv">$TMPDIR</span><span class="s2">/quick-input-translator.zip"</span> /Applications <span class="o">&amp;&amp;</span>
xattr <span class="nt">-dr</span> com.apple.quarantine <span class="s2">"/Applications/Quick Input Translator.app"</span> <span class="o">&amp;&amp;</span>
open <span class="s2">"/Applications/Quick Input Translator.app"</span>
</code></pre></div></div>
<p>アプリは自己署名で、Appleの公証（notarization）はしていないため、<code>xattr</code>でquarantine属性を解除してから起動しています。
v1.1.1以降は<a href="https://sparkle-project.org/">Sparkle</a>による自動アップデートに対応しているので、2回目以降はアプリ内で更新できます。</p>
<h3>初回の設定と言語モデルのダウンロード</h3>
<p>アプリを起動すると設定画面が開きます。
翻訳には言語モデルが必要なので、最初に設定画面からダウンロードしておきます。</p>
<ol>
<li><strong>From</strong>と<strong>To</strong>に翻訳元と翻訳先の言語を設定する（たとえば Japanese → English）</li>
<li><strong>Prepare Languages</strong>ボタンを押す</li>
<li>macOSの言語モデルのダウンロードダイアログが表示されるので、ダウンロードを完了させる</li>
<li>設定画面に”Ready for translation”と表示されれば準備完了</li>
<li>設定画面の<strong>Try it</strong>欄で翻訳を試す（この欄はアクセシビリティの許可がなくても動作する）</li>
<li>他のアプリで使うために、<strong>Allow Access</strong>ボタンを押して、システム設定 → プライバシーとセキュリティ → アクセシビリティでQuick Input Translatorを許可する</li>
</ol>
<p>冒頭の動画は、この<strong>Try it</strong>欄で行翻訳と全翻訳を試しているものです。</p>
<h2>なぜ作ったか</h2>
<p>英語で文章を書くときは、日本語で書いてから翻訳ツールに貼り付けて、訳文をコピーして入力欄に戻す、という操作をよくやっていました。
この方法だと、翻訳のたびにアプリを行き来するので、書いている流れが途切れてしまいます。</p>
<p>Quick Input Translatorでは、文章を書いている入力欄から離れずに翻訳できます。
テキストを選択したり、翻訳用のショートカットキーを押したりする必要もなく、文章の末尾にトリガーとなる文字を打つだけで翻訳されます。</p>
<h2>行翻訳と全翻訳</h2>
<p>Quick Input Translatorには、現在の行だけを翻訳する「行翻訳」と、入力欄全体を翻訳する「全翻訳」の2種類があります。</p>
<table>
<thead>
<tr>
<th>翻訳する範囲</th>
<th>デフォルトのトリガー</th>
<th>入力する場所</th>
<th>置き換わる範囲</th>
</tr>
</thead>
<tbody>
<tr>
<td>現在の行</td>
<td><code>...</code></td>
<td>その行の末尾</td>
<td>その行だけ（前後の行は変わらない）</td>
</tr>
<tr>
<td>入力欄全体</td>
<td><code>:::</code></td>
<td>入力欄の一番最後</td>
<td>複数行を含む下書き全体</td>
</tr>
</tbody>
</table>
<h3>行翻訳</h3>
<p>行翻訳は、行の末尾に<code>...</code>を入力すると、その行だけを翻訳して置き換えます。</p>
<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="text">こんにちは。今日は良い天気です。...
</code></pre></div></div>
<p>最後の<code>.</code>を入力した時点で翻訳が始まり、その行が英語に置き換わってトリガーの<code>...</code>は取り除かれます。
前後の行はそのまま残るので、1行ずつ書いては翻訳する、という使い方ができます。
なお、見た目上折り返されている行でも、改行を入れるまでは1行として扱われます。</p>
<h3>全翻訳</h3>
<p>全翻訳は、複数行の下書きを書いてから、入力欄の一番最後に<code>:::</code>を入力すると、入力欄全体をまとめて翻訳して置き換えます。</p>
<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="text">こんにちは。
明日の打ち合わせは10時からです。:::
</code></pre></div></div>
<p>途中の行に<code>:::</code>を書いても全翻訳は始まらず、入力欄の末尾で入力したときだけ動作します。
元の改行や空行は保たれます。
ただし、翻訳は行ごとに行われるため、改行をまたいだ文脈は訳文に反映されにくくなります。</p>
<h3>日本語入力のままでもトリガーできる</h3>
<p>トリガーは設定画面で<code>...</code>、<code>;;;</code>、<code>:::</code>、<code>@@@</code>から選べて、行翻訳と全翻訳にそれぞれ別のものを割り当てられます。</p>
<p>日本語入力中でも、対応する全角文字で同じようにトリガーできます。
たとえば<code>...</code>は<code>。。。</code>、<code>:::</code>は<code>：：：</code>でも動作するので、日本語入力をオフに切り替えなくても翻訳できます。
同じ文字が3つ連続したときだけ翻訳が始まり、4つ以上の場合や、トリガーを貼り付けた場合、行の途中で入力した場合は翻訳されません。</p>
<p>翻訳中はトリガーの近くにスピナーが表示され、置き換えが成功するとチェックマークが表示されます。
置き換え直後であれば、入力先アプリの⌘+Zで元に戻せます（Undoの挙動は入力先アプリのエディタに依存します）。</p>
<p>訳文は下書きとして扱われ、Quick Input Translatorが送信や投稿をすることはありません。
翻訳結果を確認してから、自分で送信や保存をする想定です。</p>
<h2>Translation frameworkでローカル翻訳</h2>
<p>Quick Input Translatorは翻訳エンジンとして、Appleの<a href="https://developer.apple.com/documentation/translation">Translation framework</a>を使っています。
Translation frameworkは、Macにダウンロードした言語モデルを使って翻訳するため、次のような利点があります。</p>
<ul>
<li>翻訳APIのキーや外部サービスの契約が不要</li>
<li>入力中の文章を外部の翻訳サービスへ送らずに翻訳できる</li>
<li>言語モデルを準備しておけば、ネットワークがなくても翻訳できる</li>
<li>ネットワーク越しのリクエストを待たないので、素早く翻訳できる</li>
</ul>
<p>言語モデルは、インストール時に設定画面の<strong>Prepare Languages</strong>からダウンロードしておきます。
他のアプリで文章を入力している最中に、モデルのダウンロードが始まることはありません。</p>
<h2>どこの入力欄で使えるか</h2>
<p>Quick Input Translatorは、入力欄に<code>...</code>などのトリガーが入力されたことを検知して翻訳します。
そのため、ブラウザの入力欄やMacアプリの入力欄など、普段文章を書いている入力欄でそのまま使えます。
ただし、パスワード欄や読み取り専用の領域では動作しません。</p>
<p>なお、他のアプリで置き換えるときは、訳文を一時的にクリップボードに入れてペーストしています。
ペーストを確認できたら、元のクリップボードの内容に戻します。</p>
<h2>まとめ</h2>
<p>Quick Input Translatorは、入力欄で書いた文章の末尾に<code>...</code>や<code>:::</code>を打つだけで、その場で翻訳して置き換えるmacOSアプリです。
現在の行だけを翻訳する行翻訳と、入力欄全体を翻訳する全翻訳を使い分けられます。</p>
<p>翻訳にはmacOSのTranslation frameworkを使っているので、APIキーなしでローカルで素早く翻訳でき、オフラインでも動作します。
翻訳ツールと入力欄を行き来せずに済むので、日本語で考えながら英語の文章を書くときに便利です。</p>
<hr />
<p>ソースコードはGitHubで公開しています。</p>
<ul>
<li>GitHub: <a href="https://github.com/azu/quick-input-translator">azu/quick-input-translator</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>「OSS開発者は今何をするべきか？ソフトウェアサプライチェーン侵害対策を考える」で「Hardening npm Publishing」という発表をしました</title>
   <link href="https://efcl.info/2026/06/23/hardening-npm-publishing/"/>
   <updated>2026-06-23T23:00:00+09:00</updated>
   <id>https://efcl.info/2026/06/23/hardening-npm-publishing</id>
   <content type="html"><![CDATA[ <p>2026年6月23日に、GMO Flatt Security主催の「<a href="https://flatt.connpass.com/event/395359/">OSS開発者は今何をするべきか？ソフトウェアサプライチェーン侵害対策を考える</a>」で「<a href="https://azu.github.io/slide/2026/hardening-npm-publishing/slide.html">Hardening npm Publishing</a>」というタイトルで、npmパッケージの公開フローをどう守るかについて話しました。</p>
<p><a href="https://azu.github.io/slide/2026/hardening-npm-publishing/slide.html"><img src="/wp-content/uploads/2026/06/hardening-npm-publishing-cover.png" alt="Hardening npm Publishingのスライド表紙" /></a></p>
<ul>
<li>スライド: <a href="https://azu.github.io/slide/2026/hardening-npm-publishing/slide.html">Hardening npm Publishing</a></li>
</ul>
<p>ローカルのトークン管理やnpm Trusted Publishingについては、以前の記事で書きました。</p>
<ul>
<li><a href="https://efcl.info/2023/01/31/remove-secret-from-local/">1Passwordを使って、ローカルにファイル(~/.configや.env)として置かれてる生のパスワードなどを削除した | Web Scratch</a></li>
<li><a href="https://efcl.info/2022/11/27/password-mfa-strategy/">パスワード管理/MFA管理の戦略 | Web Scratch</a></li>
<li><a href="https://efcl.info/2025/09/07/npm-oidc/">npm Trusted PublishingでOIDCを使ってトークンレスでCIからnpmパッケージを公開する</a></li>
</ul>
<p>この記事ではそれらを前提に、GitHub Environmentsとnpm staged publishingをどこに入れるかの話をスライドベースでかいています。</p>
<p>サプライチェーン攻撃をすべての侵害を防ぐのは難しいです。
それでも、ローカル、CI、GitHub Actions、npm registryのそれぞれに止める場所を作ると、攻撃者が1つの権限を取っただけでは公開まで進みにくくできます。</p>
<h2>公開フローをどこで止めるか</h2>
<p>npmパッケージの公開フローでは、開発者のローカル環境、GitHubリポジトリ、GitHub Actions、npm registry、利用者のインストール環境がつながっています。攻撃者の入口も1つではなく、ローカルのnpmトークン、GitHub Actionsのworkflow、CI上の認証情報、install時に実行される悪性コードなど、いくつかの場所に分かれます。</p>
<p>そのため、対策も1つでは足りません。自分は次のように、公開フローを段階ごとに分けて考えています。</p>
<ol>
<li>ローカルに強いトークンを置かない</li>
<li>CIからnpmへ公開するときは長期npmトークンを使わない</li>
<li>GitHub Actionsからnpmへ権限が広がる手前にGitHub EnvironmentsのDeployment protection rulesを置く</li>
<li>npm staged publishingでregistry公開前にもう一度止める</li>
</ol>
<p>重要なのは、それぞれの対策を単体で見るのではなく、公開までの経路として見ることです。ローカルのcredentialを減らしても、workflowを書き換えられるならCIからpublishできます。Trusted Publishingでnpmトークンを消しても、そのworkflowへ進む条件が緩いなら、別の形で攻撃できます。</p>
<h2>ローカルに強いトークンを置かない</h2>
<p>最初にやることは、ローカルにある生のパスワードやAPIトークンを減らすことです。これは「<a href="https://efcl.info/2023/01/31/remove-secret-from-local/">1Passwordを使って、ローカルにファイル(~/.configや.env)として置かれてる生のパスワードなどを削除した</a>」で書いた内容です。</p>
<p><code>.env</code>、<code>~/.npmrc</code>、<code>~/.aws/credentials</code>、<code>gh auth token</code>のようなcredentialは、開発環境に残りがちです。npmパッケージのマルウェアは、<code>postinstall</code>などのライフサイクルスクリプトや、インストール後に実行されるCLIを通じて、ローカルファイルや環境変数を読みにいくことがあります。</p>
<p>ローカルの生トークンを減らしておけば、仮にローカルで悪性コードが実行されても、盗まれる権限の範囲を小さくできます。自分の場合は、GitHubやAWSのようなCLI認証は1Password に寄せ、npm publishに使えるトークンはローカルにはない状態にしています。</p>
<p>また、利用する側では、パッケージを入れる前の防御として<a href="https://socket.dev/blog/introducing-socket-firewall">Socket Firewall</a>や<a href="https://flatt.tech/takumi/features/guard">Takumi Guard</a>を使っています。</p>
<h2>npmトークンをCIに置かない</h2>
<p>CIからnpmへ公開する場合、以前は<code>NPM_TOKEN</code>のような長期トークンをGitHub Actions Secretsに入れるのが普通でした。しかし、長期トークンは漏れると無効化するまで使われ続けます。また、monorepoではパッケージごとの細かい権限制御が難しく、広い権限を持つトークンになりがちです。</p>
<p>この問題に対して、npm Trusted Publishingを使うと、GitHub ActionsなどのCI/CD環境からOIDCでnpmにpublishできるtokenを発行できます。Trusted Publishingは、長期npmトークンを使わず、workflowごとの短命credentialでパッケージを公開する仕組みです。</p>
<ul>
<li><a href="https://docs.npmjs.com/trusted-publishers/">Trusted publishing for npm packages | npm Docs</a></li>
<li><a href="https://efcl.info/2025/09/07/npm-oidc/">npm Trusted PublishingでOIDCを使ってトークンレスでCIからnpmパッケージを公開する</a></li>
</ul>
<p>GitHub Actions側では、workflowに<code>id-token: write</code>を付けます。GitHub OIDCは、長期secretをGitHubに複製せず、workflowごとに短命トークンを取得する仕組みです。</p>
<ul>
<li><a href="https://docs.github.com/en/actions/concepts/security/openid-connect">OpenID Connect - GitHub Docs</a></li>
</ul>
<p>ただし、npmトークンを消せば公開フロー全体が安全になるわけではありません。GitHub Actionsのworkflowを書き換えられる権限があると、OIDCを使ってnpm publish用の短命credentialを得られる可能性があります。そのため、GitHub Actionsからnpmへ権限が広がる手前にも、別の制御点が必要になります。</p>
<h2>Deployment protection rulesをpublishの手前に置く</h2>
<p>GitHub EnvironmentsのDeployment protection rulesは、jobの実行前に承認やbranch/tag制限を要求する仕組みです。publish用jobの手前に置くと、GitHub Actionsからnpmへ権限が広がる境界に承認を入れられます。
environmentを参照するjobは、Deployment protection rulesを満たすまで実行されません。</p>
<ul>
<li><a href="https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments">Managing environments for deployment - GitHub Docs</a></li>
<li><a href="https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments">Deployments and environments - GitHub Docs</a></li>
</ul>
<p>npm publish用のworkflowでは、次のように<code>environment: npm</code>を付けています。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="na">name</span><span class="pi">:</span> <span class="s">Release</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">closed</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">publish</span><span class="pi">:</span>
    <span class="na">if</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">github.event.pull_request.merged == true &amp;&amp;</span>
      <span class="s">contains(github.event.pull_request.labels.*.name, 'Type: Release')</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">npm</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">contents</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v6</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v6</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">24"</span>
          <span class="na">registry-url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://registry.npmjs.org"</span>
          <span class="na">package-manager-cache</span><span class="pi">:</span> <span class="kc">false</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">npm ci</span>
      <span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">npm stage publish --access public</span>
</code></pre></div></div>
<p>この<code>npm</code>というEnvironmentには、required reviewersを設定します。required reviewersでレビューアーが明示的にApproveしないとWorkflowが動作しないように設定できます。</p>
<p><img src="/wp-content/uploads/2026/06/hardening-npm-publishing-deployment-protection-rules.png" alt="GitHub EnvironmentsのDeployment protection rules" /></p>
<p><img src="/wp-content/uploads/2026/06/hardening-npm-publishing-environment-name-match.png" alt="npm Trusted PublisherとGitHub ActionsのEnvironment名を一致させる" /></p>
<p>npm Trusted Publisher側にも、同じEnvironment nameを登録します。</p>
<p>この設定で、npm側では<code>npm</code>というEnviromentがないGitHub ActionsからのPublishは受け付けなくなります。
一方で、GitHub Actionsでは<code>npm</code>というEnviromentを参照するには、レビューアーのApproveが必要になります。</p>
<p>そのため、Workflowファイルを書き換えるだけではOIDC tokenを利用した攻撃が難しくなります。</p>
<h2>npm staged publishingで公開前に止める</h2>
<p>npm staged publishingは、<code>npm publish</code>で直接公開する代わりに、<code>npm stage publish</code>でパッケージをstaging areaへ提出する仕組みです。maintainerが中身を確認してapproveすると、registryに公開されます。</p>
<ul>
<li><a href="https://docs.npmjs.com/staged-publishing/">Staged publishing for npm packages | npm Docs</a></li>
<li><a href="https://docs.npmjs.com/cli/v11/commands/npm-stage/">npm-stage | npm Docs</a></li>
</ul>
<p><code>npm stage publish</code>自体は2FAを要求しません。CIは公開物を提出するだけで、実際にregistryへ公開するところはnpmjs.comのStaged Packages画面で止まります。ここでApproveするときに2FAが必要になります。</p>
<p><img src="/wp-content/uploads/2026/06/hardening-npm-publishing-staged-packages-approve.png" alt="npm Staged PackagesのApprove画面" /></p>
<p>npm Trusted PublisherのAllowed actionsは<code>npm stage publish</code>だけにして、CIから直接<code>npm publish</code>する経路をなくせます。
GitHub側のEnvironment approvalを通過しても、npm側のApproveが残る形です。そのため、GitHubとnpmアカウントを両方の権限がないとpublishができなくなります。</p>
<p>一方でstaged publishingはまだ出たばかりなので問題もあって、パッケージ1つずつしかApproveができないので、monorepoだとちょっと大変になります。</p>
<ul>
<li><a href="https://github.com/orgs/community/discussions/196675">Staged publishing is GA, plus new install-time security controls · community · Discussion #196675</a></li>
</ul>
<h2>実装例</h2>
<p>最小構成のサンプルとして、次のrepositoryを作りました。</p>
<ul>
<li><a href="https://github.com/azu/simple-oidc-example-package">azu/simple-oidc-example-package</a>
<ul>
<li>こっちは通常のOIDCのリリース</li>
</ul>
</li>
<li><a href="https://github.com/azu/simple-npm-staged-publish-package-example">azu/simple-npm-staged-publish-package-example</a>
<ul>
<li>Staged Publishingを使ったリリース</li>
</ul>
</li>
</ul>
<p>staged publishingのサンプルでは、release PRの作成からnpmjs.com上のapproveまでを通しています。具体的には、PRレビュー、GitHub Environment <code>npm</code>のapprove、<code>npm stage publish</code>、staged package approveという流れです。</p>
<p>この流れは、公開フローを次の2つの境界で止めるためのものです。GitHubの境界では、release PRのレビューとEnvironment approveを要求します。npmの境界では、staged package approveを要求します。どちらか一方だけではなく、GitHubとnpmの両方に承認点を置くことで、1つの権限だけではregistry公開まで進みにくくしています。</p>
<h2>まとめ</h2>
<p>npmパッケージの公開フローで重要なのは、どこから一箇所ではなくて、一連の流れとして対策を入れていく必要があります。</p>
<p>ローカルとCIには、npm publishに使える強い長期トークンを置かないようにする。
GitHub ActionsではProtection Ruleを使って、CIが動く条件を厳密にする。
npm側では、Staged Publishingを使ってMFAがないとpublishできなくする。</p>
<p>公開フローを段階ごとに分け、それぞれの段階で何かしらの対策が入れられると、新しい攻撃が出てきた時に防ぎやすくなるかなと考えています。</p>
<h2>関連リンク</h2>
<ul>
<li>スライド: <a href="https://azu.github.io/slide/2026/hardening-npm-publishing/slide.html">Hardening npm Publishing</a></li>
<li>イベント: <a href="https://flatt.connpass.com/event/395359/">OSS開発者は今何をするべきか？ソフトウェアサプライチェーン侵害対策を考える - connpass</a></li>
<li><a href="https://efcl.info/2025/09/07/npm-oidc/">npm Trusted PublishingでOIDCを使ってトークンレスでCIからnpmパッケージを公開する</a></li>
<li><a href="https://efcl.info/2023/01/31/remove-secret-from-local/">1Passwordを使って、ローカルにファイル(~/.configや.env)として置かれてる生のパスワードなどを削除した</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>automerge-gate: GitHubのAuto Mergeをひとつの必須チェックに集約するGitHub Action</title>
   <link href="https://efcl.info/2026/05/13/automerge-gate/"/>
   <updated>2026-05-13T20:00:00+09:00</updated>
   <id>https://efcl.info/2026/05/13/automerge-gate</id>
   <content type="html"><![CDATA[ <p>GitHubのAuto Mergeをひとつの必須チェックに集約するためのGitHub Action <a href="https://github.com/pkgdeps/automerge-gate">automerge-gate</a> を作ったので紹介します。</p>
<ul>
<li>GitHub: <a href="https://github.com/pkgdeps/automerge-gate">pkgdeps/automerge-gate</a></li>
</ul>
<h2>背景: GitHub Auto Mergeは集約するアクションなしだと使いにくい</h2>
<p>前提として、GitHubの<a href="https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request">Auto Merge</a>を使うには、必須チェック未達成のPRをマージできない状態にする<a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets">Branch protection ruleやRuleset</a>の設定が必要です。
これらの保護機能でPRがブロックされる状態を作ったうえで、すべての必須チェックが成功した時点でAuto Mergeが発火する、という仕組みになっています。
逆に言うと、Auto Mergeを使うには何かしらのステータスチェックを必ず必須に入れる必要があります。</p>
<p>そして、Branch protection ruleやRulesetは、マージに必要なステータスチェックを名前で列挙する形式です。
この方式は次のような場面で壊れやすいという問題があります。</p>
<ul>
<li>RenovateやDependabotなど外部のGitHub Appが追加するチェックは、PRごとにあったりなかったりする</li>
<li>monorepoでパスフィルタを使っていると、ワークフローがPRによってスキップされたりされなかったりする</li>
<li>新しいワークフローを追加する度に、Rulesetを書き換える必要がある</li>
</ul>
<p>GitHubのRulesetは複数の必須チェックをANDでつなぐ(全部成功すること)しか表現できないため、「チェック群のうちどれかが走っていればよい」みたいな条件は書けません。
そのため、PRごとに発火するチェックが違うケースだと、片方のPRでは存在しないチェックを必須にしてしまい、いつまでもマージできないという状態が発生します。</p>
<p>この問題への対処として、必須チェックを1つのステータスに集約する<a href="https://github.com/upsidr/merge-gatekeeper">upsidr/merge-gatekeeper</a>を使っているケースも多いです。
自分も<a href="https://github.com/textlint/textlint">textlint</a>などのオープンソースプロジェクトや、プライベートリポジトリで使っていました。</p>
<ul>
<li><a href="https://github.com/textlint/textlint/pull/1577">CI: add Merge Gatekeeper workflow for pull requests by azu · Pull Request #1577 · textlint/textlint</a></li>
</ul>
<p>最近はmerge-gatekeeperからautomerge-gateに入れ替えて使っています。
automerge-gateも同じ「集約された1つの必須チェック」というアプローチですが、GitHubのAuto Mergeと組み合わせて使うことを前提に作られています。
必須チェックとして登録するのは<code>automerge-gate/all-passed</code>の1つだけで、ワークフローやGitHub App由来のチェックをこのアクションが集約してくれます。</p>
<h2>仕組み</h2>
<p>automerge-gateには2つのモードがあります。</p>
<ul>
<li>Private mode — フォークPRを受け取らないリポジトリ向けのコスト最適化モード。マージ意図のないPRではアクション自体が早期returnして、ランナー時間を消費しない</li>
<li>Public mode — フォークPRを受け取るリポジトリ向け。フォークPRでは<code>GITHUB_TOKEN</code>が読み取り専用になるため、ジョブ自身の<code>check_run</code>の終了コードがゲート信号になる</li>
</ul>
<p>メインのユースケースはPrivate modeで、Public modeはオープンソースプロジェクトのようなフォークPR対応用です。</p>
<h3>Private mode (メインのユースケース)</h3>
<p>Private modeでは、アクションがREST APIで集約結果をcommit statusとして書き込みます。
重要なのは、PRが開かれただけでマージ意図がない状態(Auto Merge未有効 &amp; write権限のApproveなし)では、アクション側はポーリングをせずに何も書き込まずにすぐに終了する点です。</p>
<p>このとき、必須チェック<code>automerge-gate/all-passed</code>はGitHubのデフォルトの<code>Expected — Waiting for status to be reported</code>のままです。
そのため、マージはブロックされた状態が維持されます。
メンテナがAuto Mergeを有効化したタイミング、またはwrite権限を持つレビュアーがApproveしたタイミングで、初めてアクションがポーリングを開始してチェックを集約しにいきます。</p>
<p><img src="https://mermaid.ink/svg/c2VxdWVuY2VEaWFncmFtCiAgICBwYXJ0aWNpcGFudCBVIGFzIOODoeODs-ODhuODigogICAgcGFydGljaXBhbnQgQSBhcyBhdXRvbWVyZ2UtZ2F0ZSAoYWN0aW9uKQogICAgcGFydGljaXBhbnQgUFIgYXMgUHVsbCBSZXF1ZXN0CgogICAgVS0-PlBSOiBvcGVuIC8gcHVzaAogICAgTm90ZSBvdmVyIEE6IGFjdGlvbiDjga_jgrnjgq3jg4Pjg5cgKOODnuODvOOCuOaEj-Wbs-OBquOBlykKICAgIE5vdGUgb3ZlciBQUjog5b-F6aCI44OB44Kn44OD44Kv44GvICJFeHBlY3RlZCIg44Gu44G-44G-PGJyLz7jg57jg7zjgrjkuI3lj68KCiAgICBVLT4-UFI6IEVuYWJsZSBBdXRvIE1lcmdlIC8gQXBwcm92ZQogICAgQS0-PkE6IFBSIOS4iuOBruS7luOBruODgeOCp-ODg-OCr-OCkuODneODvOODquODs-OCsAoKICAgIGFsdCDjgZnjgbnjgabmiJDlip8KICAgICAgICBBLT4-UFI6IGNvbW1pdCBzdGF0dXMg44KSIHN1Y2Nlc3Mg44GnIFBPU1QKICAgICAgICBQUi0-PlBSOiBHaXRIdWIgYXV0by1tZXJnZSDihpIg44Oe44O844K4CiAgICBlbHNlIOOBhOOBmuOCjOOBi-WkseaVlwogICAgICAgIEEtPj5QUjogY29tbWl0IHN0YXR1cyDjgpIgZmFpbHVyZSDjgacgUE9TVAogICAgICAgIE5vdGUgb3ZlciBQUjog44Oe44O844K45LiN5Y-vCiAgICBlbmQK?bgColor=FFFFFF" alt="automerge-gate Private modeのシーケンス" /></p>
<p>ジョブ自体は軽量で、PRの<code>check_run</code>を一定間隔(デフォルト30秒)でポーリングして集約結果を計算するだけです。
依存関係のビルドもなく、<code>runs-on: ubuntu-latest</code>の標準ランナーで十分動きます。
ポーリングしない場合のジョブは数秒で終わるので、Auto MergeがONになる前のPRではランナー時間をほぼ消費しません。</p>
<p>このスキップ動作によって、プライベートリポジトリでのコスト効率が改善できます。
GitHub Actionsの料金はジョブ単位の1分未満切り上げで、<a href="https://docs.github.com/en/actions/reference/runners/github-hosted-runners">利用できるランナー</a>ごとに<a href="https://docs.github.com/en/billing/reference/actions-runner-pricing">単価</a>が異なります。
代表的なものを抜粋すると次のとおりです。</p>
<table>
<thead>
<tr>
<th><code>runs-on:</code></th>
<th>スペック</th>
<th>料金</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ubuntu-latest</code></td>
<td>Linux 2-core (x64)</td>
<td>$0.006 / 分</td>
</tr>
<tr>
<td><code>ubuntu-24.04-arm</code></td>
<td>Linux 2-core (arm64)</td>
<td>$0.005 / 分</td>
</tr>
<tr>
<td><code>ubuntu-slim</code></td>
<td>Linux 1-core (x64)</td>
<td>$0.002 / 分</td>
</tr>
</tbody>
</table>
<p><a href="https://github.com/upsidr/merge-gatekeeper">merge-gatekeeper</a>は同等の集約処理をしてくれますが、Auto MergeがONかどうかに関わらず、PRが開かれた時点から他のチェックが揃うまでポーリングを続ける設計です。
そのため、1回のポーリングはCI全体が完了するのにかかる時間(例: 10分)とほぼ同じだけ走り続けます。
さらにpushするたびに同じポーリングが起きるので、<code>push数 × ポーリング時間</code>の課金が発生します。
さらにmerge-gatekeeperは内部でDockerコマンドを使うため、Dockerが使えない<code>ubuntu-slim</code>では動きません。
そのため、$0.006/分の<code>ubuntu-latest</code>を選ぶ必要がありました。</p>
<p>automerge-gateの場合、本体はNode.js製のActionでDockerに依存していないため、<code>ubuntu-slim</code>(1-core, $0.002/分)でも動かせます。
加えてPrivate modeでは、Auto Mergeが有効化されておらずwrite権限ありのApproveもないPRに対してはポーリング自体を開始しません。
ジョブはトリガーされるので1分切り上げの最低課金(<code>ubuntu-slim</code>なら$0.002)は発生しますが、CI完了までポーリングし続けることはなくなります。</p>
<p>ただし、merge-gatekeeperとは視覚的な違いがあります。
merge-gatekeeperの場合、常にポーリングしているので、すべてのチェックが揃えば集約チェックがグリーンになります。
一方automerge-gateのPrivate modeでは、Auto MergeまたはApproveまで<code>automerge-gate/all-passed</code>は<code>pending</code>のままです。
GitHubの表示上は<code>Expected — Waiting for status to be reported</code>と出ます。</p>
<p>Commit statusは<code>(SHA, context)</code>の組をキーにしてGitHubが評価するので、新しいコミットがpushされても自動的に新しいSHAに対して再評価が走ります。Auto Mergeを一度有効にしたら、その後はpush毎に有効/無効を切り替える必要はありません。</p>
<h2>設定方法</h2>
<p>設定は次の5ステップです。</p>
<ol>
<li>モードを選ぶ(Private / Public)</li>
<li><code>.github/workflows/automerge-gate.yaml</code>を追加</li>
<li>Ruleset(またはBranch protection)で<code>automerge-gate/all-passed</code>を必須チェックに登録</li>
<li>リポジトリ設定で「Allow auto-merge」を有効化</li>
<li>PRで「Enable Auto Merge」をクリック</li>
</ol>
<h3>ワークフローファイル (Private mode)</h3>
<p><code>.github/workflows/automerge-gate.yaml</code>は次のような内容になります。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="na">name</span><span class="pi">:</span> <span class="s">automerge-gate</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">opened</span><span class="pi">,</span> <span class="nv">synchronize</span><span class="pi">,</span> <span class="nv">reopened</span><span class="pi">,</span> <span class="nv">auto_merge_enabled</span><span class="pi">]</span>
  <span class="na">pull_request_review</span><span class="pi">:</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">submitted</span><span class="pi">]</span>

<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">${{ github.workflow }}-${{ github.ref }}</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="kc">true</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">gate</span><span class="pi">:</span>
    <span class="na">if</span><span class="pi">:</span> <span class="pi">&gt;-</span>
      <span class="s">github.event_name != 'pull_request_review' ||</span>
      <span class="s">github.event.review.state == 'approved'</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">statuses</span><span class="pi">:</span> <span class="s">write</span>
      <span class="na">checks</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">pull-requests</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">actions</span><span class="pi">:</span> <span class="s">read</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pkgdeps/automerge-gate@v5.0.0</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">gate-mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">private'</span>
          <span class="na">context</span><span class="pi">:</span> <span class="s1">'</span><span class="s">automerge-gate/all-passed'</span>
</code></pre></div></div>
<p>ポイントは次のとおりです。</p>
<ul>
<li><code>pull_request_review</code>の<code>if:</code>で<code>approved</code>のみを通している。GitHubの<code>on:</code>はレビューのstateで絞り込めないので、ジョブの<code>if:</code>で弾いて空振りでもrunnerが立ち上がらないようにしている</li>
<li><code>timeout-minutes: 10</code>がポーリングループの唯一のタイムアウト。アクション側に独立した<code>timeout-seconds</code>入力はあえて用意されておらず、設定箇所を二重化しない方針になっている</li>
<li>権限は<code>statuses: write</code>(commit status書き込み)と<code>checks: read</code>(チェックの集約読み取り)が必須。<code>ignore-checks</code>で<code>workflow</code>フィールドを使う場合は<code>actions: read</code>も必要</li>
</ul>
<p>Approveをマージ意図として扱いたくないチームは、<code>on:</code>から<code>pull_request_review</code>を外せば、Auto Mergeを明示的に有効化したときだけポーリングが走るようになります。</p>
<h3>必須チェックの登録</h3>
<p>Settings → Rules → Rulesetsを開いて、<code>automerge-gate/all-passed</code>を必須チェックに追加します。</p>
<p>📝 必須チェックのドロップダウンは、過去にそのリポジトリで実行されたチェック名しかオートコンプリートしないので、初回設定時は候補に出てきません。手入力で<code>automerge-gate/all-passed</code>と入れる必要があります。</p>
<h3>Auto Mergeの有効化</h3>
<p>Settings → General → Pull Requestsで「Allow auto-merge」をチェックします。これをしないとPRに「Enable Auto Merge」ボタンが出ないので、ステップ5が動きません。</p>
<h2>除外パターンの指定</h2>
<p>CodecovやNetlifyのプレビュー、Renovateなど特定のチェック/Appをゲートから外したい場合は、<code>ignore-checks</code>で除外できます。
v5から<code>ignore-checks</code>はJSONC形式の配列で記述し、<code>app</code> / <code>name</code> / <code>workflow</code>の各フィールドで条件を指定します。
v4までの<code>ignore-apps</code>入力と改行区切りリスト形式は廃止されました。</p>
<p>v4までは<code>ignore-apps</code>と<code>ignore-checks</code>の2つの入力に除外条件が分かれており、まとめて指定しにくい問題がありました。
また、同じジョブ名(<code>check_run.name</code>)でもワークフローごとに区別したいケースを表現できませんでした。
v5では1箇所のJSONC配列にまとめ、<code>app</code> / <code>name</code> / <code>workflow</code>のAND組み合わせで指定できるようにすることでこれらを解消しています。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pkgdeps/automerge-gate@v5.0.0</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">gate-mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">private'</span>
    <span class="na">ignore-checks</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">[</span>
        <span class="s">{ "app": "dependabot" },</span>
        <span class="s">{ "app": "renovate" },</span>
        <span class="s">{ "name": "optional-*" },</span>
        <span class="s">{ "app": "xcode-cloud", "name": "Build *" },</span>
        <span class="s">{ "workflow": "ci-go.yaml", "name": "lint" }</span>
      <span class="s">]</span>
</code></pre></div></div>
<p>評価ルールは次のとおりです。</p>
<ul>
<li>同一ルール内の複数フィールドはAND、異なるルール間はORで評価される</li>
<li>省略したフィールドはワイルドカード扱いになる</li>
<li>各フィールドはglob(<code>*</code> / <code>?</code>)対応</li>
<li><code>workflow</code>はGitHub Actions由来のチェックにのみ有効で、ジョブの<code>name:</code>が同じでも別ワークフローのものだけを除外できる(<code>actions: read</code>権限が必須)</li>
</ul>
<p><code>name</code>がマッチするのはGitHub APIの<code>check_run.name</code>(=<code>jobs.&lt;key&gt;.name</code>)です。
GitHubのUIで見える<code>&lt;workflow&gt; / &lt;job&gt;</code>形式ではない点に注意してください。
実際にどの名前・App slug・ワークフローで記録されているかは、次のコマンドで確認できます。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">gh api <span class="s2">"repos/{owner}/{repo}/commits/{sha}/check-runs"</span> <span class="se">\</span>
  <span class="nt">--jq</span> <span class="s1">'.check_runs[] | {name, app: .app.slug, workflow: .check_suite.workflow_id, conclusion}'</span>
</code></pre></div></div>
<h2>Public modeについて</h2>
<p>オープンソースプロジェクトのようにフォークPRを受け付けるリポジトリでは、フォークPRに対して<code>GITHUB_TOKEN</code>が読み取り専用になります。
そのため、Private modeのようにcommit statusをPOSTする方法は使えません。
書き込みできないと「待機中(=ステータス未設定)」の状態も表現できないため、Private modeでやっている「マージ意図がなければスキップ」もそのままでは成り立ちません。</p>
<p>そこでPublic modeでは、ジョブ自身の<code>check_run</code>(GitHub Actionsが自動で作るもの)の終了コードをゲート信号として扱います。
ジョブの<code>name:</code>を必須チェックの名前(<code>automerge-gate/all-passed</code>)に揃えておくことで、ジョブの結果がそのまま必須チェックの結果になります。
この点は<a href="https://github.com/upsidr/merge-gatekeeper">merge-gatekeeper</a>とほぼ同じ仕組みです。
代わりに「スキップで節約」はできなくなるため、Public modeでは全イベントで常にポーリングする形になります。</p>
<p>代替案として<code>pull_request_target</code>で<code>GITHUB_TOKEN</code>に書き込み権限を持たせるアプローチもあります。
しかし、フォーク由来のコードを書き込み権限付きで動かすことになり、セキュリティ上の問題が大きいです。
そのため、この方式は採らずに「ジョブ自身の終了コードを信号にする」形に落ち着きました。
詳しい設計の背景は、<a href="https://github.com/pkgdeps/automerge-gate/blob/main/docs/architecture.md">architecture.md</a>にまとまっています。</p>
<p><img src="https://mermaid.ink/svg/c2VxdWVuY2VEaWFncmFtCiAgICBwYXJ0aWNpcGFudCBQUiBhcyBQdWxsIFJlcXVlc3QKICAgIHBhcnRpY2lwYW50IEogYXMgZ2F0ZSBqb2IKICAgIHBhcnRpY2lwYW50IEEgYXMgYXV0b21lcmdlLWdhdGUgKGFjdGlvbikKCiAgICBQUi0-Pko6IHdvcmtmbG93IHRyaWdnZXIgKOW4uOaZgikKICAgIE5vdGUgb3ZlciBKOiBqb2Ig44GuIGNoZWNrX3J1biA9IOW_hemgiOODgeOCp-ODg-OCrzxici8-KGpvYiDlkI3jgajkuIDoh7QpCiAgICBKLT4-QTogYWN0aW9uIOOBjOS7luOBruODgeOCp-ODg-OCr-OCkuODneODvOODquODs-OCsAoKICAgIGFsdCDjgZnjgbnjgabmiJDlip8KICAgICAgICBBLT4-SjogZXhpdCAwCiAgICAgICAgSi0-PlBSOiBqb2Ig44GuIGNoZWNrX3J1biDihpIgc3VjY2VzcwogICAgICAgIFBSLT4-UFI6IEdpdEh1YiBhdXRvLW1lcmdlIOKGkiDjg57jg7zjgrgKICAgIGVsc2Ug44GE44Ga44KM44GL5aSx5pWXCiAgICAgICAgQS0-Pko6IGV4aXQgbm9uLXplcm8KICAgICAgICBKLT4-UFI6IGpvYiDjga4gY2hlY2tfcnVuIOKGkiBmYWlsdXJlCiAgICAgICAgTm90ZSBvdmVyIFBSOiDjg57jg7zjgrjkuI3lj68KICAgIGVuZAo?bgColor=FFFFFF" alt="automerge-gate Public modeのシーケンス" /></p>
<p>ワークフローは次のようになります。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="na">name</span><span class="pi">:</span> <span class="s">automerge-gate</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">types</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">opened</span><span class="pi">,</span> <span class="nv">synchronize</span><span class="pi">,</span> <span class="nv">reopened</span><span class="pi">,</span> <span class="nv">auto_merge_enabled</span><span class="pi">]</span>

<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">${{ github.workflow }}-${{ github.ref }}</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="kc">true</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">gate</span><span class="pi">:</span>
    <span class="na">name</span><span class="pi">:</span> <span class="s">automerge-gate/all-passed</span> <span class="c1"># ruleset側の必須チェック名と一致させる</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">checks</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">pull-requests</span><span class="pi">:</span> <span class="s">read</span>
      <span class="na">actions</span><span class="pi">:</span> <span class="s">read</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">pkgdeps/automerge-gate@v5.0.0</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">gate-mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">public'</span>
</code></pre></div></div>
<p>Private modeとの違いは次のとおりです。</p>
<ul>
<li>権限は<code>checks: read</code>のみでよい(commit statusを書き込まないため)</li>
<li>「マージ意図がないPRはスキップ」というコスト最適化は行わない。常にトリガーごとにポーリングする(<code>GITHUB_TOKEN</code>が読み取り専用だと”待機中”の信号を書き込めないため)</li>
<li>ジョブの<code>name:</code>が必須チェック名そのものになる</li>
</ul>
<p>実際のPublic modeでの実行例として、<a href="https://github.com/secretlint/secretlint/pull/1557">secretlint/secretlint#1557</a>のログを見てみます。
<code>ubuntu-24.04</code>の標準ランナー上で、17個のチェックを集約している様子です。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>##[group][00:05] Poll #1 — pending, 14/15 completed
  🟡 Agent (in_progress)
  ✅ Analyze (javascript-typescript) (success)
  ✅ Analyze (javascript) (success)
  ✅ binary-test (success)
  ✅ CodeQL (success)
  ...
##[group][00:38] Poll #2 — success, 17/17 completed
  ✅ Agent (success)
  ...
✅ Passed (17):
</code></pre></div></div>
<p>CodeQL、hadolint、secretlint、各OS/Node.jsのテストなど複数ワークフロー由来のチェックが、<code>automerge-gate/all-passed</code>の1つに集約されています。
ジョブ自体はチェック結果を読んで待つだけなので、約38秒で集約完了しています。</p>
<p>オープンソースプロジェクトのようにフォークPRを受け付ける環境でなければ、Private modeを使うのが基本になります。</p>
<h2>merge-gatekeeperとの細かな違い: GitHub Actionsが作ったPRのデッドロック</h2>
<p>GitHub ActionsがPRを作る場合、<code>secrets.GITHUB_TOKEN</code>で作成されたPRに対しては、無限ループ防止のために他のGitHub Actionsワークフローが発火しません。</p>
<ul>
<li>参考: <a href="https://docs.github.com/en/actions/how-tos/manage-workflow-runs/trigger-a-workflow#triggering-a-workflow-from-a-workflow">Triggering a workflow from a workflow - GitHub Docs</a></li>
</ul>
<p>このとき、ゲート用のワークフローも発火しないため、必須チェックがいつまでも報告されません。
merge-gatekeeperの場合は、PRイベントでしか動かないため、このPRはマージできないままデッドロックします。</p>
<p>automerge-gateはPrivate/Publicどちらのモードでも、<code>pull_request</code>の<code>auto_merge_enabled</code>イベントをトリガーに含めています。
さらにPrivate modeではApprove(<code>pull_request_review</code>の<code>submitted</code>)もトリガーに含まれます。
そのため、手動でAuto Mergeを有効化するかApproveすれば、人手起点でゲートを動かせます。
完全な自動化はできませんが、デッドロック状態からは一応抜け出せる構造になっています。</p>
<h2>コスト</h2>
<p>月に5000回以上merge-gatekeeperが動いていた大きめなmonorepoでmerge-gatekeeperからautomerge-gateから切り替えてみました。
実測ベースで、実行時間は平均が1/5、原価がubuntu-latestからubuntu-slimで1/3となり、金額ベースでは$150/monthは節約できるようになりました。</p>
<p>これは、gate処理を実行するタイミングが異なるため、無駄なポーリングが減ったことによるコスト削減が大きいです。
また、gate処理自体の失敗率(9割以上は上限のtimeout)も減っているので、より安定したような感覚もあります。</p>
<h2>制限事項</h2>
<p>automerge-gateには次の制限があります。</p>
<ul>
<li>Merge Queue非対応 — GitHubの<code>merge_group</code>イベントには非対応</li>
<li>ジョブのタイムアウト — <code>timeout-minutes</code>に達するとジョブが<code>failure</code> / <code>cancelled</code>で終わり、必須チェックは赤のまま残る。GitHub Actionsの「Re-run failed jobs」でリトライするか、Auto Mergeを一度無効化して有効化し直す</li>
<li>Legacy commit status APIのみのCI — AtlantisやJenkinsの一部のような、legacy commit status APIだけを使うCIは集約対象にならない
<ul>
<li>該当するCIはRulesetに直接必須チェックとして追加し、<code>automerge-gate/all-passed</code>と並列に置く必要がある</li>
</ul>
</li>
</ul>
<h2>バージョニング</h2>
<p>automerge-gateのリリースは、<code>v5.0.0</code>のような不変のSemVerタグで公開されます。
<code>v5</code>のように移動するメジャータグは意図的に作っていないので、ワークフロー側では固定バージョンを指定して、RenovateやDependabotで更新するスタイルが推奨されています。これは、移動するタグが書き換えられるサプライチェーンリスクを避けるための設計です。</p>
<h2>まとめ</h2>
<p><a href="https://github.com/pkgdeps/automerge-gate">automerge-gate</a>は、GitHubのAuto Mergeとあわせて使う集約チェックのGitHub Actionです。</p>
<ul>
<li>Rulesetに登録する必須チェックは<code>automerge-gate/all-passed</code>の1つだけで済む</li>
<li>Renovate/DependabotやmonorepoのパスフィルタによってPRごとにチェックが増減しても、自動的に集約される</li>
<li>Private modeでは、マージ意図のないPRではポーリングをスキップするためrunner時間をほぼ消費しない</li>
<li>フォークPRを受け取るオープンソースプロジェクトなどはPublic modeで対応できる</li>
</ul>
<p>merge-gatekeeperと似たコンセプトですが、Auto Merge前提でコストを最適化している点が違います。
また、Private/Publicの2モードに分けて<code>GITHUB_TOKEN</code>の権限差に対応している点も異なります。</p>
<h2>参考</h2>
<ul>
<li><a href="https://github.com/pkgdeps/automerge-gate">pkgdeps/automerge-gate</a></li>
<li><a href="https://github.com/upsidr/merge-gatekeeper">upsidr/merge-gatekeeper</a></li>
<li><a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches">About protected branches - GitHub Docs</a></li>
<li><a href="https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/automatically-merging-a-pull-request">Automatically merging a pull request - GitHub Docs</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>Secretlint v13.0.0リリース: .gitignore済みをデフォルトで無視、Tailscale/Stripe/Cloudflareの検出に対応</title>
   <link href="https://efcl.info/2026/05/05/secretlint-v13/"/>
   <updated>2026-05-05T10:00:00+09:00</updated>
   <id>https://efcl.info/2026/05/05/secretlint-v13</id>
   <content type="html"><![CDATA[ <p>ソースコードや設定ファイルに含まれるAPIトークンやパスワードなどの機密情報を見つける<a href="https://github.com/secretlint/secretlint">Secretlint</a>のv13.0.0をリリースしました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/releases/tag/v13.0.0">Release v13.0.0 · secretlint/secretlint</a></li>
</ul>
<p>このバージョンの主な変更点は次の3つです。</p>
<ul>
<li>ファイル探索時に<code>.gitignore</code>をデフォルトで尊重するように変更（Breaking Change）</li>
<li>グロブメタ文字を含むパスが実在する場合はリテラルとして扱うように変更</li>
<li>Tailscale/Stripeの検出ルールを新規追加、CloudflareをcanaryからrecommendへPromote</li>
</ul>
<h2>Breaking Change: <code>.gitignore</code>をデフォルトで尊重</h2>
<p>v13.0.0では、ファイル探索時に<code>.gitignore</code>の内容をデフォルトで尊重するようになりました。
ripgrepと同じ挙動で、ネストされた<code>.gitignore</code>ファイルもサブディレクトリへカスケードして適用されます。深い階層のネガティブルール（<code>!</code>）で上位の判定を上書きできます。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/pull/1530">feat!: respect .gitignore by default via @secretlint/walker by azu · Pull Request #1530</a></li>
</ul>
<p><code>.gitignore</code>に一致したファイルはスキャン対象から除外されます。
そのため、これまで<code>dist/</code>や生成物などを意図的にスキャンしていたプロジェクトでは、出力されるファイル数が減ります。</p>
<p><code>.secretlintignore</code>はこれまで通り併用できます。</p>
<p>v12までの挙動に戻したい場合は、<code>--no-gitignore</code>オプションを指定します。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">secretlint <span class="nt">--no-gitignore</span> <span class="s2">"**/*"</span>
</code></pre></div></div>
<p><code>.gitignore</code>に一致しているはずのファイルが結果に含まれている場合は、Issueで報告してください。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/issues">Issues · secretlint/secretlint</a></li>
</ul>
<h3>実装: <code>@secretlint/walker</code></h3>
<p>v13.0.0では、ファイル探索の実装を<a href="https://github.com/sindresorhus/globby"><code>globby</code></a>から、新しく追加した<a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/walker"><code>@secretlint/walker</code></a>へ置き換えました。</p>
<p>設計はplanドキュメントとしてリポジトリにコミットされています。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/blob/v13.0.0/docs/superpowers/plans/2026-05-03-walker-gitignore-cascade-plan.md">docs/superpowers/plans/2026-05-03-walker-gitignore-cascade-plan.md</a></li>
</ul>
<p>Rustには<a href="https://github.com/BurntSushi/ripgrep">ripgrep</a>の<a href="https://docs.rs/ignore/"><code>ignore</code> crate</a>があり、<a href="https://github.com/oxc-project/oxc">oxc</a>などはこれを使うことで<code>.gitignore</code>のカスケードを安価に再利用できます。
JavaScript側にも、ネストされた<code>.gitignore</code>に対応するwalkerが全く無いわけではありません（<a href="https://github.com/fabiospampinato/tiny-readdir-glob-gitignore"><code>tiny-readdir-glob-gitignore</code></a>、<a href="https://github.com/npm/ignore-walk"><code>ignore-walk</code></a>など）。
ただし、ripgrepの<code>ignore</code> crateほど枯れた実績や仕様の網羅度を持つものは見当たりませんでした。
また、後述する「グロブメタ文字を含むパスが実在する場合はリテラル扱いにする」のように、走査・マッチ側に独自の制御を入れたい要件もあります。
依存ライブラリも<code>node-ignore</code>と<code>picomatch</code>まで分解すれば<code>fs.readdir</code>の上に薄く書ける範囲だったため、Secretlint側でwalkerを実装することにしました。</p>
<p><code>@secretlint/walker</code>は、ネストされた<code>.gitignore</code>のカスケードに対応するPromiseベースのファイルシステムwalkerです。
依存ライブラリは<code>ignore</code>（node-ignore）と<code>picomatch</code>の2つだけで、含めるパターン（include）と除外するパターン（ignore）でセマンティクスを分離しています。</p>
<ul>
<li>含めるパターン（include）: <a href="https://github.com/micromatch/picomatch"><code>picomatch</code></a>を使ったグロブマッチ。<code>**/*.{ts,js}</code>のようなブレース展開やドットファイルのマッチに対応する</li>
<li>除外するパターン（ignore）: <a href="https://github.com/kaelzhang/node-ignore"><code>node-ignore</code></a>を使った<code>.gitignore</code>セマンティクスのマッチ。<code>.gitignore</code>の挙動に合わせるため、ブレース展開は意図的に対応しない</li>
</ul>
<p>Node.js本体にも<a href="https://nodejs.org/api/fs.html#fsglobpattern-options-callback"><code>fs.glob</code></a>や<a href="https://nodejs.org/api/path.html#pathmatchesglobpath-pattern"><code>path.matchesGlob</code></a>が用意されていますが、これらにはドットファイルをマッチに含める<code>dot</code>オプションが存在しません。
ドットファイル（<code>.env</code>など）のスキャンが要件として外せないため、include側は<code>picomatch</code>にしています。</p>
<p>走査自体は<code>fs.readdir(dir, { withFileTypes: true })</code>をベースにしたシンプルな再帰で、各ディレクトリのエントリを<code>Promise.all</code>で並列処理します。
ディレクトリ単位でignoreを判定し、無視対象に該当したディレクトリはサブツリーごと走査をやめます。
<code>readdir</code>自体を呼ばないため、大きな<code>node_modules</code>配下などをまるごとスキップできます。</p>
<p><code>.gitignore</code>のカスケードは<code>IgnoreStack</code>という構造で扱います。
親ディレクトリの<code>ignore</code>インスタンスに対して、現在のディレクトリの<code>.gitignore</code>を読み込んで<code>extendIgnore()</code>で重ねるという形でレイヤーを積みます。
<code>.gitignore</code>がそのディレクトリに存在しない場合は親のインスタンスをそのまま返すため、不要なallocationが起きません。
深い階層のネガティブルール（<code>!pattern</code>）が浅い階層のルールを上書きする挙動も、このスタック上で自然に表現されます。
ネストされた<code>.gitignore</code>内のパターンはそのファイルの置かれたディレクトリにアンカーされます。
そのため、<code>packages/foo/.gitignore</code>の<code>src/**/*.ts</code>はリポジトリルートではなく<code>packages/foo/src/</code>配下にだけ適用されます。</p>
<p>クロスプラットフォーム対応として、<code>node-ignore</code>へ渡すパスはWindowsでも<code>/</code>区切りに正規化（<code>toPosix()</code>）した上でマッチングします。
返すパスはOSネイティブの区切り文字に戻します。
また、<code>ENOENT</code>や<code>EACCES</code>は探索全体を止めずにスキップする方針で、ファイル削除・パーミッションエラーに対して頑健になっています。</p>
<p>入力パターンは静的なプレフィックス（walk root）と動的なサフィックス（マッチパターン）に分割し、同じrootを持つパターンはグループ化して1度のwalkで処理します。
グロブメタ文字を含む入力でも、それが実際のファイル/ディレクトリとして存在する場合は<code>noGlob</code>相当の扱いにフォールバックさせるロジックもwalker側に入っています。</p>
<p>主なAPIは<code>walk(options)</code>で、パッケージ単独でも利用できます。</p>
<table>
<thead>
<tr>
<th>オプション</th>
<th>型</th>
<th>デフォルト</th>
<th>説明</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cwd</code></td>
<td><code>string</code></td>
<td>必須</td>
<td>探索の起点ディレクトリ</td>
</tr>
<tr>
<td><code>patterns</code></td>
<td><code>string[]</code></td>
<td><code>undefined</code></td>
<td>インクルードのグロブパターン</td>
</tr>
<tr>
<td><code>ignoreFiles</code></td>
<td><code>string[]</code></td>
<td><code>[]</code></td>
<td>カスケード対象のignoreファイル名（<code>.gitignore</code>など）</td>
</tr>
<tr>
<td><code>extraIgnorePatterns</code></td>
<td><code>string[]</code></td>
<td><code>[]</code></td>
<td>コードから渡す追加のignoreパターン</td>
</tr>
<tr>
<td><code>noGlob</code></td>
<td><code>boolean</code></td>
<td><code>false</code></td>
<td><code>patterns</code>をリテラルとして扱う</td>
</tr>
<tr>
<td><code>followSymlinks</code></td>
<td><code>boolean</code></td>
<td><code>true</code></td>
<td>シンボリックリンクを追従するか</td>
</tr>
</tbody>
</table>
<p>CLI側の<code>secretlint</code>では、<code>--no-gitignore</code>が指定された場合は<code>ignoreFiles</code>から<code>.gitignore</code>を外し、それ以外の場合はカスケード有効でwalkします。
<code>.secretlintignore</code>は別経路の<code>extraIgnorePatterns</code>相当で従来通り適用されるため、<code>.gitignore</code>との共存に影響はありません。</p>
<h3>パフォーマンス</h3>
<p>walker単体の実行時間で見ると、globbyからの置き換えによるパフォーマンスの大きな劣化はありません。
一方で、<code>.gitignore</code>のカスケードは「親のルールに子のルールを正しく重ねる」ことを満たさないと挙動が壊れる部分なので、ここはripgrepの実装を参考にしながら書いています。</p>
<p>実際のSecretlintの実行時間で見ると、<code>.gitignore</code>を尊重することで<code>node_modules/</code>や<code>dist/</code>などをそもそもスキャンしなくなるため、Lint対象のファイル数が減ります。
スキャン+ルール評価のコストはファイル数に比例して効いてくるため、walker自体のコストよりもLint対象が減ることによる削減のほうが支配的になります。
結果として、v12より速く終わるケースが多くなる想定です。</p>
<h2>グロブメタ文字を含むパスが実在する場合はリテラル扱いに</h2>
<p>Secretlintはコマンドライン引数をデフォルトでグロブパターンとして解釈します。
v13.0.0では、グロブメタ文字（<code>()</code>、<code>[]</code>、<code>{}</code>、<code>?</code>）を含むパスが実際にファイル/ディレクトリとして存在する場合は、リテラルとして扱うように変更しました。</p>
<p>これは、SvelteKitやNext.jsのRoute Groupなど、ディレクトリ名に<code>()</code>や<code>[]</code>を含むプロジェクトで特に有効です。</p>
<table>
<thead>
<tr>
<th>パターン</th>
<th>ディスク上</th>
<th>v12のデフォルト</th>
<th>v13のデフォルト</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>src/(group)/page.tsx</code></td>
<td>存在する</td>
<td>グロブとして解釈、マッチしない</td>
<td>リテラルとしてマッチ</td>
</tr>
<tr>
<td><code>src/(missing)/page.tsx</code></td>
<td>存在しない</td>
<td>グロブとして解釈</td>
<td>グロブとして解釈</td>
</tr>
<tr>
<td><code>src/[a-z]ormal.tsx</code></td>
<td><code>normal.tsx</code>が存在</td>
<td>グロブ経由でマッチ</td>
<td>グロブ経由でマッチ</td>
</tr>
</tbody>
</table>
<p>入力ごとに1度だけ<code>stat</code>を実行し、存在すればリテラル、存在しなければ従来通りグロブとして扱います。
従来通り常にリテラルとして扱いたい場合は、<code>--no-glob</code>オプションを指定します。</p>
<h2>新しく追加された検出ルール</h2>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>に、次の3つのルールが追加されました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-tailscale">Tailscale</a> - Tailscale APIキー（新規追加）</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-stripe">Stripe</a> - Stripe APIキー（新規追加）</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-cloudflare">Cloudflare</a> - Cloudflare APIトークン（canaryから昇格）</li>
</ul>
<p>関連PRは次のとおりです。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/pull/1536">Add Tailscale API key detection rule by azu · Pull Request #1536</a></li>
<li><a href="https://github.com/secretlint/secretlint/pull/1537">feat(secretlint-rule-stripe): add Stripe API key detection rule by azu · Pull Request #1537</a></li>
<li><a href="https://github.com/secretlint/secretlint/pull/1538">feat(secretlint-rule-preset-recommend): promote cloudflare, stripe, tailscale from canary by azu · Pull Request #1538</a></li>
</ul>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>を使っている場合は、v13.0.0にアップデートすると自動的にこれらのルールが有効になります。</p>
<h2>まとめ</h2>
<p>Secretlint v13.0.0では、ファイル探索が<code>.gitignore</code>をデフォルトで尊重するようになりました。
そのため、<code>dist/</code>などをスキャンしていたプロジェクトでは、<code>--no-gitignore</code>への切り替えや<code>.secretlintignore</code>の見直しが必要になります。</p>
<p>これに加えて、グロブメタ文字を含む実在パスをリテラルとして扱うよう調整し、Route Groupなどのディレクトリ構成でもオプションなしに動作します。
検出ルールにはTailscale/Stripe/Cloudflareを追加しています。</p>
<p>フィードバックがあればGitHubのIssueでお知らせください。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/releases/tag/v13.0.0">Release v13.0.0 · secretlint/secretlint</a></li>
<li><a href="https://github.com/secretlint/secretlint/issues">Issues · secretlint/secretlint</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>JavaScript PrimerのES2026対応を手伝ってくれるContributorとSponsorを募集しています</title>
   <link href="https://efcl.info/2026/05/01/jsprimer-es2026-proposal/"/>
   <updated>2026-05-01T12:00:00+09:00</updated>
   <id>https://efcl.info/2026/05/01/jsprimer-es2026-proposal</id>
   <content type="html"><![CDATA[ <p>JavaScript Primer (<a href="https://jsprimer.net/">https://jsprimer.net/</a>) では、毎年ECMAScriptの新しい仕様への追従を行っています。</p>
<p>ES2026は2026年6月に正式リリースされる予定です。
TC39ではすでにFeature Freezeが行われ、ES2026に入る予定の機能が確定しています。</p>
<ul>
<li><a href="https://tc39.es/process-document/">TC39 Process</a></li>
</ul>
<p>今年もES2026で追加される機能についての対応Issueを作成しました。</p>
<p>これらのIssueを一緒に進めてくれるContributorと、JavaScript Primerの活動を支援してくれるSponsorを募集しています。</p>
<p>次のDiscussionにコメントをください</p>
<ul>
<li>募集しているDiscussion: <a href="https://github.com/js-primer/js-primer/discussions/1884">ES2026に対応するIssueへのContributorを募集しています · js-primer/js-primer · Discussions</a></li>
</ul>
<h2>ES2026対応のIssue</h2>
<p>ES2026のMeta Issueとして次のIssueがあります。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/issues/1869">ES2026の対応 · Issue #1869 · js-primer/js-primer</a></li>
</ul>
<p>具体的に対応するものとして次のIssueを作成しています。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/issues/1872">ES2026: Error.isError · Issue #1872 · js-primer/js-primer</a>
<ul>
<li>見積もり: 2 point</li>
</ul>
</li>
<li><a href="https://github.com/js-primer/js-primer/issues/1873">ES2026: Map.prototype.getOrInsert / getOrInsertComputed (Upsert) · Issue #1873 · js-primer/js-primer</a>
<ul>
<li>見積もり: 3 point</li>
</ul>
</li>
<li><a href="https://github.com/js-primer/js-primer/issues/1874">ES2026: JSON.rawJSON (JSON.parse source text access) · Issue #1874 · js-primer/js-primer</a>
<ul>
<li>見積もり: 2 point</li>
</ul>
</li>
<li><a href="https://github.com/js-primer/js-primer/issues/1875">ES2026: Iterator.concat (Iterator Sequencing) · Issue #1875 · js-primer/js-primer</a>
<ul>
<li>見積もり: 2 point</li>
</ul>
</li>
</ul>
<p>各Issueには、作業量の見積もりとして<code>point</code>を付与しています。(これは感覚値なのであんまり正確ではないです。実際にやってみたら変わる可能性もあります)
この<code>point</code>は、作業の難易度や必要な調査量などを考慮して設定していて、後述するOpen Collectiveでの報酬計算にも利用します。</p>
<p><code>point</code>の目安は以下の通りです。これは作業時間ではなく、タスクの複雑さや規模を表す指標です。
例えば2 pointは「1日あれば終わるかな」という感覚値に近いものです。</p>
<table>
<thead>
<tr>
<th>Point</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>些細な変更 (typo修正など)</td>
</tr>
<tr>
<td>1</td>
<td>2 よりは簡単</td>
</tr>
<tr>
<td>2</td>
<td>大体1日分の作業量で終わる想定</td>
</tr>
<tr>
<td>3</td>
<td>2 よりは難しい</td>
</tr>
<tr>
<td>5</td>
<td>かなり難しい、調査や広範な変更が必要</td>
</tr>
<tr>
<td>8</td>
<td>難易度がとても高く、できる人が限られるレベル</td>
</tr>
</tbody>
</table>
<p>ES2026に対応するマイルストーンは、次のページで公開しています。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/milestone/7">v8(ES2026) Milestone</a></li>
</ul>
<p>ES2026は2026年6月末ぐらいに公開される予定なので、7月ぐらいには完成させる予定です。</p>
<p>去年のES2025対応 (<a href="https://efcl.info/2025/04/25/jsprimer-es2025-proposal/">JavaScript PrimerのES2025対応を手伝ってくれるContributorとSponsorを募集しています | Web Scratch</a>) と比べると、今年のES2026は粒度が比較的均一で、1人1Issueで分担しやすいラインナップになっています。</p>
<h2>Contributorを募集しています</h2>
<p>JavaScript Primerの執筆、レビュー、サンプルコード作成、仕様調査などに興味がある方を募集しています。</p>
<p>今年のIssueは1人1Issueで分担しやすい粒度なので、それぞれのIssueに興味がある人を募集しています。</p>
<ul>
<li>募集しているDiscussion: <a href="https://github.com/js-primer/js-primer/discussions/1884">ES2026に対応するIssueへのContributorを募集しています · js-primer/js-primer · Discussions</a></li>
</ul>
<p>Contributeしたい人は、次のDiscussionに参加してみてください。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/discussions">ES2026に対応するIssueへのContributorを募集しています · js-primer/js-primer · Discussions</a></li>
</ul>
<h3>Open Collectiveによる報酬</h3>
<p>JavaScript Primerは<a href="https://opencollective.com/jsprimer">Open Collective</a>を通じて、活動資金の支援を受け付けています。
Contributorとして参加していただいた方には、この予算から<a href="https://github.com/js-primer/js-primer/blob/master/CONTRIBUTING_EXPENSE.md">Contributing Expenses Policy</a>に基づき、作業量に応じた報酬を請求できます。</p>
<p>報酬額は、Issueごとに設定された<code>point</code>に基づいて計算されます。
現時点での年間予算は約$1420で、これを元に計算すると <strong>1 pointあたり約$23</strong> となります。</p>
<p>なお、報酬は自分で受け取るほかに、<strong>他のOpen Collectiveに同じ金額を寄付する</strong>という選択肢もあります。
たとえば<a href="https://opencollective.com/babel">Babel</a>など、Open Collective上の任意のCollectiveを寄付先に指定できます。
jsprimerから直接、指定されたCollectiveへ同額が寄付される仕組みです。</p>
<p>過去のIssueに対応するpointの参考値やOpen Collectiveの利用方法、寄付先の指定方法については次のページを参照してください。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/blob/master/CONTRIBUTING_EXPENSE.md">Contributing Expenses Policy</a></li>
</ul>
<h3>書き方について</h3>
<p>JavaScript Primerは技術書であるため、次の点に気をつけて書いていきます。</p>
<ul>
<li><strong>正確性</strong>: 仕様やMDN、信頼できる情報源を元に、矛盾のない正確な記述をします。</li>
<li><strong>読みやすさ</strong>: 読者が理解しやすいように、平易な言葉遣いや構成を意識しますが、<a href="https://textlint.org/">textlint</a>のチェックがあるのである程度強制されます。
<ul>
<li>LLMの利用自体は問題ありませんが、最終的な品質は人間が読みやすかどうかで判断します</li>
</ul>
</li>
<li><strong>サンプルコード</strong>: ユースケースに基づいた、実践的で理解しやすいサンプルコードを扱います。なぜそのコードが必要なのか、どのような場面で役立つのかが伝わるように意識します。
<ul>
<li>実際に使われているパターンなどをもとにサンプルコードを書きます</li>
</ul>
</li>
<li><strong>目的意識</strong>: jsprimerには<a href="https://jsprimer.net/intro/">はじめに · JavaScript Primer #jsprimer</a>に書いているように、本書の目的と目的ではないことが書かれています
<ul>
<li>毎年悩むのは「どこまで書くか」ということですが、悩んだ時は本書の目的に立ち返って判断します</li>
</ul>
</li>
</ul>
<p>実際に書籍を書くときには、<a href="https://textlint.org/">textlint</a>による文章のチェックやレビューやサンプルコードに対するテストの仕組みなどもあるので、文章ですがコードを書くような感覚で書いていくのが良いと思います。</p>
<p>詳しい書き方やルールについては、次のドキュメントを参照してください。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/blob/master/CONTRIBUTING.md">Contribution Guide</a></li>
</ul>
<h2>参加方法</h2>
<p>Contributorとして参加してみたい方は、次のDiscussionにコメントしてみてください。</p>
<ul>
<li><a href="https://github.com/js-primer/js-primer/discussions/1884">ES2026に対応するIssueへのContributorを募集しています · js-primer/js-primer · Discussions</a></li>
</ul>
<p>ご興味のある方、ぜひ参加してみてください！</p>
<h2>Sponsorを募集しています</h2>
<p>JavaScript Primerの活動は、個人や企業のSponsorからの支援によって支えられています。
書籍の継続的なメンテナンスや改善活動を支援してくださるSponsorを随時募集しています！</p>
<p>現在のGold Sponsorは次の通りです。ご支援ありがとうございます！</p>
<p><strong>Gold Sponsors</strong></p>
<p><a href="https://being-i.sh/" title="being-ish Inc."><img src="https://images.opencollective.com/being-ish/66099a1/logo/256.png?height=166" height="166" alt="" loading="lazy"></a></p>
<ul>
<li><a href="https://being-i.sh/">being-ish Inc.</a></li>
</ul>
<p><strong>Supporters</strong></p>
<p><a href="https://opencollective.com/jsprimer#backers"><img src="https://opencollective.com/jsprimer/backers.svg?width=890&amp;avatarHeight=40" alt="jsprimer backers" /></a></p>
<p>jsprimerの更新を金銭的にサポートしたいという方は、是非検討してみてください！</p>
<p>詳細は<a href="https://jsprimer.net/intro/sponsors/">JavaScript Primerスポンサー · JavaScript Primer #jsprimer</a>をご覧ください。</p>
<ul>
<li><a href="https://opencollective.com/jsprimer">JavaScript Primer - Open Collective</a></li>
<li><a href="https://jsprimer.net/intro/sponsors/">JavaScript Primerスポンサー · JavaScript Primer #jsprimer</a></li>
</ul>
<h3>参考: 前回までの募集</h3>
<ul>
<li><a href="https://efcl.info/2025/04/25/jsprimer-es2025-proposal/">JavaScript PrimerのES2025対応を手伝ってくれるContributorとSponsorを募集しています | Web Scratch</a></li>
<li><a href="https://efcl.info/2024/03/21/jsprimer-es2024-proposal/">JavaScript PrimerのES2024対応を手伝ってくれるContributorとSponsorを募集しています | Web Scratch</a></li>
<li><a href="https://github.com/asciidwango/js-primer/discussions/1789">ES2025に対応するIssueへのContributorを募集しています · asciidwango/js-primer · Discussion #1789</a></li>
<li><a href="https://github.com/asciidwango/js-primer/discussions/1727">ES2024に対応するIssueへのContributorを募集しています · asciidwango/js-primer · Discussion #1727</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>Secretlint v12.0.0リリース: Groq、Hugging Face、Notion、GitLab、Grafana、HashiCorp Vault、Vercel、Databricks、Docker、Figmaの検出に対応</title>
   <link href="https://efcl.info/2026/04/20/secretlint-v12/"/>
   <updated>2026-04-20T10:00:00+09:00</updated>
   <id>https://efcl.info/2026/04/20/secretlint-v12</id>
   <content type="html"><![CDATA[ <p>ソースコードや設定ファイルに含まれるAPIトークンやパスワードなどの機密情報を見つける<a href="https://github.com/secretlint/secretlint">Secretlint</a>のv12.0.0をリリースしました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/releases/tag/v12.0.0">Release v12.0.0 · secretlint/secretlint</a></li>
</ul>
<p>このバージョンでは、次のように追加で検知できるようになったサービスが10個あります。</p>
<ul>
<li>Groq、Hugging Face、Notion、GitLab、Grafana、HashiCorp Vault、Vercel、Databricks、Docker、Figma</li>
</ul>
<p>あわせて、<code>@secretlint/secretlint-rule-preset-recommend</code>のパッケージサイズを約80%削減しています。</p>
<h2>新しく追加された検出ルール</h2>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>に、次の10個のサービスのAPIトークンなどを検出するルールが追加されました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-groq">Groq</a> - <code>gsk_</code>から始まるAPIキー</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-huggingface">Hugging Face</a> - <code>hf_</code>から始まるUser Access Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-notion">Notion</a> - <code>secret_</code>/<code>ntn_</code>から始まるIntegration Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-gitlab">GitLab</a> - Personal Access Token、Pipeline Trigger Token、Runner Registration Tokenなど</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-grafana">Grafana</a> - Service Account TokenやCloud Access Policy Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-hashicorp-vault">HashiCorp Vault</a> - <code>hvs.</code>/<code>hvb.</code>から始まるトークン</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-vercel">Vercel</a> - Access Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-databricks">Databricks</a> - <code>dapi</code>から始まるPersonal Access Token</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-docker">Docker</a> - <code>dckr_pat_</code>から始まるPersonal Access TokenとDocker Hub認証情報</li>
<li><a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-figma">Figma</a> - <code>figd_</code>/<code>figu_</code>/<code>figoa_</code>などで始まるトークン</li>
</ul>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>を使っている場合は、v12.0.0にアップデートすると自動的にこれらのルールも有効になります。</p>
<p>今回追加したトークンは、<a href="https://docs.github.com/en/code-security/reference/secret-security/supported-secret-scanning-patterns">GitHubのSecret scanning partner</a>のリストを参照することで、確度の高いパターンを持つトークンに絞り込んでまとめて実装しました。
誤検知が少ないトークンフォーマットを持つサービスを優先したため、検出精度を保ったままカバー範囲を広げられています。</p>
<h2>Presetのパッケージサイズを約80%削減</h2>
<p><code>@secretlint/secretlint-rule-preset-recommend</code>のパッケージサイズを、v11と比べて約82%削減しました。</p>
<table>
<thead>
<tr>
<th>Preset</th>
<th>v11</th>
<th>v12</th>
<th>削減率</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@secretlint/secretlint-rule-preset-recommend</code></td>
<td>1,036KB</td>
<td>187KB</td>
<td>-82%</td>
</tr>
<tr>
<td><code>@secretlint/secretlint-rule-canary</code></td>
<td>1,060KB</td>
<td>211KB</td>
<td>-80%</td>
</tr>
</tbody>
</table>
<p>10個のルールが増えたにもかかわらず、全体としては約82%縮小しています。</p>
<p>主な要因は、GCPのService Account p12ファイル検出ルール(<code>@secretlint/secretlint-rule-gcp</code>)で使っていた<code>node-forge</code>依存の置き換えです。
Web CryptoベースのPKCS#12 MAC検証実装に差し替えました。
p12ファイルの判定に必要なのはMACの検証だけなので、パース・復号処理を含む<code>node-forge</code>全体を抱える必要がなくなりました。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/pull/1497">Replace node-forge with native PKCS#12 MAC verification by azu · Pull Request #1497</a></li>
</ul>
<h2>Breaking Change: Node.js 22+のサポート</h2>
<p>v12.0.0では、Node.js 20のサポートを終了し、Node.js 22以上が必要になりました。
Node.js 20は2026-04-30でActive LTSが終了するため、少し前倒しでサポートを切っています。</p>
<h2>Breaking Change: CommonJSビルドの削除</h2>
<p>v12.0.0では、各パッケージのCommonJSビルドと、CJS/ESMのdual packageサポートを削除しました。
ESMのみの配布になります。</p>
<p>Secretlint自体は<a href="https://efcl.info/2023/07/05/secretlint-v7/">v7.0.0でESMへ移行済み</a>でしたが、互換性のためCJS向けのビルドも残していました。
Node.js 22+でESMの利用が一般化したため、dual packageの保守コストを削減するためにCJSビルドを削除しています。</p>
<p>SecretlintをCLIとして利用している場合、特に影響はありません。
Node.js 22+では<a href="https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require"><code>require(esm)</code></a>がサポートされています。
そのため、<code>@secretlint/node</code>や<code>@secretlint/core</code>などを<code>require()</code>しているコードもそのまま動作します。</p>
<h2>使っているプロジェクト: SecureClipboard</h2>
<p>Secretlintを組み込んだプロジェクトの例として、先日公開した<a href="https://github.com/secretlint/secure-clipboard">SecureClipboard</a>を紹介します。</p>
<ul>
<li><a href="https://efcl.info/2026/04/19/secure-clipboard/">SecureClipboard: クリップボードに入った機密情報を自動でマスクするmacOSアプリ | Web Scratch</a></li>
</ul>
<p>SecureClipboardはクリップボードを監視するmacOSアプリです。
コピーされたテキストや画像に機密情報が含まれていた場合、自動でマスクします。
内部では<a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/binary">secretlintの単一バイナリ版</a>をsubprocessとして呼び出してスキャンしています。</p>
<p>v12.0.0で追加されたGroqやHugging Face、Notion、Vercel、Docker、Figmaなどのトークンも、SecureClipboard側で追加対応なしに検出できます。
このように、secretlintをライブラリ/バイナリとして組み込む使い方でも、presetをアップデートするだけで検出対象を拡張できます。</p>
<h2>まとめ</h2>
<p>Secretlint v12.0.0では、10個のサービスに対応する新しい検出ルールを<code>@secretlint/secretlint-rule-preset-recommend</code>に追加しました。
同時にpresetのサイズを約80%削減しています。
CJSビルドの削除というBreaking Changeがあるので、ライブラリとして利用している場合はESMへの移行が必要です。</p>
<p>フィードバックがあればGitHubのIssueでお知らせください。</p>
<ul>
<li><a href="https://github.com/secretlint/secretlint/releases/tag/v12.0.0">Release v12.0.0 · secretlint/secretlint</a></li>
<li><a href="https://github.com/secretlint/secretlint/issues">Issues · secretlint/secretlint</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>SecureClipboard: クリップボードに入った機密情報を自動でマスクするmacOSアプリ</title>
   <link href="https://efcl.info/2026/04/19/secure-clipboard/"/>
   <updated>2026-04-19T11:00:00+09:00</updated>
   <id>https://efcl.info/2026/04/19/secure-clipboard</id>
   <content type="html"><![CDATA[ <p>クリップボードを監視して、機密情報やAPIトークンが入ったら自動的にマスクするmacOSアプリ <a href="https://github.com/secretlint/secure-clipboard">SecureClipboard</a> を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/secretlint/secure-clipboard">secretlint/secure-clipboard</a></li>
</ul>
<p><img src="/wp-content/uploads/2026/secure-clipboard-menu.webp" alt="SecureClipboardのメニューバー" /></p>
<p>テキストだけでなく画像にも対応していて、スクリーンショットに写り込んだトークンなどもVision frameworkでOCRしてマスクします。
内部では<a href="https://github.com/secretlint/secretlint">secretlint</a>を使って、AWS、GitHub、Slack、GCP、Azure、npm、Dockerなどのトークンを検出します。
スキャン処理はすべてローカルで完結するmacOSアプリケーションなので、クリップボードの内容が外部に送信されることはありません。</p>
<h2>なぜ作ったか</h2>
<p>API Tokenをコピーして<code>.env</code>へ貼り付けたあと、そのトークンがクリップボード上に残り続けることがあります。
そのまま別のウィンドウで⌘+Vしてしまい、Slackのメッセージ欄やLinearのIssueタイトル、ブラウザの検索バーなどに意図せずペーストしてしまう事故が起きやすいです。
ペーストするまでクリップボードに何が入っているかは目に見えないので、気づきにくいというのも問題です。</p>
<p>SecureClipboardはコピーされた瞬間にマスクするため、誤ってペーストしても安全になります。
本物のトークンが必要なときはメニューから”Copy Original Text”を選ぶと取り出せますが、90秒後に自動でクリップボードから消えるようになっています。
これは<a href="https://support.1password.com/copy-passwords/">1Passwordのクリップボードクリア</a>と同じ仕組みです。</p>
<p>画像の場合も同様で、スクリーンショットを撮ってどこかに貼り付けるときに、意図しない映り込みを自動で防止します。
たとえばClaude Codeのターミナル画面をスクリーンショットしたときに、たまたまAPIキーや隠したいコードネームなどが含まれていた、というケースを防げます。</p>
<h2>主な機能</h2>
<p>SecureClipboardの主な機能は次のとおりです。</p>
<ul>
<li>クリップボードを監視して、自動的にマスキング</li>
<li>テキスト：<a href="https://github.com/secretlint/secretlint">secretlint</a>でスキャンして、検出した機密情報を<code>***</code>に置換</li>
<li>画像：Vision frameworkでOCRして、検出した領域だけをcrystallize + blurでマスク</li>
<li>検出時はメニューバーアイコンが赤くなり、macOSの通知を表示</li>
<li>“Copy Original Text”で生の値を取得（90秒後に自動消去）</li>
<li>マスクしたクリップボードは<a href="https://nspasteboard.org/">concealedとしてマーク</a>されるため、AlfredなどのクリップボードマネージャーやGrammarlyなどに記録されない</li>
<li><code>secure-pbpaste</code> / <code>secure-pbcopy</code> のCLIツール同梱</li>
<li>secretlintのバイナリはGitHub Releasesから自動更新</li>
</ul>
<h2>インストール</h2>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">curl <span class="nt">-fSL</span> https://github.com/secretlint/secure-clipboard/releases/latest/download/SecureClipboard.app.zip <span class="nt">-o</span> /tmp/SecureClipboard.app.zip
unzip <span class="nt">-o</span> /tmp/SecureClipboard.app.zip <span class="nt">-d</span> /Applications
xattr <span class="nt">-cr</span> /Applications/SecureClipboard.app
open /Applications/SecureClipboard.app
</code></pre></div></div>
<p>コード署名はしていないので、<code>xattr -cr</code>でquarantine属性を解除してから起動します。</p>
<p>アンインストールは次のコマンドでできます。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash"><span class="nb">rm</span> <span class="nt">-rf</span> /Applications/SecureClipboard.app
<span class="nb">rm</span> <span class="nt">-f</span> /usr/local/bin/secure-pbpaste /usr/local/bin/secure-pbcopy
</code></pre></div></div>
<h2>テキストへのマスキング</h2>
<p>クリップボード上のテキストにシークレットが含まれていれば、その部分を<code>***</code>で置き換えます。</p>
<p>たとえばSlackトークンを含む次のようなテキストをコピーすると、このようになります。</p>
<!-- secretlint-disable -->
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Slack Token is xoxb-1234567890123-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx
</code></pre></div></div>
<!-- secretlint-enable -->
<p>実際にクリップボードに入るのは、トークン部分だけがマスクされた次のようなテキストになります。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Slack Token is *********************************************************
</code></pre></div></div>
<p>スクリーンショットの中にテキストとして含まれているシークレットも、Vision frameworkでOCRしてからスキャンするため、同じようにマスクされます。
たとえば下のスクリーンショットでは、cmuxの通知メニューに含まれていたユーザー情報がOCR経由で検出され、その矩形領域だけがblurされています。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-text-mask.webp" alt="テキストマスクの例" /></p>
<h2>画像へのマスキング</h2>
<p>画像がクリップボードに入ると、Vision frameworkでOCRしてテキストを抽出し、検出されたシークレットの矩形領域だけにマスクをかけます。
スクリーンショットツールでクリップボードに保存するケースなどが主な対象です。</p>
<p>たとえば次のスクリーンショットには<code>xoxp-</code>から始まるSlackのUser Tokenが写り込んでいます。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-image-before.webp" alt="画像マスキング Before" /></p>
<p>スクリーンショットツールでこの画像をクリップボードに保存すると、トークンが写っていた矩形領域だけにcrystallize + blur効果が自動で適用されます。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-image-after.webp" alt="画像マスキング After" /></p>
<p>デフォルトでは、Vision frameworkで取得したテキストの矩形位置を使って、シークレットがあった部分だけをマスクするようになっています。
カスタムパターンで<code>&quot;action&quot;: &quot;discard&quot;</code>を指定した場合は、画像全体を警告画像に置き換えるといった挙動も選べます。</p>
<h2>“Copy Original”とConcealedClipboard</h2>
<p>マスクしたあとに本物の値が必要になることもあります。
そのときはメニューバーから”Copy Original Text”（画像なら”Copy Original Image”）を選ぶと、元の内容をクリップボードにコピーし直せます。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-menu.webp" alt="SecureClipboardのメニュー" /></p>
<p>このとき、クリップボードには<a href="https://nspasteboard.org/"><code>org.nspasteboard.ConcealedType</code></a>というUTIが付与されます。
これはNSPasteboardの慣習で、「このクリップボードの内容は機密情報なので、履歴に残さないでほしい」という意思表示です。
<a href="https://github.com/secretlint/secure-clipboard/releases/tag/v1.4.0">v1.4.0</a>からこのUTIに対応しており、AlfredやGrammarlyなどnspasteboard.orgの規約に従っているクリップボードマネージャーは、この値を履歴へ保存しないようになります。</p>
<p>さらに90秒後にクリップボードから自動で消えるため、生の値が手元に残り続けることもありません。</p>
<h2>CLIツール: secure-pbpaste / secure-pbcopy</h2>
<p><a href="https://github.com/secretlint/secure-clipboard/releases/tag/v1.2.1">v1.2.1</a>から、macOS標準の<code>pbpaste</code>/<code>pbcopy</code>を置き換えるCLIツールが付属しています。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash">secure-pbpaste              <span class="c"># クリップボードのテキストをマスクして出力</span>
<span class="nb">echo</span> <span class="s2">"text"</span> | secure-pbcopy <span class="c"># テキストをマスクしてからクリップボードにコピー</span>
</code></pre></div></div>
<p><code>secure-pbcopy</code>は、生のテキストを一度もクリップボードへ触れさせない設計になっています。
Unix Domain Socket経由で常駐アプリへテキストを送り、アプリ側でスキャン・マスクしてからクリップボードへ書き込みます。
通常の<code>pbcopy</code>だと、書き込んだ瞬間に他のクリップボードマネージャーが拾ってしまう可能性がありますが、<code>secure-pbcopy</code>ならその心配がありません。</p>
<p>メニューバーから”Install CLI Tools”を選ぶと、<code>/usr/local/bin/</code>にsymlinkが作成されます。</p>
<h2>設定</h2>
<p>設定ファイルは<code>~/.config/secure-clipboard/config.json</code>に置きます。
メニューから”Open config.json”でも開けます。</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="json"><span class="p">{</span><span class="w">
    </span><span class="nl">"rules"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"@secretlint/secretlint-rule-preset-recommend"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"patterns"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mask-example"</span><span class="p">,</span><span class="w"> </span><span class="nl">"pattern"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/INTERNAL_</span><span class="se">\\</span><span class="s2">w+/i"</span><span class="p">,</span><span class="w"> </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mask"</span><span class="w"> </span><span class="p">},</span><span class="w">
        </span><span class="p">{</span><span class="w"> </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"discard-example"</span><span class="p">,</span><span class="w"> </span><span class="nl">"pattern"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/CONFIDENTIAL/i"</span><span class="p">,</span><span class="w"> </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"discard"</span><span class="w"> </span><span class="p">}</span><span class="w">
    </span><span class="p">],</span><span class="w">
    </span><span class="nl">"skipScanAppIdentifiers"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"com.1password.1password"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<h3>rules</h3>
<p>secretlintのルールを指定します。
デフォルトの<code>@secretlint/secretlint-rule-preset-recommend</code>には、AWSやGitHub、Slack、GCPなどの<a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/secretlint-rule-preset-recommend#rules">検出ルール</a>が含まれています。</p>
<p>preset内の一部のルールを無効化できますが、基本的にはデフォルトのままで問題ありません。
SecureClipboardはsecretlintをpre-buildされたバイナリとして同梱しているため、独自のルールを読み込ませるような仕組みは用意していません。</p>
<h3>patterns</h3>
<p>カスタムの正規表現パターンを定義できます。<code>action</code>は次の2種類があります。</p>
<table>
<thead>
<tr>
<th></th>
<th>テキスト</th>
<th>画像</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>&quot;action&quot;: &quot;mask&quot;</code></td>
<td>マッチした部分を<code>***</code>に置換</td>
<td>マッチした矩形領域をcrystallize + blurでマスク</td>
</tr>
<tr>
<td><code>&quot;action&quot;: &quot;discard&quot;</code></td>
<td>クリップボード全体を<code>[DISCARDED: &lt;name&gt;]</code>に置換</td>
<td>画像全体を赤い警告画像に置換</td>
</tr>
</tbody>
</table>
<p>正規表現は<code>/regex/flags</code>の形式で、フラグは<code>i</code>（case-insensitive）、<code>m</code>（multiline）、<code>s</code>（dotAll）に対応しています。</p>
<p>たとえば次のように書くと、文字列に「secretlint」を含むテキスト・画像をすべてマスクできます。</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="json"><span class="p">{</span><span class="w">
    </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"demo"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"pattern"</span><span class="p">:</span><span class="w"> </span><span class="s2">"/secretlint/i"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"action"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mask"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p>このパターンを有効にした状態でメニューバーをスクリーンショットに撮ると、メニュー内の<code>secretlint v11.7.1</code>というテキストもOCR経由で検出されて、その部分だけがマスクされます。</p>
<p><img src="/wp-content/uploads/2026/secure-clipboard-custom-pattern.webp" alt="カスタムパターンでメニュー内のテキストがマスクされた例" /></p>
<h3>skipScanAppIdentifiers</h3>
<p>Bundle Identifierを指定して、特定のアプリからのコピーをスキャン対象外にできます。
1Passwordなどのパスワードマネージャーは、コピーされる時点で意図的に取り出された値なのでスキャンを除外しておくのがよさそうです。</p>
<p>設定変更は次のクリップボード操作のタイミングで自動的に反映されるので、再起動は不要です。</p>
<h2>アーキテクチャ</h2>
<p>SecureClipboardはSwiftで書かれたネイティブのmacOSアプリです。
主要なコンポーネントは次のとおりです。</p>
<ul>
<li><code>ClipboardMonitor</code> … <code>NSPasteboard</code>をポーリングしてクリップボードの変更を検出</li>
<li><code>SecretScanner</code> … secretlintのバイナリをsubprocessで呼び出してスキャン</li>
<li><code>ImageSecretDetector</code> … Vision frameworkでOCRしてテキスト + 矩形を取得</li>
<li><code>ClipboardRewriter</code> … テキスト・画像を上書きする</li>
<li><code>IPCServer</code> … Unix Domain SocketでCLIツールからのリクエストを受ける</li>
<li><code>SecretlintUpdater</code> … GitHub Releasesからsecretlintのバイナリを自動更新</li>
</ul>
<p>secretlint本体はNode.js製のCLIですが、SecureClipboardは<a href="https://github.com/secretlint/secretlint/tree/master/packages/%40secretlint/binary">secretlintの単一バイナリ版</a>をsubprocessで呼び出しています。
そのため、ホストマシンにNode.jsがインストールされていなくても動作します。</p>
<h2>まとめ</h2>
<p>SecureClipboardは、クリップボードに入った機密情報をsecretlintで検出して自動的にマスクするmacOSアプリです。
テキストだけでなく画像にも対応していて、スクリーンショットに写り込んだトークンなどもVision frameworkでOCRしてマスクできます。</p>
<p>そもそもシークレットは生で扱わないのが基本で、自分も<a href="https://efcl.info/2023/01/31/remove-secret-from-local/">1Passwordを使って、ローカルにファイル(<code>~/.config</code>や<code>.env</code>)として置かれてる生のパスワードなどを削除した</a>りしています。
ただ、サービスから発行されたAPIキーを1Passwordに入れる場面など、どうしても一度クリップボードを経由する瞬間があります。
クリップボード自体はデフォルトでセキュアな設計とはいえないので、SecureClipboardはこの隙間を補う仕組みとして使えます。</p>
<p>また、スクリーンショットに特定の文字列が写り込んでいても目視では検知しにくいです。
カスタムパターンを使えば、スクリーンショットにうっかり写り込んでしまうコードネームのような文字列を自動的にマスクするレイヤーとしても利用できます。
Vision frameworkのOCR精度に依存するので100%ではありませんが、検出時に通知が出るので気づきやすくなり、うっかり漏洩する確率を減らせます。</p>
<hr />
<ul>
<li>GitHub: <a href="https://github.com/secretlint/secure-clipboard">secretlint/secure-clipboard</a></li>
</ul>
 ]]></content>
 </entry>
 
 <entry>
   <title>dockerfile-pin: DockerfileやComposeのイメージをSHA256でピン留めするCLIツールを作った</title>
   <link href="https://efcl.info/2026/04/01/dockerfile-pin/"/>
   <updated>2026-04-01T20:00:00+09:00</updated>
   <id>https://efcl.info/2026/04/01/dockerfile-pin</id>
   <content type="html"><![CDATA[ <p>DockerfileやComposeファイルのイメージ参照に<code>@sha256:&lt;digest&gt;</code>を自動で追加するCLIツール <a href="https://github.com/azu/dockerfile-pin">dockerfile-pin</a> を作りました。</p>
<ul>
<li>GitHub: <a href="https://github.com/azu/dockerfile-pin">azu/dockerfile-pin</a></li>
</ul>
<h2>なぜ作ったか</h2>
<p><a href="https://www.aquasec.com/blog/trivy-supply-chain-attack-what-you-need-to-know/">trivyへのサプライチェーン攻撃</a>などの事件を見ていると、次に狙われるのはDocker Hubかなと思ったのがきっかけです。
CIでDocker Hubへのpushをしているケースは多いので、そこに悪意あるコードが混入する事件は今後も起きるだろうと思っています。</p>
<p>Dockerイメージのタグ（例：<code>node:20</code>）はデフォルトで可変（mutable）です。同じタグ名で中身を上書きできるため、悪意ある第三者がレジストリへのアクセスを得た場合、既存タグに対して改竄されたイメージをpushできます。</p>
<ul>
<li><a href="https://forums.docker.com/t/can-a-docker-hub-tag-have-its-content-changed/139358">Can a Docker Hub tag have its content changed? - Docker Community Forums</a></li>
</ul>
<p>Docker Hubなどのレジストリは安全とは限りません。
npmのように<a href="https://github.blog/changelog/2025-11-05-npm-security-update-classic-token-creation-disabled-and-granular-token-changes/">トークンの制限が厳しくなっていたり</a>、デフォルトでタグがimmutableな場所であっても、<a href="https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan">axiosのように問題が起きる</a>ことはあります。
Docker Hubには<a href="https://docs.docker.com/docker-hub/repos/manage/hub-images/immutable-tags/">Immutable tags</a>という機能がありますが、これはリポジトリオーナー側が設定するもので、イメージを利用する側がコントロールできるものではありません。</p>
<p><a href="https://docs.docker.com/reference/cli/docker/image/pull/#pull-an-image-by-digest-immutable-identifier"><code>@sha256:&lt;digest&gt;</code></a>を付与することで、イメージの不変性を保証できます。digestはイメージのコンテンツハッシュなので、内容が異なればdigestも変わり、改竄を検知できます。npmのlockfileがパッケージのintegrityをハッシュで固定するのと同じ考え方です。</p>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="dockerfile"><span class="c"># Before: タグのみ（可変）</span>
<span class="k">FROM</span><span class="s"> node:20.11.1</span>

<span class="c"># After: タグ + digest（不変）</span>
<span class="k">FROM</span><span class="s"> node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5</span>
</code></pre></div></div>
<p>タグとdigestを両方残す形式にしておくと便利です。タグは人間が読むため、digestは不変性の保証のために残します。<a href="https://docs.renovatebot.com/docker/">Renovate</a>はこの形式でタグとdigestの両方を更新できます。Dependabotもdigestが既に付いている場合は<a href="https://github.com/dependabot/dependabot-core/issues/14065">タグとdigestを同時に更新</a>できます。</p>
<p>Dockerfileでは明示的にSHA256 digestを指定しないとハッシュ固定ができません。これはGitHub Actionsの<code>uses:</code>をコミットSHAでpin留めしていないのと同じ状態で、サプライチェーン攻撃のリスクがあります。</p>
<p>GitHub Actionsについては<a href="https://github.com/suzuki-shunsuke/pinact">pinact</a>で自動化できますが、DockerfileのFROM行については同様のシンプルなツールがありませんでした。</p>
<h3>既存ツールが不十分だった</h3>
<p>DockerfileのSHA pinを補助する既存ツールとして<a href="https://github.com/Jille/dockpin">dockpin</a>がありますが、2023年以降メンテナンスが停滞しています。</p>
<p>また、<a href="https://github.com/hadolint/hadolint">hadolint</a>にはdigest pin強制ルールがなく（<a href="https://github.com/hadolint/hadolint/issues/773">hadolint#773</a>、2022年2月〜OPEN）、プラグイン機構もありません（<a href="https://github.com/hadolint/hadolint/issues/1001">hadolint#1001</a>）。CIでdigestのpin漏れをチェックできるシンプルなlintツールが見当たりませんでした。</p>
<p>そのため、<a href="https://github.com/suzuki-shunsuke/pinact">pinact</a>のDockerfile版をイメージして、<a href="https://github.com/google/go-containerregistry">craneライブラリ</a>（Googleが管理、メンテナンスが活発）をベースに<code>dockerfile-pin</code>として自作しました。</p>
<p>作った後に気づきましたが、<a href="https://github.com/stacklok/frizbee">frizbee</a>がGitHub ActionsとDockerの両方に対応した近いツールとして存在しています。dockerfile-pinはdigestの付与に加えて、CIで新しくdigestなしのイメージが入るのを防ぐ<code>check</code>コマンドがあるので、目的が少し異なる気がします。(おそらく似たことはできるはず?)</p>
<h2>使い方</h2>
<h3>インストール</h3>
<p>📝 主にCIとかで使いたい目的で作ったので<a href="https://aquaproj.github.io/">aqua</a>などのチェックサムをチェックしてインストールできる方法を推奨しています。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash"><span class="c"># curl</span>
curl <span class="nt">-sL</span> <span class="s2">"https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_darwin_arm64.tar.gz"</span> | <span class="nb">tar </span>xz
<span class="nb">sudo mv </span>dockerfile-pin /usr/local/bin/

<span class="c"># aqua</span>
aqua generate <span class="nt">-i</span> azu/dockerfile-pin

<span class="c"># Go</span>
go <span class="nb">install </span>github.com/azu/dockerfile-pin@latest
</code></pre></div></div>
<h3><code>run</code> コマンド: digestの追加</h3>
<p><code>run --write</code>コマンドで、DockerfileやComposeファイルのイメージ参照にSHA256 digestを追加します。
デフォルトではDry-Runになっているので <code>--write</code> フラグを使うと実際にファイルを書き換えます。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash"><span class="c"># ドライラン（プレビュー）</span>
dockerfile-pin run <span class="nt">-f</span> Dockerfile

<span class="c"># 実際にファイルを書き換える</span>
dockerfile-pin run <span class="nt">-f</span> Dockerfile <span class="nt">--write</span>

<span class="c"># globパターンで複数ファイルを対象にする</span>
dockerfile-pin run <span class="nt">--glob</span> <span class="s1">'**/{Dockerfile,docker-compose.yml}'</span> <span class="nt">--write</span>

<span class="c"># 引数なしだと **/{Dockerfile,Dockerfile.*,docker-compose*.yml,docker-compose*.yaml,compose.yml,compose.yaml} を対象にします</span>
<span class="c"># ドライラン</span>
dockerfile-pin run
<span class="c"># Docker関係のファイルを自動的に書き換える</span>
dockerfile-pin run <span class="nt">--write</span>
</code></pre></div></div>
<p>たとえば、次のようにタグのみの指定にdigestが追加されます。</p>
<p><strong>変換前:</strong></p>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="dockerfile"><span class="k">FROM</span><span class="s"> node:20.11.1</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">python:3.12</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>
</code></pre></div></div>
<p><strong>変換後:</strong></p>
<div class="language-dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="dockerfile"><span class="k">FROM</span><span class="s"> node:20.11.1@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5</span>
<span class="k">FROM</span><span class="w"> </span><span class="s">python:3.12@sha256:...</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span>
</code></pre></div></div>
<p>すでにdigestが付いているイメージはスキップされます。<code>--update</code>オプションをつけると既存のdigestも更新します。</p>
<h3><code>check</code> コマンド: CIでのdigest検証</h3>
<p>CIで使うことを想定した<code>check</code>コマンドもあります。チェックは2段階です。</p>
<ol>
<li><strong>構文チェック</strong>: FROM行に<code>@sha256:</code>が含まれているか</li>
<li><strong>存在チェック</strong>: 記載されたdigestがレジストリに実際に存在するか（HEADリクエストで検証）</li>
</ol>
<p>存在チェックがあることで、typoや削除済みdigestが<code>docker build</code>時まで発覚しないという問題を防げます。
HEADリクエストを使っているため、Docker Hubのpull rate limitを消費しません。</p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="bash"><span class="c"># すべてのDockerfileをチェック（git ls-filesから自動検出）</span>
dockerfile-pin check

<span class="c"># 構文チェックのみ（レジストリへのアクセスなし）</span>
dockerfile-pin check <span class="nt">--syntax-only</span>

<span class="c"># JSON形式で出力</span>
dockerfile-pin check <span class="nt">--format</span> json

<span class="c"># 特定のイメージを無視</span>
dockerfile-pin check <span class="nt">--ignore-images</span> scratch
</code></pre></div></div>
<p>出力例は次のとおりです。</p>
<div class="highlighter-rouge"><div class="highlight"><pre class="highlight"><code>FAIL  Dockerfile:1    FROM node:20.11.1                missing digest
OK    Dockerfile:3    FROM python:3.12@sha256:abc123...
SKIP  Dockerfile:5    FROM scratch                     scratch image
</code></pre></div></div>
<h3>対応しているパターン</h3>
<p><strong>Dockerfile:</strong></p>
<ul>
<li><code>FROM image:tag</code> — digestを追加</li>
<li><code>FROM image:tag AS stagename</code> — <code>AS</code>付きも対応</li>
<li><code>FROM --platform=linux/amd64 image:tag</code> — <code>--platform</code>付きも対応</li>
<li><code>ARG VERSION=1.0</code> + <code>FROM image:${VERSION}</code> — ARGにデフォルト値がある場合は展開して解決</li>
<li><code>ARG BASE_IMAGE</code> + <code>FROM ${BASE_IMAGE}</code> — デフォルト値がない場合はwarningでスキップ</li>
<li><code>FROM scratch</code> — スキップ</li>
<li><code>FROM &lt;stagename&gt;</code> — マルチステージビルドの参照はスキップ</li>
<li>プライベートレジストリ（ghcr.io, GCR, ECRなど）にも対応</li>
</ul>
<p><strong>docker-compose.yml:</strong></p>
<ul>
<li><code>image: node:20</code> — digestを追加</li>
<li><code>build:</code>ディレクティブがあるサービス — スキップ</li>
</ul>
<h3>CI/CDでの利用</h3>
<p>GitHub Actionsでの利用例です。curlでインストールする方法と、<a href="https://aquaproj.github.io/">aqua</a>を使う方法があります。aquaを使うと、dockerfile-pin自体のチェックサムを検証してインストールできます。</p>
<p>なお、dockerfile-pinのリリースでは<a href="https://github.blog/changelog/2025-08-26-releases-now-support-immutability-in-public-preview/">GitHub Releases Immutability</a>を有効にしています。</p>
<p>curlでインストールする場合:</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">curl -sL "https://github.com/azu/dockerfile-pin/releases/latest/download/dockerfile-pin_linux_amd64.tar.gz" | tar xz</span>
    <span class="s">sudo mv dockerfile-pin /usr/local/bin/</span>
<span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">dockerfile-pin check</span>
</code></pre></div></div>
<p>aqua経由で利用する場合は、<code>aqua.yaml</code>に <code>dockerfile-pin</code>を入れてインストールします。</p>
<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="yaml"><span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">aquaproj/aqua-installer@d1fe50798dbadd4eb5b98957290ca175f6b4870f</span> <span class="c1"># v4.0.2</span>
  <span class="na">with</span><span class="pi">:</span>
    <span class="na">aqua_version</span><span class="pi">:</span> <span class="s">v2.57.1</span>
<span class="pi">-</span> <span class="na">run</span><span class="pi">:</span> <span class="s">dockerfile-pin check</span>
</code></pre></div></div>
<p>CIで<code>dockerfile-pin check</code>を実行することで、digestが付いていないイメージをプルリクエスト時に検出できます。</p>
<h3>Renovateとの併用</h3>
<p>初回のdigest付与は<code>dockerfile-pin run --write</code>で行い、その後の継続的な更新は<a href="https://docs.renovatebot.com/">Renovate</a>に委譲します。</p>
<p>Renovateの<code>docker:pinDigests</code>プリセットを有効にすると、<code>image:tag@sha256:digest</code>形式のdigestを自動更新するPRを生成してくれます。</p>
<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code data-lang="json"><span class="p">{</span><span class="w">
  </span><span class="nl">"extends"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"config:best-practices"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>
<p><code>config:best-practices</code>に<code>docker:pinDigests</code>が含まれています。digest更新のみ自動マージしたい場合は<code>default:automergeDigest</code>も利用できます。</p>
<p>運用の流れとしては次のようになります。</p>
<ol>
<li><code>dockerfile-pin run --write</code>で既存ファイルにdigestを一括付与</li>
<li>CIに<code>dockerfile-pin check</code>を組み込み、digest未指定のFROM行がマージされないようにする</li>
<li>Renovateの<code>docker:pinDigests</code>で継続的にdigestを最新に保つ</li>
</ol>
<h2>まとめ</h2>
<p>Dockerイメージのタグはデフォルトでmutableなので、タグだけの指定ではサプライチェーン攻撃のリスクがあります。
npmのlockfileやGitHub ActionsのSHA pinと同様に、Dockerfileでも<code>@sha256:&lt;digest&gt;</code>でイメージを固定した方が良いでしょう。</p>
<p>既存ツール（dockpin、docker-lock）はメンテナンスが停滞しており、hadolintにもdigest pinのルールがありません。そのため、シンプルにpin付与とCIチェックを行う<a href="https://github.com/azu/dockerfile-pin">dockerfile-pin</a>を作りました。</p>
<ul>
<li><code>dockerfile-pin run --write</code> で既存ファイルにdigestを一括追加</li>
<li><code>dockerfile-pin check</code> でCIでdigestの付け忘れを検出</li>
<li>Renovateと組み合わせて継続的にdigestを最新に保つ</li>
</ul>
<h2>参考</h2>
<ul>
<li><a href="https://github.com/azu/dockerfile-pin">azu/dockerfile-pin</a></li>
<li><a href="https://github.com/suzuki-shunsuke/pinact">suzuki-shunsuke/pinact</a></li>
<li><a href="https://github.com/google/go-containerregistry">google/go-containerregistry</a></li>
</ul>
 ]]></content>
 </entry>
 

</feed>
