Mirrativ tech blog

株式会社ミラティブの開発者(サーバサイドエンジニア,iOSエンジニア,Androidエンジニア,機械学習エンジニア,インフラエンジニア, etc...)によるブログです

MirrativのiOSアプリリリースを支える自動化技術

 こん○○は。エンジニアのshogo4405です。普段は、ミラティブで開発しながら、余暇にOSSのHaishinKit*1をつくっています。

はじめに

 MirrativのiOSアプリは、git-flow で開発を行なっています。git-flow や日々の開発を運用する中で、次のような考慮すべきタイミングがありました。今回は、これらを自動化した際の話をコード付き*2で紹介します。

  • ライブラリーのアップデート
  • releaseブランチ作成
  • tag付けの実施
  • releaseブランチのmasterへのマージとdevelopへのマージ

 なお、リリース頻度は、1週間に1回以上。毎週火曜日に通常版の申請を実施しています。手動で運用していた時には、文字通り、気を付け ながら運用していました。

週の業務の流れ

 MirrativのiOSチームの業務の流れと共に、曜日ベースで📝自動化した内容を紹介していきます。

月曜日

 ミラティブ社の週始めは、10:30に全体で集合する朝会から始まります。朝会は、当週の方針や社の方向性を共有する大事な会です。

📝ライブラリーの更新

 開発は、ライブラリーの更新から始まります。10:00の定時バッチで、自動的に更新を行っています。以下の作業を行ったPRが作成されて、朝会後にレビューをしてからマージをしています。

f:id:shogo4405:20200214192147p:plain

  1. bundleの更新
  2. CocoaPodsの更新
  3. Carthageの更新
  4. SwiftLintの自動実行
  5. ライセンスの更新
lane :update_dependencies do |options|
  date = Date.today.to_s # 現在日付を取得
  token = options[:token] || ENV["GITHUB_API_TOKEN"]
  branch_name = "feature/update-dependencies-" + date # branch名

  sh('cd ../ && find ./mirrorman -name "*.storyboard" -or -name "*.xib" | xargs -IFILE xcrun ibtool --upgrade FILE --write FILE')

  # bundleの更新 
  sh("cd ../ && bundle update")
  # CocoaPodsの更新
  sh("cd ../ && bundle exec pod update")
  # Carthageの更新
  sh("cd ../ && carthage update --platform iOS --use-ssh --no-use-binaries --cache-builds")
  # SwiftLintの自動実行
  sh("cd ../ && ./Pods/SwiftLint/swiftlint autocorrect")
  # ライセンスの更新 LicensePlist 感謝
  sh("cd ../ && ./Pods/LicensePlist/license-plist --output-path mirrorman/Settings.bundle --github-token #{token}")
  sh("git checkout -b #{branch_name}")

  git_add
  git_commit(
    path: "./",
    message: "定期のライブラリーの更新(#{date})"
  )

  push_to_git_remote(
    remote: "origin",
    local_branch: branch_name,
    remote_branch: branch_name,
    tags: false
  )

  create_pull_request(
    repo: "path_to/repo",
    title: "[定期] ライブラリーの更新(#{date})",
    head: branch_name,
    base: "develop",
    body: "定期のライブラリーの更新です"
  )

end

火曜日

 前週の金曜日に締めたソースコードを、AppStoreへ申請する日です。

📝 AppStoreへの定期アップロード

 月・火曜日では、前週に締めたアプリのリグレッションテストを行っています。このテストで確認した不具合を修正してAppStoreへ申請することになります。申請作業の短縮化のために、Bitrise上で、tag付けをフックとして、AppStoreへアップロードするようにしています。

 tag付け*3と同時に、AppStoreへアップロードすることにより、git-flow上でのtag付け忘れに役に立ちました f:id:shogo4405:20200214191623p:plain

desc "Releaseビルドを作成する"
lane :release do |options|
  v = get_info_plist_value(path: "./mirrorman/Supporting Files/mirrativ-prod-Info.plist", key: "CFBundleVersion")

  slack(
    message: "iOSのReleaseビルド(#{v})を作成中",
    payload: { "Build by": `whoami` },
    channel: "#github_mr-ios"
  )

  # build your iOS app
  gym(
    scheme: "mirrativ",
    configuration: "Release",
    include_bitcode: true,
    export_method: "app-store"
  )

  upload_to_testflight(
    skip_submission: true,
    skip_waiting_for_build_processing: true
  )

  slack(
    message: "iOSのReleaseビルド(#{v})をAppStoreにアップロードしました。",
    default_payloads: [],
    channel: "#github_mr-ios"
  )

  clear_derived_data()

end

📝 releaseブランチからmasterへのマージ作業

 GitHub上で、masterへマージすると同時に、developにもマージしています。現在は、別のスクリプトエラーで止まっています😭

 GitHub上のWebhookをトリガーで実現しています。Webhookの受け口側で、以下のようなRubyのコードを実行すれば master へマージ時と同時に、developへ戻すこと可能です。

octkit.merge("レポジトリー名", 'develop', pull_request['head']['ref'])

