はじめに

GitHub でのプロジェクト管理において,Storybookを継続的にどこかにデプロイして,ローカルでわざわざビルドする必要を削減したいという欲求があるとします.

例えば,デザイナ側とコーダ側でデザインの擦り合わせをする際,以下の理由から URL から気楽にアクセス可能であると取り回しが良いと考えられます.

  • デザイナ側に環境設定を含めて Storybook のビルドをさせるのに負担がある.
  • ビルドそのものにそれなりに時間がかかる.

このような用途を目的として,Storybook のメンテナによって提供されているChromaticというサービスが存在しており, これは GitHub と良く統合されます.参考. とはいえ,ちょっと個人/小規模な組織に対して価格,機能の両面ではオーバーすぎるという感が否めません.

本記事では,GitHub Actionsを用いて, Storybook をNetlify手動でデプロイする方法について記載します.

成果物,およびリポジトリは以下.

用語について

  • Live URL
    • 例えば,storybook-preview.netlify.app
    • 基本的に恒久的に変わらない.
  • Preview URL
    • 例えば,xxxxxxxxxxxxx-storybook-preview.netlify.app
    • 基本的に使い捨ての URL.
  • Storybook Production
    • 今回はmainブランチに push する度にデプロイされる.
    • Live URL でアクセス可能.
    • 今回ではここにあります
  • Storybook Preview
    • Pull Request を作成,更新するたびにデプロイされる.
    • Preview URL でアクセス可能.
    • Heroku の Review Appのようなものを想定.
    • 今回ではここにあります

選定理由

デプロイ先としていくつかある中から Netlify が選定した理由を述べます.

今日日のデプロイ先として考えられるサービス,その利点と欠点については大体この記事にまとまっています. 記事内で挙げられているものとしては以下.

これらの他,Surge.shというホスティングサービスもあります.

この内,Cloudflare Pages,Ampllify と Cloud Run は私が触ったことがないので今回は断念しました. それ以外について,採用しなかった理由を列挙すると以下.

  • Surge.sh
    • CLI からの出力がリッチでパースしずらい.
      • この件に関して Issue / PR を投げようか迷ったが,開発自体がかなり停滞していたことから断念.
  • Firebase Hosting
    • 公式の Actionsにおいて,同一リポジトリで複数サイトを運用するとプレビュー URL のコメントが他のプレビューのコメントと競合する.
    • Storybook のデプロイという目的の為だけに Firebase のプロジェクトを建てるのはちょっとオーバーすぎる.
  • Vercel
    • 1 リポジトリあたり 1 つのプロジェクトしか紐付けられない.
      • これは例えば,プロジェクト自体が Vercel であった場合に困る.
  • GitHub Pages
    • そもそもプレビュー機能が無い.

上記の問題に対して Netlify が以下の点で優れていたので,今回のケースで採用しました.

  • プレビュー機能がある.
  • 複数のサイトを一つのリポジトリに紐付けられる.
  • CLI が優秀.
    • JSON 形式で出力を返すことが可能.

Workflows の設定

まず workflow の設定を以下に全文示します. Composite Actionを使って Action を再利用することに注意.これは後で説明します.

# https://github.com/SnO2WMaN-HQ/deploy-storybook-to-netlify/blob/df1649d8cb3fefcc30ec747c412b43d4441deeb6/.github/workflows/ci.yml
name: CI

on:
  push:
    branches:
      - "main"
  pull_request:
    types:
      - opened
      - synchronize
      - reopened

