UIレビューを楽にするためにPR作成をトリガーにしたStorybookをS3 + CloudFrontへ自動デプロイの仕組みを作った

UIレビューを楽にするためにPR作成をトリガーにしたStorybookをS3 + CloudFrontへ自動デプロイの仕組みを作った

PR作成をトリガーにStorybookをS3 + CloudFrontへ自動デプロイし、PRコメントのURLを開くだけでUIを確認できるようにした仕組みを紹介します。
2026.09.04

こんにちは、リテールアプリ共創部の戸田駿太です。

今回は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に投稿されたStorybookプレビューのコメント

PRに追加でpushすると同じコメントが更新され、PRをクローズ(マージ)するとプレビューは削除されます。

なぜ作ったのか

最近のフロントエンド開発では、ほとんどの実装をAIに任せています。
AIはコードを大量に生成してくれますが、「UIが本当に正しいか」は、AI自身であってもコードを読むだけでは判断が難しいです。
実装したのがAIでも人間でも、実行してUIを確認する作業は必要だと感じており、私の場合は必ず一度UIを開いて確認するようにしています。

今までUIを確認するには、以下のような手順が必要でした。

  1. PRのブランチをローカルにcheckoutする
  2. 依存関係をインストールする
  3. アプリかStorybookを起動する
  4. ブラウザで該当のコンポーネントを探して確認する

コードのレビューはGitHub上で完結するのに、UIの確認だけはローカルに戻る必要がありました。
AIによってPRの数が増えるほど、この「ローカルで開く」作業がレビューのボトルネックになっていました。

そのため、PRを作ったら自動でStorybookがデプロイされて、URLを開くだけで確認できる状態を目指しました。

構成

PRごとのStorybookプレビューの構成図

流れはこのようになっています。

  1. PRの作成・更新をトリガーにGitHub ActionsがStorybookをビルドする
  2. OIDCで権限を絞ったIAMロールを引き受ける
  3. ビルド成果物をS3バケットの pr/{PR番号}/ 配下へ同期する
  4. CloudFrontのキャッシュを無効化する
  5. PRにプレビューURLをコメントする
  6. レビュアーは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_requestopened / 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がコードを書く時代になり、人間の仕事は「確認する」ことに寄っていきます。
確認の手間を減らす仕組みは、これからどんどん導入していくべきだと思うので、ぜひ試してみてください!

参考

https://docs.github.com/ja/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services

https://storybook.js.org/docs/sharing/publish-storybook

https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cloudfront-functions.html

この記事をシェアする

関連記事