水曜日

 火曜日に、申請したアプリのリリース日です。

📝GitHubのリリースノートの作成

 リリース内容を後から見返すために、GitHubのリリース情報の更新を行います。AppStoreから届くメールをフックにして、リリース情報を更新しています。iOSチームではマイルストーン管理しており、リリース内容は、該当のマイルストーンからのプルリクエストのタイトルを元に作成しています。

f:id:shogo4405:20200214180926p:plain

desc 'リリースノートを作成する'
desc '    version: バージョン番号(フォーマットは、7.5.1)'
desc '    target: Slackのチャンネル名(デフォルトは、#bot_test)'
desc '    preview: ストア文言作成用の文言か否か(デフォルトは、false)'
desc '    code: githubのリリースノート更新に必要なコード. iOSだったら:7.5.1(7.5.1.0), Androidだったら:7.5.1(232)みたいな、()の部分'
desc "    ENV['GITHUB_API_TOKEN']: https://github.com/settings/tokens で取得してして Shell に定義しておいてください"
lane :release_note do |options|
  target = options[:target] || '#bot_test'
  version = options[:version] || ENV['VERSION']
  preview = options[:preview] || false
  code = options[:code] || ENV['CODE']

  token = ENV['GITHUB_API_TOKEN']
  UI.user_error!('Required version and token. ex: fastlane release_note version:7.5.1') unless version || token

  if target == 'github'

    value = github_release_note(
      version: version,
      format: 'md',
      repository_name: ENV['GITHUB_PATH']
    )

    set_github_release(
      repository_name: ENV['GITHUB_PATH'],
      name: version,
      tag_name: "#{version}(#{code})",
      description: value,
      commitish: 'master'
    )

    github_close_milestone(
      repository_name: ENV['GITHUB_PATH'],
      title: version
    )

  else

    value = github_release_note(
      version: version,
      repository_name: ENV['GITHUB_PATH']
    )

    pretext = preview ?
      "#{ENV['NAME']}(#{version})のリリースが近づいてきたよ!ストア文言作成お願いします。 " :
      "#{ENV['NAME']}(#{version})を審査にだすよ!リリース内容は以下の通りだよ! "

    slack(
      default_payloads: [],
      channel: target,
      attachment_properties: {
        text: [value].join("\n"),
        pretext: pretext,
        title: ":notebook: リリースノート(#{version})",
        mrkdwn_in: ['text']
      }
    )
  end
end

github_release_note

 GitHubから該当マイルストーンのPRからタイトルを抽出するために、fastlaneにて、次のような、カスタムアクションを作成しています。

module Fastlane
  module Actions
    module SharedValues
      GITHUB_RELEASE_NOTE_CUSTOM_VALUE = :GITHUB_RELEASE_NOTE_CUSTOM_VALUE
    end

    class GithubReleaseNoteAction < Action
      def self.run(params)
        version = params[:version]
        repository_name = params[:repository_name]
        @client = Octokit::Client.new(access_token: ENV['GITHUB_API_TOKEN'])
        params[:format] == 'md' ? output_md(repository_name, version) : output_slack(repository_name, version)
      end

      #####################################################
      # @!group Documentation
      #####################################################

      def self.description
        'A short description with <= 80 characters of what this action does'
      end

      def self.details
        'You can use this action to do cool things...'
      end

      def self.available_options
        [
          FastlaneCore::ConfigItem.new(key: :version,
                                       env_name: 'FL_GITHUB_RELEASE_NOTE_API_TOKEN',
                                       description: 'API Token for GithubReleaseNoteAction',
                                       verify_block: proc do |value|
                                         UI.user_error!("No API token for GithubReleaseNoteAction given, pass using `api_token: 'token'`") unless value && !value.empty?
                                       end),
          FastlaneCore::ConfigItem.new(key: :format,
                                       env_name: 'FL_GITHUB_RELEASE_NOTE_FORMAT',
                                       description: 'Create a development certificate instead of a distribution one',
                                       is_string: false,
                                       default_value: 'slack'),
          FastlaneCore::ConfigItem.new(key: :repository_name,
                                       env_name: 'FL_GITHUB_RELEASE_NOTE_GITHUB_PATH',
                                       description: 'Create a development certificate instead of a distribution one',
                                       is_string: true,
                                       default_value: '')
        ]
      end

      def self.output
        [
        ]
      end

      def self.return_value
        # If your method provides a return value, you can describe here what it does
      end

      def self.authors
        ['shogo4405']
      end

      def self.is_supported?(platform)
        platform == :ios
      end

      def self.milestone(repo, name)
        number = @client.list_milestones(repo).select do |milestone|
          milestone.title == name
        end.first.number
        @client.list_issues(repo, milestone: number, state: 'all')
      end

      def self.group(issue)
        return 'プロダクト' if issue.title.start_with?('[MIP-')
        return 'CS' if issue.title.start_with?('[CS-')
        return '不具合管理' if issue.title.start_with?('[MIR-')
        '開発'
      end

      def self.issues(repo, name)
        result = {}
        issues = milestone(repo, name)

        issues.each do |issue|
          result[group(issue)] = [] unless result[group(issue)].instance_of?(Array)
          result[group(issue)].push(issue)
        end

        result
      end

      def self.output_md(repo, name)
        message = ''
        issues = issues(repo, name)
        issues.sort.each do |key, value|
          message += "## #{key}\n"
          i = 1
          value.each do |issue|
            title = issue.title
                         .sub(/\[(MIP-\d+)\]/, '[[\1]](https://mirrativ.backlog.com/view/\1)')
                         .sub(/\[(CS-\d+)\]/, '[[\1]](https://mirrativ.backlog.com/view/\1)')
                         .sub(/\[(MIR-\d+)\]/, '[[\1]](https://mirrativ.atlassian.net/browse/\1)')
            message += "#{i}. #{title} (##{issue.number})\n"
            i += 1
          end
          message += "\n"
        end
        message
      end

      def self.output_slack(repo, name)
        message = ''
        issues = issues(repo, name)
        issues.sort.each do |key, value|
          message += ":mira1: *#{key}*\n"
          i = 1
          value.each do |issue|
            title = issue.title
                         .sub(/\[(MIP-\d+)\]/, '<https://mirrativ.backlog.com/view/\1|[\1]>')
                         .sub(/\[(CS-\d+)\]/, '<https://mirrativ.backlog.com/view/\1|[\1]>')
                         .sub(/\[(MIR-\d+)\]/, '<https://mirrativ.atlassian.net/browse/\1|[\1]>')
            message += "#{i}. #{title} (<#{issue.html_url}|##{issue.number}>)\n"
            i += 1
          end
          message += "\n"
        end
        message
      end
    end
  end
