
UIレビューを楽にするためにPR作成をトリガーにしたStorybookをS3 + CloudFrontへ自動デプロイの仕組みを作った
こんにちは、リテールアプリ共創部の戸田駿太です。
今回はPRを作成するとそのブランチのStorybookが自動でAWSにデプロイされ、PRのコメントにプレビューURLが投稿される仕組みをご紹介します。
この仕組みを入れてから、AIが書いたフロントエンドの実装をレビューするときにローカルでStorybookを起動する必要がなくなり、UIの確認がかなり楽になりました。
PRを作るとこうなります
PRを作ると、数分後にGitHub ActionsがそのブランチのStorybookをデプロイして、PRにURLをコメントします。
レビュアーはそのURLを開くだけで、ローカルで何も起動せずにUIを確認できます。
1. PRを作成する
↓
2. GitHub ActionsがそのブランチのStorybookをビルドしてAWSにデプロイする
↓
3. PRにプレビューURLがコメントされる
↓
4. URLを開いてUIを確認する
実際のコメントはこのようになっています。

PRに追加でpushすると同じコメントが更新され、PRをクローズ(マージ)するとプレビューは削除されます。
なぜ作ったのか
最近のフロントエンド開発では、ほとんどの実装をAIに任せています。
AIはコードを大量に生成してくれますが、「UIが本当に正しいか」は、AI自身であってもコードを読むだけでは判断が難しいです。
実装したのがAIでも人間でも、実行してUIを確認する作業は必要だと感じており、私の場合は必ず一度UIを開いて確認するようにしています。
今までUIを確認するには、以下のような手順が必要でした。
- PRのブランチをローカルにcheckoutする
- 依存関係をインストールする
- アプリかStorybookを起動する
- ブラウザで該当のコンポーネントを探して確認する
コードのレビューはGitHub上で完結するのに、UIの確認だけはローカルに戻る必要がありました。
AIによってPRの数が増えるほど、この「ローカルで開く」作業がレビューのボトルネックになっていました。
そのため、PRを作ったら自動でStorybookがデプロイされて、URLを開くだけで確認できる状態を目指しました。
構成

