エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

GitHub Actionsをもっと安全に、もっと便利に運用する方法

【Unit4 ブログリレー4日目】

はじめまして! Unit4でエンジニアをしている大熊です。こちらはUnit4ブログリレーの4日目の記事です。

エムスリーではGitLabからGitHubへの移行が進められています。私は現在、Unit4で最初のGitHubを活用したプロダクトの開発に取り組んでいます。 今回はGitHubの強力なCI/CD機能であるGitHub ActionsのTipsをご紹介します。

Unit4の概要については1日目の関根の投稿をご覧ください!

www.m3tech.blog

サードパーティのActionを安全に使う

GitHub Actionsの最大の魅力は豊富なサードパーティ製のActionです。私たちが必要と思うものはだいたい誰かがすでに作っており、Actionをつなぎ合わせるだけでやりたいことができてしまいます。

このような手軽さの反面、サプライチェイン攻撃のリスクがあります。攻撃者が悪意のあるコードを混入させ、利用者の情報を抜き取られた等の被害を耳にすることも多いと思います。

GitHub Actionsにはこのような攻撃の対策は標準的には組み込まれておらず、開発者自身が防衛する必要があります。ここでは ghats を活用した対策方法をご紹介します。

ghats

ghats はWorkflowをTypeScriptで記述するツールです。GitHub ActionsのYAMLの構文は正直覚えられないので型のチェックや補完が効くのは非常に便利です。

使い方の詳細は開発者のブログをご覧ください!

zenn.dev

Actionをコミットハッシュで固定

GitHub ActionsではサードパーティのActionを使用する際はタグではなく、コミットハッシュでバージョン指定することが強く推奨されています。

docs.github.com

タグは作り直しが可能であり、攻撃者が悪意のあるコードを混入させた後にタグを上書きしてしまうと、意図しないコードが実行される可能性があります。一方でコミットハッシュは一意であり、過去のコミットを上書きして偽装することは困難です。

ghats のさらにスゴいところは、このようなコミットハッシュを使ったサプライチェイン攻撃対策の機能も組み込まれているという点です。

たとえば、とあるサードパーティのActionをghats経由でインストールしてみます。

npx ghats install aws-actions/configure-aws-credentials

インストールが完了するとactions-lock.jsonにコミットハッシュが記録されます。

// actions-lock.json
{
  "actions": {
    "aws-actions/configure-aws-credentials@v4.3.1": "7474bc4690e29a8392af63c5b98e7449536d5c3a"
  }
}

TypeScriptのコード中にActionを記述するときには特にコミットハッシュを指定不要なところも嬉しいポイントです。うっかり指定を忘れてしまうこともなくなります。

.uses(
  // 裏側で `actions-lock.json` のコミットハッシュを参照してくれる!
  action("aws-actions/configure-aws-credentials", {
    with: {
      ...
    },
  }),
)

TypeScriptが苦手な方や、すでに多くのコード資産があって移行が難しい場合は pinact というツールもおすすめです。

コミットハッシュをYAMLの中に自動的に追記してくれたり、linterのように設定忘れの検知もできます。後述の自作Action用のリポジトリではこちらを活用しています。

github.com

ところで、Workflowの完全性を担保するという点でコミットハッシュを固定することは重要ですが、そもそも信頼できるActionなのかを精査することが重要です。発行元やStarの数、更新日などを確認しましょう。実際にコードを読んでみることも必要です。

自作Actionをモノレポで管理する

サードパーティのActionが豊富とはいえ、やはりチームの開発プロセスに合わせた自作のActionを作りたくなります。

Unit4ではDockerイメージのビルドやデプロイ、レビューチケットの作成などのプロジェクト横断的に必要になるActionを専用のリポジトリで管理しています。

一般的には1つのActionを1つのリポジトリで管理することが多いですが、Unit4ではモノレポで管理しています。こうすることでActionを探しやすくなりますし、Actionごとに繰り返し設定ファイルを記述したり、CI/CDの設定する必要がなくなります。

構成

unit4-actions
├── .github
│   └── workflows               // 自作ActionそのもののCI/CD
│       ├── build.yml
│       └── review.yml
├── create-review-ticket        // アクションごとにフォルダを分ける
│   ├── action.yml
│   ├── dist
│   │   └── index.js
│   ├── src
│   │   └── index.ts
│   ├── package.json
│   └── tsconfig.json
├── build-push-image
│   └── action.yml
│
... その他いろいろ
│
├── .gitignore                  // 依存関係や各種設定ファイルは一元管理
├── .tool-versions
├── biome.jsonc
├── package-lock.json
└── package.json

使用する際は<owner>/<repo>/<action>の形式で指定します。ghatsから使う場合も同様です。

GITHUB_TOKEN="$(gh auth token)" npx ghats install unit4/unit4-actions/build-push-image

ちなみにghatsでprivate/internalのリポジトリを参照する場合はGITHUB_TOKENが必要です。Personal Access Tokenを使いたくない場合はGitHub CLI (gh) のgh auth tokenコマンドでトークンを発行するのが簡単でおすすめです。

ghatsは最新のReleaseから取得する仕様になっているようです。Releaseを作成しない運用の場合はブランチを明示的に指定します。

GITHUB_TOKEN="$(gh auth token)" npx ghats install unit4/unit4-actions/build-push-image@main

インストールができてしまえば後は通常のActionと同じように使うことができます。

ビルドとデプロイを分離する

