ハコヅミを作る #2:雛形・CI・自動デプロイ・Cloudflare Accessまでの初期構築

前回(#1)で紹介したキャンプ道具のチェックリストアプリ「ハコヅミ」の、開発の土台を作りました。

やったことは次のとおりです。

  • GitHubのリポジトリ作成と、Issueのテンプレート
  • SvelteKitの雛形
  • PRごとの自動テスト(CI)
  • develop は開発用アカウント、main は本番用アカウントへの自動デプロイ
  • 開発・本番の両方をCloudflare Accessで保護

作業は、決めた開発の流れどおり、土台づくりの段階から「Issue → ブランチ → PR → マージ」で進めました。 実装とPRの作成はClaude Codeが行い、自分はレビュー・判断・マージを担当しています。


1. リポジトリとGitHubのアカウント

GitHubのアカウントは、個人用(練習用のアプリ)と事業用(ブログなど公開するもの)を分けています。 ハコヅミは最終的に事業として公開するので、事業用のアカウントに非公開のリポジトリを作りました。コードを公開するかどうかは、後で決めればよいと考えています。

コミットの作者情報をフォルダで切り替える

アカウントを分けていても、Gitの作者情報(名前とメールアドレス)が1つのままだと、事業用のリポジトリに個人名のコミットが混ざります。 そこで、事業用のリポジトリはすべて1つのフォルダの下に置き、そのフォルダの下でだけ事業用の作者情報を使うようにしました。Gitの includeIf という機能です。

# ~/.gitconfig(全体の設定=個人用)
[user]
	name = 個人用の名前
	email = 個人用のメールアドレス

# ~/Jiru-Labo/ の下のリポジトリだけ、別ファイルの設定を読む
[includeIf "gitdir:~/Jiru-Labo/"]
	path = ~/.gitconfig-jiru-labo
# ~/.gitconfig-jiru-labo(事業用)
[user]
	name = Jiru-Labo
	email = 事業用のメールアドレス

これで、リポジトリごとに設定し忘れる心配がなくなりました。 ちなみに、設定を確認してみたら、全体の設定が今は使っていないメールアドレスのままになっていました。ついでに整理できてよかったです。

デフォルトブランチは develop

開発プロセスの記事で書いたとおり、PRの Closes #番号 はデフォルトブランチへのマージでIssueを閉じます。 develop にマージした時点で閉じたいので、デフォルトブランチを develop にしました。

その代わり、develop → main のPRを作るときは、向き先を main と明示する必要があります(gh pr create --base main)。省略すると develop 向けになってしまうためです。


2. SvelteKitの雛形と、つまずいた点

雛形は、Svelte公式のCLI(sv)で作りました。技術スタックに合わせて、必要なものをまとめて指定しています。

npx sv create . --template minimal --types ts \
  --add prettier eslint "vitest=usages:unit" playwright "tailwindcss=plugins:none" \
  "sveltekit-adapter=adapter:cloudflare+cfTarget:workers" "drizzle=database:d1" \
  --install npm

一発で済むかと思いきや、いくつかつまずきました。

Node.jsが古かった

npm install が、次のエラーで失敗しました。

npm error Cannot read properties of null (reading 'edgesOut')

原因が分かりにくいエラーです。新しいnpmで試したところ、本当の原因が表示されました。Node.jsのバージョンが古かったのです(ESLint 10が、手元のNode.jsより新しいバージョンを要求していた)。 Node.jsを最新のLTS(24)に更新して解決しました。あわせて、.node-version ファイルと package.json の engines で、Node.js 24以上を前提にすることを明記しています。

Drizzleの雛形が、手元から本番のDBに書き込める設定だった

sv が作ったDrizzleの設定は、D1のAPIトークンを使って、Cloudflare上のD1に手元から直接書き込むものでした。

// 雛形の drizzle.config.ts(抜粋)
export default defineConfig({
	dialect: 'sqlite',
	driver: 'd1-http',
	dbCredentials: {
		accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
		databaseId: process.env.CLOUDFLARE_DATABASE_ID,
		token: process.env.CLOUDFLARE_D1_TOKEN
	}
});

これだと、手元の操作ひとつで本番のデータベースを変えられてしまいます。 方針は「本番へは、PRのマージからCIでだけ反映する」なので、DrizzleはマイグレーションのSQLを生成するだけに使い、適用は wrangler で行う形に変えました。

// 変更後の drizzle.config.ts
export default defineConfig({
	schema: './src/lib/server/db/schema.ts',
	out: './drizzle',
	dialect: 'sqlite'
});
  • ローカル:npm run db:generate でSQLを作り、wrangler d1 migrations apply DB --local で適用
  • 開発・本番:CIが、デプロイの前にその環境のD1へ適用する

wrangler types --check が、ビルドの前後で結果が変わった

雛形では、型チェックとビルドの前に wrangler types --check(Workersの型ファイルが最新かの確認)が走るようになっていました。 ところが、ビルドした後だけこの確認が失敗し、E2Eテストが動きませんでした。

調べてみると、ビルドの成果物があるかどうかで、生成される型ファイルの中身が変わっていました。ファイルをコミットしておくと、ビルドの前後どちらかで必ず「古い」と判定されてしまいます。 型ファイルはコミットせず、npm install・型チェック・ビルドのたびに作り直す形にしました。

ビルド後の型チェックで、400件以上のエラー

さらに、ビルドした後に型チェックを走らせると、ビルドの成果物(.svelte-kit/ の中のJavaScript)に400件以上の型エラーが出ました。 雛形の設定がJavaScriptの型チェック(checkJs)を有効にしていたためです。JavaScriptは手で書かず、TypeScriptだけを使うので、checkJs を外しました。

Prettierが CLAUDE.md の表を崩した

最後に、コードの整形(npm run format)をかけたら、CLAUDE.md の表がきれいに幅揃えされました。 見た目は整っていますが、1行がとても長くなり、手で編集しにくくなります。Markdownは整形の対象から外しました。


3. PRごとの自動テスト(CI)

GitHub Actionsで、develop と main 向けのPRごとに、次の順でテストを走らせます。

npm ci → 型チェック → Lint → Vitest → Playwright

Playwright(E2Eテスト)は、CIの中でビルド・起動したアプリに向けて実行します。Cloudflare Accessで保護した開発環境に向けると、ログインを突破する仕組みが必要になるためです。

main のブランチ保護が使えなかった

main には、「テストが通ったPRしかマージできない」ブランチ保護をかけるつもりでした。 ところが設定しようとすると、**「GitHub Proにするか、リポジトリを公開すれば使える」**と返されました。無料プランの非公開リポジトリでは使えない機能だったのです。

有料プランにする、リポジトリを公開する、という選択肢も考えましたが、今は1人の開発です。当面は保護なしで進め、「main と develop に直接pushしない」をリポジトリの CLAUDE.md にルールとして書いて、Claude Codeにも守らせることにしました。 本番へのデプロイでもテストを走らせ、失敗したらデプロイしないようにしているので、安全の大部分は確保できていると考えています。

E2Eテストの置き場所

雛形は、E2Eテストのファイルを src/routes/(ページのソースと同じ場所)に置いていました。 レビューのときに気になって、置き場所を変えてもらいました。

  • E2Eは、「プランを選ぶ → チェックリストを作る → コンテナを一括チェックする」のように、ページをまたぐ流れを確かめるもの。特定のページの隣に置く理由がない
  • ユニットテスト(Vitest)は、1つの関数を確かめるものなので、ソースの隣に置いたままにする

移動先は、ルートの e2e/ ではなく tests/e2e/ にしました。SvelteKitが自動で作るTypeScriptの設定が、tests/ の下を最初から型チェックの対象に含めているためです。

わざと失敗させてみたら、足りないものが見つかった

CIが本当に失敗を検知するか確かめるため、トップページの見出しを「ハコヅミ」から**「ハコヅメ」**にわざと変えてpushしてみました。

狙いどおり、E2Eテストで失敗し、CIは赤になりました。

Expected: "ハコヅミ"
Received: "ハコヅメ"

ところが、失敗したときに残すように設定していた結果ファイルを見ると、スクリーンショットが入っていませんでした。Playwrightは、設定しないと失敗時のスクリーンショットを撮らないのです。

// playwright.config.ts に追加
use: {
	screenshot: 'only-on-failure',
	trace: 'retain-on-failure'
}

「失敗したら画面を見られる」つもりでいたので、一度わざと壊してみた甲斐がありました。


4. developは開発用、mainは本番用アカウントへの自動デプロイ

技術スタックの記事で決めた環境構成を、実際に組みました。

ブランチ GitHubの環境 デプロイ先
develop development 開発用のCloudflareアカウント
main production 本番用のCloudflareアカウント

GitHubの「環境」で、アカウントを切り替える

GitHubのリポジトリの設定には「Environments(環境)」という機能があります。環境ごとに、別々のSecrets(秘密の値)を持たせられます。 development と production のどちらにも、同じ名前(CLOUDFLARE_API_TOKEN、CLOUDFLARE_ACCOUNT_ID)で、それぞれのアカウント用の値を登録しました。

デプロイのワークフローでは、pushされたブランチで環境を選ぶだけです。

jobs:
  test:
    uses: ./.github/workflows/ci.yml   # PRと同じテストを呼び出す

  deploy:
    needs: test                        # テストが通らないとデプロイしない
    environment: ${{ github.ref_name == 'main' && 'production' || 'development' }}
    env:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
      WRANGLER_ENV_ARGS: ${{ github.ref_name == 'main' && '--env production' || '' }}
    steps:
      # (checkout・setup-node・npm ci は省略)
      - run: npm run build
      - name: D1 マイグレーション
        run: |
          if ls drizzle/*.sql >/dev/null 2>&1; then
            npx wrangler d1 migrations apply DB --remote $WRANGLER_ENV_ARGS
          else
            echo "マイグレーションはまだありません"
          fi
      - run: npx wrangler deploy $WRANGLER_ENV_ARGS

マイグレーションがまだ1つもないと wrangler d1 migrations apply がエラーになるので、SQLがあるときだけ実行するようにしています。

非公開リポジトリでは使えない機能が多い中、環境と環境ごとのSecretsは、無料プランでも使えました。

APIトークンは、1つのアカウントだけに絞る

CloudflareのAPIトークンは、テンプレート「Edit Cloudflare Workers」に「D1 → Edit」を足して作りました。 大事なのは、「Account Resources」を、そのトークンを使うアカウントだけに絞ることです。こうしておけば、開発用のトークンで本番のアカウントを操作することはできません。

GitHubへの登録は、値をコマンドに直接書かず、入力欄に貼り付ける形にしました。コマンドの履歴にトークンが残りません。

gh secret set CLOUDFLARE_API_TOKEN --env development

手元のwranglerは、開発用アカウントにだけログインしておく

開発用のD1は、手元の wrangler d1 create で作りました。 一方、本番用のD1は、あえてCloudflareのダッシュボードで作りました。

手元の wrangler が本番用アカウントにログインしていると、うっかり実行したコマンドが本番に反映される危険があります。本番用のD1を作るのは最初の1回だけなので、手作業でも手間はほとんど変わりません。 「手元から本番に触らない」を、心がけではなく仕組みで守るための選択です。

wrangler.jsonc の落とし穴2つ

本番用の設定は、wrangler.jsonc の env.production に書き分けます。ここで2つ、知らないとはまる点がありました。

{
	"name": "hakozumi",
	"d1_databases": [{ "binding": "DB", "database_name": "hakozumi", "database_id": "(開発用のID)" }],
	"env": {
		"production": {
			// 指定しないと Worker 名が hakozumi-production になる
			"name": "hakozumi",
			// バインディングは env に引き継がれないので、もう一度書く
			"d1_databases": [{ "binding": "DB", "database_name": "hakozumi", "database_id": "(本番用のID)" }]
		}
	}
}
  • env.production に name を書かないと、Worker名が自動で hakozumi-production になる
  • D1などのバインディングは env に引き継がれないので、両方に書く必要がある

初めてのデプロイ

develop にマージすると、テスト → ビルド → デプロイが自動で走り、開発用アカウントに hakozumi のWorkerができました。 続けて develop → main のPRをマージすると、本番用アカウントにも同じようにデプロイされました。

develop → main のPRは、「Squash and merge」ではなく**「Create a merge commit」**でマージしています。Squashにすると main 側に別のコミットができ、develop と main の履歴がずれてしまうためです。


5. Cloudflare Accessで、自分だけが見られるようにする

workers.dev のURLは、そのままだとURLを知っている人なら誰でも開けます。開発中のアプリを、開発用・本番用とも自分だけが見られるようにするため、Cloudflare Accessをかけました。Accessを使うのは初めてです。

設定した場所

事前に調べた手順(Settings → Domains & Routes)と、実際の日本語の画面は少し違っていました。 Workerの画面の「ドメイン」タブに、URLごとに「Accessを有効化」というリンクがありました。

設定したのは2つだけです。

  • スコープ:「すべてのトラフィック」。初期値の「プレビューのみ」だと、確認用のURLしか守られず、本体のURLは誰でも開けるままになる
  • 認証ポリシー:事前設定済みの「Cloudflareアカウント」。そのCloudflareアカウントのメンバーだけが入れる

ポリシーには「メールドメイン」という選択肢もありましたが、これはドメイン全体を許可する方式です。gmail.com を指定すると、Gmailの利用者なら誰でも入れてしまいます。

気をつけたいのは、画面の右側にある青いスイッチです。これはURLそのもののオン・オフで、Accessとは別物です。また、URLに付いている「プロダクション」という表示は、Cloudflareの用語で「そのWorkerの本番のURL」という意味で、開発用アカウントでもそう表示されます。

ボタン1つで入れた理由

設定後にURLを開くと、Accessのログイン画面に転送されるようになりました。 メールアドレスを入力してコードを受け取る流れを想像していたのですが、実際には「Cloudflare」のボタンを押すだけで、そのままページが表示されました。

選んだポリシーが、Cloudflareへのログインで本人確認をする方式だったためです。ダッシュボードにログインしたままのブラウザだったので、ボタン1つで「アカウントのメンバー」と判断されました。 ダッシュボードにログインしていないブラウザで開くと、まずCloudflareへのログインを求められます。

なお、本番用のWorkerは、一度デプロイしてWorkerができてからでないとAccessをかけられません。最初のデプロイからAccessを設定するまでの数分間は、誰でも開ける状態でした(中身は見出し1つだけです)。


Claude Codeとの分担を振り返って

今回は、IssueとPRの作成、実装、テスト、手元での確認はClaude Codeが行い、自分はレビューと判断を担当しました。 振り返ると、自分が判断したのは次のような点でした。

  • 事業用のアカウントに作るか、コミットの作者情報をどうするか
  • Node.jsを更新するか(ほかのプロジェクトにも影響するので、自分で更新した)
  • ブランチ保護が使えないとき、どうするか(有料プランにするか、ルールで守るか)
  • E2Eテストの置き場所(レビューで気になって変えてもらった)
  • CIが本当に落ちるか、わざと壊して確かめるか
  • 本番用のD1を、どこで作るか

APIトークンの作成と登録、Accessの設定は、Cloudflareのダッシュボードで自分で行いました。トークンの値をClaude Codeに渡さずに済むよう、手順だけを教えてもらう形です。

開発プロセスの記事で「何を作るか・コードの理解・受入判断は、自分で持つ」と書きました。土台づくりでも、この分担はうまく回ったと感じています。


おわりに

これで、開発の土台がそろいました。

  • PRを作ると、テストが自動で走る
  • develop にマージすると開発環境に、main にマージすると本番環境に、自動でデプロイされる
  • 開発環境も本番環境も、自分しか見られない

ひとつだけ、まだ確かめられていないことがあります。マイグレーションが、デプロイ先と同じ環境のD1に適用されることです。マイグレーションがまだ1つもないためです。 次回は、最初のテーブル(ギア)を作り、ギアの登録画面を作りながら、この点も確かめます。

← トップページに戻る