GitHub Actions でコミットに署名する
動機と前提 #
GitHub Actions を利用した自動化の結果として、なんらかのファイル編集をリポジトリに自動的に反映したい場合があります。 たとえば、バージョン番号の更新や、自動生成されるコードの取り込みなどが考えられるでしょう。
GitHub Actions からリポジトリにプッシュするには #
GitHub Actions からプッシュする方法は大きく分けて 3 種類あります。
- Deploy Key
- 最も古典的な手法で、事前にリポジトリに登録した SSH キーにより認証しリポジトリにアクセスする方法です。
- 他の手法と比較して依存する要素が少ない(git + ssh ネイティブの仕組みで動く)ため、構成はシンプルです。
- ただし、SSH キーは通常は長期クレデンシャルであり、セキュリティ上のリスクが高いです。
- また、アクセスするリポジトリが複数の場合、複数の SSH キーを扱う必要があり、スケーラブルではありません。
- Personal Access Token (PAT)
- あるユーザーに紐づくアクセストークンを事前に発行し、これを利用して HTTPS 認証しリポジトリにアクセスする方法です。
- 特定のユーザーアカウントに紐づいてしまうため運用上懸念があり、これを解決するため PAT を発行する専用のマシンアカウントを用意する場合もあるでしょう。
- 開発者自身の PAT は権限が強すぎセキュリティ上の懸念がありましたが、Fine-grained PAT の登場によりある程度は緩和されました。
- 長期クレデンシャルとなるため流出時のリスクがあり、有効期限を設ける、またはローテーションが必要、といった運用上の課題があります。
- PAT で認証されたユーザーがアクセスできるリソースに同等にアクセスできる (Classic PAT)、または、事前に許可された複数のリポジトリに操作ができる (Fine-grained PAT) ことから、Deploy Key よりも利便性は高いでしょう。
- GitHub App Installation Access Token (IAT)
- 最も新しく利用できるようになった手法で、GitHub Actions ジョブ実行時に GitHub App として認証し、この App の権限によってリポジトリにアクセスします。
- 実行時に生成されるスコープの限定された短期クレデンシャルであり、運用・セキュリティの面では優位性があります。
- App が複数のリポジトリに対してインストールされていれば、それらに単一のアクセストークンでアクセスすることができます。
- GITHUB_TOKEN
GITHUB_TOKEN
は GitHub Actions ジョブ実行時に自動的に生成される認証トークンであり、権限を適切に設定すればこれを利用してリポジトリに push することができます。- ドキュメントで言及されている通り、これは GitHub Actions を有効化したときに暗黙にインストールされる GitHub App の IAT です。
- ただし、独自に作成した GitHub App を利用する場合と比較して、固有の制約があります。
- ジョブが実行されているリポジトリにのみアクセスできます。
- このトークンを利用したプッシュに対して on: push トリガーによるワークフロー実行ができません。
- 回避策として
workflow_run
やrepository_dispatch
を利用することはできます。
- 回避策として
それぞれ単純な機能差分に加えて構成や運用コストの差があり、要件に適合する限りにおいていずれも採用することができるでしょう。 ただし、GitHub App の利用が推奨されていることと、後述の署名の観点から、今後は GitHub App の採用を重視したいところです。
IAT によるリポジトリへのアクセス #
理解の前提になる IAT によるリポジトリへのアクセス(クローンおよびプッシュ)について確認しましょう。 次に示すのは、IAT を利用してリポジトリに変更をコミットするワークフローの例です。
GITHUB_TOKEN を利用する場合
name: Push by GitHub App
on:
workflow_dispatch:
jobs:
push:
permissions:
# GITHUB_TOKEN で push できるように write 権限を与えます。
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# ダミーの差分を生じさせます。
- name: Make changes
run: |
echo "{\"value\":$RANDOM}" > a.txt
git add a.txt
# GITHUB_TOKEN を利用して HTTPS 認証しプッシュします。
# ここで設定している user は GITHUB_TOKEN (IAT) に対応する GitHub App の Bot ユーザーです。
# 実際にはこの値以外を利用してコミットおよびプッシュすることもできますが、
# 後で説明する署名付きコミットの手法で得られる結果と対応づけるためにこのようにしています。
- name: Commit and Push
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git commit -m 'automated'
git remote set-url origin 'https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git'
git push
独自の GitHub App を利用する場合
name: Push by GitHub App
on:
workflow_dispatch:
jobs:
push:
permissions:
# プッシュは GitHub App の権限によって行うため、GITHUB_TOKEN 自身に write 権限は不要です。
contents: read
runs-on: ubuntu-latest
steps:
# GitHub App Installation Access Token (IAT) を取得します。
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: auth
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# 取得した IAT を利用して HTTPS 認証しリポジトリクローンします。
# ジョブが実行されているリポジトリ自身であればクローン自体は secrets.GITHUB_TOKEN を利用しても良いですが、
# この方法であれば App がアクセスできる任意のリポジトリに対してクローンすることができます。
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
token: ${{ steps.auth.outputs.token }}
# GITHUB_TOKEN とは異なり App に対応する Bot ユーザーは固有のため、ユーザー情報を動的に取得します。
# このステップは GITHUB_TOKEN を利用した場合と同様に github-actions[bot] としても良いですが、
# 後に説明する署名付きコミットの手法で得られる結果と対応づけるためにこのようにしています。
- name: Configure git user info
env:
GH_TOKEN: ${{ steps.auth.outputs.token }}
run: |
set -x
USER_NAME='${{ steps.auth.outputs.app-slug }}[bot]'
USER_ID="$(gh api "/users/$USER_NAME" --jq .id)"
git config user.name "$USER_NAME"
git config user.email "$USER_ID+$USER_NAME@users.noreply.github.com"
# ダミーの差分を生じさせます。
- name: Make changes
run: |
echo "{\"value\":$RANDOM}" > a.txt
git add a.txt
# IAT を利用して HTTPS 認証しプッシュします。
- name: Commit and Push
run: |
git commit -m 'automated'
git remote set-url origin 'https://x-access-token:${{ steps.auth.outputs.token }}@github.com/${{ github.repository }}.git'
git push
いずれの例についても、方針は共通していることがわかります。
- GitHub App の Installation Access Token (IAT) を取得する。
- GITHUB_TOKEN を利用する場合は取得済みとみなすことができます。
- IAT を利用して HTTP 認証しリポジトリを読み書きする。
- actions/checkout では
token
input に IAT を指定します。 git {clone,push}
コマンドでは IAT による HTTPS 認証を行うように Remote URL を設定します。
- actions/checkout では
さて、この例を利用して Bot ユーザーによりリポジトリにプッシュすることはできました。

