本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。
データベースの更新やサイトの再ビルドを、開発者以外の方に担当してもらう場面があります。作業自体は GitHub Actions の workflow を 1 つ起動するだけなのに、その人は GitHub アカウントを持っていない、ということが珍しくありません。
結果として、更新のたびに「反映をお願いします」という連絡が開発者に来ます。連絡が来なければ、データだけが新しくてサイトが古いままになります。
この隙間を埋めるための小さな画面をテンプレートとして公開しました。
nakamura196/admin-console(MIT、GitHub の template repository)
どういう画面か
config.yml に「サイト」と「操作」を書くと、カードが並んだ画面になります。以下は、リポジトリに同梱している記入例そのままで起動した状態です。

画面は日本語と英語を切り替えられます。文言は設定ファイルに両方書きます。
カードを開くと、操作ごとのタブと実行ボタン、実行履歴が出ます。押すと workflow_dispatch が飛び、進行中のジョブとログをこの画面で追えます。

対応する設定はこれだけです。
sites:
- id: example-site
name:
ja: サンプルサイト
description:
ja: このカードの説明。作業者が読んで何をする画面か分かる文にします。
url: https://example.org/
actions:
- id: deploy
label:
ja: デプロイを実行
description:
ja: 最新のデータを取得し、ビルドして配信します(数分かかります)。
type: github-workflow
repo: your-org/your-repo
workflow: prod.yml
ref: main
ja と同じ階層に en も書きます(上では省いています)。省くと英語画面に既定値が出ます。
操作の種別は GitHub Actions のほかに、Vercel の Deploy Hook も指定できます(type: vercel-deploy-hook)。
作業者に見せる文章を、こちらの言葉で書けるのが要点だと考えています。「prod.yml を dispatch する」ではなく「最新のデータを取得し、ビルドして配信します」と書けます。GitHub の Actions の画面でも workflow 名と入力欄の説明は自由に書けますが、調べた限り、操作全体の手順や注意書きを置ける場所は見当たりませんでした。
入力欄が必要な操作にも対応しています。inputs に書いた項目がフォームになり、fixedInputs は作業者に見せずに固定値として渡されます。
inputs:
- name: target
type: string
default: all
label:
ja: 対象 (all または ID)
- name: dry_run
type: boolean
default: false
label:
ja: 確認のみ(実際には更新しない)
fixedInputs:
triggered_by: admin-console
構成
この構成そのものは、以前 GitHub App と Cloudflare Access で構築する組織向け管理コンソール に書きました。個人アクセストークンや OAuth App ではなく GitHub App を選んだ理由は、そちらに整理してあります。この記事は、それを設定ファイルだけで複製できる形にまとめ直したものです。
技術的には Next.js(App Router)を @opennextjs/cloudflare で Cloudflare Pages に載せたものです。常駐するサーバはありません。画面のリクエストは Workers の無料枠を消費しますが、数人が月に数回押す程度なら余裕があります。現在の使い方(作業者数名)では、Cloudflare Pages と Access の無料の範囲に収まっています。
設計で意識したのは 3 点です。
認証のコードを書かない
作業者の認証は Cloudflare Access(Zero Trust)に任せています。許可したメールアドレスの人だけが入れて、アプリ側は認証を知りません。middleware.ts にあるのは言語の判定だけです。
ログイン方法は Access 側の設定に従うので、メールに届くコードでも、Google などの ID プロバイダでも、アプリの変更なしに切り替わります。
パスワードをアプリに持たせない、セッションを自前で管理しない、という状態を保てるのが利点だと感じています。
組織固有の値をコードに置かない
画面の文言もメタデータも config.yml から生成します。
生成物は .gitignore に入れ、リポジトリには含めていません。含めたままテンプレートとして複製すると、複製直後の画面に前の組織の名前が出ます。実際にそうなったので外しました。
「使い方」ページに載せるスクリーンショットも、同じ理由でコードから外して config.yml で指定する形にしています。
相手ごとにインスタンスを分けられるようにする
GitHub App の installation を 1 つしか持たない作りなので、1 つのデプロイで扱えるのは 1 つの GitHub 組織までです。複数組織を 1 画面に混ぜるなら、リポジトリの持ち主から installation を引く実装に変える必要があります。
ただ、分けたほうが都合が良い場面も多そうです。相手ごとに見せる範囲が違いますし、Cloudflare Access のポリシーもインスタンス単位のほうが単純になります。テンプレートから複製する形にしているのは、この判断に合わせたものです。
各手順が、コマンドとブラウザのどちらで済むか
実際に 1 つ立てながら、どこでブラウザ操作が要るかを整理しました。
| 作業 | 手段 |
|---|---|
| GitHub App の作成 | ブラウザ(マニフェスト方式を使えばクリック 1 回まで減らせます) |
| App のインストール | ブラウザ(1 クリック)。Installation ID は API で取得できます |
| Cloudflare Pages プロジェクト作成 | wrangler pages project create(OAuth ログインで可能。トークン不要) |
| Pages の環境変数投入 | wrangler pages secret put(値は 1Password からパイプ) |
| Cloudflare API トークン作成 | ブラウザのみ。「トークンを作れるトークン」が無いと API では作れません |
| Access のアプリ・ポリシー作成 | API(上のトークンが必要) |
| GitHub Secret 登録 | gh secret set |
Installation ID は、App の秘密鍵で署名した JWT(JSON Web Token)を使えば取得できます。秘密鍵も JWT もターミナルに表示せずに済みます。
// head / pay は base64url (+ → -、/ → _、= を除去)。素の base64 だと復号に失敗します。
// pay の exp - iat は 600 秒以内。iat はクロックのずれを見込んで 60 秒ほど戻します。
const jwt = `${head}.${pay}.${sig}` // sig は `${head}.${pay}` を App の秘密鍵で RS256 署名
const r = await fetch('https://api.github.com/app/installations', {
headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/vnd.github+json' },
})
// => [{ id: ..., account: { login: '...' }, repository_selection: 'selected' }]
秘密鍵は、ダウンロードした直後に 1Password へ入れ、内容の一致を確認してからディスクから削除しました。
PK="$(cat "$PEM")"
op item create --category="API Credential" --vault=Personal --title="<name>" "app_id[text]=..." "private_key[concealed]=$PK"
op read "op://Personal/<name>/private_key" | diff - "$PEM" && rm -f "$PEM"
op read は末尾に改行を 1 つ付けて返すため、元のファイルの末尾に改行が無いと diff に差が出ます。その場合は削除まで進みません。
Access の設定も API から行えるので、スクリプトにまとめてリポジトリに置いてあります(scripts/setup/cloudflare-access.zsh)。許可するメールアドレスをスクリプト冒頭に書き、実行するとアプリケーションとポリシーを作り、最後に「ログイン画面にリダイレクトされるか」まで確認します。
引っかかった点
Cloudflare のトークン検証は、窓口が 2 つある
セットアップスクリプトが「トークンが無効です」で止まりました。しかしトークン自体は有効でした。
# My Profile で作った、ユーザーに紐づくトークン
curl -H "Authorization: Bearer $TOKEN" https://api.cloudflare.com/client/v4/user/tokens/verify
# => "success": true
# アカウントに紐づくトークン用のエンドポイント
curl -H "Authorization: Bearer $TOKEN" https://api.cloudflare.com/client/v4/accounts/$ACCT/tokens/verify
# => "success": false
片方だけを見ると、有効なトークンを無効と誤判定します。両方試すのが確実なようです。
NEXT_PUBLIC_ はビルド時に埋め込まれる
NEXT_PUBLIC_SITE_URL を Cloudflare Pages の環境変数に設定しても効きませんでした。この接頭辞の環境変数はビルド時にバンドルへ埋め込まれる仕様で、ビルドは GitHub Actions 側で走るためです。環境変数で渡すならビルドを走らせる側に設定する必要があります。配信先 URL は config.yml に持たせ、環境変数があればそちらを優先する形にしたので、通常は設定ファイルに書けば足ります。
記入例のままだとビルドが落ちる
テンプレートを使う人が最初に踏む箇所でした。
Error: Invalid URL
app.url の記入例(https://<your-console>.pages.dev)をそのまま new URL() に渡していたためです。メッセージからは原因が分かりにくい状態でした。妥当な URL でなければ警告を出して既定値に落とすようにし、記入例も、そのまま URL として解釈できる形に直しました。
テンプレートを配るなら、記入例のままビルドが通るかを一度試しておくとよさそうです。公開直前に気づきました。
公開前の履歴スキャン
gitleaks を全履歴に掛けたところ、1 件見つかりました。
Finding: curl -u [REDACTED] https://es.aws...
File: docs/elasticsearch-infrastructure.md
運用メモの中に Elasticsearch の Basic 認証が残っていました(該当ホストは既に停止済みでした)。既存のリポジトリを公開に切り替えるのはやめ、履歴を引き継がず、その時点のコードだけを新しいリポジトリに置き直しました。履歴の書き換えで消し切れたかを確かめるより、公開するものだけを新しく置くほうが確実だと考えたためです。1 件見つかったなら他にもある前提で動くべきだとも思いました。
既存のツールとの距離
同じ用途に使えるものは既にあります。今回テンプレートとして切り出したのは、それぞれ想定している規模や前提が違ったためです。
| 選択肢 | 今回の用途との関係 |
|---|---|
| GitHub の Actions 画面 | GitHub アカウントが必要。開発者向けの画面で、対象リポジトリの他の workflow も一覧に並びます |
| Backstage | 開発者ポータル。カタログや認証基盤の運用が前提で、作業者 1 人の用途には規模が合いません |
| Rundeck / Jenkins | 同種のセルフサービス実行の仕組み。常駐プロセスと権限設計が必要です |
| Retool / Appsmith / Budibase | 画面を組み立てられます。ログイン機構は組み込みで持っており、設計するのは画面と権限の割り当てです。Retool は 5 ユーザーまで無料で、それ以上は有償になります |
| n8n の Form 機能 | 用途は近いです。n8n 自体を運用する構成になります |
| Vercel / Netlify の Deploy Hook | 操作が 1 つなら最も簡単です。Vercel は payload を受け取らず、Netlify は INCOMING_HOOK_BODY として渡せます。実行ログの表示や複数操作の出し分けは、いずれも自前になります |
探した範囲では、「常駐なし・認証の実装なし・設定 1 枚・無料の範囲」という帯が空いているように見えました。作業者が技術者ではないという状況は、大学や図書館・博物館の案件では珍しくないので、同じ形で困っている方はいるかもしれません。
使い始めるには
GitHub の Use this template で複製し、config.example.yml を config.yml にコピーして書き換えます。あとは GitHub App、Cloudflare Pages のプロジェクト、Cloudflare Access のポリシーの 3 つを用意すれば動きます。手順は README にまとめてあります。

コメント
…