Google スプレッドシートで行うリリース管理（ Android ）

この記事は[くふうカンパニー Advent Calendar 2019](https://qiita.com/advent-calendar/2019/kufu)の18日目の記事となります<(_ _)>

**おはこんばんにちは！**

アプリのリリースは自動化されていますか？
私の個人的な Android アプリも多数の先人の知恵をお借りして、 fastlane で自動化しています。
この記事は、その運用のご紹介です。
（そういうやり方もあるんだな…程度にご覧いただけたら嬉しいです。）

## はじめに

rbenv や gem をはじめとする Ruby の環境構築や、assembleRelease など Android の設定、Fastfile などの fastlane の記入内容に関しては細かく触れません。
ある程度、個人や会社でリリースまでできてる状態を前提に、私なりの変更点を記載させていただきます。

## 流れ

以下の流れでリリースされます。

* master （とか fix ）にリリース最終版を用意
* Google スプレッドシートでリリース内容を記載
* Google スプレッドシートでリリース（キーワードで発火してタグを打つまで）
* タグを検知して Circle CI でビルド
* workflow で fastlane を使用してリリース

## Google スプレッドシート

内容は以下の通りです。

### 表

<img width="1148" alt="スクリーンショット 2019-12-16 9.35.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90107/5f325c10-7a63-6a6f-e2b7-dcf00c95f5ef.png">


### GAS

``` javascript:release.gs
function release() {
  var sheet = SpreadsheetApp.getActiveSheet()
  if (sheet.getRange(sheet.getLastRow(), 6).getValue() != 'release') {
    return
  }
  var relaeseSelect = Browser.msgBox(sheet.getName() + ' を Play ストアにリリースします。', 'よろしいですか？', Browser.Buttons.OK_CANCEL)
  if (relaeseSelect == 'cancel') {
    Browser.msgBox('リリースを取り止めました。')
    return
  }
  if (relaeseSelect == 'ok') {
    
    if (!sheet.getRange(sheet.getLastRow(), 1).getValue()
        || !sheet.getRange(sheet.getLastRow(), 2).getValue()
        || !sheet.getRange(sheet.getLastRow(), 3).getValue()
        || !sheet.getRange(sheet.getLastRow(), 4).getValue()
        || !sheet.getRange(sheet.getLastRow(), 5).getValue()) {
      Browser.msgBox('記載内容が不足しています。')
      return
    }
    
    // リリースタグを打つ
    var releasePushUrl = 'https://api.github.com/repos/bvlion/' + sheet.getName() + '/releases'
    var headers = {
      'Authorization': 'token -----token-----'
    }
    var data = {
      tag_name: 'v' + sheet.getRange(sheet.getLastRow(), 2).getValue(),
      target_commitish: sheet.getRange(sheet.getLastRow(), 5).getValue(),
      name: 'v' + sheet.getRange(sheet.getLastRow(), 2).getValue(),
      body: sheet.getRange(sheet.getLastRow(), 4).getValue()
    }
    var options = {
     method : 'post',
     contentType: 'application/json',
     headers : headers,
     payload : JSON.stringify(data)
    }
    var response = UrlFetchApp.fetch(releasePushUrl, options)
    sheet.getRange(sheet.getLastRow(), 7).setValue(response)
  }
}
```

### トリガー

<img width="937" alt="スクリーンショット 2019-12-16 10.18.29.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/90107/f27cdacb-2cce-27f0-e4a8-848ef9752d4e.png">

### Google スプレッドシートでやっていること

見ての通りですが、シートを更新するとスクリプトが発火します。
スクリプトでは、最終行の特定カラムが「 release 」になっていて、かつ全ての項目が埋まっているかを確認し、 GitHub API 経由でリリースタグを打ちます。

## Circle CI

Circle CI ではタグの変更を検知して、 GAS の内容を見に行き、ビルドからリリースまでを行います。

### GAS

GAS では doGet を公開し、 versionCode や versionName を返すようにしています。

``` javascript:doGet
function doGet(e) {
  var ss = SpreadsheetApp.getActiveSpreadsheet()
  var sheet = ss.getSheetByName(e.parameter.app)
  
  if (sheet == null) {
    return ContentService.createTextOutput(JSON.stringify({error: 'sheet is null [' + e.parameter.app + ']'})).setMimeType(ContentService.MimeType.JSON);
  }
 
  var data = {
    code: sheet.getRange(sheet.getLastRow(), 1).getValue(),
    name: sheet.getRange(sheet.getLastRow(), 2).getValue(),
    english: sheet.getRange(sheet.getLastRow(), 3).getValue(),
    japanese: sheet.getRange(sheet.getLastRow(), 4).getValue()
  }
  
  return ContentService.createTextOutput(JSON.stringify(data)).setMimeType(ContentService.MimeType.JSON)
}
```

### 取得して整形

GAS から返される json を整形して、所定のファイルに書き出します。

``` ruby:create-version.rb
require "json"
require "uri"
require "net/http"

uri = URI.parse("https://script.google.com/macros/s/${hash}/exec?app=${app_name}")
redirect_url = Net::HTTP.get_response(uri)["location"]
response = Net::HTTP.get_response(URI.parse(redirect_url))
json = JSON.parse(response.body)

if json["error"] then
  File.open("exit_message", mode = "w") {|f|
    f.write(json["error"])
  }
  exit
end

File.open("dependencies/ext.gradle", mode = "w") {|f|
  f.write("ext {\n")
  f.write("  appVersionCode = ")
  f.write(json["code"])
  f.write("\n")
  f.write("  appVersionName = '")
  f.write(json["name"])
  f.write("'\n")
  f.write("}")
}

File.open("fastlane/metadata/android/en-US/changelogs/" + json["name"] + ".txt", mode = "w") {|f|
  f.write(json["english"])
}
File.open("fastlane/metadata/android/ja-JP/changelogs/" + json["name"] + ".txt", mode = "w") {|f|
  f.write(json["japanese"])
}
```

名称は何でもいいんですが、私は「 dependencies/ext.gradle 」という名称で versionCode と versionName  を外出ししています。

### Circle CI

バージョンを設定、ビルド、 fastlane でリリース、を行います。

``` yaml:config.yml
version: 2

jobs:
  version_setting:
    docker:
      - image: circleci/ruby:2.5.1

    steps:
      - checkout

      - run:
          name: set version
          command: ruby create-version.rb

      - run:
          name: Error Check
          command: |
            if [ -f exit_message ]; then
              echo `cat exit_message`
              circleci step halt
            fi

      - persist_to_workspace:
          root: .
          paths:
            - .
  build:
    docker:
      - image: circleci/android:api-29

    environment:
      JVM_OPTS: -Xmx3200m

    steps:
      - attach_workspace:
          at: .
          
      - restore_cache:
          key: jars-{{ checksum "build.gradle" }}-{{ checksum  "app/build.gradle" }}
      - run:
          name: gradle dependencies
          command: ./gradlew androidDependencies
      - save_cache:
          paths: ~/.gradle
          key: jars-{{ checksum "build.gradle" }}-{{ checksum  "app/build.gradle" }}
      - run:
          name: Build apk
          command: ./gradlew :app:assembleRelease

      - persist_to_workspace:
          root: .
          paths:
            - .

  deploy:
    docker:
      - image: circleci/ruby:2.5.1

    steps:
      - attach_workspace:
          at: .

      - run:
          name: bundler
          command: bundle install

      - restore_cache:
          key: gem-cache-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
      - run:
          name: up play store
          command: bundle exec fastlane play_store
      - save_cache:
          key: gem-cache-{{ arch }}-{{ .Branch }}-{{ checksum "Gemfile.lock" }}
          paths: vendor/bundle

workflows:
  version: 2
  build_and_deploy:
    jobs:
      - version_setting:
          filters:
            tags:
              only: /v.*/
            branches:
              ignore: /.*/
      - build:
          requires:
            - version_setting
      - deploy:
          requires:
            - build
```

## 最後に…

個人のアプリなので、ほぼエラーハンドリングは入れていません。
また、 Circle CI の persist_to_workspace で「 . 」を指定して全体を渡してしまっているのも、いつか直さないとな〜とは感じつつ、大変便利に運用しています。
良し悪しがあるとは思いますが、個人的にバージョンコードや Play ストアへ表示するメッセージを git で管理しなくて良くなっている点がお気に入りです！


## 参考

配置の参考程度にリポジトリを作成しました。
[GitHub](https://github.com/bvlion/fastlanetest)