Bot ユーザーによってプッシュされたコミット
署名が入ってないやん!署名をつけたいから認証とかちゃんとしたの!!
見ての通り、Bot ユーザーによるコミットは署名がされておらず、Verified マークが表示されていません。 リポジトリへの認証と署名は無関係であり、かつ明示的に署名の手続きを行なったわけでもないため、これは予期された結果です。
署名の重要性についてはここでは詳しく述べませんが、なりすましの防止、改竄の検知、などに役立つという程度の理解にとどめておいても、それを行うモチベーションになるでしょう(なりますよね?)。 では、このように GitHub Actions から自動的に行われるコミットに対して署名をするようにするには、どうしたらよいでしょうか?
GitHub Actions で行うコミットに署名するには #
自動化されたプロセスによって作成されたコミットに署名するためには、プッシュするための手法と関連づけていくつかのパターンが考えられます。
- Deploy Key
- リポジトリに登録された SSH キーは「所有者」に紐づかないため、コミットしたユーザーとして便宜上適当なユーザーアカウントを選択して署名者として採用することになります。
- そのユーザーに署名キーペアの公開鍵を登録し、その秘密鍵を GitHub Actions で利用してコミットに署名し、プッシュすることができます。
- 認証情報とは別に署名鍵という別の鍵の管理があり、運用上は手間です。
- また、GitHub Actions で自動的に行われたコミットとユーザーによるコミットが一見して区別できないこと。
- これについては、マシンアカウントを採用する、鍵を分ける、といった緩和策を検討することはできるでしょう。
- PAT
- ほぼ Deploy Key と同様の議論です。
- PAT に紐づくユーザーとして署名するのがよいでしょう。
- IAT
- 現在のところ最も有力な選択肢です。
- IAT を利用して GitHub API 経由でコミットを作成することにより、そのコミットは GitHub の所有する GPG 鍵によって署名されます。これにより、Bot ユーザーの作成したコミットが署名された状態とすることができます。具体的には:
- ただし、コミットおよびプッシュ操作が API 経由となるため、手続きが通常の git コマンドによる操作とは異なることが難点です。
リポジトリへのアクセス観点だけを考慮しても Deploy Key, PAT は相対的にレガシーな手法であり、GitHub App の利用が推奨されていることもあり、ここでは IAT を利用したアプローチを検証します。
IAT による署名付きコミット #
前提
- GitHub App を作成します。
- ここでは App 権限によりリポジトリへプッシュすることを考えているので、権限として Contents: write を要求するよう設定します。
- App の Private Key を発行し、アクセスしたいリポジトリのシークレットに登録します。
- アクセスしたいリポジトリへ App をインストールします。
ここまでセットアップした上で、前述した IAT によるプッシュを行うワークフローを改修する形で、署名付きコミットが行われる例を示します。
共通で利用するスクリプト (.github/workflows/push.js)
const fs = require("node:fs/promises");
const path = require("node:path");
async function main({ core, exec, github }) {
const cwd = process.env.ROOT_DIR || process.cwd();
const owner = process.env.REPOSITORY.split("/")[0];
const repo = process.env.REPOSITORY.split("/")[1];
const ref = `heads/${process.env.REF || "main"}`;
const message = process.env.MESSAGE || "automated";
const debug = false ? core.info.bind(core) : core.debug.bind(core); // for debug output
// resolve base commit and tree
const _parent = await github.rest.git.getRef({ owner, repo, ref });
debug(`getRef -> ${JSON.stringify(_parent, null, 2)}`);
const parent = _parent.data.object.sha;
core.info(`Resolved ${ref} to commit ${parent}`);
const _commit = await github.rest.git.getCommit({ owner, repo, commit_sha: parent });
debug(`getCommit -> ${JSON.stringify(_commit, null, 2)}`);
const base_tree = _commit.data.tree.sha;
core.info(`Base tree is ${base_tree}`);
// get staged changes
const { stdout: diff } = await exec.getExecOutput(
"git",
["diff", "--cached", "--no-renames", "--name-status"],
{ cwd },
);
debug(`git diff: ${diff}`)
// create blobs
const blobs = [];
for (const line of diff.split("\n")) {
const segments = line.trim().split("\t");
if (segments.length !== 2) {
continue;
}
const [status, file] = segments;
if (status === "D") {
// deleted
blobs.push({ path: file, mode: "100644", type: "blob", sha: null });
} else {
// created or modified
const content = (await fs.readFile(path.join(cwd, file))).toString("base64");
const blob = await github.rest.git.createBlob({ owner, repo, content, encoding: "base64" });
debug(`createBlob -> ${JSON.stringify(blob, null, 2)}`);
blobs.push({ path: file, mode: "100644", type: "blob", sha: blob.data.sha });
}
}
if (blobs.length === 0) {
core.notice("No staged files, skip creating tree");
return;
}
debug(`blobs: ${JSON.stringify(blobs, null, 2)}`);
core.info(`Staged ${blobs.length} file(s)`);
// create tree
const _tree = await github.rest.git.createTree({ owner, repo, base_tree, tree: blobs });
debug(`createTree -> ${JSON.stringify(_tree, null, 2)}`);
const tree = _tree.data.sha;
core.info(`Tree ${tree} created`);
// create commit
const commit = await github.rest.git.createCommit({ owner, repo, message, tree, parents: [parent] });
debug(`createCommit -> ${JSON.stringify(commit, null, 2)}`);
const sha = commit.data.sha;
core.info(`Commit ${sha} created`);
// update ref
const _ref = await github.rest.git.updateRef({ owner, repo, ref, sha });
debug(`updateRef -> ${JSON.stringify(_ref, null, 2)}`);
core.info(`Ref ${_ref.data.ref} updated to ${_ref.data.object.sha}`);
}
module.exports = main;
GITHUB_TOKEN を利用する場合
name: Push by GitHub App
on:
workflow_dispatch:
jobs:
push:
permissions:
# GITHUB_TOKEN で push できるように write 権限を与えます。
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0生
# ダミーの差分を生じさせます。
- name: Make changes
run: |
echo "{\"value\":$RANDOM}" > a.txt
git add a.txt
# GITHUB_TOKEN を利用して API を呼び出してコミットおよびプッシュします。
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
REPOSITORY: ${{ github.repository }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: await (require("./.github/workflows/push.js")({ core, exec, github }))
独自の GitHub App を利用する場合
name: Push by GitHub App
on:
workflow_dispatch:
jobs:
push:
permissions:
# プッシュは GitHub App の権限によって行うため、GITHUB_TOKEN 自身に write 権限は不要です。
contents: read
runs-on: ubuntu-latest
steps:
# GitHub App Installation Access Token (IAT) を取得します。
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: auth
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# 取得した IAT を利用して HTTPS 認証しリポジトリクローンします。
# ジョブが実行されているリポジトリ自身であればクローン自体は secrets.GITHUB_TOKEN を利用しても良いですが、
# この方法であれば App がアクセスできる任意のリポジトリに対してクローンすることができます。
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
token: ${{ steps.auth.outputs.token }}
# ダミーの差分を生じさせます。
- name: Make changes
run: |
echo "{\"value\":$RANDOM}" > a.txt
git add a.txt
# 発行した IAT を利用して API を呼び出してコミットおよびプッシュします。
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
REPOSITORY: ${{ github.repository }}
with:
github-token: ${{ steps.auth.outputs.token }}
script: await (require("./.github/workflows/push.js")({ core, exec, github }))
独自の GitHub App を利用して別のリポジトリにアクセスする場合
name: Push by GitHub App
# このワークフローでは GitHub App を利用して、このワークフローが実行されるのとは別のリポジトリへアクセスします。
# 以下の変数を定義しておきます。
# - vars.TARGET_REPO_OWNER
# - vars.TARGET_REPO_NAME
# - vars.TARGET_REPO_BRANCH
on:
workflow_dispatch:
jobs:
push:
permissions:
# プッシュは GitHub App の権限によって行うため、GITHUB_TOKEN 自身に write 権限は不要です。
contents: read
runs-on: ubuntu-latest
steps:
# .github/workflows/push.js を利用するため、本体リポジトリも取得しておく必要があります。
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# GitHub App Installation Access Token (IAT) を取得します。
- uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: auth
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ vars.TARGET_REPO_OWNER }}
repositories: ${{ vars.TARGET_REPO_NAME }}
# アクセスする対象のリポジトリをクローンします。
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
path: target
repository: ${{ vars.TARGET_REPO_OWNER }}${{ '/' }}${{ vars.TARGET_REPO_NAME }}
ref: ${{ vars.TARGET_REPO_BRANCH }}
persist-credentials: false
token: ${{ steps.auth.outputs.token }}
# ダミーの差分を生じさせます。
- name: Make changes
working-directory: target
run: |
echo "{\"value\":$RANDOM}" > a.txt
git add a.txt
# 発行した IAT を利用して API を呼び出してコミットおよびプッシュします。
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
ROOT_DIR: target
REPOSITORY: ${{ vars.TARGET_REPO_OWNER }}${{ '/' }}${{ vars.TARGET_REPO_NAME }}
REF: ${{ vars.TARGET_REPO_BRANCH }}
with:
github-token: ${{ steps.auth.outputs.token }}
script: await (require("./.github/workflows/push.js")({ core, exec, github }))
ワークフローを実行するたびに、当該リポジトリに署名付きの GitHub App によるコミットがプッシュされるはずです。

