くりにっき

フルスタックキュアエンジニアです

compact_blank gemのメンテを終了した

Rails 6.1.0.rc1がリリースされましたが*1activesupport本体に #compact_blank#compact_blank! のメソッドが入った*2ので6.1.0.rc1以降と一緒に入れられないようにしてメンテ終了宣言しました。

github.com

rubocop v1系 + rubocop-rspec v1系でエラーになる件の回避策

tl;dr;

どっちのケースの場合もrubocop-rspec v2.0.0.preで直ってるので Gemfile に下記のように書けば直る

group :development do
  gem "rubocop-rspec", ">= 2.0.0.pre", require: false
end

ケース1: rubocop-rspec内でエラーになる

エラー内容

-dデバッグ出力)を有効にしないとエラー内容が表示されないので注意

$ bundle exec rubocop -d

(中略)

An error occurred while RSpec/FactoryBot/CreateList cop was inspecting /Users/sue445/workspace/github.com/sue445/app-stat-api/spec/app_spec.rb:1:0.
undefined method `to_sym' for nil:NilClass
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/mixin/configurable_enforced_style.rb:69:in `style'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-rspec-1.42.0/lib/rubocop/cop/rspec/factory_bot/create_list.rb:51:in `on_block'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/commissioner.rb:100:in `public_send'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/commissioner.rb:100:in `block (2 levels) in trigger_responding_cops'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/commissioner.rb:166:in `with_cop_error_handling'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/commissioner.rb:99:in `block in trigger_responding_cops'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/commissioner.rb:98:in `each'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/commissioner.rb:98:in `trigger_responding_cops'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/commissioner.rb:69:in `on_block'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-ast-1.1.0/lib/rubocop/ast/traversal.rb:20:in `walk'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/commissioner.rb:86:in `investigate'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/team.rb:157:in `investigate_partial'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cop/team.rb:83:in `investigate'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:315:in `inspect_file'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:259:in `block in do_inspection_loop'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:293:in `block in iterate_until_no_changes'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:286:in `loop'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:286:in `iterate_until_no_changes'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:255:in `do_inspection_loop'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:132:in `block in file_offenses'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:157:in `file_offense_cache'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:131:in `file_offenses'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:122:in `process_file'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:101:in `block in each_inspected_file'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:100:in `each'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:100:in `reduce'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:100:in `each_inspected_file'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:86:in `inspect_files'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/runner.rb:47:in `run'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cli/command/execute_runner.rb:25:in `execute_runner'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cli/command/execute_runner.rb:17:in `run'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cli/command.rb:11:in `run'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cli/environment.rb:18:in `run'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cli.rb:65:in `run_command'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cli.rb:72:in `execute_runners'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/lib/rubocop/cli.rb:41:in `run'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/exe/rubocop:13:in `block in <top (required)>'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/benchmark.rb:308:in `realtime'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/gems/rubocop-1.1.0/exe/rubocop:12:in `<top (required)>'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/bin/rubocop:23:in `load'
/Users/sue445/workspace/github.com/sue445/app-stat-api/vendor/bundle/ruby/2.7.0/bin/rubocop:23:in `<top (required)>'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/cli/exec.rb:63:in `load'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/cli/exec.rb:63:in `kernel_load'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/cli/exec.rb:28:in `run'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/cli.rb:476:in `exec'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/vendor/thor/lib/thor/command.rb:27:in `run'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/vendor/thor/lib/thor/invocation.rb:127:in `invoke_command'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/vendor/thor/lib/thor.rb:399:in `dispatch'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/cli.rb:30:in `dispatch'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/vendor/thor/lib/thor/base.rb:476:in `start'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/cli.rb:24:in `start'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/bundler-2.1.4/libexec/bundle:46:in `block in <top (required)>'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/2.7.0/bundler/friendly_errors.rb:123:in `with_friendly_errors'
/Users/sue445/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/bundler-2.1.4/libexec/bundle:34:in `<top (required)>'
/Users/sue445/.rbenv/versions/2.7.2/bin/bundle:23:in `load'
/Users/sue445/.rbenv/versions/2.7.2/bin/bundle:23:in `<main>'

関連するissue

github.com

実際に対応したコミット

https://github.com/sue445/app-stat-api/pull/631/commits/7013ba612d2f1f663e2b8e60816655de6770602e

ケース2: 意図せずrubocop-rspecのバージョンが下がる

https://github.com/sue445/twittodon/pull/441/commits/3abc21db31db2532283af5ef790ea32e62a804cb#diff-89cade48462044ee1b672dc5f4c3ec250fbd29effcd8932096a23c1283c6731fR102

