BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

GitHub Actions で Storybook をお手軽に共有するやつ作ってみた

f:id:yaakaito:20191205230746p:plain

この記事はBASE Advent Calendar 2019の8日目の記事です。

devblog.thebase.in

エンジニアの右京です!

みなさん!Storybook は使っていますか?BASE ではUIコンポーネントの社内展開はもちろん、日々の業務の中でもサンプルの実装を共有したりするために Storybook が使われています。BASEではこれを「特定のリポジトリにコードをコミットすると、自動的に社内向けサーバーへデプロイされる仕組み (ようするに社内 GitHub Pages ですね)」を利用して社内共有しているのですが、毎度のセットアップが大変なので Gtihub Actions を使ってお手軽に設定できるようにしてみたよ、という内容です。

github.co.jp

TL;DR

  • 社内用向けドキュメントサーバーへのデプロイを他のリポジトリから使いやすいように Action 化して配信するようにしました
  • プライベートリポジトリにある Action は直接参照することができないので Personal access token と npx を使います
  • CircleCI から GitHub Actions に変更することで様々なイベントにフックすることができ、柔軟性のあるデプロイが可能になりました

デプロイ(コミット)の Action 化

これまでこのサーバーを利用したい時は、利用側の CircleCI で専用のリポジトリを clone して commit を作って push して...といった処理を用意する必要がありました。利用するプロジェクトが増えていく中、この利用法のスケールのしにくさに問題を感じていたため、GitHub Actions でスパっといけないか?と思ったのが今回のスタート地点です。 このエントリでは今後はこのリポジトリのことを「static-pages-repo」と呼ぶことにします。

まず、前提として static-pages-repo は master に push されると public ディレクトリ以下が自動で社内向けのサーバーへデプロイされるようになっています。

└── public
    ├── webservice1/master/...
    ├── webservice1/hogefuga/...
    ├── webservice2/master/...
    ├── ...
    └── index.html // デプロイされているURLのリストが入っています

今回作る Action では、上記の構成に沿って以下のフローを実行します

  • この配置に合わせて利用側から成果物をコピー
  • コピーした成果物をコミット
  • アクセスしやすいようにpublic/index.htmlを更新
  • リモートへ push

これを行うためには3つの引数が必要そうです。

  • public の配置を決定するためのプロジェクト名(prefix)
  • public の配置を決定するためのブランチ名
  • 成果物(コピー元)のパスの指定

つまり Action としてはこんな感じに利用できるのがよさそうです。

- uses: static-pages-repo
  with:
    project: webservice1
    branch: feature/hogefuga
    source: dist

早速実装に...と行きたいところですが、気をつけるべき点が2つあります。

  • プライベートリポジトリのアクションは直接実行することはできない
  • Workflow 内で他のリポジトリを扱う場合は secrets.GITHUB_TOKEN では権限不足

それぞれ見ていきます。

プライベートリポジトリのアクションは直接実行することはできない

パブリックなリポジトリの場合はリポジトリ名やパスを指定することで直接他のリポジトリから Action を参照して、利用することできます。ですが、プライベートリポジトリの場合はこの方法は使えないため、回避方法を考える必要があります。 static-pages-repo を clone...? と思いましたが、それだとあまり現状と変わらない...ので多少見栄えがよくなりそうだと npx で Action をインストールできるようにしてみることにしました。 一度 npx 経由で Action をコピーしてしまうことで、自身のリポジトリ内の Action として実行します。

- run: npx https://github.com/baseinc/static-pages-repo install
- uses: ./.github/actions/static-pages-repo

Workflow 内で他のリポジトリを扱う場合は secrets.GITHUB_TOKEN では権限不足

GitHub Actions を使ったことがある人は知っているかもしれないのですが、元々 Workflow で使える変数として secrets.GITHUB_TOKEN が用意されています。通常であれば、このトークンを使えばプライベートリポジトリでも GitHub API を呼び出したり、リポジトリに Git での書き込みができるのですが、残念ながらこのトークンではリポジトリを跨いだ操作を行うことはできません。 これは代わりに Personal access token (以下PAT) を使用することで解決します。actions/checkout にもこれに関する記述 があるので参考にしてみてください。

実際に Action を作る

では、以上の2つの注意点に気をつけながら実際に Action を作っていきます。

Action の開発方法にはDocker コンテナ版JavaScript 版 があり、今回は Git での操作がメインになりそうだなという理由でシンプルに実装できそうなDockerコンテナ版を選択しました。

