GitHub ActionsからJenkinsのCIジョブを利用する

# はじめに
[GitHub Actions](https://docs.github.com/ja/actions)はGitHubでCI/CDを支援するべく、GitHubや自前のRunnerを通してビルドおよびテストを行える機能です[^1]。コードの管理とCI/CD環境を一元化できる点は魅力的ですが、[Jenkins](https://www.jenkins.io/)には及ばない所も多々あり、自分はJenkinsを使わざるを得ない状況です。

Webhookなどの簡単な連結方式も存在しますが、Jenkins Jobの結果をGitHubで管理したかったのでGitHub ActionsからJenkinsのAPIをポーリングする方向に統合を行いました。一般的なユースケースではないため、ハマったポイントなどを整理しておきます。

**目的**

- GitHub ActionsでJenkinsのジョブを作動させ、結果を読み込む。

**利益**

- PRで作業するとき、自動でJenkinsでテストなどが行われる。
- GitHubからJenkinsの細やかな機能が利用できる。
- GitHub上でPipelineの流れが把握できる。
![例1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/286572/9f1c1365-5926-7a86-e128-c0b4bea5c1ea.png)

**限界**

- 結局のところ、JenkinsのAPIをポーリングするだけ。

# JenkinsでAPIを使えるようにする
Jenkinsのユーザー設定(`http://{jenkins_server_url}/user/{user_id}/configure`)で`APIトークン`を発行します。`APIトークン`を使ってAPIを呼ぶ時は下記の二通りの方法があります。

```bash
curl -X POST --user "{user_id}:{api_token}" "http://{jenkins_server_url}/job/{job_name}/build"
```

```bash
curl -X POST "http://{user_id}:{api_token}@{jenkins_server_url}/job/{job_name}/build"
```

# GitHub ActionsでJenkinsのAPIを使う
GitHubはプロジェクトディレクトリにある.github/workflowsの下のYMALファイルを認識します[^2]。

## Workflowの全体像

```yml:.github/workflows/build.yml
name: build

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the main branch
  push:
    branches: [ main ]
  pull_request:
    types: [review_requested, closed]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:
    inputs:
      branch:
        description: 'Target branch to build'
        required: true
        default: 'main'

concurrency: build

env:
  jenkins_url: ${{secrets.JENKINS_URL}}
  jenkins_user: ${{secrets.JENKINS_USER}}
  jenkins_token: ${{secrets.JENKINS_TOKEN}}

jobs:
  # A single job called "build"
  build:
    env:
      jenkins_job_name: "build_job"

    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      - name: Extract branch name
        shell: bash
        run: echo "::set-output name=branch::${GITHUB_REF#refs/heads/}"
        id: extract_branch
      - name: Build 
        shell: bash
        run: |
          branch_name=${{github.head_ref}}
          if [ -z "$branch_name" ]; then branch_name=${{github.event.inputs.branch}}; fi
          if [ -z "$branch_name" ]; then branch_name=${{steps.extract_branch.outputs.branch}}; fi
          if [ -z "$branch_name" ]; then echo "branch_name is empty"; exit 1; fi
          response=$(curl -si -w "\n%{size_header},%{size_download}" -X POST --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/buildWithParameters?BRANCH=${branch_name}")
          header_size=$(sed -n '$ s/^\([0-9]*\),.*$/\1/ p' <<< "${response}")
          headers="${response:0:${header_size}}"
          if ! echo ${headers} | grep -q "HTTP/2 201"; then exit 1; fi
          target_queue_id=$(sed -n 's/^.*item\/\([0-9]*\).*$/\1/ p' <<< "${headers}")
          while ! curl --silent --globoff --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/api/xml?tree=builds[id,number,result,queueId]&xpath=//build[queueId=$target_queue_id]" | grep \<result\> > /dev/null; do echo "Waiting..."; sleep 15s; done;
          result=$(curl --silent --globoff --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/api/xml?tree=builds[id,number,result,queueId]&xpath=//build[queueId=$target_queue_id]")
          status=$(sed -n 's/^.*<result>\([A-Z]*\)<\/result>.*$/\1/ p' <<< "${result}")
          if [ "$status" == "SUCCESS" ]; then exit 0; else exit 1; fi

  # A single job called "test"
  test:
    needs: [build]

    env:
      jenkins_job_name: "test_job"

    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Runs a set of commands using the runners shell
      - name: Run tests
        shell: bash
        run: |
          response=$(curl -si -w "\n%{size_header},%{size_download}" -X POST --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/build")
          header_size=$(sed -n '$ s/^\([0-9]*\),.*$/\1/ p' <<< "${response}")
          headers="${response:0:${header_size}}"
          if ! echo ${headers} | grep -q "HTTP/2 201"; then exit 1; fi
          target_queue_id=$(sed -n 's/^.*item\/\([0-9]*\).*$/\1/ p' <<< "${headers}")
          while ! curl --silent --globoff --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/api/xml?tree=builds[id,number,result,queueId]&xpath=//build[queueId=$target_queue_id]" | grep \<result\> > /dev/null; do echo "Waiting..."; sleep 15s; done;
          result=$(curl --silent --globoff --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/api/xml?tree=builds[id,number,result,queueId]&xpath=//build[queueId=$target_queue_id]")
          status=$(sed -n 's/^.*<result>\([A-Z]*\)<\/result>.*$/\1/ p' <<< "${result}")
          if [ "$status" == "SUCCESS" ]; then exit 0; else exit 1; fi
```

## グローバル設定の仕方

### on
`on`ではいつワークフローが作動するかを宣言します[^3]。上記の例ではmainブランチにプッシュした時、PRでレビューを要請した時、PRを閉じる時に自動的にワークフローが作動するようにしています。また`workflow_dispatch`によって手動で作動させることも許可しています。その場合は`branch`という値を利用してどのブランチをビルドするかを決定するようにしています。

### concurrency
`concurrency`オプションで同じ値を保持しているワークフローは同時に実行されません。Jenkinsでもジョブ毎に並行実行に関する設定はできますが、このGitHub Actionsはワークフロー自体がJenkinsのpipelineに相当するものですのでpipelineの並行実行を禁止したい時に必要になります。

### env
ワークフローで使用するグローバル変数です。ジョブごとにも宣言できます。ここではJenkins APIを叩くときに使う値をGitHub Secretsから持ってきます[^4]。基本的にCIのログはオープンされますのでAPIのUSERとTOKENは必ずSecretsを使うべきです。

## ジョブ設定の仕方
ジョブは基本的にシェル環境で実行するコマンドを順に並べる感じです。下記の例は`build`と`test`という二つのジョブを宣言しています。

### need
`need`はジョブの間の依存関係を表現します。上記の例では`build`が実行された後`test`が実行されます。

### branch名の読み取り方
```bash
- name: Extract branch name
  shell: bash   
  run: echo "::set-output name=branch::${GITHUB_REF#refs/heads/}"
  id: extract_branch
- name: Build 
  shell: bash
  run: |
    branch_name=${{github.head_ref}}
    if [ -z "$branch_name" ]; then branch_name=${{github.event.inputs.branch}}; fi
    if [ -z "$branch_name" ]; then branch_name=${{steps.extract_branch.outputs.branch}}; fi
    if [ -z "$branch_name" ]; then echo "branch_name is empty"; exit 1; fi
```
Github Actionsはトリガーによってbranch名を取得する方法が異なります。PRでトリガーされた時は`${{github.head_ref}}`を読めないいのですが、プッシュされたときはExtract branch nameステップを通して名前を読み取る必要があります[^5]。手動で実行させる時は床からも値がきませんので上記の`workflow_dispatch.inputs`で強制した値を利用します。

## Jenkinsとの繋げ方

### JenkinsのジョブをAPIから実行
基本的にはトークンと一緒に`POST`でREST APIを叩くだけです。パラメーターがある場合は`buildWithParameters`をない場合は`build`を使います。`-w "\n%{size_header},%{size_download}"`はヘッダーを読み取る時に使います。最後にヘッダー利用して要請が正常的に実行されたかを確認します。

```bash
response=$(curl -si -w "\n%{size_header},%{size_download}" -X POST --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/build")
header_size=$(sed -n '$ s/^\([0-9]*\),.*$/\1/ p' <<< "${response}")
headers="${response:0:${header_size}}"
if ! echo ${headers} | grep -q "HTTP/2 201"; then exit 1; fi
```

```bash
response=$(curl -si -w "\n%{size_header},%{size_download}" -X POST --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/buildWithParameters?BRANCH=${branch_name}")
header_size=$(sed -n '$ s/^\([0-9]*\),.*$/\1/ p' <<< "${response}")
headers="${response:0:${header_size}}"
if ! echo ${headers} | grep -q "HTTP/2 201"; then exit 1; fi
```

### Jenkinsのジョブが完了するまで待機
Jenkinsのジョブを実行すると`target queue id`という値が返ってきます。これがJenkinsがジョブに付与するグローバルな管理用IDです。下記のAPIを使うことで該当するジョブが完了したかいなか確認できます。下のコードはポーリングの一例になります。

```bash
target_queue_id=$(sed -n 's/^.*item\/\([0-9]*\).*$/\1/ p' <<< "${headers}")
while ! curl --silent --globoff --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/api/xml?tree=builds[id,number,result,queueId]&xpath=//build[queueId=$target_queue_id]" | grep \<result\> > /dev/null; do echo "Waiting..."; sleep 15s; done;
```

### ジョブの成否を読み取る
ポーリングが終わったらポーリングと同じAPIを通してジョブの結界を読み取ることができます。
ジョブの成否をGitHub Actionsに反映するために結果をリターンコードに変換します。

```bash
result=$(curl --silent --globoff --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/api/xml?tree=builds[id,number,result,queueId]&xpath=//build[queueId=$target_queue_id]")
status=$(sed -n 's/^.*<result>\([A-Z]*\)<\/result>.*$/\1/ p' <<< "${result}")
if [ "$status" == "SUCCESS" ]; then exit 0; else exit 1; fi
```

### おまけ：Jenkinsジョブのコンソールを読み取る
Jenkinsの`job/${{ env.jenkins_job_name }}/${build_id}/consoleText`を叩けばコンソールログを読み取ることもできます。

```bash
result=$(curl --silent --globoff --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/api/xml?
tree=builds[id,number,result,queueId]&xpath=//build[queueId=$target_queue_id]")
build_id=$(sed -n 's/^.*<id>\([0-9]*\)<\/id>.*$/\1/ p' <<< "${result}")
curl -i -X POST --user "${{ env.jenkins_user }}:${{ env.jenkins_token }}" "${{ env.jenkins_url }}/job/${{ env.jenkins_job_name }}/${build_id}/consoleText"
```

[^1]: GitHub Actions, https://docs.github.com/ja/actions
[^2]: ワークフロー用のYAML構文について, https://docs.github.com/ja/actions/learn-github-actions/workflow-syntax-for-github-actions#about-yaml-syntax-for-workflows
[^3]: workflow-syntax-for-github-actions#on, https://docs.github.com/ja/actions/learn-github-actions/workflow-syntax-for-github-actions#on
[^4]: 暗号化されたシークレット, https://docs.github.com/ja/actions/security-guides/encrypted-secrets
[^5]: GitHub Actionsでブランチ名を扱う方法, https://qiita.com/iery/items/43b72813c394050b8bbc