RustのLinux/Windows/macOS向け64bitバイナリをAzure Pipelinesで生成する

# はじめに

これまでRustのCIビルドはTravis CIやAppVeyorを使ってきましたが、[Azure Pipelines](https://azure.microsoft.com/ja-jp/services/devops/pipelines/)にてLinux/Windows/macOSが全て賄えるとのことで試してみました。
すでに先行記事として「[Azure Pipelines で Rust プロジェクトを CI する](https://qiita.com/ubnt_intrepid/items/0d8d32ca9844d4b739d4)」があるためわざわざ記事にするほどでもないと思っていましたが、結構ハマりポイントがあったのでまとめておきます。

実際に適用したリポジトリは以下になります。
まだTravis CIと併用ですが安定してきたらAzure一本にするつもりです。

https://github.com/dalance/procs

# できること

- コミット毎にCIする
- タグを打ったらGitHubにリリースする
- ターゲットは以下
  - Linux: x86_64-unknown-linux-musl
  - Windows: x86_64-pc-windows-msvc
  - macOS: x86_64-apple-darwin

# スクリプト

Azure Pipelinesも他のCIと同様にYAML（`azure-pipelines.yml`）で設定を書きます。まず全体を示して、そのあとポイントを解説していきます。また、各項目に関連する公式リファレンスへのリンクを張っておきます。

## 全体

```azure-pipelines.yml
trigger:
    branches:
        include:
            - '*'
    tags:
        include:
            - '*'

jobs:
    - job: Linux
      pool:
          vmImage: 'ubuntu-16.04'
      steps:
          - script: |
              curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable
              echo '##vso[task.setvariable variable=PATH]$(PATH):$(HOME)/.cargo/bin'
            displayName: Install rustup

          - script: |
              cargo install cargo2junit
              rustup target add x86_64-unknown-linux-musl
              sudo apt-get -qq install musl-tools
            displayName: Install tools

          - script: |
              cargo test -- -Z unstable-options --format json | cargo2junit > results.xml
            displayName: Run test

          - task: PublishTestResults@2
            inputs:
                testResultsFormat: 'JUnit'
                testResultsFiles: 'results.xml'
            condition: succeededOrFailed()

          - script: |
              make release_lnx
              cp *.zip $(Build.ArtifactStagingDirectory)
            displayName: Build release binary

          - task: PublishBuildArtifacts@1
            inputs:
                artifactName: 'Linux'
                pathtoPublish: $(Build.ArtifactStagingDirectory)

    - job: macOS
      pool:
          vmImage: 'macos-10.13'
      steps:
          - script: |
              curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable
              echo '##vso[task.setvariable variable=PATH]$(PATH):$(HOME)/.cargo/bin'
            displayName: Install rustup

          - script: |
              cargo install cargo2junit
            displayName: Install tools

          - script: |
              cargo test -- -Z unstable-options --format json | cargo2junit > results.xml
            displayName: Run test

          - task: PublishTestResults@2
            inputs:
                testResultsFormat: 'JUnit'
                testResultsFiles: 'results.xml'
            condition: succeededOrFailed()

          - script: |
              make release_mac
              cp *.zip $(Build.ArtifactStagingDirectory)
            displayName: Build release binary

          - task: PublishBuildArtifacts@1
            inputs:
                artifactName: 'macOS'
                pathtoPublish: $(Build.ArtifactStagingDirectory)

    - job: Windows
      pool:
          vmImage: 'vs2017-win2016'
      steps:
          - script: |
              curl -sSf -o rustup-init.exe https://win.rustup.rs
              rustup-init.exe -y --default-toolchain stable
              set PATH=%PATH%;%USERPROFILE%\.cargo\bin
              echo '##vso[task.setvariable variable=PATH]%PATH%;%USERPROFILE%\.cargo\bin'
            displayName: Install rustup

          - script: |
              choco install -y zip
              cargo install cargo2junit
            displayName: Install tools

          - script: |
              cargo test -- -Z unstable-options --format json | cargo2junit > results.xml
            displayName: Run test

          - task: PublishTestResults@2
            inputs:
                testResultsFormat: 'JUnit'
                testResultsFiles: 'results.xml'
            condition: succeededOrFailed()

          - script: |
              make release_win
              cp *.zip $(Build.ArtifactStagingDirectory)
            displayName: Build release binary

          - task: PublishBuildArtifacts@1
            inputs:
                artifactName: 'Windows'
                pathtoPublish: $(Build.ArtifactStagingDirectory)

    - job: Release
      dependsOn:
          - Linux
          - macOS
          - Windows
      steps:
          - task: DownloadBuildArtifacts@0
            inputs:
                downloadType: 'specific'
                itemPattern: '**'
                downloadPath: $(Build.ArtifactStagingDirectory)

          - script: ls $(Build.ArtifactStagingDirectory)

          - task: GitHubRelease@0
            inputs:
                gitHubConnection: 'dalance'
                repositoryName: 'dalance/procs'
                tagSource: 'auto'
                releaseNotesSource: 'input'
                releaseNotes: '[Changelog](https://github.com/dalance/procs/blob/master/CHANGELOG.md)'
                assets: '$(Build.ArtifactStagingDirectory)/*/*'
                assetUploadMode: 'replace'
                isDraft: false
                isPreRelease: false
                addChangeLog: false
            displayName: Release to GitHub
```

## 全体構成

全体的には4つのジョブ（Linux/macOS/Windows/Release）で構成されています。まず3つのビルドジョブが実行されてから最後にReleaseを実行したいので、`dependsOn`を使って依存関係を指定します。

```yml
- job: Linux

- job: macOS

- job: Windows

- job: Release
  dependsOn:
      - Linux
      - macOS
      - Windows
```

https://docs.microsoft.com/ja-jp/azure/devops/pipelines/process/multiple-phases

## trigger

triggerはCIの実行条件を指定します。

```yml

trigger:
    branches:
        include:
            - '*'
    tags:
        include:
            - '*'
```

ここでは全てのブランチ、全てのタグに対して実行するようにしています。
デフォルト（triggerを指定しなかった場合）ではタグに対して実行されません。
また、ここの設定をミスると自動で実行されなくなったりします。
最後に実行した際のtrigger設定で監視しているような挙動になっていて、
例えばtrigger条件を空にしてコミットしてしまうと、それを修正してコミットしても反応しなくなります。
そうなった場合は、Web UI上のQueueボタンから手動で実行すればよいです。

https://docs.microsoft.com/en-us/azure/devops/pipelines/build/triggers

## Rustインストール・PATH設定

Azure Pipelinesは標準ではRust環境がないので自分でインストールする必要があります。
コンテナも使えるのですがLinux限定なので、今回は全てrustupでインストールにしました。
ポイントは`##vso...`のところで、こういった文字列をechoするとCIの実行環境に対してタスクを実行できます。
ここではPATHの追加を行っており、これ以降のscriptでは追加されたPATHが利用できます。
逆にこれを実行したscript内ではPATH追加の効果がないので、追加されたPATHを期待するscriptは分ける必要があります。
ちなみにPATHを追加するためには`##vso[task.prependpath`というぴったりのタスクがあるようだったのですが、うまく動きませんでした。

```yml
          - script: |
              curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable
              echo '##vso[task.setvariable variable=PATH]$(PATH):$(HOME)/.cargo/bin'
            displayName: Install rustup
```

また、Windows環境ではscriptはcmd.exeで実行されるので環境変数の指定に注意が必要です。以下のうち`set PATH=...`は不要な気がしているのですが、ないとうまく動かないようです。

```yml
          - script: |
              curl -sSf -o rustup-init.exe https://win.rustup.rs
              rustup-init.exe -y --default-toolchain stable
              set PATH=%PATH%;%USERPROFILE%\.cargo\bin
              echo '##vso[task.setvariable variable=PATH]%PATH%;%USERPROFILE%\.cargo\bin'
            displayName: Install rustup
```

https://docs.microsoft.com/ja-jp/azure/devops/pipelines/scripts/cross-platform-scripting
https://github.com/Microsoft/azure-pipelines-tasks/blob/master/docs/authoring/commands.md

## テスト

Azure PipelinesではJUnitのテストレポートを取り込めるので対応してみました。
cargoのテスト結果をJUnit形式に変換するツールを探したところ、`cargo2junit`が見つかったのでこれを使っています。
（他にもいくつかあったのですがうまく動くのはこれだけでした）
cargoのテスト結果を`--format json`で出力して`cargo2junit`でJUnitに変換、`PublishTestResult`タスクで取り込みます。
タスクはscriptとは違ってAzure Pipelinesにデフォルトで用意されたタスクを実行するものです。
後でGitHubにリリースする時にも使います。

```yml
          - script: |
              cargo test -- -Z unstable-options --format json | cargo2junit > results.xml
            displayName: Run test

          - task: PublishTestResults@2
            inputs:
                testResultsFormat: 'JUnit'
                testResultsFiles: 'results.xml'
            condition: succeededOrFailed()
```

https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/test/publish-test-results

## ビルドとアーティファクトの保存

make内で`cargo build --release`とzipアーカイブの生成を行っているので適当に読み替えてください。
zipができたら後続のReleaseジョブで使うために`PublishBuildArtifacts`タスクで保存しておきます。
本当はジョブ間のファイル共有専用の`PublishPipelineArtifact`というタスクがあるのですが、まだPreviewということかmacOSでのみエラーで実行できず、こちらに切り替えました。
また、`artifactName`は他のジョブと被るとエラーになるのでユニークに指定する必要があります。
（指定しないとデフォルトの`drop`が使われて被ってしまいます）
`pathtoPublish`は`*.zip`などのワイルドカードが使えません。ファイル名はバージョン番号を含むのでここに直接書くことは出来ず、`$(Build.ArtifactStagingDirectory)`ディレクトリに一旦コピーしてから渡しています。

```yml
          - script: |
              make release_lnx
              cp *.zip $(Build.ArtifactStagingDirectory)
            displayName: Build release binary

          - task: PublishBuildArtifacts@1
            inputs:
                artifactName: 'Linux'
                pathtoPublish: $(Build.ArtifactStagingDirectory)
```

https://docs.microsoft.com/en-us/azure/devops/pipelines/artifacts/build-artifacts
https://docs.microsoft.com/en-us/azure/devops/pipelines/artifacts/pipeline-artifacts

## アーティファクトのダウンロード

`DownloadBuildArtifacts`で各ジョブで保存したzipを取ってきます。

```yml
          - task: DownloadBuildArtifacts@0
            inputs:
                downloadType: 'specific'
                itemPattern: '**'
                downloadPath: $(Build.ArtifactStagingDirectory)
```

https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/utility/download-build-artifacts

## GitHubへのリリース

GitHubへのリリースは`GitHubRelease`タスクを使います。まず`gitHubConnection`ですが、Web UIのプロジェクトページにある編集アイコンを押してService connectionsを選んだ中にあるものを使います。GitHubと連携した場合はGitHubのユーザ名になると思います。また、デフォルトでは使えないようになっていて、PoliciesのPipeline policiesにある"Allow all pipelines to use this endpoint"にチェックを入れる必要があるようです。チェックを入れないと認証エラーか何かが出ます。

```yml
          - task: GitHubRelease@0
            inputs:
                gitHubConnection: 'dalance'
                repositoryName: 'dalance/procs'
                tagSource: 'auto'
                releaseNotesSource: 'input'
                releaseNotes: '[Changelog](https://github.com/dalance/procs/blob/master/CHANGELOG.md)'
                assets: '$(Build.ArtifactStagingDirectory)/*/*'
                assetUploadMode: 'replace'
                isDraft: false
                isPreRelease: false
                addChangeLog: false
            displayName: Release to GitHub
```

releaseNotesを指定しておくとGitHubのリリースノートに反映されます。また、`addChangelog`はデフォルトでtrueになっていて、リリース間のコミットログ一覧をリリースノートに挿入してくれますが、個人的には不要だったので切りました。
`tagSource`は`Git tag`にするという情報もありましたが、`auto`が正解のようです。


https://docs.microsoft.com/en-us/azure/devops/pipelines/tasks/utility/github-release

## まとめ

まだ出来立てということか、結構大変でした。ドキュメントは整備されている感があるのですが、仕様変更に追従できていないためか矛盾する記述もあって、それなりに試行錯誤が必要です。とはいえこのあたりは時間が解決すると思いますし、3プラットフォームをまとめられるのは便利なのでこれから使っていきたいと思います。