流れはこのようになっています。
- PRの作成・更新をトリガーにGitHub ActionsがStorybookをビルドする
- OIDCで権限を絞ったIAMロールを引き受ける
- ビルド成果物をS3バケットの
pr/{PR番号}/配下へ同期する - CloudFrontのキャッシュを無効化する
- PRにプレビューURLをコメントする
- レビュアーはURLを開くだけで確認できる
ポイントは以下の4つです。
PRごとにスタックを作らない
Storybookの配信基盤(S3 + CloudFront)はstg環境に1つだけCDKで用意しておき、PRプレビューは同じバケットの pr/{PR番号}/ 配下に aws s3 sync で直接同期します。
CloudFormationを通さないので、S3への同期から反映までは数十秒で終わります。
本体のStorybookとPRプレビューを同居させる
ルート直下には develop ブランチ由来の本体Storybookを、pr/ 配下にはPRごとのプレビューを置きます。
本体のデプロイは pr/* を同期対象から除外して、お互いに消し合わないようにしています。
PRのブランチが引き受けるロールは権限を絞る
PRのブランチではworkflow定義を自由に書き換えられます。そのため本体のデプロイに使うロールとは別に、「pr/ 配下のS3の一覧・書き込み・削除」と「CloudFrontのキャッシュ無効化」だけができるロールをOIDCで用意しています。
消し忘れはS3ライフサイクルで回収する
PRクローズ時のworkflowが失敗してもプレビューが溜まらないように、pr/ 配下は30日で自動削除されるようにしています。
実装
GitHub Actions
pull_request の opened / synchronize / reopened でデプロイ、closed で削除します。
Storybookに影響しないパス(APIだけの変更など)では起動しないように paths で絞っています。
name: Storybook Preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
paths:
- "apps/web/**"
- "packages/**"
- ".github/workflows/storybook-preview.yml"
# 同じPRの連続pushは最新だけ残す
concurrency:
group: storybook-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
id-token: write # OIDC
contents: read
pull-requests: write # プレビューURLのコメント投稿・更新
env:
AWS_REGION: ap-northeast-1
STORYBOOK_STACK_NAME: MyApp-stg-storybook
PREVIEW_PREFIX: pr/${{ github.event.pull_request.number }}
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: pnpm install --frozen-lockfile
- run: pnpm build-storybook
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_STORYBOOK_PREVIEW_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: 配信先の解決
id: target
run: |
resolve_output() {
aws cloudformation describe-stacks --stack-name "$STORYBOOK_STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text
}
echo "bucket=$(resolve_output WebBucketName)" >> "$GITHUB_OUTPUT"
echo "distribution_id=$(resolve_output DistributionId)" >> "$GITHUB_OUTPUT"
echo "preview_url=$(resolve_output WebUrl)/${PREVIEW_PREFIX}/" >> "$GITHUB_OUTPUT"
- name: S3へ同期
run: |
aws s3 sync apps/web/storybook-static "s3://${{ steps.target.outputs.bucket }}/${PREVIEW_PREFIX}/" \
--delete --no-progress
- name: CloudFrontキャッシュ無効化
run: |
aws cloudfront create-invalidation \
--distribution-id "${{ steps.target.outputs.distribution_id }}" \
--paths "/${PREVIEW_PREFIX}/*"
- name: PRにプレビューURLをコメント
uses: actions/github-script@v7
env:
PREVIEW_URL: ${{ steps.target.outputs.preview_url }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
with:
script: |
const marker = "<!-- storybook-preview -->";
const body = [
marker,
"### 📚 Storybook プレビュー",
"",
process.env.PREVIEW_URL,
"",
`- コミット: ${process.env.HEAD_SHA.slice(0, 7)}`,
"- PRをpushするたびに更新され、クローズ時に削除されます",
].join("\n");
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_STORYBOOK_PREVIEW_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: 配信先の解決
id: target
run: |
resolve_output() {
aws cloudformation describe-stacks --stack-name "$STORYBOOK_STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text
}
echo "bucket=$(resolve_output WebBucketName)" >> "$GITHUB_OUTPUT"
echo "distribution_id=$(resolve_output DistributionId)" >> "$GITHUB_OUTPUT"
- name: プレビューを削除
run: |
aws s3 rm "s3://${{ steps.target.outputs.bucket }}/${PREVIEW_PREFIX}/" --recursive
aws cloudfront create-invalidation \
--distribution-id "${{ steps.target.outputs.distribution_id }}" \
--paths "/${PREVIEW_PREFIX}/*"
同期先のバケット名とディストリビューションIDは、CDKスタックの CfnOutput から describe-stacks で解決しています。
workflowにリソース名をハードコードしなくて済むので、スタック側の変更に追従しやすいです。
コメントは <!-- storybook-preview --> というマーカーで既存のコメントを探し、あれば更新し、なければ新規作成しています。
これでpushのたびにコメントが増え続けることがなくなります。
CDK(配信基盤)
Storybookの配信基盤は通常のSPA配信と同じS3 + CloudFrontの構成です。
PRプレビュー向けに変えたのは以下の3点だけです。
1. pr/ 配下のライフサイクルルール
const bucket = new s3.Bucket(this, "WebBucket", {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
// PRクローズ時の削除に失敗しても溜まらないよう、30日で自動削除する
lifecycleRules: [
{ prefix: "pr/", expiration: cdk.Duration.days(30) },
{ abortIncompleteMultipartUploadAfter: cdk.Duration.days(1) },
],
});
2. 本体デプロイから pr/* を除外
new s3deploy.BucketDeployment(this, "DeployWeb", {
sources: [s3deploy.Source.asset("../../../apps/web/storybook-static")],
destinationBucket: bucket,
distribution,
distributionPaths: ["/*"],
// BucketDeploymentはソースに無いオブジェクトを削除(prune)するため、
// 本体のデプロイでPRプレビューまで消さないよう除外する
exclude: ["pr/*"],
});
// IAMロール側がこのタグでキャッシュ無効化の対象をStorybook配信だけに絞る
cdk.Tags.of(this).add("variant", "storybook");
// workflowが同期先を解決するための出力
new cdk.CfnOutput(this, "WebUrl", { value: `https://${distribution.domainName}` });
new cdk.CfnOutput(this, "WebBucketName", { value: bucket.bucketName });
new cdk.CfnOutput(this, "DistributionId", { value: distribution.distributionId });
3. ディレクトリ単位で index.html に解決するCloudFront Function
SPA用のリライト関数は拡張子のないパスをすべてルートの /index.html に寄せるため、/pr/123/ を開いても本体のStorybookが返ってきてしまいます。
Storybook配信では「そのディレクトリの index.html」に解決する関数に差し替えました。
function handler(event) {
var request = event.request;
var uri = request.uri;
// /pr/123/ → /pr/123/index.html
if (uri.endsWith("/")) {
request.uri = uri + "index.html";
return request;
}
// /pr/123 → /pr/123/ へリダイレクト
// Storybookはアセットを相対パス(./sb-manager/...)で参照するため、
// 末尾スラッシュがないとブラウザが /pr/ 基準で解決して壊れる
// ※ クエリ文字列(?path=/story/... など)の引き継ぎは記事では省略しています
if (!uri.includes(".")) {
var origin = "https://" + request.headers.host.value;
return {
statusCode: 301,
statusDescription: "Moved Permanently",
headers: { location: { value: origin + uri + "/" } },
};
}
return request;
}
IAMロール(OIDC)
PRのブランチから起動されるworkflowに渡すロールなので、権限は最小限にしています。
const previewRole = new iam.Role(this, "GithubStorybookPreviewRole", {
assumedBy: new iam.WebIdentityPrincipal(githubOidcProvider.openIdConnectProviderArn, {
StringEquals: {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
// フォークからのpull_requestはOIDCトークンを発行できないため、同一リポジトリのPRに限られる
"token.actions.githubusercontent.com:sub": `repo:${GITHUB_REPO}:pull_request`,
},
}),
inlinePolicies: {
StorybookPreviewSync: new iam.PolicyDocument({
statements: [
// 一覧取得は pr/ 配下に限る
new iam.PolicyStatement({
actions: ["s3:ListBucket"],
resources: [storybookBucketArn],
conditions: { StringLike: { "s3:prefix": ["pr/*"] } },
}),
// AWS CLIがリージョン解決のために呼ぶことがある
new iam.PolicyStatement({
actions: ["s3:GetBucketLocation"],
resources: [storybookBucketArn],
}),
new iam.PolicyStatement({
actions: ["s3:PutObject", "s3:DeleteObject"],
resources: [`${storybookBucketArn}/pr/*`],
}),
// ディストリビューションIDは後から決まるため、タグ条件でStorybook配信だけに絞る
new iam.PolicyStatement({
actions: ["cloudfront:CreateInvalidation"],
resources: [`arn:aws:cloudfront::${this.account}:distribution/*`],
conditions: { StringEquals: { "aws:ResourceTag/variant": "storybook" } },
}),
new iam.PolicyStatement({
actions: ["cloudformation:DescribeStacks"],
resources: [`arn:aws:cloudformation:${this.region}:${this.account}:stack/MyApp-stg-storybook/*`],
}),
],
}),
},
});
なお、フォークからのPRにはOIDCトークンが発行されないため、この仕組みは同一リポジトリのブランチでPRを作る運用が前提になります。
使ってみて
レビューがGitHub上で完結する
コードを読み、コメントのURLを開いて見た目を確認し、承認する。この流れがブラウザだけで完結します。
デザイナーやPMにもそのまま共有できる
「このPRのUI見てほしい」とURLを送るだけで確認してもらえます。ローカル環境を持っていない人にも見せられるのが大きいです。
AIに実装を任せやすくなった
確認コストが下がったので、AIに「このコンポーネントを実装して」と任せてPRを作らせ、プレビューを見て指示を出す、というループが回しやすくなりました。
まとめ
PR作成をトリガーにStorybookをS3 + CloudFrontへ自動デプロイして、PRコメントにプレビューURLを投稿する仕組みをご紹介しました。
PRごとにスタックを作らず、既存のバケットの pr/ 配下に直接同期する構成なので、すでにS3 + CloudFrontでStorybookを配信している方なら、workflowとIAMロールを追加する程度で導入できます。
AIがコードを書く時代になり、人間の仕事は「確認する」ことに寄っていきます。
確認の手間を減らす仕組みは、これからどんどん導入していくべきだと思うので、ぜひ試してみてください!
参考