注意点

https://github.com/rubocop-hq/rubocop-rspec/blob/v2.0.0.pre/CHANGELOG.md#200pre-2020-10-22

v2で 下記のようなbreaking changeがあるので注意

(Potentially breaking) Change namespace of several cops (Capybara/* -> RSpec/Capybara/*, FactoryBot/* -> RSpec/FactoryBot/*, Rails/* -> RSpec/Rails/*).

プリマップというプリパラやプリ☆チャンが遊べるお店を探せる地図を作った

タイトルが全て

primap.web.app

f:id:sue445:20201013232154p:plain

モチベーション

以前 プリ☆チャンMAP を作ったことがあるのですが、下記のような不満がありました。

  • プリ☆チャンの公式店舗一覧スクレイピングしてスプレッドシートに入れるところまでは自動化できたが、そこからGoogleマイマップに取り込む作業が手作業
  • スプレッドシートに住所カラムを作っておけばGoogleマイマップがいい感じに緯度経度に変換してくれるのだが、公式の住所のフォーマットがおかしいと稀に緯度経度が取れないので手動で補正する必要がある
  • 公式のショップ一覧は定期的に更新されるのでそれに追従するのが大変
  • プリ☆チャンMAP作成後にプリパラオルフレンズの稼働が始まったのだがそれの地図を作るのも大変

そのため完全に作り直しました。

使い方

https://primap.web.app/ を開くといい感じに現在地周辺のプリ☆チャンやプリパラで遊べる店舗の一覧が出ます。

f:id:sue445:20201013233308p:plain

技術的なこと

リポジトリ

github.com

大雑把な仕組み

  1. PrismDB から店舗一覧を取得
  2. Geocoding APIで店舗の住所から緯度経度を取得してFirestoreに保存
  3. Firestoreに保存されている店舗をいい感じに地図上に表示

開発期間はトータルで1ヶ月くらいです

サーバサイド

俗に言うサーバサイドは最初はあったけど途中で捨てました

当初の構成だとCloud RunでGoで書いたAPIやジョブをホスティングする予定だったんですが、APIはFirebaseでいいことに気づいたので途中で方針転換して、その結果サーバサイドがなくなりました。(サーバレス)

ジョブ

採用技術は下記

  • Go
  • Cloud Functions
  • Cloud Scheduler
  • Cloud Pub/Sub
  • Secret Manager
  • Geocoding API
  • Serverless Framework

店舗が数千件あって1つのファンクションの中で全ての緯度経度の取得をするのは厳しいと判断して、PrismDBで取得した店舗の件数分だけPub/Subでqueueに積んで非同期で処理するようにしました

Geocoding APIを一度にたくさん叩きすぎるとReteLimitに引っかかるのでリトライも必要になるのですが、Pub/Subで店舗1件ずつ処理することでその辺のリトライを何も考えなくてよかったのは楽でした。(Pub/Subで呼んだファンクションがエラーになれば勝手にリトライされるので)

フロントエンド

採用技術は下記

  • TypeScript
  • React
    • Vue.js + TSは過去にやったことあるので今回は今まで使ったことのないReactを採用
    • React歴3週間くらい
  • Tailwind CSS
    • 社内でちょいちょい名前を聞いていたので採用
  • Firebase Hosting
  • Google Maps

その他使ったもの

  • GitHub Actions
  • Sentry
  • Terraform

あたり

こだわりポイント

Cloud RunからCloud Functionsに移行して変わったこと

メリット

  • デプロイフローがシンプルになった
    • Cloud Runでアプリを動かすためにはDockerイメージが必要なので必然的にCIでもdocker buildしてContainer Registryにpushする必要があるのだが(GitHub Actions上で1分弱)、Cloud Functions化することでそのフローが完全に不要になった
  • Cloud RunでCloud SchedulerやPub/Subのリクエストを受けるためにはhttpのエンドポイントが必要だったが、Cloud Functions化することでhttp周りの実装が不要になった
    • Cloud Functions側にPub/Sub専用のエンドポイントがある

デメリット

  • Cloud Run時代はデプロイが2分弱だったが*1、Cloud Functions移行後にデプロイに5〜6分かかるようになった
    • Serverless Frameworkのバックエンドで使ってるDeployment Managerの様子を見る感じ純粋にCloud Functionsへのデプロイが一番時間がかかってるのだが、具体的にどこで時間がかかってるのかが特定できないので謎。(おそらくgo getかビルドのどちらかだと思うのだが...)
    • 1つのバイナリに2つのファンクションが含まれているのでそれらを分割すれば早くなる可能性はありそう
  • Cloud Functionsだと最新のGoが1.13
    • 早く1.15使わせてほしい...

GoのstructからTypeScriptのclassを自動生成するようにした

FirestoreにはGoのstructで保存してるのですが、全く同じデータをフロントエンドで使うために tscriptify でTypeScriptのclassに変換しています。

Firestoreに緯度経度の型はあるのだが、実際にはそれだけだと検索で使えなくてハマった

現在地の緯度経度からFirestoreで検索しようとしたのですが当初うまくいきませんでした。(明らかに現在地から外れた店舗が検索にヒットする)

調べると https://stackoverflow.com/questions/46630507/how-to-run-a-geo-nearby-query-with-firestore

Firestore does not support actual GeoPoint queries at present so while the below query executes successfully, it only filters by latitude, not by longitude and thus will return many results that are not nearby.

とあるように、Firestoreは実際のGeoPointクエリをサポートしていないため、latlng型で検索しても緯度しか考慮されないということでした。

そのため緯度経度から算出したGeoHashも別途Firestoreに保存することでようやく現在地周辺の検索ができるようになりました。

ディレクトリ構成を色々変えた

開発中に何回かディレクトリ構成を作り直しました

  • 1段階目
    • 最初はオーソドックスなGoのアプリのディレクトリ構成
  • 2段階目
    • frontend実装直後
    • GoはそのままでTSとReact関係のソースだけfrontendに入れた形
  • 3段階目
    • serverとfrontendの2つのディレクトリ構成
    • この時点ではmakeやnpmのコマンド実行時にいちいちcdするのが嫌だという気持ちがあってこの構成でした
    • TSやReact関係のソースは全部frontendに格納できたのですが、Goはビルドの関係でトップに一部ソースが残ってます
  • 4段階目(今)
    • 「makeやnpmのコマンド実行時にいちいちcdするのが嫌だ」という気持ちはあったんですが、それ以上にトップの見通しが悪いのが嫌だったので完全にディレクトリを分けました(手の平クルー)

f:id:sue445:20201014000907p:plain

frontendとfunctionの接点がfirestoreだけだったのでここまで振り切れたかもしれないです。

本当ならfrontendとfunctionでリポジトリを分けてもよかったのですが、前述の通りGoのstructからTypeScriptのclassを生成してるためリポジトリを分けると逆に連携が難しくなるためモノリシックリポジトリにしました。

port 55301

せっかくなので(?)devServerは55301番ポートを使うようにしました。

副産物

プリマップでSecretManagerを使ってるのですがラッパとして途中でgcp-secretmanagerenvができました

sue445.hatenablog.com

*1:docker buildに1分半でgcloud run deployが30秒

pixela gem v2.0.0をリリースした

pixela v1.19.0 で機能削除が行われたのでgemのメジャーバージョンを上げました。

blog.a-know.me

Breaking changeついでに他にも非互換の変更を入れています

変更内容と変更意図

https://github.com/sue445/pixela/blob/master/CHANGELOG.md

  • faraday v0.x系のサポート終了
  • Ruby 2.3のサポート終了
    • https://github.com/sue445/pixela/pull/78
    • faraday v1準拠で最低バージョンは2.3のままでいこうかなと思ったのですが、Ruby 2.3をサポートしたままだと Hash#compact が使えなくて微妙な感じなので最低バージョンを2.4に引き上げました

gcp-secretmanagerenvを作った

gcp-secretmanagerenvについて

GCPのSecret Managerのラッパみたいなやつで、 gcp-kmsenv のSecret Manager版です。

github.com

作った経緯

本番環境(GCP)ではSecret Managerを使うのでいいんだけど、ローカル開発時は環境変数を使いたかった *1 のですが、Secret Managerの値の取得と環境変数の取得を透過的に扱う部分をアプリケーションコード内に書きたくなくて先にライブラリ化しました。

使い方

READMEからの抜粋ですがこれがだいたい全てです。

package main

import (
    "context"
    "github.com/sue445/gcp-secretmanagerenv"
)

func main() {
    projectID := "gcp-project-id"
    c, err := secretmanagerenv.NewClient(context.Background(), projectID)
    if err != nil {
        panic(err)
    }

    // get from environment variable
    value, err := c.GetValueFromEnvOrSecretManager("SOME_KEY", true)
    // => return value from environment variable or Secret Manager

    // When key is not found in both environment variable and Secret Manager, returned empty string (not error)
    value, err := c.GetValueFromEnvOrSecretManager("INVALID_KEY", false)
    // => ""

    // When key is not found in both environment variable and Secret Manager, returned error
    value, err := c.GetValueFromEnvOrSecretManager("INVALID_KEY", true)
    // => error
}
  • GetValueFromEnvOrSecretManager の第1引数は検索するkey
    • 環境変数にkeyがあればその値を、SecretManagerにkeyがあればその値を返す
    • 探索順は環境変数 -> Secret Managerの順
  • projectIDが渡されなければSecretManagerの呼び出し自体を行わない
    • 本番環境のみ GCP_PROJECT のような環境変数を設定しておくような想定
  • GetValueFromEnvOrSecretManager の第2引数は必須とみなすかどうかのフラグ
    • true ならkeyが環境変数とSecret Managerの両方になかった場合にはエラーを返す
    • false ならkeyが環境変数とSecret Managerの両方になかった場合にはエラーを返さない(空文字を返す)

*1:OSSとして公開する可能性があるのでローカル開発時にサービスアカウント必須にしたくなかった

Cloud Functions CI/CD俺的ベストプラクティス

AWS Lambda CI/CD俺的ベストプラクティス - くりにっき のCloud Functions版です

sue445.hatenablog.com

最近Cloud Functions使うことがあってCI面からフレームワークなどを検討しました。AWS Lambdaほどガッツリ使ってないので比較対象が少ないのはご愛嬌

Terraform

https://www.terraform.io/

Deployment Manager

https://cloud.google.com/deployment-manager?hl=ja

Serverless Framework

https://www.serverless.com/

  • 今回比較した中では一番まとも
  • デプロイ関係の面倒くさい処理(GCSのバケットを作ってソースをzipに固めてアップロードとか)はよしなにやってくれる
  • プラグインが充実してるのも嬉しい
  • 不満点
    • デプロイのdry runや serverless.yml のvaridateができないところ
      • master自動デプロイにしてるとmasterにマージするまで動作確認できない
    • Serverless Framework + Cloud Functions + Goで書いてるんだけど情報が英語含めてほとんどないので直感で書いてる
  • 凝ったことをやりたくなると(例:Cloud Functionsで使うPubsub topicをServerless側で作る) serverless.yml にDeployment Managerのymlを書く必要があるのだが、それはAWSで分かってたことなので諦めてた

GitHub Actions上でPRを作る時はsecrets.GITHUB_TOKENは使わない方がよさそう

事象

GitHub Actions上で使える secrets.GITHUB_TOKEN だと別のジョブを起動できないというのが一番の理由。

https://docs.github.com/en/actions/reference/events-that-trigger-workflows#triggering-new-workflows-using-a-personal-access-token

具体的にどういうケースで困るのかというと、GitHub Actions上でPRを作った場合にそのPRに対するビルドが行われません。

普通だったらコミットIDの左側に✔が出るはずなんだけど出ない図

f:id:sue445:20200828222616p:plain

pushやPRなどのビルドをGitHub Actions以外(例:CircleCIなど)で行っている場合には問題ないです。

解決策

いくつか案はあるのですが現状だと GitHub App Token を使うのがいいと思ってます。

App作成の手間はあるもののインストールするリポジトリを指定できるので権限の分離ができるのが一番の理由です。 (パーソナルアクセストークンだと業務で所属してるorg含めて自分が見れる全リポジトリが対象になるので漏洩時のリスクが怖い)

f:id:sue445:20200828223657p:plain

実際に動かした図

sue445-botGitHub App)でPRを作ったのでGitHub Actionsでビルドが実行されるようになってます

f:id:sue445:20200831080805p:plain

上記AppだとパーミッションはActionsとPull requestsにRead & writeをつけたけどActionsの方は不要だったかもしれないです。

2020/09/12追記:Pull requestsのRead & writeだけで良かった

f:id:sue445:20200831082740p:plain

go-mod-tidy-prでも同様の問題を抱えていたのでGitHub App Token推奨にしました。

https://github.com/sue445/go-mod-tidy-pr#note-warning

謝辞

という情報を プリッカソン のSlackで知って便利

f:id:sue445:20200829212144p:plain

2020/9/1 22:50 追記

GutHub Appを作った場合は今度はAppの秘密鍵の管理を考える必要がありますが、Organizationであれば Organization secrets が使えるのでこの手の秘密鍵の管理に便利そうです。(ってことを ruby-jp Slack で話していて思い出した)

github.blog