CI/CD革新 GitHub Script活用術

ogp

はじめに

こんにちは、enechainでeSquare Liveを開発しているエンジニアの古瀬(@tsuperis3112)です!

今回は、マニュアル依存になりがちなデプロイフローの問題を actions/github-script で解消した方法についてお話します。 eSquare Liveの開発では、効率的かつ信頼性の高い開発フローを維持するために様々なツールを試行錯誤しています。その中で、GitHub Actionsをさらに強化するためにGitHub scriptを導入し、大きな効果を得ることができました。

GitHub Script概要

複雑なCIを設定する時、どのようなスクリプトを書くでしょうか? 例えば、「特定の条件を満たす場合のみ、特定の環境へのデプロイを行う」「複数のブランチの状態を比較し、コンフリクトがないかチェックする」といった処理が必要になるかもしれません。

Github Scriptを利用しない場合、ghコマンドで情報を取得し環境変数に格納、その値をシェルスクリプトでパースして、など複雑な手順を踏む必要があります。
シェルスクリプトは静的解析が難しいため、IDEの補完も限定的にしか利用できず、ハードワークになる可能性が高いです。さらに、bashやzsh、BSD系/GNU系など、ローカル環境とCI環境でコマンドの挙動に差分が発生する可能性もあり、予期せぬエラーに悩まされることも少なくありません。

Github Scriptを利用する場合、JavaScriptで実装できるため、以下のようなメリットがあります。

  • 複雑な処理を比較的簡単に記述できる: JavaScriptの強力な制御構文(条件分岐やループ)により、複雑なロジックも簡単に実装できます。
  • 型システムの恩恵を受けられる: JSDocやTypeScriptの型定義を利用することで、IDEの強力なコード補完や、ESLintなどのリンターによる静的解析のサポートを受けられます。これにより、タイプミスや型エラーを減らし、開発効率とコード品質を向上させることができます。
  • テストが容易: JestやVitestなどのテスティングフレームワークを用いて、GitHub Scriptのロジックに対してユニットテストを記述できます。これにより、バグの早期発見やリファクタリングの容易化、CI/CDパイプラインへの信頼性向上が期待できます。
  • JavaScriptエコシステムの活用: npmで公開されている豊富なライブラリを活用することで、開発を効率化できます。

以下のactionはPull Requestのベースブランチがmainかそれ以外かを判定するためのスクリプトです。
actions/github-scriptを利用することで、GitHub APIの操作やイベント(Pull Request、Workflow Dispatchなど)のコンテキストを簡単に取得・活用できます。

steps:
  - uses: actions/github-script@v7
    id: myscript
    with:
      result-encoding: string
      script: |
        const branch = context.payload.pull_request?.base === 'main' ? 'main' : 'other'
        return branch
  - uses: sample-action
    with:
      branch: {{ steps.myscript.outputs.result }}

usesactions/github-scriptを指定し、withresult-encodingとしてstringまたはjsonを指定し、scriptに実行するJavaScriptを記述します。

result-encodingはデフォルトではjsonですが、使い勝手を考慮してここではstringを使用しています。公式の例でもstringが多く使用されています。

contextには現在のイベントに含まれるコンテキスト情報が含まれており、イベント別に様々な情報を取得できます。Pull Requestの場合、ベースブランチ、ターゲットブランチ、作成者、PRメッセージなど多くの情報を取得可能です。

最終的に戻り値として出力した内容が.outputs.resultに格納され、以降のステップやジョブで利用できるようになります。

セットアップ

GitHub Scriptで記述する言語はJavaScriptのためGitHub特有のオブジェクトや型の知識を持ちません。しかしJavaScriptはJSDocを使用することで、型情報をIDE(e.g. VSCode, IntelliJ IDEA)に認識させることが可能です。

そこでまず、GitHub Scriptの型情報をインストールし、それを設定したアクションファイルを作成します。

npm install -D @types/github-script
  • .github/actions/action.js1

型情報をJSDocに記述することでIDEが引数の型を認識します。

/** @param {import('github-script').AsyncFunctionArguments} AsyncFunctionArguments */
module.exports = async ({ github, core, context }) => {
  return "action";
};
  • .github/workflows/ci.js

Node.jsではデフォルトのモジュールシステムがcjsなので、requireで実行ファイルのインポートを行います。

name: "Sample CI"

on:
  push:
  workflow_dispatch:

