GitHub Actions でコミットに署名する

動機と前提 #

GitHub Actions を利用した自動化の結果として、なんらかのファイル編集をリポジトリに自動的に反映したい場合があります。 たとえば、バージョン番号の更新や、自動生成されるコードの取り込みなどが考えられるでしょう。

GitHub Actions からリポジトリにプッシュするには #

GitHub Actions からプッシュする方法は大きく分けて 3 種類あります。

それぞれ単純な機能差分に加えて構成や運用コストの差があり、要件に適合する限りにおいていずれも採用することができるでしょう。 ただし、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

いずれの例についても、方針は共通していることがわかります。

  1. GitHub App の Installation Access Token (IAT) を取得する。
    • GITHUB_TOKEN を利用する場合は取得済みとみなすことができます。
  2. IAT を利用して HTTP 認証しリポジトリを読み書きする。
    • actions/checkout では token input に IAT を指定します。
    • git {clone,push} コマンドでは IAT による HTTPS 認証を行うように Remote URL を設定します。

さて、この例を利用して Bot ユーザーによりリポジトリにプッシュすることはできました。

Bot ユーザーによってプッシュされたコミット

Bot ユーザーによってプッシュされたコミット

署名が入ってないやん!署名をつけたいから認証とかちゃんとしたの!!

見ての通り、Bot ユーザーによるコミットは署名がされておらず、Verified マークが表示されていません。 リポジトリへの認証と署名は無関係であり、かつ明示的に署名の手続きを行なったわけでもないため、これは予期された結果です。

署名の重要性についてはここでは詳しく述べませんが、なりすましの防止、改竄の検知、などに役立つという程度の理解にとどめておいても、それを行うモチベーションになるでしょう(なりますよね?)。 では、このように GitHub Actions から自動的に行われるコミットに対して署名をするようにするには、どうしたらよいでしょうか?

GitHub Actions で行うコミットに署名するには #

自動化されたプロセスによって作成されたコミットに署名するためには、プッシュするための手法と関連づけていくつかのパターンが考えられます。

リポジトリへのアクセス観点だけを考慮しても Deploy Key, PAT は相対的にレガシーな手法であり、GitHub App の利用が推奨されていることもあり、ここでは IAT を利用したアプローチを検証します。

IAT による署名付きコミット #

前提

  1. GitHub App を作成します。
    • ここでは App 権限によりリポジトリへプッシュすることを考えているので、権限として Contents: write を要求するよう設定します。
  2. App の Private Key を発行し、アクセスしたいリポジトリのシークレットに登録します。
  3. アクセスしたいリポジトリへ 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 App によってプッシュされた署名付きコミット

解説

まとめ #

GitHub Actions から署名付きコミットをプッシュするには、GitHub App を利用する手法が有力です。

なお、私はこの手法を具体的に以下のようなケースで応用しています。