jobs:
  storybook-preview:
    name: Storybook Preview
    if: ${{ github.event_name == 'pull_request' }}
    runs-on: ubuntu-latest
    environment:
      name: Storybook Preview
      url: ${{ steps.deploy.outputs.preview_url }}
    steps:
      - uses: actions/checkout@v2
      - id: deploy
        uses: ./.github/workflows/composites/deploy_storybook_to_netlify
        with:
          netlify_site_id: ${{ secrets.STORYBOOK_NETLIFY_SITE_ID }}
          netlify_auth_token: ${{ secrets.STORYBOOK_NETLIFY_AUTH_TOKEN }}
      - name: Comment on PR
        if: ${{ success() }}
        env:
          PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
          LOGS_URL: ${{ steps.deploy.outputs.logs_url }}
        uses: actions/github-script@v5
        with:
          script: |
            const { PREVIEW_URL, LOGS_URL } = process.env;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Storybook Preview\n||URL|\n|-|:-|\n|Preview|${PREVIEW_URL}\n|Logs|${LOGS_URL}`,
            });

  storybook-production:
    name: Storybook Production
    if: ${{ github.event_name == 'push' }}
    runs-on: ubuntu-latest
    environment:
      name: Storybook Production
      url: ${{ steps.deploy.outputs.live_url }}
    steps:
      - uses: actions/checkout@v2
      - id: deploy
        uses: ./.github/workflows/composites/deploy_storybook_to_netlify
        with:
          production: "true"
          netlify_site_id: ${{ secrets.STORYBOOK_NETLIFY_SITE_ID }}
          netlify_auth_token: ${{ secrets.STORYBOOK_NETLIFY_AUTH_TOKEN }}
# https://github.com/SnO2WMaN-HQ/deploy-storybook-to-netlify/blob/df1649d8cb3fefcc30ec747c412b43d4441deeb6/.github/workflows/composites/deploy_storybook_to_netlify/action.yml
name: "Deploy Storybook to Netlify"
description: "Deploy Storybook to Netlify"

inputs:
  production:
    description: "Is production"
    required: false
    default: "false"
  netlify_site_id:
    description: "Netlify site ID"
    required: true
  netlify_auth_token:
    description: "Netlify auth token"
    required: true

outputs:
  preview_url:
    description: "Netlify Preview URL"
    value: ${{ steps.deploy_to_netlify.outputs.preview_url }}
  live_url:
    description: "Netlify Live URL"
    value: ${{ steps.deploy_to_netlify.outputs.live_url }}
  logs_url:
    description: "Netlify Logs URL"
    value: ${{ steps.deploy_to_netlify.outputs.logs_url }}

runs:
  using: composite
  steps:
    - uses: actions/checkout@v2
    - uses: actions/setup-node@v2
      with:
        node-version: "16"
    - uses: actions/cache@v2
      with:
        path: ~/.pnpm-store
        key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
        restore-keys: |
          ${{ runner.os }}-
    - uses: pnpm/action-setup@v2.0.1
      with:
        version: 6.24.1
        run_install: |
          - args: [--frozen-lockfile]
    - run: pnpm run storybook:build
      shell: bash
    - id: deploy_to_netlify
      env:
        PRODUCTION: ${{ inputs.production == 'true' }}
        NETLIFY_SITE_ID: ${{ inputs.netlify_site_id }}
        NETLIFY_AUTH_TOKEN: ${{ inputs.netlify_auth_token }}
      run: |
        DEPLOY_COMMAND="netlify deploy -d storybook-static --json"
        if [ "$PRODUCTION" = "true" ]; then DEPLOY_COMMAND+=" --prod"; fi

        RESULT=$(pnpx $DEPLOY_COMMAND)

        echo "::set-output name=preview_url::$(echo $RESULT | jq -r .deploy_url)"
        echo "::set-output name=logs_url::$(echo $RESULT | jq -r .logs)"
        echo "::set-output name=live_url::$(echo $RESULT | jq -r .url)"
      shell: bash

pnpm について

今回なんとなく npm および yarn の Alternative として pnpm を使ってみました. 従来のパッケージマネージャとそう変わりませんが,一応説明を入れておきます.

  • pnpm install --frozen-lockfile
    • npm ciあるいはyarn --frozen-lockfileと同等.
    • Actions 内ではpnpm/action-setup内で実行されています.
  • pnpm run storybook:build
    • npm runと同等.ここでは Storybook のビルドを行う.
  • pnpx
    • npxのエイリアス.

Composite Action について

GitHub Actions では重複する設定について Composite Action を用いて再利用することが出来ます.

日本語ではこの記事が参考になると思います.

今回,Storybook Production, Preview で Environment やデプロイ後の処理を変更しますが,デプロイ処理に関してはある程度共通化できるので利用しました.

いくつかの点で注意する点があるので記載.

Composite Action の呼び出し方

示したコードの中では,具体的にはこのように呼び出しています.

steps:
  - uses: actions/checkout@v2
  - id: deploy
    uses: ./.github/workflows/composites/deploy_storybook_to_netlify
    with:
      netlify_site_id: ${{ secrets.STORYBOOK_NETLIFY_SITE_ID }}
      netlify_auth_token: ${{ secrets.STORYBOOK_NETLIFY_AUTH_TOKEN }}

まず,Composite Action 自体はどこに配置しても良いのですが, 必ずaction.ymlとして保存されなければなりません. 今回は.github/workflows/compsites以下に置きましたが,Actionlintcomposites/以下も見てしまい文法エラーを踏むので, .github/compositesぐらいに置くのが良いでしょう.

配置後,チェックアウトしたパスの起点から./<action_dir>で利用することが出来ます.このとき,先頭の./必ず記載しなければなりません.

それ以外については従来のusesと同様に利用することが出来ます.

Composite Action の書き方

runs以下で必ずusing: compositeを記載しなければなりません.

その他に関しては,大体目立った文法の違いなどは無いので割愛.

run

全てのrunを利用する step で,毎回shellを指定する必要があります.

参考にした記事では複数行(|)が使えないと書いてありますが,使えるようになっています.

inputs

Composite Action 側でinputsを定義することで, 再利用する側でwithを利用して引数を渡すことが出来ます.

この際,全ての引数は文字列( String )である必要があり,Booleanなど,つまりtruefalseを渡すことは出来ません.

渡された引数は,Composite Action 側では${{ inputs.xxx }}で利用可能です.

outputs

Composite Action 側から何らかの出力を返すのはトリッキーな手法を取る必要があるので,これを解説します.

まず,Composite Action 内のstepsで, idSTEP_IDの step 内のrunecho "::set-output name=NAME::VALUE"を実行すると, ${{ steps.STEP_ID.outputs.NAME }}としてVALUEを取得することが出来ます.

次に,outputsの中で,value: ${{ steps.STEP_ID.outputs.NAME }}を呼び出すことで,出力を返すことが出来ます.

出力は,Composite Action を呼び出した側では,${{ steps.CALLER_STEP_ID.outputs.NAME }}で利用可能です.(idCALLER_STEP_IDだとして)

CI 本体について

以下抜粋.mainブランチへの push とpull_requestopenedsynchronize(プルリクエストの対象ブランチに追加でコミットが発生した際),reopenedイベントで走らせる job を切り分けています.(CI を走らせる条件はこの記事を参考にしています.)

on:
  push:
    branches:
      - "main"
  pull_request:
    types:
      - opened
      - synchronize
      - reopened

jobs:
  storybook-preview:
    name: Storybook Preview
    if: ${{ github.event_name == 'pull_request' }}
    environment:
      name: Storybook Preview
      url: ${{ steps.deploy.outputs.preview_url }}

  storybook-production:
    name: Storybook Production
    if: ${{ github.event_name == 'push' }}
    environment:
      name: Storybook Production
      url: ${{ steps.deploy.outputs.live_url }}

environment

environmentを各 job で指定するとこのようにデプロイの履歴などが見れます.このとき,GitHub 側 で予め作っておく必要はなく,GitHub Actions 側が勝手に作ってくれます.

actions/github-script

- name: Comment on PR
  if: ${{ success() }}
  env:
    PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
    LOGS_URL: ${{ steps.deploy.outputs.logs_url }}
  uses: actions/github-script@v5
  with:
    script: |
      const { PREVIEW_URL, LOGS_URL } = process.env;
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: `## Storybook Preview\n||URL|\n|-|:-|\n|Preview|${PREVIEW_URL}\n|Logs|${LOGS_URL}`,
      });

actions/github-scriptを用いて Storybook Preview のデプロイが成功した場合,その URL をプルリクエストにコメントする step を組みました.気をつけるべき点はprocess.envで環境変数を取り出す点ぐらいでしょうか.

おわりに

actによるローカルでの CI 設定のチェックが出来るかどうかは試してないので今後の課題とします.

リポジトリ内の複数サイトを Netlify でデプロイするとき,素朴に考えるならyarn buildyarn storybook:buildを実行するプロジェクトを 2 個作っておけば良いのですが,それらの設定をファイルとしてnetlify.tomlとして書いておけないので(どちらかのプロジェクトの設定しか書いておけない,少なくとも私が調べた限り),今回このような措置が行われています.正直なところとしてはそれで全然気にしないのであればそちらでも構わないと思います.

デプロイ先が必要であるのはそもそも GitHub Actions の成果物( Artifacts )が Web ページとして閲覧できない(zip ファイルとしてダウンロードされる)からなのですが, 例えば Circle CI では Artifacts をそのままページとして閲覧できます(本来はテストカバレッジの為に). 身も蓋もない話をすれば,この記事でやったことは Circle CI を使えばまあまあスマートに解決出来る筈です.

トホホ…

参考文献


Twitterでフォローしよう

おすすめの記事