これまでUnit4ではGitLab CI/CDを使って次のような手順でビルドやデプロイをしていました。

  1. ソースをリポジトリにpushする
  2. 自動でbuildtest等のジョブが実行され、アーチファクト (ビルド成果物) が生成される
  3. 開発者が手動でdeployジョブを実行し、2のアーチファクトを開発環境にデプロイする

開発環境は複数のエンジニアが共用しているということもあり、ビルドとデプロイは別々に実行できる必要がありました。GitLab CI/CDはPipelineの中のJob (ビルド、デプロイなど) が比較的密に連携しているため、実行タイミングが違っていても容易にアーチファクトを共有できました。

一方でGitHub Actionsでは処理の実行タイミングを分離するためにはWorkflowを分ける必要がありますが、Workflowを分けてしまうとアーチファクトの共有はやや難しくなります。

ここではUnit4の開発スタイルを維持するべく、GitHub Actionsでもビルドとデプロイを分離して実行できるようにした工夫をご紹介します。

アーチファクトの共有方法

Workflow間のアーチファクトの共有には actions/upload-artifactactions/download-artifact を使います。

actions/download-artifactで異なるWorkflowのアーチファクトを取得するにはrun-idを指定する必要があります。たとえば次のように記述します。

steps:
- uses: actions/download-artifact@v5
  with:
    run-id: 1234

run-idはWorkflowの実行 (Run) ごとに付与される一意のIDですが、GitHubのUI上でbuildのWorkflowの実行を手動で見つけ出し、そのIDを入力させるというようなことはとても現実的ではありません。

そこで、直近の成功したWorkflowのrun-idを取得するAction (get-last-workflow-run) を作成し、これを使ってアーチファクトの橋渡しをできるようにしました。コードは次のようになります。

// src/index.ts
import * as core from "@actions/core";
import { context, getOctokit } from "@actions/github";

async function run(): Promise<void> {
  try {
    const workflowFile = core.getInput("workflow-file");
    const branch = core.getInput("branch") || process.env.GITHUB_REF_NAME;
    const token = core.getInput("github-token") || process.env.GITHUB_TOKEN;

    if (!workflowFile) {
      core.setFailed("The workflow file input is required");
      return;
    }

    if (!branch) {
      core.setFailed("Target branch is not set");
      return;
    }

    if (!token) {
      core.setFailed("Invalid GitHub token");
      return;
    }

    const octokit = getOctokit(token);
    const { owner, repo } = context.repo;
    const response = await octokit.rest.actions.listWorkflowRuns({
      owner,
      repo,
      workflow_id: workflowFile,
      branch,
      per_page: 1,
    });

    const targetRun = response.data.workflow_runs[0];
    if (!targetRun) {
      core.setFailed(
        `No workflow runs found for ${workflowFile} on branch ${branch}`,
      );
      return;
    }

    if (targetRun.conclusion !== "success") {
      core.setFailed(
        `Last workflow run for ${workflowFile} on branch ${branch} did not complete successfully. Conclusion: ${targetRun.conclusion}`,
      );
      return;
    }

    core.info(
      `Last successful workflow run for ${workflowFile} on branch ${branch} found: ${targetRun.id}`,
    );

    core.setOutput("run-id", targetRun.id.toString());
  } catch (err) {
    core.setFailed((err as Error).message);
  }
}

void run();

前記のTypeScriptのコードをビルドしたものをdist/index.jsに配置し、action.ymlから参照します。

# actions.yml
name: get-last-workflow-run
description: 直近の成功したWorkflowのRunを取得する
inputs:
  workflow-file:
    description: Workflowのファイル名
    required: true
  branch:
    description: Workflowに紐づくブランチ名
    required: false
  github-token:
    description: GitHub Token
    required: false
outputs:
  run-id:
    description: WorkflowのRun ID
runs:
  using: node20
  main: dist/index.js

それでは実際にこのActionを使ってみます。

buildのワークフローのYAMLです。こちらはアップロード側ですが、特に注意すべきことはありません。

# build.yml (build用のWorkflow)
...

- uses: actions/upload-artifact@v5
  with:
    name: backend-build    # backendのアーチファクトをアップロード
    path: build

次にdeployのWokflowのYAMLです。actions/download-artifact の前に get-last-workflow-run を実行します。取得したrun-idを使ってアーチファクトをダウンロードします。permission の設定を忘れないように注意してください。

# deploy.yml (deploy用のWorkflow)

...

permissions:
  # actionのread権限が必要
  actions: read

...

- uses: unit4/unit4-actions/get-last-workflow-run
  id: last-workflow
  with:
    workflow-file: build.yml                          # 検索したいWorkflowのファイル名を指定
    github-token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/download-artifact@v5
  with:
    run-id: ${{ steps.last-workflow.outputs.run-id }} # 取得したrun-idを使ってアーチファクトをダウンロード
    github-token: ${{ secrets.GITHUB_TOKEN }}

  # あとはDockerイメージのビルドやデプロイなどを行う

おわりに

いかがでしたでしょうか。初歩的な内容も多かったと思いますが、少しでも参考になれば幸いです。まだまだGitHubの機能を使いこなせていない部分も多いので、今後も色々と試していきたいと思います。

We are Hiring!

エムスリーでは一緒にプロダクト開発をするエンジニアを絶賛募集中です。ご興味ある方は是非カジュアル面談等ご応募ください!

エンジニア採用ページはこちら

jobs.m3.com

エンジニア新卒採用サイト

fresh.m3recruit.com

カジュアル面談はこちら

jobs.m3.com