本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

データベースの更新やサイトの再ビルドを、開発者以外の方に担当してもらう場面があります。作業自体は GitHub Actions の workflow を 1 つ起動するだけなのに、その人は GitHub アカウントを持っていない、ということが珍しくありません。

結果として、更新のたびに「反映をお願いします」という連絡が開発者に来ます。連絡が来なければ、データだけが新しくてサイトが古いままになります。

この隙間を埋めるための小さな画面をテンプレートとして公開しました。

nakamura196/admin-console(MIT、GitHub の template repository)

どういう画面か

config.yml に「サイト」と「操作」を書くと、カードが並んだ画面になります。以下は、リポジトリに同梱している記入例そのままで起動した状態です。

管理コンソールのホーム画面のスクリーンショット。ヘッダーに「サンプル組織 管理コンソール」、右側に「使い方」「ダークモード切り替え」「English」が並ぶ。本文には「管理コンソール」という見出しと「各サイトのカードを開いて、デプロイやデータ更新などの操作を実行できます。」という説明があり、その下に「サンプルサイト(example-site)」のカードが 1 枚。カードの右上には「サイトを開く」リンク、本文にはサイトの説明文、「デプロイを実行」「検索インデックスを更新」の 2 つの操作名、「詳細を開く」リンクが並ぶ。ページ下部のフッターには「© サンプル組織」と表示されている。

画面は日本語と英語を切り替えられます。文言は設定ファイルに両方書きます。

カードを開くと、操作ごとのタブと実行ボタン、実行履歴が出ます。押すと workflow_dispatch が飛び、進行中のジョブとログをこの画面で追えます。

管理コンソールのサイト詳細画面のスクリーンショット。左上に「← ホーム」リンク、その下に「サンプルサイト」という見出しと「サイトを開く」リンク。さらに下に「デプロイを実行」「検索インデックスを更新」の 2 つのタブが並ぶ。選択中のタブには「最新のデータを取得し、ビルドして配信します(数分かかります)。」という説明と、右端に「デプロイを実行」ボタンがある。その下は左に「実行履歴」パネル(「このアクションは未実行です。」と表示)、右に「左から実行を選択してください」と表示された領域が並んでいる。

対応する設定はこれだけです。

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 を選んだ理由は、そちらに整理してあります。この記事は、それを設定ファイルだけで複製できる形にまとめ直したものです。

管理コンソールの構成図。見出しは「管理コンソールで経路を繋ぐ」。左端に「作業者(GitHub アカウントなし)」の箱があり、「メール認証」と注記された矢印で「Cloudflare Access(許可した宛先だけ通す)」へ繋がる。そこから右の「管理コンソール(Cloudflare Pages 上の Next.js)」へ矢印が伸びる。管理コンソールからは「GitHub App の installation token」と注記された矢印が下の「GitHub Actions(workflow_dispatch)」へ下り、さらに下の「サイトを組み立て直す」へ続く。左下には「config.yml が 1 枚(サイト・操作・画面の文言・メタデータの出どころ)」の箱があり、「読み込んで画面を組み立てる」と注記された矢印が管理コンソールへ向かう。図の下部に、アプリ側に認証のコードは無いこと、コードに組織固有の値は無いこと、installation は 1 つ = 1 つの GitHub 組織であることが記されている。

技術的には Next.js(App Router)を @opennextjs/cloudflare で Cloudflare Pages に載せたものです。常駐するサーバはありません。画面のリクエストは Workers の無料枠を消費しますが、数人が月に数回押す程度なら余裕があります。現在の使い方(作業者数名)では、Cloudflare Pages と Access の無料の範囲に収まっています。

設計で意識したのは 3 点です。

認証のコードを書かない

作業者の認証は Cloudflare Access(Zero Trust)に任せています。許可したメールアドレスの人だけが入れて、アプリ側は認証を知りません。middleware.ts にあるのは言語の判定だけです。

ログイン方法は Access 側の設定に従うので、メールに届くコードでも、Google などの ID プロバイダでも、アプリの変更なしに切り替わります。

パスワードをアプリに持たせない、セッションを自前で管理しない、という状態を保てるのが利点だと感じています。

組織固有の値をコードに置かない

画面の文言もメタデータも config.yml から生成します。

config.yml 1 枚から生成されるものを示した図。見出しは「config.yml 1 枚から、画面に必要なものを生成する」。左に「config.yml(サイトと操作の定義/各言語の文言/アプリ名・説明・配信先 URL)」の箱があり、「npm run config:generate」と注記された 3 本の矢印が右へ伸びて、「sites.generated.ts」「messages/ja.json messages/en.json」「app.generated.ts」の 3 つの箱に繋がる。そこから右の「画面」へ矢印が向かう。図の下部に、生成物はリポジトリに含めないこと(含めるとテンプレートとして複製したときに前の組織の文言が画面に残る)、CI(自動ビルドの仕組み)とビルドの前段で必ず生成すること、コード側に組織固有の値を持たないので別の相手向けに立てるときは 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.ymlconfig.yml にコピーして書き換えます。あとは GitHub App、Cloudflare Pages のプロジェクト、Cloudflare Access のポリシーの 3 つを用意すれば動きます。手順は README にまとめてあります。

nakamura196/admin-console