jobs:
  set-env:
    runs-on: ubuntu-latest
    outputs:
      target: ${{ steps.script.outputs.result }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/github-script@v7
        id: script
        with:
          script: |
            # action.js呼び出し
            # リポジトリルートをカレントディレクトリとしている
            const script = require('./.github/actions/action.js')
            return await script({github, core, context})
          result-encoding: string
  ...snip...

これで最小限のアクションが完成しました。この空のアクションに処理を追加していき、CI/CDに関するデプロイ作業を自動化していきましょう!

script関数の引数はそれぞれ以下のように定義されています。

引数 説明
github GitHub の API を操作します
core Action の成否を決めたり、ログの出力などを行います
context 現在のイベントやワークフローの情報が含まれています。情報の参照をする場合はここをみることが多いです。

ほとんどのケースではサンプルの通りgithub, core, contextを扱うことで十分な操作ができます。

context の中身

実際にどんな値が入っているのかをconsole.log(JSON.stringify(context))して確認しました。基本的な情報が豊富に含まれているため、詳細はそれぞれ開いてご確認ください。

Push

{
  "payload": {
    "after": "b042efbe914c0a84146cb39d1a44d1c9f1511321",
    "base_ref": null,
    "before": "d6e674261c20f08c2ac841f12034f7034aff6a8b",
    "commits": [
      {
        "author": {
          "email": "[email protected]",
          "name": "Takeru Furuse",
          "username": "tsuperis3112"
        },
        "committer": {
          "email": "[email protected]",
          "name": "Takeru Furuse",
          "username": "tsuperis3112"
        },
        "distinct": true,
        "id": "b042efbe914c0a84146cb39d1a44d1c9f1511321",
        "message": "fix",
        "timestamp": "2024-12-24T17:23:15+09:00",
        "tree_id": "50fc2599903d7d6d82b1cb0cbed8fa9686b5eb02",
        "url": "https://github.com/tsuperis3112/samplerepo/commit/b042efbe914c0a84146cb39d1a44d1c9f1511321"
      }
    ],
    "compare": "https://github.com/tsuperis3112/samplerepo/compare/d6e674261c20...b042efbe914c",
    "created": false,
    "deleted": false,
    "forced": false,
    "head_commit": {
      "author": {
        "email": "[email protected]",
        "name": "Takeru Furuse",
        "username": "tsuperis3112"
      },
      "committer": {
        "email": "[email protected]",
        "name": "Takeru Furuse",
        "username": "tsuperis3112"
      },
      "distinct": true,
      "id": "b042efbe914c0a84146cb39d1a44d1c9f1511321",
      "message": "fix",
      "timestamp": "2024-12-24T17:23:15+09:00",
      "tree_id": "50fc2599903d7d6d82b1cb0cbed8fa9686b5eb02",
      "url": "https://github.com/tsuperis3112/samplerepo/commit/b042efbe914c0a84146cb39d1a44d1c9f1511321"
    },
    "pusher": {
      "email": "[email protected]",
      "name": "tsuperis3112"
    },
    "ref": "refs/heads/main",
    "repository": {
      "allow_forking": true,
      "archive_url": "https://api.github.com/repos/tsuperis3112/samplerepo/{archive_format}{/ref}",
      "archived": false,
      "assignees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/assignees{/user}",
      "blobs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/blobs{/sha}",
      "branches_url": "https://api.github.com/repos/tsuperis3112/samplerepo/branches{/branch}",
      "clone_url": "https://github.com/tsuperis3112/samplerepo.git",
      "collaborators_url": "https://api.github.com/repos/tsuperis3112/samplerepo/collaborators{/collaborator}",
      "comments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/comments{/number}",
      "commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/commits{/sha}",
      "compare_url": "https://api.github.com/repos/tsuperis3112/samplerepo/compare/{base}...{head}",
      "contents_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contents/{+path}",
      "contributors_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contributors",
      "created_at": 1728260001,
      "default_branch": "main",
      "deployments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/deployments",
      "description": null,
      "disabled": false,
      "downloads_url": "https://api.github.com/repos/tsuperis3112/samplerepo/downloads",
      "events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/events",
      "fork": false,
      "forks": 0,
      "forks_count": 0,
      "forks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/forks",
      "full_name": "tsuperis3112/samplerepo",
      "git_commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/commits{/sha}",
      "git_refs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/refs{/sha}",
      "git_tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/tags{/sha}",
      "git_url": "git://github.com/tsuperis3112/samplerepo.git",
      "has_discussions": false,
      "has_downloads": true,
      "has_issues": true,
      "has_pages": false,
      "has_projects": true,
      "has_wiki": false,
      "homepage": null,
      "hooks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/hooks",
      "html_url": "https://github.com/tsuperis3112/samplerepo",
      "id": 868662533,
      "is_template": false,
      "issue_comment_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/comments{/number}",
      "issue_events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/events{/number}",
      "issues_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues{/number}",
      "keys_url": "https://api.github.com/repos/tsuperis3112/samplerepo/keys{/key_id}",
      "labels_url": "https://api.github.com/repos/tsuperis3112/samplerepo/labels{/name}",
      "language": "C++",
      "languages_url": "https://api.github.com/repos/tsuperis3112/samplerepo/languages",
      "license": null,
      "master_branch": "main",
      "merges_url": "https://api.github.com/repos/tsuperis3112/samplerepo/merges",
      "milestones_url": "https://api.github.com/repos/tsuperis3112/samplerepo/milestones{/number}",
      "mirror_url": null,
      "name": "samplerepo",
      "node_id": "R_kgDOM8a9BQ",
      "notifications_url": "https://api.github.com/repos/tsuperis3112/samplerepo/notifications{?since,all,participating}",
      "open_issues": 0,
      "open_issues_count": 0,
      "owner": {
        "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
        "email": "[email protected]",
        "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
        "followers_url": "https://api.github.com/users/tsuperis3112/followers",
        "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
        "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
        "gravatar_id": "",
        "html_url": "https://github.com/tsuperis3112",
        "id": 152579884,
        "login": "tsuperis3112",
        "name": "tsuperis3112",
        "node_id": "U_kgDOCRgvLA",
        "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
        "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
        "repos_url": "https://api.github.com/users/tsuperis3112/repos",
        "site_admin": false,
        "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
        "type": "User",
        "url": "https://api.github.com/users/tsuperis3112",
        "user_view_type": "public"
      },
      "private": true,
      "pulls_url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls{/number}",
      "pushed_at": 1735028599,
      "releases_url": "https://api.github.com/repos/tsuperis3112/samplerepo/releases{/id}",
      "size": 226,
      "ssh_url": "[email protected]:tsuperis3112/samplerepo.git",
      "stargazers": 0,
      "stargazers_count": 0,
      "stargazers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/stargazers",
      "statuses_url": "https://api.github.com/repos/tsuperis3112/samplerepo/statuses/{sha}",
      "subscribers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscribers",
      "subscription_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscription",
      "svn_url": "https://github.com/tsuperis3112/samplerepo",
      "tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/tags",
      "teams_url": "https://api.github.com/repos/tsuperis3112/samplerepo/teams",
      "topics": [],
      "trees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/trees{/sha}",
      "updated_at": "2024-12-24T08:22:31Z",
      "url": "https://github.com/tsuperis3112/samplerepo",
      "visibility": "private",
      "watchers": 0,
      "watchers_count": 0,
      "web_commit_signoff_required": false
    },
    "sender": {
      "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
      "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
      "followers_url": "https://api.github.com/users/tsuperis3112/followers",
      "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
      "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
      "gravatar_id": "",
      "html_url": "https://github.com/tsuperis3112",
      "id": 152579884,
      "login": "tsuperis3112",
      "node_id": "U_kgDOCRgvLA",
      "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
      "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
      "repos_url": "https://api.github.com/users/tsuperis3112/repos",
      "site_admin": false,
      "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
      "type": "User",
      "url": "https://api.github.com/users/tsuperis3112",
      "user_view_type": "public"
    }
  },
  "eventName": "push",
  "sha": "b042efbe914c0a84146cb39d1a44d1c9f1511321",
  "ref": "refs/heads/main",
  "workflow": "Deploy: Excel Add-in",
  "action": "script",
  "actor": "tsuperis3112",
  "job": "set-env",
  "runNumber": 4,
  "runId": 12479116463,
  "apiUrl": "https://api.github.com",
  "serverUrl": "https://github.com",
  "graphqlUrl": "https://api.github.com/graphql"
}

Pull Request

{
  "payload": {
    "action": "synchronize",
    "after": "bafe574499f02df1f38234e29ea6b411f21da12d",
    "before": "5993b1c07e0e2182b260df23ca3cc28ef47b555b",
    "number": 1,
    "pull_request": {
      "_links": {
        "comments": {
          "href": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/1/comments"
        },
        "commits": {
          "href": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls/123/commits"
        },
        "html": {
          "href": "https://github.com/tsuperis3112/samplerepo/pull/1"
        },
        "issue": {
          "href": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/1"
        },
        "review_comment": {
          "href": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls/comments{/number}"
        },
        "review_comments": {
          "href": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls/123/comments"
        },
        "self": {
          "href": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls/123"
        },
        "statuses": {
          "href": "https://api.github.com/repos/tsuperis3112/samplerepo/statuses/bafe574499f02df1f38234e29ea6b411f21da12d"
        }
      },
      "active_lock_reason": null,
      "additions": 2,
      "assignee": null,
      "assignees": [],
      "author_association": "OWNER",
      "auto_merge": null,
      "base": {
        "label": "tsuperis3112:main",
        "ref": "main",
        "repo": {
          "allow_auto_merge": false,
          "allow_forking": true,
          "allow_merge_commit": true,
          "allow_rebase_merge": true,
          "allow_squash_merge": true,
          "allow_update_branch": false,
          "archive_url": "https://api.github.com/repos/tsuperis3112/samplerepo/{archive_format}{/ref}",
          "archived": false,
          "assignees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/assignees{/user}",
          "blobs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/blobs{/sha}",
          "branches_url": "https://api.github.com/repos/tsuperis3112/samplerepo/branches{/branch}",
          "clone_url": "https://github.com/tsuperis3112/samplerepo.git",
          "collaborators_url": "https://api.github.com/repos/tsuperis3112/samplerepo/collaborators{/collaborator}",
          "comments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/comments{/number}",
          "commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/commits{/sha}",
          "compare_url": "https://api.github.com/repos/tsuperis3112/samplerepo/compare/{base}...{head}",
          "contents_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contents/{+path}",
          "contributors_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contributors",
          "created_at": "2024-10-07T00:13:21Z",
          "default_branch": "main",
          "delete_branch_on_merge": false,
          "deployments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/deployments",
          "description": null,
          "disabled": false,
          "downloads_url": "https://api.github.com/repos/tsuperis3112/samplerepo/downloads",
          "events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/events",
          "fork": false,
          "forks": 0,
          "forks_count": 0,
          "forks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/forks",
          "full_name": "tsuperis3112/samplerepo",
          "git_commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/commits{/sha}",
          "git_refs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/refs{/sha}",
          "git_tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/tags{/sha}",
          "git_url": "git://github.com/tsuperis3112/samplerepo.git",
          "has_discussions": false,
          "has_downloads": true,
          "has_issues": true,
          "has_pages": false,
          "has_projects": true,
          "has_wiki": false,
          "homepage": null,
          "hooks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/hooks",
          "html_url": "https://github.com/tsuperis3112/samplerepo",
          "id": 868662533,
          "is_template": false,
          "issue_comment_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/comments{/number}",
          "issue_events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/events{/number}",
          "issues_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues{/number}",
          "keys_url": "https://api.github.com/repos/tsuperis3112/samplerepo/keys{/key_id}",
          "labels_url": "https://api.github.com/repos/tsuperis3112/samplerepo/labels{/name}",
          "language": "C++",
          "languages_url": "https://api.github.com/repos/tsuperis3112/samplerepo/languages",
          "license": null,
          "merge_commit_message": "PR_TITLE",
          "merge_commit_title": "MERGE_MESSAGE",
          "merges_url": "https://api.github.com/repos/tsuperis3112/samplerepo/merges",
          "milestones_url": "https://api.github.com/repos/tsuperis3112/samplerepo/milestones{/number}",
          "mirror_url": null,
          "name": "samplerepo",
          "node_id": "R_kgDOM8a9BQ",
          "notifications_url": "https://api.github.com/repos/tsuperis3112/samplerepo/notifications{?since,all,participating}",
          "open_issues": 1,
          "open_issues_count": 1,
          "owner": {
            "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
            "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
            "followers_url": "https://api.github.com/users/tsuperis3112/followers",
            "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
            "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
            "gravatar_id": "",
            "html_url": "https://github.com/tsuperis3112",
            "id": 152579884,
            "login": "tsuperis3112",
            "node_id": "U_kgDOCRgvLA",
            "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
            "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
            "repos_url": "https://api.github.com/users/tsuperis3112/repos",
            "site_admin": false,
            "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
            "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
            "type": "User",
            "url": "https://api.github.com/users/tsuperis3112",
            "user_view_type": "public"
          },
          "private": true,
          "pulls_url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls{/number}",
          "pushed_at": "2024-12-24T08:49:19Z",
          "releases_url": "https://api.github.com/repos/tsuperis3112/samplerepo/releases{/id}",
          "size": 226,
          "squash_merge_commit_message": "COMMIT_MESSAGES",
          "squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
          "ssh_url": "[email protected]:tsuperis3112/samplerepo.git",
          "stargazers_count": 0,
          "stargazers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/stargazers",
          "statuses_url": "https://api.github.com/repos/tsuperis3112/samplerepo/statuses/{sha}",
          "subscribers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscribers",
          "subscription_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscription",
          "svn_url": "https://github.com/tsuperis3112/samplerepo",
          "tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/tags",
          "teams_url": "https://api.github.com/repos/tsuperis3112/samplerepo/teams",
          "topics": [],
          "trees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/trees{/sha}",
          "updated_at": "2024-12-24T08:23:56Z",
          "url": "https://api.github.com/repos/tsuperis3112/samplerepo",
          "use_squash_pr_title_as_default": false,
          "visibility": "private",
          "watchers": 0,
          "watchers_count": 0,
          "web_commit_signoff_required": false
        },
        "sha": "8422d518823c8ff55beb7d57d74bc81dee3aa99e",
        "user": {
          "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
          "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
          "followers_url": "https://api.github.com/users/tsuperis3112/followers",
          "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
          "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
          "gravatar_id": "",
          "html_url": "https://github.com/tsuperis3112",
          "id": 152579884,
          "login": "tsuperis3112",
          "node_id": "U_kgDOCRgvLA",
          "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
          "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
          "repos_url": "https://api.github.com/users/tsuperis3112/repos",
          "site_admin": false,
          "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
          "type": "User",
          "url": "https://api.github.com/users/tsuperis3112",
          "user_view_type": "public"
        }
      },
      "body": null,
      "changed_files": 1,
      "closed_at": null,
      "comments": 0,
      "comments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/1/comments",
      "commits": 2,
      "commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls/123/commits",
      "created_at": "2024-12-24T08:48:34Z",
      "deletions": 4,
      "diff_url": "https://github.com/tsuperis3112/samplerepo/pull/1.diff",
      "draft": false,
      "head": {
        "label": "tsuperis3112:tmp",
        "ref": "tmp",
        "repo": {
          "allow_auto_merge": false,
          "allow_forking": true,
          "allow_merge_commit": true,
          "allow_rebase_merge": true,
          "allow_squash_merge": true,
          "allow_update_branch": false,
          "archive_url": "https://api.github.com/repos/tsuperis3112/samplerepo/{archive_format}{/ref}",
          "archived": false,
          "assignees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/assignees{/user}",
          "blobs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/blobs{/sha}",
          "branches_url": "https://api.github.com/repos/tsuperis3112/samplerepo/branches{/branch}",
          "clone_url": "https://github.com/tsuperis3112/samplerepo.git",
          "collaborators_url": "https://api.github.com/repos/tsuperis3112/samplerepo/collaborators{/collaborator}",
          "comments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/comments{/number}",
          "commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/commits{/sha}",
          "compare_url": "https://api.github.com/repos/tsuperis3112/samplerepo/compare/{base}...{head}",
          "contents_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contents/{+path}",
          "contributors_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contributors",
          "created_at": "2024-10-07T00:13:21Z",
          "default_branch": "main",
          "delete_branch_on_merge": false,
          "deployments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/deployments",
          "description": null,
          "disabled": false,
          "downloads_url": "https://api.github.com/repos/tsuperis3112/samplerepo/downloads",
          "events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/events",
          "fork": false,
          "forks": 0,
          "forks_count": 0,
          "forks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/forks",
          "full_name": "tsuperis3112/samplerepo",
          "git_commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/commits{/sha}",
          "git_refs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/refs{/sha}",
          "git_tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/tags{/sha}",
          "git_url": "git://github.com/tsuperis3112/samplerepo.git",
          "has_discussions": false,
          "has_downloads": true,
          "has_issues": true,
          "has_pages": false,
          "has_projects": true,
          "has_wiki": false,
          "homepage": null,
          "hooks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/hooks",
          "html_url": "https://github.com/tsuperis3112/samplerepo",
          "id": 868662533,
          "is_template": false,
          "issue_comment_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/comments{/number}",
          "issue_events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/events{/number}",
          "issues_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues{/number}",
          "keys_url": "https://api.github.com/repos/tsuperis3112/samplerepo/keys{/key_id}",
          "labels_url": "https://api.github.com/repos/tsuperis3112/samplerepo/labels{/name}",
          "language": "C++",
          "languages_url": "https://api.github.com/repos/tsuperis3112/samplerepo/languages",
          "license": null,
          "merge_commit_message": "PR_TITLE",
          "merge_commit_title": "MERGE_MESSAGE",
          "merges_url": "https://api.github.com/repos/tsuperis3112/samplerepo/merges",
          "milestones_url": "https://api.github.com/repos/tsuperis3112/samplerepo/milestones{/number}",
          "mirror_url": null,
          "name": "samplerepo",
          "node_id": "R_kgDOM8a9BQ",
          "notifications_url": "https://api.github.com/repos/tsuperis3112/samplerepo/notifications{?since,all,participating}",
          "open_issues": 1,
          "open_issues_count": 1,
          "owner": {
            "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
            "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
            "followers_url": "https://api.github.com/users/tsuperis3112/followers",
            "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
            "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
            "gravatar_id": "",
            "html_url": "https://github.com/tsuperis3112",
            "id": 152579884,
            "login": "tsuperis3112",
            "node_id": "U_kgDOCRgvLA",
            "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
            "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
            "repos_url": "https://api.github.com/users/tsuperis3112/repos",
            "site_admin": false,
            "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
            "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
            "type": "User",
            "url": "https://api.github.com/users/tsuperis3112",
            "user_view_type": "public"
          },
          "private": true,
          "pulls_url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls{/number}",
          "pushed_at": "2024-12-24T08:49:19Z",
          "releases_url": "https://api.github.com/repos/tsuperis3112/samplerepo/releases{/id}",
          "size": 226,
          "squash_merge_commit_message": "COMMIT_MESSAGES",
          "squash_merge_commit_title": "COMMIT_OR_PR_TITLE",
          "ssh_url": "[email protected]:tsuperis3112/samplerepo.git",
          "stargazers_count": 0,
          "stargazers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/stargazers",
          "statuses_url": "https://api.github.com/repos/tsuperis3112/samplerepo/statuses/{sha}",
          "subscribers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscribers",
          "subscription_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscription",
          "svn_url": "https://github.com/tsuperis3112/samplerepo",
          "tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/tags",
          "teams_url": "https://api.github.com/repos/tsuperis3112/samplerepo/teams",
          "topics": [],
          "trees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/trees{/sha}",
          "updated_at": "2024-12-24T08:23:56Z",
          "url": "https://api.github.com/repos/tsuperis3112/samplerepo",
          "use_squash_pr_title_as_default": false,
          "visibility": "private",
          "watchers": 0,
          "watchers_count": 0,
          "web_commit_signoff_required": false
        },
        "sha": "bafe574499f02df1f38234e29ea6b411f21da12d",
        "user": {
          "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
          "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
          "followers_url": "https://api.github.com/users/tsuperis3112/followers",
          "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
          "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
          "gravatar_id": "",
          "html_url": "https://github.com/tsuperis3112",
          "id": 152579884,
          "login": "tsuperis3112",
          "node_id": "U_kgDOCRgvLA",
          "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
          "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
          "repos_url": "https://api.github.com/users/tsuperis3112/repos",
          "site_admin": false,
          "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
          "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
          "type": "User",
          "url": "https://api.github.com/users/tsuperis3112",
          "user_view_type": "public"
        }
      },
      "html_url": "https://github.com/tsuperis3112/samplerepo/pull/1",
      "id": 2250680933,
      "issue_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/1",
      "labels": [],
      "locked": false,
      "maintainer_can_modify": false,
      "merge_commit_sha": "04ec1b445e480053f5105afbe1fc8a17bb129a85",
      "mergeable": null,
      "mergeable_state": "unknown",
      "merged": false,
      "merged_at": null,
      "merged_by": null,
      "milestone": null,
      "node_id": "PR_kwDOM8a9Bc6GJqpl",
      "number": 1,
      "patch_url": "https://github.com/tsuperis3112/samplerepo/pull/1.patch",
      "rebaseable": null,
      "requested_reviewers": [],
      "requested_teams": [],
      "review_comment_url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls/comments{/number}",
      "review_comments": 0,
      "review_comments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls/123/comments",
      "state": "open",
      "statuses_url": "https://api.github.com/repos/tsuperis3112/samplerepo/statuses/bafe574499f02df1f38234e29ea6b411f21da12d",
      "title": "tmp",
      "updated_at": "2024-12-24T08:49:21Z",
      "url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls/123",
      "user": {
        "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
        "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
        "followers_url": "https://api.github.com/users/tsuperis3112/followers",
        "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
        "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
        "gravatar_id": "",
        "html_url": "https://github.com/tsuperis3112",
        "id": 152579884,
        "login": "tsuperis3112",
        "node_id": "U_kgDOCRgvLA",
        "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
        "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
        "repos_url": "https://api.github.com/users/tsuperis3112/repos",
        "site_admin": false,
        "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
        "type": "User",
        "url": "https://api.github.com/users/tsuperis3112",
        "user_view_type": "public"
      }
    },
    "repository": {
      "allow_forking": true,
      "archive_url": "https://api.github.com/repos/tsuperis3112/samplerepo/{archive_format}{/ref}",
      "archived": false,
      "assignees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/assignees{/user}",
      "blobs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/blobs{/sha}",
      "branches_url": "https://api.github.com/repos/tsuperis3112/samplerepo/branches{/branch}",
      "clone_url": "https://github.com/tsuperis3112/samplerepo.git",
      "collaborators_url": "https://api.github.com/repos/tsuperis3112/samplerepo/collaborators{/collaborator}",
      "comments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/comments{/number}",
      "commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/commits{/sha}",
      "compare_url": "https://api.github.com/repos/tsuperis3112/samplerepo/compare/{base}...{head}",
      "contents_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contents/{+path}",
      "contributors_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contributors",
      "created_at": "2024-10-07T00:13:21Z",
      "default_branch": "main",
      "deployments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/deployments",
      "description": null,
      "disabled": false,
      "downloads_url": "https://api.github.com/repos/tsuperis3112/samplerepo/downloads",
      "events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/events",
      "fork": false,
      "forks": 0,
      "forks_count": 0,
      "forks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/forks",
      "full_name": "tsuperis3112/samplerepo",
      "git_commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/commits{/sha}",
      "git_refs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/refs{/sha}",
      "git_tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/tags{/sha}",
      "git_url": "git://github.com/tsuperis3112/samplerepo.git",
      "has_discussions": false,
      "has_downloads": true,
      "has_issues": true,
      "has_pages": false,
      "has_projects": true,
      "has_wiki": false,
      "homepage": null,
      "hooks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/hooks",
      "html_url": "https://github.com/tsuperis3112/samplerepo",
      "id": 868662533,
      "is_template": false,
      "issue_comment_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/comments{/number}",
      "issue_events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/events{/number}",
      "issues_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues{/number}",
      "keys_url": "https://api.github.com/repos/tsuperis3112/samplerepo/keys{/key_id}",
      "labels_url": "https://api.github.com/repos/tsuperis3112/samplerepo/labels{/name}",
      "language": "C++",
      "languages_url": "https://api.github.com/repos/tsuperis3112/samplerepo/languages",
      "license": null,
      "merges_url": "https://api.github.com/repos/tsuperis3112/samplerepo/merges",
      "milestones_url": "https://api.github.com/repos/tsuperis3112/samplerepo/milestones{/number}",
      "mirror_url": null,
      "name": "samplerepo",
      "node_id": "R_kgDOM8a9BQ",
      "notifications_url": "https://api.github.com/repos/tsuperis3112/samplerepo/notifications{?since,all,participating}",
      "open_issues": 1,
      "open_issues_count": 1,
      "owner": {
        "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
        "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
        "followers_url": "https://api.github.com/users/tsuperis3112/followers",
        "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
        "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
        "gravatar_id": "",
        "html_url": "https://github.com/tsuperis3112",
        "id": 152579884,
        "login": "tsuperis3112",
        "node_id": "U_kgDOCRgvLA",
        "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
        "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
        "repos_url": "https://api.github.com/users/tsuperis3112/repos",
        "site_admin": false,
        "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
        "type": "User",
        "url": "https://api.github.com/users/tsuperis3112",
        "user_view_type": "public"
      },
      "private": true,
      "pulls_url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls{/number}",
      "pushed_at": "2024-12-24T08:49:19Z",
      "releases_url": "https://api.github.com/repos/tsuperis3112/samplerepo/releases{/id}",
      "size": 226,
      "ssh_url": "[email protected]:tsuperis3112/samplerepo.git",
      "stargazers_count": 0,
      "stargazers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/stargazers",
      "statuses_url": "https://api.github.com/repos/tsuperis3112/samplerepo/statuses/{sha}",
      "subscribers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscribers",
      "subscription_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscription",
      "svn_url": "https://github.com/tsuperis3112/samplerepo",
      "tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/tags",
      "teams_url": "https://api.github.com/repos/tsuperis3112/samplerepo/teams",
      "topics": [],
      "trees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/trees{/sha}",
      "updated_at": "2024-12-24T08:23:56Z",
      "url": "https://api.github.com/repos/tsuperis3112/samplerepo",
      "visibility": "private",
      "watchers": 0,
      "watchers_count": 0,
      "web_commit_signoff_required": false
    },
    "sender": {
      "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
      "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
      "followers_url": "https://api.github.com/users/tsuperis3112/followers",
      "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
      "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
      "gravatar_id": "",
      "html_url": "https://github.com/tsuperis3112",
      "id": 152579884,
      "login": "tsuperis3112",
      "node_id": "U_kgDOCRgvLA",
      "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
      "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
      "repos_url": "https://api.github.com/users/tsuperis3112/repos",
      "site_admin": false,
      "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
      "type": "User",
      "url": "https://api.github.com/users/tsuperis3112",
      "user_view_type": "public"
    }
  },
  "eventName": "pull_request",
  "sha": "211eeb5476250a2cbb08a086000a5f091d60883a",
  "ref": "refs/pull/1/merge",
  "workflow": "Deploy: Excel Add-in",
  "action": "script",
  "actor": "tsuperis3112",
  "job": "set-env",
  "runNumber": 10,
  "runId": 12479397618,
  "apiUrl": "https://api.github.com",
  "serverUrl": "https://github.com",
  "graphqlUrl": "https://api.github.com/graphql"
}

Workflow Dispatch

{
  "payload": {
    "inputs": { "foo": "bar" },
    "ref": "refs/heads/main",
    "repository": {
      "allow_forking": true,
      "archive_url": "https://api.github.com/repos/tsuperis3112/samplerepo/{archive_format}{/ref}",
      "archived": false,
      "assignees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/assignees{/user}",
      "blobs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/blobs{/sha}",
      "branches_url": "https://api.github.com/repos/tsuperis3112/samplerepo/branches{/branch}",
      "clone_url": "https://github.com/tsuperis3112/samplerepo.git",
      "collaborators_url": "https://api.github.com/repos/tsuperis3112/samplerepo/collaborators{/collaborator}",
      "comments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/comments{/number}",
      "commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/commits{/sha}",
      "compare_url": "https://api.github.com/repos/tsuperis3112/samplerepo/compare/{base}...{head}",
      "contents_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contents/{+path}",
      "contributors_url": "https://api.github.com/repos/tsuperis3112/samplerepo/contributors",
      "created_at": "2024-10-07T00:13:21Z",
      "default_branch": "main",
      "deployments_url": "https://api.github.com/repos/tsuperis3112/samplerepo/deployments",
      "description": null,
      "disabled": false,
      "downloads_url": "https://api.github.com/repos/tsuperis3112/samplerepo/downloads",
      "events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/events",
      "fork": false,
      "forks": 0,
      "forks_count": 0,
      "forks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/forks",
      "full_name": "tsuperis3112/samplerepo",
      "git_commits_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/commits{/sha}",
      "git_refs_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/refs{/sha}",
      "git_tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/tags{/sha}",
      "git_url": "git://github.com/tsuperis3112/samplerepo.git",
      "has_discussions": false,
      "has_downloads": true,
      "has_issues": true,
      "has_pages": false,
      "has_projects": true,
      "has_wiki": false,
      "homepage": null,
      "hooks_url": "https://api.github.com/repos/tsuperis3112/samplerepo/hooks",
      "html_url": "https://github.com/tsuperis3112/samplerepo",
      "id": 868662533,
      "is_template": false,
      "issue_comment_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/comments{/number}",
      "issue_events_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues/events{/number}",
      "issues_url": "https://api.github.com/repos/tsuperis3112/samplerepo/issues{/number}",
      "keys_url": "https://api.github.com/repos/tsuperis3112/samplerepo/keys{/key_id}",
      "labels_url": "https://api.github.com/repos/tsuperis3112/samplerepo/labels{/name}",
      "language": "C++",
      "languages_url": "https://api.github.com/repos/tsuperis3112/samplerepo/languages",
      "license": null,
      "merges_url": "https://api.github.com/repos/tsuperis3112/samplerepo/merges",
      "milestones_url": "https://api.github.com/repos/tsuperis3112/samplerepo/milestones{/number}",
      "mirror_url": null,
      "name": "samplerepo",
      "node_id": "R_kgDOM8a9BQ",
      "notifications_url": "https://api.github.com/repos/tsuperis3112/samplerepo/notifications{?since,all,participating}",
      "open_issues": 0,
      "open_issues_count": 0,
      "owner": {
        "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
        "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
        "followers_url": "https://api.github.com/users/tsuperis3112/followers",
        "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
        "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
        "gravatar_id": "",
        "html_url": "https://github.com/tsuperis3112",
        "id": 152579884,
        "login": "tsuperis3112",
        "node_id": "U_kgDOCRgvLA",
        "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
        "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
        "repos_url": "https://api.github.com/users/tsuperis3112/repos",
        "site_admin": false,
        "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
        "type": "User",
        "url": "https://api.github.com/users/tsuperis3112",
        "user_view_type": "public"
      },
      "private": true,
      "pulls_url": "https://api.github.com/repos/tsuperis3112/samplerepo/pulls{/number}",
      "pushed_at": "2024-12-24T08:23:53Z",
      "releases_url": "https://api.github.com/repos/tsuperis3112/samplerepo/releases{/id}",
      "size": 226,
      "ssh_url": "[email protected]:tsuperis3112/samplerepo.git",
      "stargazers_count": 0,
      "stargazers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/stargazers",
      "statuses_url": "https://api.github.com/repos/tsuperis3112/samplerepo/statuses/{sha}",
      "subscribers_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscribers",
      "subscription_url": "https://api.github.com/repos/tsuperis3112/samplerepo/subscription",
      "svn_url": "https://github.com/tsuperis3112/samplerepo",
      "tags_url": "https://api.github.com/repos/tsuperis3112/samplerepo/tags",
      "teams_url": "https://api.github.com/repos/tsuperis3112/samplerepo/teams",
      "topics": [],
      "trees_url": "https://api.github.com/repos/tsuperis3112/samplerepo/git/trees{/sha}",
      "updated_at": "2024-12-24T08:23:56Z",
      "url": "https://api.github.com/repos/tsuperis3112/samplerepo",
      "visibility": "private",
      "watchers": 0,
      "watchers_count": 0,
      "web_commit_signoff_required": false
    },
    "sender": {
      "avatar_url": "https://avatars.githubusercontent.com/u/152579884?v=4",
      "events_url": "https://api.github.com/users/tsuperis3112/events{/privacy}",
      "followers_url": "https://api.github.com/users/tsuperis3112/followers",
      "following_url": "https://api.github.com/users/tsuperis3112/following{/other_user}",
      "gists_url": "https://api.github.com/users/tsuperis3112/gists{/gist_id}",
      "gravatar_id": "",
      "html_url": "https://github.com/tsuperis3112",
      "id": 152579884,
      "login": "tsuperis3112",
      "node_id": "U_kgDOCRgvLA",
      "organizations_url": "https://api.github.com/users/tsuperis3112/orgs",
      "received_events_url": "https://api.github.com/users/tsuperis3112/received_events",
      "repos_url": "https://api.github.com/users/tsuperis3112/repos",
      "site_admin": false,
      "starred_url": "https://api.github.com/users/tsuperis3112/starred{/owner}{/repo}",
      "subscriptions_url": "https://api.github.com/users/tsuperis3112/subscriptions",
      "type": "User",
      "url": "https://api.github.com/users/tsuperis3112",
      "user_view_type": "public"
    },
    "workflow": ".github/workflows/action.yaml"
  },
  "eventName": "workflow_dispatch",
  "sha": "8422d518823c8ff55beb7d57d74bc81dee3aa99e",
  "ref": "refs/heads/main",
  "workflow": "Deploy: Excel Add-in",
  "action": "script",
  "actor": "tsuperis3112",
  "job": "set-env",
  "runNumber": 6,
  "runId": 12479140273,
  "apiUrl": "https://api.github.com",
  "serverUrl": "https://github.com",
  "graphqlUrl": "https://api.github.com/graphql"
}

詳細やその他のデータはドキュメントをご覧ください。

eSquare Liveでの活用事例

ここではeSquare Live開発において発生した課題と、GitHub Scriptを用いた実際の対処方法について説明します。

eSquare Liveは以下のようなフローで開発しています。

graph LR
    subgraph " "
        C[開発環境]
        B[検証環境]
        A[商用環境]
    end

    M1(main)
    D0(develop)
    D1(develop)
    F0(feature)
    R0(release/vX.Y.Z)
    R1(release/vX.Y.Z)

    subgraph 作業フロー
        D0 --> F0

        D0 --- D1
        F0 -->|マージ| D1
        D1 --> |デプロイ| C
        D1 --> R0
        R0 -.- |バグ修正| R1
        R0 --> |デプロイ&QA| B
        R1 -->|マージ| M1
        M1 --> |最終デプロイ&動作確認| B
        M2 --> |デプロイ| A
        M1 ---|vX.Y.Z| M2(main)

    end


    style A fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#ffcc99,stroke:#333,stroke-width:2px
    style C fill:#ccffcc,stroke:#333,stroke-width:2px
    style M1 stroke:#333,stroke-width:4px
    style M2 stroke:#333,stroke-width:4px
  1. developブランチから派生したfeatureブランチで開発
  2. featureブランチの内容をdevelopブランチにマージして開発環境にデプロイ
  3. マイルストーン単位でreleaseブランチを作成し検証環境にデプロイ
  4. QA中に検証環境でバグが見つかったらreleaseブランチに向けて修正
  5. リリース直前、mainブランチへマージして検証環境への最終デプロイ&動作確認
  6. vX.Y.Zのタグを打って商用環境にリリース

発生した問題

この開発フローを運用する中で、2つの問題が顕在化しました。

タグの打ち間違い

GitHubのリリース機能から直接タグを打つ際に、意図しないブランチやコミットにタグを付与してしまうリスクがありました。特に、デフォルトブランチがdevelopであるため誤ってタグを打ってしまうと、リリースプロセス全体に混乱を招き、未検証のコードが商用環境にデプロイされる可能性があります。このようなタグの打ち間違いは、リリースの信頼性を損なう重大な問題です。

releaseブランチが複数存在する場合のデプロイ先選択の複雑化

プロジェクトの進行に伴い、メインストリームのreleaseブランチ以外にも、特定の機能や修正に対応する複数のreleaseブランチが存在する状況に陥りました。これらのブランチが混在していることでデプロイ先の選択が複雑化し、誤って意図しないブランチからデプロイされるリスクが高まりました。

特に、hotfixや小規模なリリースの際に、どのreleaseブランチがどこまで検証されているかの確認が複雑になっていきました。

解決策としてのGitHub Scriptの活用

これらの問題を解決するために、GitHub Scriptを活用してCI/CDのフローを制御することにしました。

flowchart LR
    A5[/Workflow Dispatch/] -- develop/staging --> S
    A4([Merge into main]) --> S
    A3([Merge into develop]) --> S
    A2([Create tag]) --> S
    A1([Merge into release/*]) --> S
    
    subgraph github[Github]
      Repo[(Application<br />Repository)]
      Manifest[(k8s manifest<br />Repository)]

      subgraph actions[Actions]
        direction LR
        S{Check input} -- workflow dispatch --> Deploy[Deployment]

        S -- main→stg -->Deploy
        S -- develop→dev -->Deploy
        S -- tag→prod -->CheckMain
        CheckMain{機能1<br>same hash?}
        S -- release/*→stg --> CheckLatest
        CheckLatest{機能2<br>latest?}
        
        CheckMain --> Deploy
        CheckLatest --> Deploy
        
        Repo -.-> CheckMain
        Repo -.-> CheckLatest
      end
    end

    Deploy -- update --> Manifest -. Sync .-> Argo[ArgoCD]

機能1 vX.Y.Zのタグがmainブランチのコミットハッシュと一致することを確認する

デフォルトブランチ(develop)からのタグの打ち間違いを防ぐために、リリースタグとmainブランチが一致していることを確認します。

タグをプッシュした際に発火するアクションを設定し、mainブランチとタグのコミットハッシュを比較します。これにより、タグが最新のmainブランチから切られているかを確認できます。

タグのコミットハッシュはcontext.shaに含まれているため、そのまま取得できます。

const tagHash = context.sha

mainブランチのハッシュ値はGitHub APIのGet a reference を使って取得します

const { data: main } = await github.rest.git.getRef({
  ...context.repo,
  ref: 'heads/main',
})
core.info(`tag: ${tagHash}, main: ${main.object.sha}`)

これらの値を使用して、mainブランチからタグが切られたかを確認します。一致しない場合はタグの打ち間違いの可能性が高いため、CIを失敗させます。

GitHub Script内でステップを失敗させるには、core.setFailedを使用します。
最終的には以下のように記述します。

const tagHash = context.sha;

const { data: main } = await github.rest.git.getRef({
  ...context.repo,
  ref: 'heads/main',
})
core.info(`tag: ${tagHash}, main: ${main.object.sha}`)

if (tagHash !== main.object.sha) {
  core.setFailed(`tag is not the same as main branch`)
  return
}

これにより、タグは必ず最新のmainブランチと一致していることが保証されました。

機能2 releaseブランチは最新バージョンのみ自動で検証環境にデプロイする

複数のreleaseブランチが存在する場合、最新のブランチのみを自動で検証環境にデプロイする必要があります。
これにより、古いreleaseブランチや並行して存在する他のreleaseブランチからの誤ったデプロイを防ぎ、デプロイ先の選択をシンプルに保つことができます。

以下に、最新のreleaseブランチを確認するための具体的なスクリプトとその実装方法を紹介します。

  1. ブランチ名のバージョン解析
    ブランチ名がセマンティックバージョニング(SemVer)に基づいているかを確認します。
  2. 全ブランチの取得とソート
    リポジトリ内の全てのブランチを取得し、releaseブランチのバージョンを降順にソートします。
  3. 最新リリースブランチの判定
    ソートされたブランチリストから最新のreleaseブランチを特定し、現在のブランチがその最新ブランチであるかを確認します。
  4. デプロイの制御
    最新のreleaseブランチでない場合、即時リターンすることでデプロイを中断します。

releaseブランチをメインストリームのブランチとして扱っていますが、メインストリーム以外のブランチを検証環境にデプロイする場合は、以下のいずれかの方法で対応します。

  • 手動(Workflow Dispatch)実行
  • mainブランチへのマージ
...snip...

  // check if release branch ... (1)
  const branchVersion = parseSemVer(ref)
  if (!branchVersion.valid) {
    core.setFailed(`${ref} is not SemVer`)
    return
  }

  const data = await github.paginate(github.rest.repos.listBranches, { ...context.repo, per_page: 100 })

  // sort by version and get the latest release branch ... (2)
  const versions = data
    .flatMap((b) => {
      const ver = parseSemVer(b.name)
      return ver.valid ? [ver] : []
    })
    .sort(compareSemVer)

  core.info(`versions: ${JSON.stringify(versions)}`)

  if (versions.length === 0) {
    core.setFailed('There is no release branch')
    return
  }

  // (3)
  const latestReleaseBranchVersion = versions[0]
  if (latestReleaseBranchVersion.sha !== branchVersion.sha) {
    core.warning(
      `This branch is not the latest release branch. The latest release branch is ${latestReleaseBranchVersion}`,
    )
    // (4)
    return
  }
  
...snip...

完成版スクリプト

ここまでの内容を踏まえ最終調整を加えて以下のようなスクリプトが完成しました。

  • developブランチにpushされたとき、開発環境にデプロイ
  • 最新のreleaseブランチにpushされたとき、検証環境にデプロイ
  • mainブランチにpushされたとき、検証環境にデプロイ
  • vから始まるタグが打たれたとき、商用環境にデプロイ (e.g. v1.2.3)
    • 検証済みのDockerイメージにタグを付ける
  • Workflow Dispatchで特定の環境に特定のブランチをデプロイ
    • develop, staging環境のみ
on:
  push:
    branches:
      - develop # develop
      - main # staging
      - release/* # staging
    tags:
      - v* # production
  workflow_dispatch:
    inputs:
      environment:
        required: true
        default: develop
        type: choice
        options:
          - develop
          - staging

jobs:
  set-env:
    runs-on: ubuntu-latest
    outputs:
      target: ${{ steps.script.outputs.result }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/github-script@v7
        id: script
        with:
          script: |
            const script = require('./.github/actions/set-env.js')
            return await script({github, core, context})
          result-encoding: string

  deployment:
    needs: [set-env]
    uses: --enechain社内用workflow--
    with:
      environment: ${{ needs.set-env.outputs.target }}
/** @param {import('github-script').AsyncFunctionArguments} AsyncFunctionArguments */
module.exports = async ({ github, core, context }) => {
  core.info(`payload: ${JSON.stringify(context.payload)}`)

  const target = await module.exports.getTarget({ github, core, context })
  core.info(`target: ${target}`)

  return target
}

/** @param {import('github-script').AsyncFunctionArguments} AsyncFunctionArguments */
module.exports.getTarget = async ({ github, core, context }) => {
  const stg = 'staging'
  const dev = 'develop'
  const prd = 'production'

  // check environment
  const input = context.inputs?.target // workflow dispatch
  const replaceHeads = (s) => s.replace(/^refs\/heads\//, '')

  const payload = context.payload.pull_request?.base || { ref: context.ref }
  let { ref } = replaceHeads(payload)

  core.info(`ref: ${ref}`)

  if (input) {
    // workflow dispatch
    return input
  } else if (ref.startsWith('refs/tags/')) {
    // tag image
    const tagHash = context.sha

    const { data: main } = await github.rest.git.getRef({
      ...context.repo,
      ref: 'heads/main',
    })
    core.info(`tag: ${tagHash}, main: ${main.object.sha}`)

    if (tagHash !== main.object.sha) {
      core.setFailed(`tag is not the same as main branch`)
      return
    }

    return prd
  } else if (ref === 'main') {
    return stg
  } else if (ref === 'develop') {
    return dev
  }

  // check if release branch
  const branchVersion = parseSemVer(ref)
  if (!branchVersion.valid) {
    core.setFailed(`${ref} is not SemVer`)
    return
  }

  const data = await github.paginate(github.rest.repos.listBranches, { ...context.repo, per_page: 100 })

  // sort by version and get the latest release branch
  const versions = data
    .flatMap((b) => {
      const ver = parseSemVer(b.name)
      return ver.valid ? [ver] : []
    })
    .sort(compareSemVer)

  core.info(`versions: ${JSON.stringify(versions)}`)

  if (versions.length === 0) {
    core.setFailed('There is no release branch')
    return
  }

  const latestReleaseBranchVersion = versions[0]
  if (latestReleaseBranchVersion.sha !== branchVersion.sha) {
    core.warning(
      `This branch is not the latest release branch. The latest release branch is ${latestReleaseBranchVersion}`,
    )
    return
  }
  return stg
}

/**
 * @param {string} branch
 */
const parseSemVer = (branch) => {
  const semverRegex = /^release\/(?:v)?(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?$/ // SemVerに基づいた正規表現
  const match = branch.match(semverRegex)

  if (!match) {
    return { valid: false } // 不正なバージョン
  }

  const [sha, major, minor, patch, pre] = match
  return {
    version: branch,
    major: Number(major),
    minor: Number(minor),
    patch: Number(patch),
    pre: pre || null,
    valid: true,
    sha,
  }
}

/**
 * Compare two semver versions
 * @param {{major: number, minor: number, patch: number, pre: string}} a
 * @param {{major: number, minor: number, patch: number, pre: string}} b
 */
const compareSemVer = (a, b) => {
  // Compare major version
  if (a.major !== b.major) {
    return b.major - a.major
  }

  // Compare minor version
  if (a.minor !== b.minor) {
    return b.minor - a.minor
  }

  // Compare patch version
  if (a.patch !== b.patch) {
    return b.patch - a.patch
  }

  // Both have same major, minor, and patch
  const aHasPre = !!a.pre
  const bHasPre = !!b.pre

  if (aHasPre && bHasPre) {
    // Both have prerelease, compare in reverse to get newer first
    return a.pre === b.pre ? 0 : -a.pre.localeCompare(b.pre)
  } else if (aHasPre) {
    return 1 // a is prerelease, b is release; release comes first
  } else if (bHasPre) {
    return -1 // a is release, b is prerelease
  } else {
    return 0 // Both are release versions
  }
}

まとめ

これらのGitHub Scriptを導入することで、以下のようなメリットが得られました。

  1. 自動化によるエラーの削減
    タグの打ち間違いや誤ったブランチからのデプロイを自動的に検出・防止することで、人的ミスによるエラーを大幅に削減できます。

  2. リリースプロセスの一貫性の向上
    一定のルールに基づいてリリースプロセスを自動化することで、リリース作業の一貫性が向上し、品質の安定化が図れます。

  3. ユニットテストによる実行前動作確認
    通常GitHub Actionsは実行環境の準備や実環境での動作確認をする必要があり、簡単にテストすることは難しいです。今回はgetTargetをスクリプト化することでjestやvitestによる動作検証できるようになりました。

今後もこの手法を基に、さらなるCI/CDの最適化を目指していきたいと考えています。 後々にはTerraformやKubernetesと共働でリリースブランチ毎の環境構築まで自動で行いたいと考えています。

enechainでは、事業拡大のために共に技術力で道を切り拓いていく仲間を募集しています。

herp.careers

herp.careers


  1. プロジェクト配下ならどこでも配置可能です