private ComposerパッケージのCI認証をSSH鍵からGitHub Appに移した
うちのCIには、しばらく前から放置している既知の問題がありました。人間が作ったPRではCIが通るのに、Dependabotが作ったPRだけが落ちるのです。
Error: The ssh-private-key argument is empty. Maybe the secret has not been
configured, or you are using a wrong secret name in your workflow file.
原因は分かっていました。Dependabotが起動したワークフローではDependabot secretsのみが参照可能で、Actions secretsは読めません。
the only secrets available to the workflow are Dependabot secrets. GitHub Actions secrets are not available. — GitHub Docs「Troubleshooting Dependabot on GitHub Actions」
GitHubのシークレットは Actions / Dependabot / Codespaces でストアが完全に独立しています。設定画面のタブが分かれているのは見た目の話ではなく、本当に別の保管庫なんですね。うちのSSH鍵はActions secrets側にしか登録されていなかったので、Dependabot PRでは空文字に解決されていた、というわけです。
前提として、構成はこうです。アプリケーション側のリポジトリが、社内のprivateリポジトリで管理しているComposerパッケージに依存しており、CIでは webfactory/ssh-agent にdeploy keyを渡して composer install していました。
止血だけなら、Dependabot secretsに同じSSH鍵をコピーすれば済みます。これは認証方式の問題ではなく保管場所の問題なので、SSH鍵のままでもトークン方式でも、Dependabot PRで使うなら両方のストアに登録が必要になる。それが分かっていたからこそ、雑に鍵を複製して終わりにしたくなくて手を付けずにいました。今回ようやく余裕ができたので、この機会に認証方式ごと見直して根本解決することにしました。
認証方式の選択
まず、そもそもの認証方式を比較してみました。
| 資格情報 | スコープ | 寿命 | 所有者 |
|---|---|---|---|
| Deploy key(現状) | 1リポジトリ / read-only | 無期限 | リポジトリ |
Classic PAT repo |
アクセス可能な全リポジトリにread/write | 任意 | 個人 |
| Fine-grained PAT | リポジトリ+権限を限定可 | 最大366日 | 個人 |
| GitHub App | リポジトリ+権限を限定可 | 1時間(自動失効) | 組織 |
比較して見えてきたポイントが2つあります。
- classic PATはdeploy keyより権限が広い。「トークン化=より安全」は成り立ちません。read-onlyで1リポジトリ限定のdeploy keyを、全リポジトリread/writeのPATに置き換えたら明確な後退です
- fine-grained PATは作成者個人に紐づく。公式に “tied to the user who generated them and will become inactive if the user loses access to the resource” と明記されており、作成者が組織を離れた瞬間、そのPATを使っている全リポジトリのCIが一斉に止まります
GitHub Appなら組織所有で、installation tokenは1時間で自動失効します。というわけでGitHub Appを採用しました。
GitHub Appの設定
やることは少ないです。
- Organization Settings → Developer settings → GitHub Apps → New GitHub App
- Webhook: Activeのチェックを外す(CIでトークンを発行するだけなら不要)
- Repository permissions: Contents: Read-only のみ
- インストール先: 依存される側(パッケージ)のリポジトリのみ
ワークフローの書き換え
ssh-agentのステップを、actions/create-github-app-token によるトークン発行に置き換えます。
- - name: Set up SSH agent
- uses: webfactory/ssh-agent@v0.10.0
+ - name: Generate token for private Composer packages
+ uses: actions/create-github-app-token@v2
+ id: composer-token
with:
- ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
+ app-id: ${{ secrets.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+ owner: my-org
+ repositories: my-private-package
発行したトークンは COMPOSER_AUTH 環境変数で渡します。
- name: Composer install
run: composer validate --no-check-publish && composer install ...
+ env:
+ COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ steps.composer-token.outputs.token }}"}}'
composer.json のリポジトリURLもSSHからHTTPSへ。
- "url": "git@github.com:my-org/my-private-package.git"
+ "url": "https://github.com/my-org/my-private-package.git"
そしてシークレット(App IDと秘密鍵)は、冒頭の話のとおり ActionsとDependabotの両方に登録します。
途中で判明した落とし穴
ここからが本題かもしれません。移行作業そのものより、途中で踏んだ罠のほうが学びが多かったです。
1. Organization secretsはGitHub Freeではprivateリポジトリから読めない
App IDと秘密鍵を組織シークレットに一度だけ登録して全リポジトリで共有——としたかったのですが、できませんでした。
Organization-level secrets and variables are not accessible by private repositories for GitHub Free.
Selected repositories の選択UI自体は表示されるのに、privateリポジトリは候補に出てきません。Freeプランではリポジトリ個別のRepository secretsを使うしかない。つまり消費側リポジトリごとに、Actions / Dependabotの2箇所へ登録することになります。地味につらい。
2. composer.jsonのURLを変えるとcomposer.lockのcontent-hashがずれる
repositories セクションを変更すると composer validate がexit 2になります。composer update <package> --no-install でlockを更新すれば解消。
面白いのは、config セクション(後述の preferred-install など)はcontent-hashの計算対象外なこと。そちらの変更ではlock更新は不要です。
3. GitHub Appトークンでは preferred-install: source が使えない
これが一番の罠でした。CIはsuccessなのに、ログをよく見るとこうなっていたんです。
Failed to download my-org/my-private-package from source
remote: Invalid username or token. Password authentication is not supported for Git operations.
Now trying to download from dist ← フォールバックで救われていた
composerは自社パッケージを source(git clone)で取得する設定にしていました。composerはgithub-oauthトークンをユーザー名の位置に置いたURLで git clone しますが、GitHub Appのinstallation tokenは x-access-token:<token>@github.com 形式のユーザー名を要求するため、この認証は通りません。PATなら通るのに、installation tokenだけ落ちる。
一方 dist は api.github.com のzipballをAuthorizationヘッダで取得するため問題なく動きます。だからフォールバックで「たまたま」成功していたわけです。
- "preferred-install": {
- "my-org/*": "source",
- "*": "dist"
- },
+ "preferred-install": "dist",
dist に統一して解消。副産物としてinstall時間も2m30sから1m22sに短縮されました。
「privateリポジトリはdistで取得できない」という前提は、認証トークンがあれば成り立ちません。昔の名残でsource指定が残っているプロジェクト、意外と多いのではないでしょうか。
4. Dependabot PRのRe-runでは直らない
ワークフローを修正してbaseにマージしたあと、失敗していたDependabot PRを「Re-run」しても直りません。pull_request イベントのRe-runはPR作成時点のmerge commitのワークフローファイルで走るからです。
PRに @dependabot rebase とコメントしてブランチを作り直してもらう必要があります。これを知らないと「直したはずなのに落ち続ける」で無限に混乱します。
まとめ
- Dependabot PRでシークレットが空になるのは、Actions / Dependabot / Codespacesでシークレットストアが独立しているから
- 「トークン化=安全」ではない。classic PATはdeploy keyより権限が広く、fine-grained PATは個人に紐づく。組織で持つならGitHub App
- GitHub Appのinstallation tokenは
git clone(source取得)では使えない。preferred-install: distとセットで使う - Dependabot PRはRe-runではなく
@dependabot rebase
放置していた既知の問題でしたが、腰を据えて取り組んだら、蓋を開ければシークレット管理と資格情報の設計の話でした。同じ構成(private Composerパッケージ+deploy key)のプロジェクトは他にもあるので、順次同じ移行を進めていきます。