こん○○は。エンジニアの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が作成されて、朝会後にレビューをしてからマージをしています。
- bundleの更新
- CocoaPodsの更新
- Carthageの更新
- SwiftLintの自動実行
- ライセンスの更新
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付け忘れに役に立ちました
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チームではマイルストーン管理しており、リリース内容は、該当のマイルストーンからのプルリクエストのタイトルを元に作成しています。
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へのアップデート文言作成に利用したり、今週リリースする内容のサマリーとして社内全体で共有しています。
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!
ミラティブでは、このように開発フローの効率化なども得意な、エンジニアを募集しています!副業や体験入社も行っておりますので是非遊びに来てください。
うちの会社では、こういうことをやっているぞーということをはてブのコメントで残してもらえると嬉しいです!