GitHubAction備忘録

ここ半年くらい、GitHubActionを触る機会が多く、そこで学んだ点や躓いた点を列挙していきます。

### 目次
[1.入力への参照はinputsを使うべき](#入力への参照はinputsを使うべき)
[2.environmentとreusableWorkflow併用時の注意点](#environmentとreusable-workflow併用時の注意)
[3.environmentとreusableWorkflow併用時の注意点2](#environmentとreusable-workflow併用時の注意2)
[4.awsへのアクセスでoidc利用したときの注意点](#awsへのアクセスでoidc利用したときの注意点)
[5.reusable-workflowのネストは４層まで](#reusable-workflowのネストは４層まで)
[6.apiからworkflow実行時に実行したrunの特定ができない](#apiからworkflow実行時に実行したrunの特定ができない)

### 入力への参照はinputsを使うべき

例えば手動実行できるこんなスクリプトがあるとします。
ここでinputへの参照を`github.event.inputs`で書いてしまうと
あとで、ワークフローから再利用できるようにこうすると、inputにアクセスできず、あれーってなります。
```diff_yaml
name: foo

on:
  workflow_dispatch:
    inputs:
      foo:
        required: true
        type: string
    　bar:
        required: true
        type: string
+  // ワークフローからの呼び出しを追加。
+  workflow_call:
+    inputs:
+      foo:
+        required: true
+        type: string
env:
  FOO_INPUT: ${{ github.event.inputs.foo }}
  BAR_INPUT: ${{ github.event.inputs.bar || 'BAR'}}
jobs:
  do-something:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
```
workflow_dispatchとworkflow_callどちらのinputにもアクセスできる[inputsコンテキスト](https://docs.github.com/ja/enterprise-cloud@latest/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#inputs-context)があるので、それで参照しましょう。
```diff_yaml
name: foo

on:
  workflow_dispatch:
    inputs:
      foo:
        required: true
        type: string
    　bar:
        required: true
        type: string
  workflow_call:
    inputs:
      foo:
        required: true
        type: string
env:
-  FOO_INPUT: ${{ github.event.inputs.foo }}
-  BAR_INPUT: ${{ github.event.inputs.bar || 'BAR'}}
+  FOO_INPUT: ${{ inputs.foo }}
+  BAR_INPUT: ${{ inputs.bar || 'BAR'}}
jobs:
  do-something:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
```


### environmentとreusable-workflow併用時の注意

既存の親スクリプトをEnvironmentに対応させたいというとき、こんな風にワークフロー呼び出しジョブへ直接指定することはできません。（エラーになります）
```diff_yaml
name: do all

on:
  workflow_dispatch:
    inputs:
      foo:
        type: string
        default: 'fooo'
+     env:
+       type: environment
+       default: 'dev'
jobs:
  call-build:
    uses: ./.github/workflows/build.yml
    with:
      foo: ${{ inputs.foo }}
  call-deploy:
+   environment: ${{ inputs.env }}
    uses: ./.github/workflows/deploy.yml
    with:
      foo: ${{ inputs.foo }}
```

呼び出しジョブではなく呼び出し先のジョブをenvironment対応させて引数を渡す形で対応しましょう。
```diff_yaml
name: do all

on:
  workflow_dispatch:
    inputs:
      foo:
        type: string
        default: 'fooo'
+     env:
+       type: environment
+       default: 'dev'
jobs:
  call-build:
    uses: ./.github/workflows/build.yml
    with:
      foo: ${{ inputs.foo }}
  call-deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      foo: ${{ inputs.foo }}
+     environment: ${{ inputs.foo }}
```
ジョブ側はこんな感じ
```diff_yaml
name: deploy

on:
  workflow_call:
    inputs:
      foo:
        type: string
        required: true
+    environment:
+      type: environment
+      required: true
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
+   environment: ${{ inputs.environment }}
```
書いてみると、そりゃそうかってなりますね。

### environmentとreusable-workflow併用時の注意2
呼び出し先のジョブにenvironmentを指定する場合に、複数のジョブがあると、その度に承認が必要になります。
```diff_yaml
name: deploy

on:
  workflow_dispatch:
    inputs:
      foo:
        type: string
        default: 'fooo'
+     env:
+       type: environment
+       default: 'dev'
jobs:
  call-pre-deploy:
    uses: ./.github/workflows/pre-deploy.yml
    with:
      foo: ${{ inputs.foo }}
+     environment: ${{ inputs.foo }} // ここで一度承認
  call-deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      foo: ${{ inputs.foo }}
+     environment: ${{ inputs.foo }} // さらにもう一度承認
```
さすがに毎回承認させるのもアレだったんで、environmentを使うジョブでOIDCのロール名を解決してから、それを引き渡す形で対応しました。
OIDCロールの利用可否は、Role側で制約を課している（後述）のでこれ自体が露出するのは問題なし。

```diff_yaml
name: deploy

on:
  workflow_dispatch:
    inputs:
      foo:
        type: string
        default: 'fooo'
+     env:
+       type: environment
+       default: 'dev'
jobs:
+ get-aws-role:
+   name: Get AWS Role
+   runs-on: ubuntu-latest
+   environment: ${{ inputs.env }}
+   outputs:
+     role-to-assume: ${{ steps.resolve-role-to-assume.outputs.role-to-assume }}
+   steps:
+     - id: resolve-role-to-assume
+       run: |
+         AWS_ROLE_TO_ASSUME=${{ vars.AWS_ROLE_TO_ASSUME }}
+        echo "role-to-assume=$AWS_ROLE_TO_ASSUME" >> $GITHUB_OUTPUT
  call-pre-deploy:
    uses: ./.github/workflows/pre-deploy.yml
    with:
      foo: ${{ inputs.foo }}
+     role-to-assume: ${{ needs.get-aws-role.outputs.role-to-assume }}

  call-deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      foo: ${{ inputs.foo }}
+     role-to-assume: ${{ needs.get-aws-role.outputs.role-to-assume }}
```
こんな感じ


### awsへのアクセスでoidc利用したときの注意点

それなに？ってひとは[こちら](https://docs.github.com/ja/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services)をどうぞ。

注意点としては、OIDCを導入したはいいものの、検証を `repo:my-org/my-repo:*` 等がばがばにしちゃうと、AWS_SECRET等を直接使う場合とさほど変わりません。

```diff_typescript
 const githubOidcRole = new cdk.aws_iam.Role(this, "MyGithubOidcRole", {
      roleName: "MyGithubOidcRole",
      assumedBy: new cdk.aws_iam.FederatedPrincipal(
        gitHubIdProvider.openIdConnectProviderArn,
        {
          StringLike: {
            "token.actions.githubusercontent.com:sub": [
              "repo:my-org/my-repo:*", // ガバガバです。なんでも通します。
            ],
          },
          StringEquals: {
            "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          },
        },
        "sts:AssumeRoleWithWebIdentity",
      ),
});
```

この状態だと、プロテクトされていないブランチで好きに使えるようになってしまいます。
そこで、特に商用アカウントへアクセスするロールについては、プロテクトされたブランチもしくは承認が必要なEnvironmentのときのみ利用可能にしましょう。

CDK(on typescript)の例。
```diff_typescript
 const githubOidcRole = new cdk.aws_iam.Role(this, "MyGithubOidcRole", {
      roleName: "MyGithubOidcRole",
      assumedBy: new cdk.aws_iam.FederatedPrincipal(
        gitHubIdProvider.openIdConnectProviderArn,
        {
          StringLike: {
            "token.actions.githubusercontent.com:sub": [
-             "repo:my-org/my-repo:*",
+             "repo:my-org/my-repo:ref:refs/heads/main", // mainブランチ上で実行されたものはOK
+             "repo:my-org/my-repo:environment:production", // environment=productionのジョブはOK
            ],
          },
          StringEquals: {
            "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          },
        },
        "sts:AssumeRoleWithWebIdentity",
      ),
});
```
ちなみに、同一job内でenvironmentが使っている場合には、`repo:my-org/my-repo:environment:production`みたいなenvironmentの値が渡ってきます。
先述のように別ジョブで解決している場合にはenvironmentではなくbranchがくるので、そっちの許可もいれています。

### reusable-workflowのネストは４層まで
ちょっとしたことですが、あんまり細かくジョブ分けて再利用するようにしちゃうと、後々詰みますのでお気をつけて。
https://docs.github.com/ja/actions/sharing-automations/reusing-workflows#limitations

### apiからworkflow実行時に実行したrunの特定ができない
※修正済みかもしれないので、状況知りたい人は[このIssue](https://github.com/orgs/community/discussions/9752)をみてください！

octokit使って、workflowをRunして、ステータスを監視して、完了したら通知する、みたいなことがしたかったです。

簡単にできるだろうなって思ったらできませんでした。

なぜならworkflowの実行後、そのジョブを特定する方法(RunId)がないからです。
[Issue](https://github.com/orgs/community/discussions/9752)もあがってました。ので、対応してくださいるのを待ちましょう。

が、待ってられない人もいるかもなので回避方法を下記に説明します。
あんまりきれいではないです。

簡単な説明：ユニークなキーをワークフローに渡して、それを使って特定する。

#### ステップ1: まずは既存ワークフローに特定用のキー(distinct_id)を受け付けます
```diff_yaml
name: deploy

on:
  workflow_dispatch:
    inputs:
      env:
        type: environment
+     distinct_id:
+       type: string
jobs:
+ distinct-id:
+   name: Workflow ID Provider
+   runs-on: ubuntu-latest
+   steps:
+     - name: ${{inputs.distinct_id}}
+       run: echo run identifier ${{ inputs.distinct_id }}
  call-pre-deploy:
    uses: ./.github/workflows/migrate.yml
    with:
      env: ${{ inputs.stage }}
  call-deploy:
    uses: ./.github/workflows/deploy.yml
    with:
      env: ${{ inputs.stage }}
```

#### ステップ2： ワークフローをディスパッチする側で特定キーを渡します
```diff_typescript
export const runWorkflow = async (
  workflowId: string,
  inputs: Record<string, string>,
  branch: string,
) => {
  const secret = await getGitHubPat();
  const octokit: Octokit = new Octokit({ auth: secret });
+ // ユニークな文字列を生成  
+ const distinctId =
+   Date.now().toString(36) + Math.random().toString(36).substring(8);
  await octokit.rest.actions.createWorkflowDispatch({
      owner,
      repo,
      workflow_id: workflowId,
      ref: branch,
-     inputs,
+     inputs: { ...inputs, ...{ distinct_id: distinctId } },
  });
  // この関数の中身は↓に書きます。
  const runId = await getRunIdFromDistinctId(
      workflowId,
      distinctId,
      branch,
      octokit,
  );
  // RunIdを使って監視を実装します。
};
```
#### ステップ3： 特定キーからRunIdを解決してできあがり
```diff_typescript
// すぐにはとれないのでとれるまでぐるぐる...
const getRunIdFromDistinctId = async (
  workflowId: string,
  distinctId: string,
  branch: string,
  octokit: Octokit,
): Promise<number | null> => {
  const interval = 1000;
  const timeout = 1000 * 60;
  let elapsed = 0;
  while (elapsed < timeout) {
    try {
      const { runId } = await getRunId(
        workflowId,
        distinctId,
        branch,
        octokit,
      );
      if (runId) {
        return runId;
      }
    }
    await new Promise((resolve) => setTimeout(resolve, interval));
    elapsed += interval;
  }
  return null;
};

const getRunId = async (
  workflowId: string,
  distinctId: string,
  branch: string,
  octokit: Octokit,
) => {
  // Runしているワークフローを取得
  const runsResponse = await octokit.rest.actions.listWorkflowRuns({
    owner,
    repo,
    workflow_id: workflowId,
    ref: branch,
  });

  const runs = runsResponse.data.workflow_runs;
  const runIds = runs.map((run) => run.id);
  for (const runId of runIds) {
    // RunIdをもとにジョブ詳細を取得して
    const ret = await octokit.request(
      "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs",
      {
        owner,
        repo,
        run_id: runId,
        headers: {
          "X-GitHub-Api-Version": "2022-11-28",
        },
      },
    );
    const run = ret.data;
    for (const job of ret.data.jobs) {
      // 発行したdistinctIdを持っているジョブが対象ののジョブ
      if (job.steps?.map((s: { name: any }) => s.name).includes(distinctId)) {
        const runId = job.run_id;
        const status = job.status;
        const conclusion = job.conclusion;
        console.log(
          `実行ID: ${runId}, ステータス: ${status}, 結果: ${conclusion}`,
        );
        return {
          runId,
          status,
          conclusion,
        };
      }
    }
  }
  console.log("Runが見つかりませんでした");
  return {
    runId: null,
    status: "",
    conclusion: "",
  };
};
```