end

金曜日

 お疲れ様でした。週終わりの日です。ミラティブ社では、金曜日の18:30開始の夕会でその週の振り返りを全体で共有しています。

 金曜日までに、developへマージしたコードをコードフリーズして翌週にリリースする運用を行っています。

日曜日

 金曜日に締めたコードからgit-flowのreleaseブランチを作成する日です。

📝リリースブランチの作成

 毎時18:00に、自動的にreleaseブランチを作成しています。また、Slack上に、GitHubのPR一覧を投稿しています。この投稿情報は、AppStoreへのアップデート文言作成に利用したり、今週リリースする内容のサマリーとして社内全体で共有しています。

f:id:shogo4405:20200214060601p:plain

lane :release_branch do |options|
  token = options[:token] || ENV["GITHUB_API_TOKEN"]
  UI.user_error!("Required release token. ex: fastlane release_branch version:7.5.1 token:${github_api_token}") unless token

  unless options[:version]
    github = Octokit::Client.new access_token: token
    milestone = github.milestones("path_to/repo").sort { |a, b| a["due_on"] <=> b["due_on"] }.first
    UI.user_error!("直近リリースはないようです") if 5 < (milestone.due_on.to_date - Date.today).to_i
    UI.user_error!("マイルストーンが見つからないです") unless milestone
    options[:version] = milestone.title
  end

  branch = "release/#{options[:version]}"

  sh("git checkout develop && git pull origin develop")
  sh("git checkout -b #{branch}")

  version(v: options[:version] + ".0")

  File.open(".timestamp", "w") do |f|
    f.puts(Time.now.to_s)
  end

  short_version = get_info_plist_value(path: "./mirrorman/Supporting Files/mirrativ-prod-Info.plist", key: "CFBundleShortVersionString")

  # release_note作成のlane
  release_note(
    target: "#release_note",
    version: short_version,
    token: token,
    preview: true
  )

  git_commit(path: "*", message: "Bump to #{options[:version]}")
  add_git_tag(tag: "#{short_version}(#{short_version}.0)")

  push_to_git_remote

  create_pull_request(
    repo: "path_to/repo",
    title: "v#{short_version}",
    body: "release v#{short_version}"
  )

  deliver(
    submit_for_review: false,
    skip_binary_upload: true,
    skip_screenshots: true,
    force: true
  )
end

自動化によって得られたもの

 年間7人日程度*4の工数の削減に寄与と推定しています。2019年の実績として57回のリリースを行いました。1回のリリースに関わる作業として週あたり概算累計1時間程度と計算しました。

 また、ついうっかり作業を忘れてしまって、同僚につつかれるといったことが無くなりました。 リリースに関する一連の作業を円滑に行うという心理面のプレッシャーが減り、結果としてコードに集中して向き合う時間が増えました。

We are hiring!

 ミラティブでは、このように開発フローの効率化なども得意な、エンジニアを募集しています!副業や体験入社も行っておりますので是非遊びに来てください。

www.mirrativ.co.jp

 うちの会社では、こういうことをやっているぞーということをはてブのコメントで残してもらえると嬉しいです!

*1:iOS向けのRTMPストリーミングライブラリー

*2:こちらのコードは、ご自由にお使いください

*3:git-flow上では、masterブランチへマージした時点でtag付けを行いますが、releaseブランチへtag付けを行う運用を行なっています。

*4:57 * 1 = 57時間 = 約7人日