iOSアプリの証明書・プロビジョニングプロファイルの有効期限を定期的にチェックする仕組みをfastlaneで実装する


## 概要
iOSアプリの各種証明書とプロビジョニングプロファイルは1年で有効期限が切れるので、その度に更新が必要ですが、1年に1回なのでけっこう忘れやすいのではないかと思います。特にプッシュ通知の証明書とかは普段意識しないので忘れやすいんじゃないでしょうか。実際、飛ばなくなってから期限切れに気づくということがあったため、再発防止のために定期的に有効期限をチェックしてSlackに投げる仕組みをfastaneのlaneとして実装してみました。

<img width="600" src="https://qiita-image-store.s3.amazonaws.com/0/3752/78a8e3cc-bec9-1d57-61d2-04956a0cf64f.png">

これで来年は同じ過ちを犯さないと思います。


## 実装方法
以下に実装方法をまとめます。

### 1. 証明書とプロビジョニングプロファイルの情報をDeveloper Portalから取得する
* [fastlane/spaceship](https://github.com/fastlane/fastlane/tree/master/spaceship)を使う
* spaceship自体はactionとして用意されていないが、fastlaneに組み込まれていて、sighなどの内部で使われているらしい
* Spaceship::Portal.loginでログイン（ID/パスはAppfileとFASTLANE_PASSWORDに設定）したあと、Spaceship.provisioning_profile、Spaceship.certificateを呼び出すことでDeveloper PortalのProvisioningProfileとCertificateの情報を取得することができる。

	```ruby
	fastlane_require 'spaceship'
	
	Spaceship::Portal.login
	
	certs = Spaceship.certificate.all
	profiles = Spaceship.provisioning_profile.all
	
	```


### 2. 対象のProvisioningProfileとCertificateの有効期限をチェックして、残り日数に応じて警告を設定する
* チェック対象とするProvisioningProfileとCertificateに絞る
	* 以下のような形で各タイプに絞った証明書やプロビジョニングプロファイルを取得できる
	
	```
	Spaceship.certificate.Production
	Spaceship.certificate.ProductionPush
	Spaceship.certificate.VoipPush
	Spaceship.provisioning_profile.AppStore
	Spaceship.provisioning_profile.AdHoc
	~
	```
		
	* また、certificateとprovisioning_profileは以下のようなプロパティを持っているのでそれらの値でも絞ることができる
		* [Spaceship::Certificate](https://www.rubydoc.info/github/fastlane/spaceship/Spaceship/Certificate)のInstance Attribute  
			expires, id, name, owner_id, owner_name, owner_type, status, type_display_id
		
		* [Spaceship::ProvisioningProfile](https://www.rubydoc.info/github/fastlane/spaceship/Spaceship/ProvisioningProfile)のInstance Attribute  
			app, certificates, devices, distribution_method, expires, id, managing_app, name, platform, status, type, uuid, version

* 取得したcertificate、provisioning_profileのexpiresから残り日数を計算する

### 3. fastlaneのslackアクションでSlackにいい感じで投稿する
* Slackで3段階のステータスごとに証明書とプロビジョニングプロファイルを並べて表示する
* [fastlane/slack](https://docs.fastlane.tools/actions/slack/)アクションのattachment_propertiesパラメータにattachmentを渡してアクションを実行する。Slack APIのドキュメントを見るとattachmentsのarrayを送ることができるようだったが、fastlane/slackアクションでは配列を渡せないようだったので、attachmentごとにslackアクションを実行するようにした。
 
	```ruby
	attachments.each do |attachment|
	  slack(
	    message: "",
	    default_payloads: [],
	    slack_url: ENV["WEBHOOK_URL"],
	    use_webhook_configured_username_and_icon: true,
	    attachment_properties: attachment
	    )
	end
	```

* attachmentのデータは以下のような項目からなるhash形式にする。fieldsにcertificateとprovisioning_profileの各アイテムについての表示を配列で入れる。
	
	```ruby
	{
	  "color": "#2eb886",
	  "pretext": "Optional text that appears above the attachment block",
	  "title": "Slack API Documentation",
	  "title_link": "https://api.slack.com/",
	  "text": "Optional text that appears within the attachment",
	  "fields": [
	      {
	          "title": "Priority",
	          "value": "High",
	          "short": false
	      }
	  ],
	  ~
	}
	```

### 4. BitriseのScheduled Buildで定期的に有効期限チェックのlaneを実行する
* Scheduled Buildの設定はBuild一覧からStart/Schedule a Buildを選択すれば設定できる。
<img width="600" src="https://qiita-image-store.s3.amazonaws.com/0/3752/46dabc06-0f3c-9fb2-4604-41fed7fa188e.png">


## コード
```ruby
desc 'Check expirarion date of certificates and provisioning profiles in Developer Center'
desc 'ex: fastlane ios check_expiration_date'
lane :check_expiration_date do
  Spaceship::Portal.login

  certs = Spaceship.certificate.Production.all + Spaceship.certificate.ProductionPush.all + Spaceship.certificate.VoipPush.all
  profiles = Spaceship.provisioning_profile.all.select { |p| p.status != "Invalid" && p.type == "iOS Distribution" }

  attachments = get_attachments(certs, profiles)
  puts attachments

  attachments.each do |attachment|
    slack(
      message: "",
      default_payloads: [],
      slack_url: ENV["WEBHOOK_URL"],
      use_webhook_configured_username_and_icon: true,
      attachment_properties: attachment
      )
  end
end

CERTIFICATE = "証明書"
MOBILEPROVISION = "プロビジョニングプロファイル"
def get_attachments(certificates, profiles)
  cer_profile_list = { CERTIFICATE => certificates, MOBILEPROVISION => profiles }

  attachments = []
  initial_attachment = {
    "pretext" => "📱 CLINICSアプリ(iOS)の証明書・プロビジョニングプロファイル有効期限切れチェック",
    "color"   => "#FFFFFF"
  }
  expired_attachment = {
    "title"  => "以下の#{CERTIFICATE}または#{MOBILEPROVISION}の有効期限が切れています！！",
    "color"  => "#EEEEEE",
    "fields" => []
  }
  
  danger_attachment = {
    "title"  => "以下の#{CERTIFICATE}または#{MOBILEPROVISION}の有効期限がもうすぐ切れます",
    "color"  => "danger",
    "fields" => []
  }

  warning_attachment = {
    "title"  => "以下の#{CERTIFICATE}または#{MOBILEPROVISION}の有効期限が近づいています",
    "color"  => "warning",
    "fields" => []
  }

  other_attachment = {
    "title"  => "その他の#{CERTIFICATE}と#{MOBILEPROVISION}",
    "color"  => "good",
    "fields" => []
  }
  last_attachment = {
    "text": "Distributionの更新手順は<https://xxxxx|こちら>、APNS・VoIPの更新手順は<https://xxxxx|こちら>",
    "actions": [
      {
        "type": "button",
        "text": "View Apple Developer Portal",
        "url": "https://developer.apple.com/account/ios/certificate/?teamId=#{ENV["TEAM_ID"]}"
      }
    ],
    "color"   => "#FFFFFF"
  }

  cer_profile_list.each() { |type, items|
    items.each { |item|
      field = get_field(item, type)
      days_left = get_days_left(item.expires.to_time)

      if days_left < 0
        expired_attachment["fields"].push(field)
      elsif days_left < ENV["DANGER_DAY"].to_i
        danger_attachment["fields"].push(field)
      elsif days_left < ENV["WARNING_DAY"].to_i
        warning_attachment["fields"].push(field)
      else
        other_attachment["fields"].push(field)
      end
    }
  }
  attachments.push(initial_attachment)
  attachments.push(expired_attachment) unless expired_attachment["fields"].empty?
  attachments.push(danger_attachment) unless danger_attachment["fields"].empty?
  attachments.push(warning_attachment) unless warning_attachment["fields"].empty?
  attachments.push(other_attachment) unless other_attachment["fields"].empty?
  attachments.push(last_attachment)
end

def get_days_left(expiration_date)
  now = Time.now
  today = Time.local(now.year, now.month, now.day)
  diff = ((expiration_date - today) / 3600 / 24).floor
end

def get_field(item, type)
  if type == CERTIFICATE
    title = "#{item.owner_name} - #{item.name}"
    extension = ".cer"
  elsif type == MOBILEPROVISION
    title = item.name
    extension = ".mobileprovision"
  end
  days_left = get_days_left(item.expires.to_time)
  expires = item.expires.to_time.strftime("%Y/%m/%d")

  return {
    "title" => "#{title}(#{extension})",
    "value" => "残り #{days_left}日 (有効期限：#{expires})"
  }
end
```

## 参考にしたもの
### Documents
Spaceship

* https://www.rubydoc.info/github/fastlane/spaceship/Spaceship

fastlane/slack

* https://docs.fastlane.tools/actions/slack/
* https://api.slack.com/docs/message-attachments

### SpaceshipとSlack APIの使い方で参考にした記事とソース
* https://qiita.com/mii-chan/items/3a9d9f45b2c370372d45
* https://github.com/mii-chan/ios-cer-profile-expiration-date-checker