公式ドキュメントにそってまず Dockerfile を作ります。alpine でもよいですが、public/index.html を Node.js で作成しているため、 node:12 を選択しています。 ファイルの置き方ですが、これらを直接 Actions が実行するわけではないので(コピー元)、安直に action というディレクトリを作って配置することにしました。

FROM node:12

COPY entrypoint.sh /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]

次に action.yml を作ります。action.yml は引数や出力、実行方法を設定するためのファイルです。

name: 'static-pages-repo'
description: 'deploy static pages'
inputs: # ここに追加したキーが引数となります
  pat:
    description: 'Actionで必要になるPAT'
    required: true
  project:
    description: 'ディレクトリ名のprefix'
    required: true
  branch:
    description: 'ディレクトリ名'
    required: false
    default: 'master'
  source:
    description: '配信したいコンテンツへのパス'
    required: true
outputs:
  url:
    description: 'デプロイ先となるURL'
runs:
  using: 'docker'
  image: 'Dockerfile'
  args:
    - ${{ inputs.pat }}
    - ${{ inputs.project }}
    - ${{ inputs.branch }}
    - ${{ inputs.source }}

最後に entrypoint.sh を作って実際に動かすスクリプトを書いていきます。PAT を受け取って、static-pages-repoを操作していきます。

#!/bin/sh -l

# 作業用のリポジトリをクローンします、PAT($1)を付加していることに気をつけてください
git clone https://$1@github.com/baseinc/static-pages-repo.git .github/tmp/repo

# project/branchなディレクトリを作って成果物をsourceからコピーしてきます
mkdir -p .github/tmp/repo/public/$2/$3 
cp -rT $4 .github/tmp/repo/public/$2/$3 

cd .github/tmp/repo    

# 配置されたファイルへのリンクを含む public/index.html を生成します
npm ci
npm run build-index

# 最終的に差分が生まれていれば git commit して push します
git add .
git config user.name actions
git config user.email actions@binc.jp
if git commit --dry-run > /dev/null; then
    git commit -m "Update $2/$3"
    git push origin HEAD
fi
git push https://$1@github.com/baseinc/static-pages-repo.git master

# このようにして値を出力しておくと、次の step でこれを元に検証したりすることができます
echo "::set-output name=url::https://static-pages-repo.com/$2/$3/"

Action を配信する npx スクリプトを作る

npx にあまり馴染みのない方もいるかもしれませんが、簡単にいえば「インストールせずに一度だけ使えるパッケージ」という感じでしょうか。 package.json を作って以下のようなスクリプトで Action をローカルにコピーしています。

#!/usr/bin/env node

const path = require('path')
const fs = require('fs-extra')

const src = path.resolve(__dirname, '../action')
const out = path.resolve(process.cwd(), '.github/actions')

;(async() => {
    await fs.ensureDir(out)
    await fs.copy(src, `${out}/static-pages-repot`)
})()

これを上のものと合わせると、最終的には他のリポジトリからこのような step で static-pages-repo デプロイすることができるようになります。

- run: npx https://${{secrets.PAT}}@github.com/baseinc/static-pages-repo install
- uses: ./.github/actions/static-pages-repo
  with:
    pat: ${{ secrets.PAT }}
    project: webservice
    source: dist

便利そうに見えませんか?実際に PAT を設定するのは、利用側であることに気をつけてください。

これで static-pages-repo 側は完成です! ここまでのディレクトリ構造はこのようになっています。

├── action
│   ├── Dockerfile
│   ├── action.yml
│   └── entrypoint.sh
├── bin
│   └── install.js // npx で実行されるスクリプト
├── webroot
│   ├── webservice1/master/...
│   ├── webservice1/hogefuga/...
│   ├── webservice2/master/...
│   ├── ...
│   └── index.html
├── README.md
├── package-lock.json
└── package.json

実際に Action を使ってデプロイする

これで準備が整ったので、あとは各リポジトリに導入するだけです!

今回は、実際に BASE で運用しているパターンから3つをご紹介します。

  • master に push されたらデプロイ
  • Pull Request が作られたらデプロイ
  • Pull Request に特定のコメントがついたらデプロイ

master に push されたらデプロイ

一番簡単でわかりやすい、馴染みのある感じだと思います。 ほとんどのリポジトリは、master にマージされたときに更新を実行するようにしています。

name: deploy master

on:
  push:
    branches:
      - master

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - uses: actions/setup-node@v1
      with:
        node-version: '12.x'
    - uses: actions/cache@v1
      with:
        path: node_modules
        key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
        restore-keys: |
          ${{ runner.os }}-node-
    - run: yarn install --frozen-lockfile
    - run: yarn storybook -o dist
    - run: npx https://${{secrets.PAT}}@github.com/baseinc/static-pages-repo install
    - uses: ./.github/actions/static-pages-repo
      with:
        pat: ${{ secrets.PAT }}
        project: webservice
        source: dist