GitHub App によってプッシュされた署名付きコミット
解説
- .github/workflows/push.js は、git commit および git push コマンドを呼び出す代わりに、GitHub API の呼び出しでこれに相当する手続きを行っています。
- このスクリプトでは Git の低レベル操作を API 経由で行っています。
- 前提知識の解説まではしませんが、たとえば 10.2 Gitの内側 - Gitオブジェクト, よくわかるGitの仕組み などを参考に概念を把握しておくと、以降の記述の理解の助けになるでしょう。
- スクリプトにおいて git commit に対応する API 呼び出しは createCommit です。
- createCommit を呼び出すためには
tree
およびparents
パラメータが必要です。 tree
は createTree の呼び出しによって作成される tree object です。- createTree を呼び出すためには
base_tree
およびtree
パラメータが必要です。 base_tree
は作成する tree object の親を指定します。この実装では最も単純なケースのみを考慮し、プッシュ先としたブランチの最新コミットを取得し (getRef, getCommit) そのコミットに紐づく tree object を指定しています。tree
には blob object のリストを与えます。- その要素は:
- ファイルの作成・編集差分に対しては createBlob により作成することができます。
- ファイルの削除差分に対しては
sha: null
の形で与えることで表現します。
- この実装では単純化のため、事前に git add コマンドによってステージングされた差分を git diff コマンドによって取得し、コミット対象としています。
- コミット対象の一覧を得る方法はこれに限られるわけではありません。たとえば、明示的にコミットしたいファイルの一覧を与えることもできるでしょう。
- その要素は:
- createTree を呼び出すためには
parents
で親コミットを指定します。ここでは前述の手続きですでに得たプッシュ先ブランチの最新コミットを与えています。
- createCommit を呼び出すためには
- スクリプトにおいて git push に対応する API 呼び出しは updateRef です。
- この呼び出しにより、プッシュ先ブランチは createCommit で作成したコミットを先頭に 1 つ進みます。
- ⚠️ このスクリプトは非常に限定的なシナリオでのみ動作することが想定され、API で実現されうるあらゆる操作に対応しているわけではありません。
- たとえば createTree では tree.mode として
100755
(executable),120000
(symlink) などがありますが、まったく考慮していません。
- たとえば createTree では tree.mode として
まとめ #
GitHub Actions から署名付きコミットをプッシュするには、GitHub App を利用する手法が有力です。
- この手法では Installation Access Token (IAT) を利用して API を呼び出すことでコミットおよびプッシュを行い、git コマンドによる操作を行いません。
- Blob, Tree, Commit, Reference といった一連の低レベル操作を API によって実装しなければならず、実装コスト・保守性には課題があります。
なお、私はこの手法を具体的に以下のようなケースで応用しています。
- on: schedule トリガーによって起動された処理でリポジトリ内のデータファイルを更新しこれをプッシュ、プッシュに起因して別のデプロイ用ワークフローを発動させる。
- これは secrets.GITHUB_TOKEN の制約を回避しつつ、定期的なデータの更新からデプロイまでを自動化した例です。
- 別のアプローチとして schedule された処理でデプロイまで行うよう実装することも可能ではありますが、責務を分離してシンプルなワークフロー構成とできる利点があります。
- Pages のソースリポジトリと公開用リポジトリを分離する。
- Free アカウントの場合 Pages はパブリックリポジトリでしか利用できませんが、この手法を採用すると、成果物だけを公開用のリポジトリに配置し(これらはどのみちブラウザアクセスできる)、ソースリポジトリはプライベートとすることができます。
- 具体的にはこの web サイトをホストしているリポジトリです → fuzmish/fuzmish.github.io