PRが作られたタイミングでGitHub PagesにStorybookをデプロイするCI【GitHub Actions】
はじめに
お疲れ様です。ふぁるです。
デザインシステムやコンポーネントライブラリを運用していると、「PRでStorybookのプレビューを確認したい」という要望はほぼ必ず出てくるかと思います。
今回は、PRが作られたタイミングで自動的にStorybookをビルドしてGitHub PagesにデプロイするGitHub Actionsワークフローを実装したので、その内容をまとめます。
(VercelやChromaticとの詳しい比較は後述します)
この記事でやること
- PRごとに独立したStorybookプレビューをGitHub Pagesにデプロイ
- PRにコメントでプレビューリンクを自動投稿
- PRがクローズされたら自動でプレビューを削除
- 古いプレビューの自動クリーンアップ(最新10個のみ保持)
最終的な構成
gh-pages ブランチ
├── main/ # mainブランチの最新Storybook
├── pr-1/ # PR #1のプレビュー
├── pr-2/ # PR #2のプレビュー
└── pr-10/ # PR #10のプレビュー
こんな感じで、PR番号ごとにディレクトリを切ってデプロイします。
実装
1. PRプレビューをデプロイするワークフロー
.github/workflows/pr-storybook-preview.yml を作成します。
name: PR Storybook Preview
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install
- name: Build Storybook
run: yarn build-storybook
# gh-pagesブランチの準備(存在確認と初期化)
- name: Setup gh-pages branch
run: |
# リモートにgh-pagesブランチが存在するか確認
if git ls-remote --heads origin gh-pages | grep -q gh-pages; then
echo "gh-pages branch exists. Checking out..."
git clone --depth=1 --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages
else
echo "gh-pages branch does not exist. Creating..."
mkdir -p gh-pages
cd gh-pages
git init -b gh-pages
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
echo "# Storybook Previews" > README.md
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md
git commit -m "Initialize gh-pages branch"
git push -u origin gh-pages
fi
# PRディレクトリを作成・更新
- name: Deploy to PR directory
run: |
PR_DIR="gh-pages/pr-${{ github.event.pull_request.number }}"
rm -rf "$PR_DIR"
mkdir -p "$PR_DIR"
cp -r storybook-static/* "$PR_DIR/"
cd gh-pages
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Deploy Storybook for PR #${{ github.event.pull_request.number }}" || echo "No changes"
git push origin gh-pages
# 古いPRプレビューをクリーンアップ(最新10個のみ保持)
- name: Cleanup old PR previews
run: |
cd gh-pages
# pr-* ディレクトリを取得し、日付順にソートして10個より古いものを削除
ls -dt pr-* 2>/dev/null | tail -n +11 | xargs rm -rf || echo "No old previews to clean"
if [ -n "$(git status --porcelain)" ]; then
git add .
git commit -m "Cleanup old PR previews"
git push origin gh-pages
fi
# PRにコメントを追加
- name: Comment PR
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const url = `https://${context.repo.owner}.github.io/${context.repo.repo}/pr-${prNumber}/`;
const comment = `## 📚 Storybook Preview
プレビューが更新されました!
🔗 **[Storybookを開く](${url})**
> このプレビューはPRがマージまたはクローズされるまで利用可能です。
> 最新10個のPRプレビューのみ保持されます。`;
// 既存のコメントを探す
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('Storybook Preview')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: comment,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: comment,
});
}
2. PRクローズ時のクリーンアップワークフロー
PRがマージまたはクローズされたら、そのプレビューは不要になるので削除します。
.github/workflows/cleanup-pr-preview.yml を作成。
name: Cleanup PR Preview
on:
pull_request:
types: [closed]
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Setup gh-pages branch
run: |
# リモートにgh-pagesブランチが存在するか確認
if git ls-remote --heads origin gh-pages | grep -q gh-pages; then
git clone --depth=1 --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages
else
echo "gh-pages branch does not exist"
exit 0
fi
- name: Remove PR directory
run: |
cd gh-pages
PR_DIR="pr-${{ github.event.pull_request.number }}"
if [ -d "$PR_DIR" ]; then
rm -rf "$PR_DIR"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Cleanup Storybook for PR #${{ github.event.pull_request.number }}"
git push origin gh-pages
else
echo "No preview directory found for PR #${{ github.event.pull_request.number }}"
fi
- name: Comment PR
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '🧹 Storybook プレビューが削除されました。',
});
3. mainブランチのStorybookデプロイ(おまけ)
PRプレビューとは別に、mainブランチの最新版もデプロイしておくと便利です。
.github/workflows/deploy-storybook.yml
name: Deploy Storybook to Pages
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'
- name: Enable Corepack
run: corepack enable
- name: Install dependencies
run: yarn install
- name: Build Storybook
run: yarn build-storybook
- name: Setup gh-pages branch
run: |
if git ls-remote --heads origin gh-pages | grep -q gh-pages; then
echo "gh-pages branch exists. Checking out..."
git clone --depth=1 --branch gh-pages https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git gh-pages
else
echo "gh-pages branch does not exist. Creating..."
mkdir -p gh-pages
cd gh-pages
git init -b gh-pages
git remote add origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
echo "# Storybook" > README.md
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add README.md
git commit -m "Initialize gh-pages branch"
git push -u origin gh-pages
fi
- name: Deploy to main directory
run: |
cd gh-pages
rm -rf main
mkdir -p main
cp -r ../storybook-static/* main/
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "Deploy Storybook from main branch" || echo "No changes"
git push origin gh-pages
実装のポイント解説
gh-pagesブランチの存在確認
if git ls-remote --heads origin gh-pages | grep -q gh-pages; then
最初はこの確認を入れてなくて、初回実行時にエラーになりました。git clone しようとしてもブランチが存在しないから失敗する。
なので、git ls-remote でリモートブランチの存在を確認してから、存在しなければ初期化する処理を入れました。この辺りは試行錯誤の結果です。
PR番号ごとのディレクトリ分け
PR_DIR="gh-pages/pr-${{ github.event.pull_request.number }}"
github.event.pull_request.number でPR番号を取得できるので、これを使ってディレクトリ名を決めています。
古いプレビューの自動削除
ls -dt pr-* 2>/dev/null | tail -n +11 | xargs rm -rf
これがポイントで、ls -dt で日付順にソートして、tail -n +11 で11番目以降(つまり10個より古いもの)を取得して削除しています。
プロジェクトによって環境数は調整してください。
PRコメントの重複防止
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('Storybook Preview')
);
if (botComment) {
// 既存コメントを更新
await github.rest.issues.updateComment({...});
} else {
// 新規コメントを作成
await github.rest.issues.createComment({...});
}
PRを更新するたびに新しいコメントが追加されると邪魔なので、既存のbotコメントを探して、あれば更新、なければ新規作成という処理にしています。
これで、1つのPRに対して常に1つのStorybookプレビューコメントが表示される状態を保てます。
プライベートリポジトリの場合の注意点
プライベートリポジトリでGitHub Pagesを使う場合、URLの形式が少し特殊になります。
https://<自動生成されたドメイン>.pages.github.io/pr-1/
例:https://barnacle-xxxxxx.pages.github.io/pr-1/
このドメインは、リポジトリのSettings → PagesでGitHub Pagesを有効化すると確認できます。
公開リポジトリの場合は、普通に https://<username>.github.io/<repo>/pr-1/ の形式になります。
実際に動かしてみた
実際にPRを作ってみると、こんな感じでコメントが自動で投稿されます:
## 📚 Storybook Preview
プレビューが更新されました!
🔗 **Storybookを開く**
> このプレビューはPRがマージまたはクローズされるまで利用可能です。
> 最新10個のPRプレビューのみ保持されます。
リンクをクリックすると、そのPR専用のStorybookが開きます。
PRを更新(新しいコミットをプッシュ)すると、コメントも自動で更新されて、最新のプレビューが反映されます。
そして、PRをマージまたはクローズすると、こんなコメントが投稿されます:
🧹 Storybook プレビューが削除されました。
同時に、gh-pagesブランチからもそのPRのディレクトリが削除されます。
ハマりポイント
1. permissions の設定
最初、contents: write と pull-requests: write の両方を設定し忘れてて、pushもコメントもできませんでした。
GitHub Actionsのワークフローで外部操作をする場合は、必要な権限をちゃんと明示する必要があります。
2. GITHUB_TOKEN の使い方
git clone https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/...
最初は普通に git clone しようとしてたんですが、認証が必要なので x-access-token: プレフィックスをつけたトークンをURLに埋め込む形式にする必要がありました。
3. Storybookのビルド時間
当たり前ですが、PRを作るたびにStorybookをビルドするので、それなりに時間がかかります。
我々のプロジェクトでは3〜5分くらいかかってるので、大規模なStorybookの場合はキャッシュを工夫するなどの最適化が必要かもしれません。
他のサービスとの比較
さて、冒頭で触れたとおり、Storybookのプレビューデプロイにはいくつかのサービスがあります。主要なものと比較してみましょう。
Chromatic vs GitHub Pages
Chromaticは、Storybookの開発元が提供する公式のビジュアルテストツールです。
Chromaticの良いところ:
- ビジュアルテスト機能: これが最大の強み。UIの変更を自動で検出してくれる
- 公式ツール: Storybookとの統合が完璧
- 高速: CDN配信で爆速
- UIレビュー機能: PRごとにUIの変更を可視化してレビューできる
- セットアップが楽: npmパッケージ入れて、トークン設定するだけ
Chromaticのつらいところ:
-
無料枠が少ない: Free planだと月5,000スナップショットまで
- 中規模のStorybookだと、すぐに使い切ってしまう
- Storiesが100個あって、50PRあったら... もう無理
- 有料プランが高い: 月$149〜等、個人プロジェクトや小規模チームには厳しい
- 外部サービス依存: 会社のセキュリティポリシーで使えないケースも
- Closedにする場合の認証関連: GitHubアカウントでの認証にした場合、非エンジニアのレイアウトRevを受けるために権限整備の必要が生じる
GitHub Pagesの良いところ:
- 完全無料: 容量制限(1GB)はあるけど、課金は不要
- GitHub完結: 外部サービスなし、セキュリティポリシーもクリアしやすい
- 自由度が高い: ワークフローを自分好みにカスタマイズできる
- 制限が緩い: スナップショット数の制限がない
GitHub Pagesのつらいところ:
- ビジュアルテストなし: 単純にデプロイするだけ
- セットアップが面倒: ワークフローを自分で書く必要がある
- 遅い: ビルドに数分かかる(Chromaticはキャッシュが効くので速い)
結論:
ビジュアルテストが必要なら→ Chromatic一択です。UIの変更を自動で検出してくれる機能は、非常に強力です。
ただ、「単純にStorybookをプレビューしたいだけ」なら→ GitHub Pagesで十分だと思います。
Vercel vs GitHub Pages
Vercelは、Next.jsの開発元が提供するホスティングサービス。Storybookのデプロイにも使えます。
Vercelの良いところ:
- 高速: ビルドもデプロイも速い
- プレビューURL自動生成: PRごとに自動でURLが発行される
- 使いやすいUI: ダッシュボードが見やすい
- カスタムドメイン: 簡単に独自ドメインを設定できる
Vercelのつらいところ:
-
無料枠の制限: Hobby planだとプライベートリポジトリは1つまで
- 複数のプライベートプロジェクトがあると詰む
- 商用利用不可: Hobby planは商用利用できない(個人プロジェクトのみ)
- 有料プランが必要: Pro planは月$20〜、チームは$40/月
GitHub Pagesの良いところ(再掲):
- 完全無料
- GitHub完結
- プライベートリポジトリ無制限
結論:
Next.jsアプリ全体をデプロイするなら→ Vercel最高です。
でも、Storybookだけなら→ わざわざVercel使わなくてもGitHub Pagesで十分じゃないかな、と。
比較表
| Chromatic | Vercel | GitHub Pages | |
|---|---|---|---|
| 料金 | 無料枠あり(5,000スナップショット/月) 有料: $149/月〜 |
無料枠あり(Hobby plan) 有料: $20/月〜 |
完全無料(1GBまで) |
| ビジュアルテスト | ✅ あり | ❌ なし | ❌ なし |
| セットアップ | 簡単(5分) | 簡単(5分) | やや面倒(30分) |
| ビルド速度 | 速い(キャッシュ効く) | 速い | 普通(3〜5分) |
| 外部サービス | 必要 | 必要 | 不要 |
| プライベートリポジトリ | 制限あり | 制限あり(Hobbyは1つ) | 無制限 |
| 商用利用 | 可能(有料) | 不可(Hobbyは個人のみ) | 可能 |
| カスタマイズ性 | 低い | 中 | 高い |
まとめ
GitHub ActionsとGitHub Pagesを使って、PRごとのStorybookプレビューを実現しました。
メリット:
- 外部サービス不要、GitHub完結
- 無料(GitHub Pagesの容量制限内であれば)
- PRレビューがしやすくなる
デメリット:
- ビルド時間がかかる
- GitHub Pagesの容量制限に注意(1GBまで)
- プライベートリポジトリだとURLが特殊
- ビジュアルテスト機能はない(Chromaticにはある)
VercelやChromaticなどの専用サービスに比べるとちょっと手間はかかりますが、「単純にStorybookをプレビューしたいだけ」なら、GitHub Pagesでも十分実用的なプレビュー環境が作れることが分かりました。
ビジュアルテストが必要な場合はChromaticを、Next.jsアプリ全体をデプロイするならVercelを、Storybookのプレビューだけなら無料で使えるGitHub Pagesを、という感じで使い分けるのが良さそうです。
コンポーネントライブラリやデザインシステムを運用している方の参考になれば嬉しいです。