Pull Request が作られたらデプロイ

UIコンポーネントのような基本的に Storyboard がセットになるものはこれを設定しています。 Pull Request が Open されると同時にデプロイされるため、レビューの際にはスムーズに Storybook を確認することができます。

name: open pull-request

on:
  pull_request:
    types: [opened, reopened]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - uses: actions/setup-node@v1
      with:
        node-version: '12.x'
    - run: yarn install --frozen-lockfile
    - run: yarn storybook -o dist
    - run: npx https://${{secrets.PAT}}@github.com/baseinc/static-pages-repo install
    - id: deploy
      uses: ./.github/actions/static-pages-repo
      with:
        pat: ${{ secrets.PAT }}
        project: webservice
        branch: ${{ github.event.pull_request.head.ref }} # 実行時の event を受け取って使うことができるため、ここから branch 名を取得して決定しています
        source: dist
    - if: "steps.deploy.outputs.url" # 前の step が成功したときに発行される url があったら、PRのコメントにURLを書き込みます
      env:
        URL: ${{ steps.deploy.outputs.url }}
        API_ENDPOINT: ${{ github.event.pull_request.comments_url }}
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # ここは同じリポジトリ内なので GITHUB_TOKEN でアクセスできます
      run: |
        curl -X POST -H "Authorization: token ${GITHUB_TOKEN}" -i ${API_ENDPOINT} -d "`printf '{\"body\":\"deploy to %s\"}' ${URL}`"

Pull Request に特定のコメントがついたらデプロイ

f:id:yaakaito:20191205230426p:plain

必ずしも Storybook が必要ではないアプリケーションのリポジトリでは、Pull Request にコマンドコメントを書くことで任意のタイミングでデプロイが行えるようにしています。 これには issue_comment というイベントを使うのですが、これは特定ブランチには紐づかないイベントで、デフォルトブランチを起点に実行されるため少し複雑です。

name: on comment pull request

on:
  issue_comment:
    types: [created]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - id: check
      # コメントに deploy-storybook の文字列が含まれていて、pull_requrest なら実行します
      if: "contains(github.event.comment.body, 'deploy-storybook') && github.event.issue.pull_request" 
      env:
        API_ENDPOINT: ${{ github.event.issue.pull_request.url }}
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: | 
        # pull_request のデータを API 経由で取得して、ブランチ名を特定します
        branch=`curl -X GET -H "Authorization: token ${GITHUB_TOKEN}" ${API_ENDPOINT} | jq -r '.head.ref'`
        echo ::set-output name=branch::$branch # このようにすることで id(=check) に紐づく outputs を step からも書き出すことができます
    # これ以降は branch が特定できていたら続行します
    - if: "steps.check.outputs.branch" 
      uses: actions/checkout@v1
      with:
        ref: ${{ steps.check.outputs.branch }}
    - if: "steps.check.outputs.branch"
      uses: actions/setup-node@v1
      with:
        node-version: '12.x'
    - if: "steps.check.outputs.branch"
      run: |
            yarn install --frozen-lockfile
            yarn storybook -o dist
            npx https://${{secrets.PAT}}@github.com/baseinc/static-pages-repo install
    - id: deploy
      if: "steps.check.outputs.branch"
      uses: ./.github/actions/static-pages-repo
      with:
        pat: ${{ secrets.PAT }}
        project: webservice
        branch: ${{ steps.check.outputs.branch }} # 前の step で特定した branch 名を使用します
        source: dist
    - if: "steps.deploy.outputs.url"
      env:
        URL: ${{ steps.deploy.outputs.url }}
        API_ENDPOINT: ${{ github.event.issue.comments_url }}
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        curl -X POST -H "Authorization: token ${GITHUB_TOKEN}" -i ${API_ENDPOINT} -d "`printf '{\"body\":\"deploy to %s\"}' ${URL}`"

一つ気をつけるべき点として、 job 自体にも if を設定できもっと簡潔にかけそうなのですが、if が通らなかった場合に job が 0個となり Workflow 自体が失敗したことになってしまいます。

また、実際にはこれらと「 Pull Request が close された際にディレクトリを削除する Action 」をあわせて運用しています。

めでたしめでたし

Storybook を Move Fast にデプロイできるようになったことで、Speak Openlyな開発にまた一歩近くことができたと思います。 お気づきの方もいると思いますが、単なる静的ファイルホスティングなので Storybook 以外にももちろん使うことができます!

明日は id:kmotokiid:tatsuta2 です!