見出し画像

デスクトップアプリのデリバリーを自動化する

はじめに

こんにちは、PKSHA Communication の林良祐です。

林 良祐(Speech Insight 事業部 Software Engineering グループ)
大学院博士課程(東京大学大学院理学系研究科化学専攻)では超短パルスレーザーと金属の相互作用について研究する。2022 年 4 月に PKSHA Technology にソフトウエアエンジニアとして新卒入社し、駐車場領域でのプロダクト開発に従事。現在は PKSHA Communication の Speech Insight 事業部で「PKSHA Speech Insight」の開発を行っている。

私が担当している PKSHA Speech Insight(PSI)というプロダクトでは、Windows デスクトップアプリをお客様に提供しています。

▼PSI のプロダクト紹介記事はこちら

デスクトップアプリはブラウザで動く Web アプリとは異なり、お客様の環境にアプリをインストールしていただく必要があります。Microsoft Store で配布されるアプリもありますが、我々のデスクトップアプリは契約いただいているお客様にインストーラーを配布し、それをインストールしていただく方式になっています。

新機能開発などを積極的に行っているので、多いときは約 2 週間に一回くらいのペースでバージョンアップを行っています。iOS アプリや Android アプリなどは、IDE の機能や fastlane を用いることで配布でき、アプリの自動アップデートなどはプラットフォーム側が自動で行ってくれます。しかし、インストーラーを配布する方式では、インストーラーの作成や配布、自動アップデートの仕組みなどは自身で構築する必要があります。
本記事では、GitHub Actions によるデスクトップアプリのインストーラー作成の仕組みについて記述します。


アプリの概要

デスクトップアプリの実装の詳細や、運用については本筋ではないので詳細には記述しませんが、ビルドや配布、自動アップデートの仕組みとは不可分なので、概要を記述します。

デスクトップアプリは Flutter と C++(Visual C++)で開発されています。
ほとんどの機能は Flutter で実装されていますが、一部 Windows API を使う必要がある部分は C++ で実装されています。以下、psi_app は Flutter のアプリケーションで、 native_app は C++ のアプリケーションです。native_app.exe は Flutter の asset に置かれ、psi_app 側から起動されます。それぞれのアプリケーションプロセスは gRPC で通信をしています。

API なども含め、サービスの環境としては development(dev), staging(stg), production(prd)の3つがあります。dev 環境は主にエンジニアが開発のために使う環境で、stg 環境は QA を行ったり、BizDev がリリース前に検証を行ったりするための環境です。prd 環境が実際にお客様に提供している環境になります。Flutter ビルドの際は、Flavor で環境を切り替え、設定値などを切り替えています。

ビルド & インストーラー作成

インストーラーの作成は GitHub Actions で行っており、actions と workflows は以下のような構成になっています。

.github
    |- actions/build-app
    |   |- actions.yaml
    |
    |- workflows
        |- build-dev.yaml
        |- build-prd.yaml
        |- build-stg.yaml

build-dev は main branch に PR がマージされたタイミング、もしくは PR コメントをトリガーに走るようになっており、 build-stgbuild-prd は tag が push されたタイミングでトリガーされるようになっています。

actions では PR コメントをトリガーにしてビルドしたり、環境名をアプリのアイコンに入れたりなど、いくつか工夫をしているのですが、本筋とは外れるので付録を参照ください。

build-prd はアプリをビルドするジョブと、インストーラーを作成する 2 つのジョブで構成されています。

ビルド

まず、ビルドするジョブは、以下のようになっています。
実際にはもう少し処理を行っていますが、重要な部分だけを載せています。


jobs:
  build:
    runs-on:
      labels: windows-latest-x64-32core
    steps:
      - uses: actions/checkout@v4
      - name: install yq
        run: choco install yq

      - uses: ./.github/actions/build-app
        with:
          flavor: prd
      - name: make iss file
        run: |
          $version = yq '.version' psi_app\\pubspec.yaml
          $iss = Get-Content inno\\setup.iss.template
          $iss = $iss.Replace('$APP_NAME_SUFFIX', '')
          $iss = $iss.Replace('$APP_VERSION', $version)
          $iss = $iss.Replace('$SIGN_TOOL', 'signtool')
          Set-Content setup.iss $iss
      - name: make build-artifact
        run: |
          mkdir build
          mv setup.iss build
          mv psi_app\\build\\windows\\x64\\runner\\Release build\\dist
          mkdir build\\misc
          curl.exe "https://aka.ms/vs/17/release/vc_redist.x86.exe" -o .\\build\\misc\\vc_redist.x86.exe -L -vvv
      - uses: actions/upload-artifact@v4
        with:
          name: prd-build
          path: build

Windows アプリをビルドするので、runner は windows です。
標準の hosted runner ではビルドに時間がかかるので、larger runner を用いており、windows-latest-x64-32core はそのラベルです。

実際にビルドする処理は Composite Actions(./.github/actions/build-app)に切り出しています。

name: build app

inputs:
  flavor:
    required: true

runs:
  using: 'composite'
  steps:
    - id: flutter-version
      run: |
          $VERSION=(cat psi_app/.fvmrc | jq -r .flutter)
          echo "flutter-version=$VERSION" | Out-File -FilePath $Env:GITHUB_OUTPUT -Encoding utf8 -Append
      shell: pwsh
    - id: flutter-action
      uses: subosito/flutter-action@v2
      with:
        flutter-version: ${{ steps.flutter-version.outputs.flutter-version }}
        channel: "stable"
        cache: true

    - uses: microsoft/setup-msbuild@v1.3
    - name: cache
      id: cache-native
      uses: actions/cache@v4
      with:
        path: psi_app/assets/native_app
        key: native_app-${{ hashFiles('native_app') }}
    - name: build
      if: steps.cache-native.outputs.cache-hit != 'true'
      run: |
        vcpkg integrate install
        msbuild native/native.vcxproj -p:Configuration=Release -p:Platform=x86 -p:DebugSymbols=false -p:DebugType=None -p:OutDir=Release -p:VcpkgTriplet=x86-windows -p:VcpkgHostTriplet=x86-windows
      working-directory: native_app
      shell: pwsh
    - name: copy native_app
      if: steps.cache-native.outputs.cache-hit != 'true'
      run: |
        cp -r "native_app/native_app/Release/*" "psi_app/assets/native_app"
      shell: pwsh

    - run: flutter pub get
      shell: pwsh
      working-directory: psi_app
    - run: flutter pub run build_runner build --delete-conflicting-outputs
      shell: pwsh
      working-directory: psi_app
    - name: build app
      run: flutter build windows --verbose --target lib/main.dart --dart-define-from-file=env/${{ inputs.flavor }}.json
      shell: pwsh
      working-directory: psi_app

ビルド処理は、C++ で記述された native_app.exe のビルドと、Flutter アプリのビルドの大きく 2 つを行っています。Visual C++ のビルド処理は、 microsoft/setup-msbuild を用いて行っています。Platform=x86 となっているのは、一部の依存系が 32bit でしか動かないためです。

ビルド後、Inno setup でインストーラーを作成するための iss ファイルを作成し、それらを artifact としてアップロードしています。

インストーラー作成

jobs:
  build:
    # 省略
  make-installer:
    needs: build
    runs-on:
      labels: windows-latest-8core
    environment:
      name: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: prd-build
          path: build
      - name: install azure sign tool
        run: dotnet tool install --no-cache --global AzureSignTool --version 4.0.1
      - name: azure login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: azure token
        run: |
          $az_token=$(az account get-access-token --scope "https://vault.azure.net/.default" --query accessToken --output tsv)
          echo "::add-mask::$az_token"
          echo "AZ_TOKEN=$az_token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
      - name: install Inno setup
        run: choco install innosetup
      - name: build singed installer
        run: 'iscc /Ssigntool="azuresigntool.exe sign --verbose -kvu https://code-signing-xxxxxx.vault.azure.net/ -kvc xxxxxx -kva %AZ_TOKEN% -fd sha256 -tr http://timestamp.globalsign.com/tsa/r6advanced1 -s $f" setup.iss'
        shell: cmd
        working-directory: build
      - name: make installer-artifact
        run: |
          mkdir installer
          mv build\\setup-psi-*.exe installer
      - uses: actions/upload-artifact@v4
        with:
          name: prd-installer
          path: installer

インストーラーの作成は Inno Setup を用いています。
以下は、iss ファイルのテンプレートで、ビルドの際に $APP_NAME_SUFFIX$APP_VERSION を書き換えるようにしています。

#define AppId "{{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}" + "$APP_NAME_SUFFIX"
#define AppName "PKSHA Speech Insight" + "$APP_NAME_SUFFIX"
#define AppVersion "$APP_VERSION"
#define AppPublisher "PKSHA Communication Inc."
#define AppURL "<https://aisaas.pkshatech.com/speechinsight/>"
#define AppExeName "psi_app.exe"

[Setup]
AppId={#AppId}
AppName={#AppName}
AppVersion={#AppVersion}
AppVerName={#AppName} {#AppVersion}
AppPublisher={#AppPublisher}
AppPublisherURL={#AppURL}
AppSupportURL={#AppURL}
AppUpdatesURL={#AppURL}
DefaultDirName={autopf}\\{#AppName}
DisableProgramGroupPage=yes
OutputDir=.
OutputBaseFilename=setup-psi-{#AppVersion}
Compression=lzma
SolidCompression=yes
WizardStyle=modern
PrivilegesRequiredOverridesAllowed=dialog
#if '$SIGN_TOOL' != ''
SignTool=$SIGN_TOOL
#endif

[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "japanese"; MessagesFile: "compiler:Languages\\Japanese.isl"

[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked

[Files]
Source: "dist\\{#AppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "dist\\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "misc\\vc_redist.x86.exe"; DestDir: "{tmp}"; Flags: dontcopy

[Icons]
Name: "{autoprograms}\\{#AppName}"; Filename: "{app}\\{#AppExeName}"
Name: "{autodesktop}\\{#AppName}"; Filename: "{app}\\{#AppExeName}"; Tasks: desktopicon

[Run]
Filename: "{app}\\{#AppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall
Filename: "{tmp}\\vc_redist.x86.exe"; StatusMsg: "Installing VC++ Redistributables..."; Parameters: "/quiet"; Check: VC2017RedistNeedsInstall; Flags: waituntilterminated

[Code]
function VC2017RedistNeedsInstall: Boolean;
var
  Version: String;
begin
  if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x64', 'Version', Version) then
  begin
    Log('VC Redist Version check : found ' + Version);
    Result := (CompareStr(Version, 'v14.40.33810.0')<0);
  end
  else
  begin
    Result := True;
  end;
  if (Result) then
  begin
    ExtractTemporaryFile('vc_redist.x86.exe');
  end;
end;

dev, stg ではインストーラーに署名を行っていないので $SIGN_TOOL='' を、prd のときは署名を行うため $SIGN_TOOL=signtool を指定します。

必要なファイルを集めたら、iscc setup.iss を実行してインストーラーを作成します。prd の場合は、後述の azuresigntool によるコード署名のため、/S オプションで署名処理を指定しています。

作成されたインストーラーは artifact としてアップロードし、リリース前の最終動作確認などを行った後に、S3 にアップロードし、お客様の下に配信されます。

コードサイニング

コードサイニングは、デジタル署名技術を使用してソフトウェアの配布元がそのソフトウェアを正式に認証し、改ざんされていないことを保証するプロセスです。

このプロセスにより、ソフトウェアが信頼できるソースから来ていることをエンドユーザーが確認できます。Windows の場合、署名されていないインストーラーでソフトウェアをインストールしようとすると、SmartScreen が表示されます。

SmartScreen

コードサイニングするためにはコードサイニング証明書が必要です。
コードサイニング証明書は外部の機関から購入するのですが、コードサイニング証明書の秘密鍵は、FIPS140 Level2、 Common Criteria EAL 4+、または同等のセキュリティ要件を満たすハードウェアに格納されて提供されます。
なので、.pfx ファイルなどではなく、USB トークンや HSM(Hardware Security Module)の形で提供されます。

以前は USB トークンで署名を行っていたのですが、この部分は手動で作業する必要があり、USB トークンの管理なども含め、非常に手間がかかっていました。なので、GlobalSign のコードサイニング証明書と、Microsoft Azure Key Vault を利用し、この署名も GitHub Actions 内で行うようにしました。

まず、GlobalSign のコードサイニング証明書を以下の手順で Azure Key Vault にインポートします。

Action では、Azure にログインしてトークンを取得し、そのトークンを使って、azuresigntool.exe sign で署名することができます。

azuresigntool.exe sign --verbose -kvu <azure-key-vault-url> -kvc <friendly-name> -kva <azure-token> -fd sha256 -tr http://timestamp.globalsign.com/tsa/r6advanced1 -s <target-file>

Inno setup で署名を行うときは、/S で上記コマンドを指定することで、azuresigntool による署名を行うことができます。

まとめ

この記事では、GitHub Actions 上で PSI デスクトップアプリをビルドし、署名されたインストーラーを作成する手順について紹介しました。
我々は、開発したプロダクトを、お客様に安定して早く、安全にデリバリするために、自動化できるところは可能な限り自動化するようにしています。

インストーラー作成までの処理を自動化することで、開発や QA のイテレーションを早く回すことができるようになりました。
また、コードサイニングまで含めて Actions 上で行えるようになったことで、USB トークンの管理が不要になり、いつでも安全に prd アプリの作成ができるようになりました。

付録

コメントをトリガーにしてアプリをビルドする

PR に /build-dev とコメントすると、そのブランチで dev のアプリをビルドするようにしています。

name: build-dev

on:
  push:
    branches:
      - develop
    tags-ignore:
      - "*"
  issue_comment:
    types:
      - created

jobs:
  build:
    runs-on:
      labels: windows-latest-x64-32core
    permissions:
      contents: write
      issues: read
      pull-requests: read
    if: |
      github.event_name == 'push' ||
      (
        github.event_name == 'issue_comment'
        && github.event.action == 'created'
        && github.event.issue.pull_request != null
        && startsWith(github.event.comment.body, '/build-dev')
      )
    steps:
      - name: Get PR branch
        uses: xt0rted/pull-request-comment-branch@v2
        id: get-branch
        if: github.event_name == 'issue_comment'
      - name: checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ steps.get-branch.outputs.head_ref }}
        if: github.event_name == 'issue_comment'

      - uses: actions/checkout@v4
        if: github.event_name == 'push'
        
      # 省略

      - name: Comment version.json
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: gh pr comment "${{ github.event.issue.number }}" -R PKSHATechnology/psi-app -F info.txt
        if: github.event_name == 'issue_comment'

develop ブランチにマージされたときは github.event_namepush になり、PR にコメントされたときは issue_comment となります。checkout する際に、issue_comment の場合はブランチを指定する必要があるので、いくつか分岐させています。また、issue_comment の場合はアップロード先などを PR にコメントするようにしています。

dev/stg 環境のアプリはアイコンに環境名を入れる

imagemagick でアイコンに文字を入れることができます。

- run: magick .\\psi_app\\windows\\runner\\resources\\app_icon.ico -pointsize 96 -fill red -annotate +80+80 'dev' .\\psi_app\\windows\\runner\\resources\\app_icon.ico

タグで指定された version と pubspec.yaml の version が一致しているか確認する

stg/prd アプリを作成するときは、prd-v1.2.3 のようにタグを打っているのですが、pubspec.yaml のバージョンの修正を忘れることがあります。
そのまま出してしまうとややこしいことになってしまうので、以下のチェックを入れています。

name: build-prd

on:
  push:
    tags:
      - "prd-v*"

jobs:
    # 省略
    steps:
      - name: check version
        run: |
          $version = yq '.version' psi_app\\pubspec.yaml
          $tag_version = $ENV:GITHUB_REF.Replace("refs/tags/prd-v", "")
          If ($version -ne $tag_version) {
              exit 1
          }

インストール時に Visual C++ 再頒布可能パッケージがインストールされていないときにインストールする

Visual C++ 再頒布可能パッケージは Visual C++ で構築されたアプリケーションで必要になるライブラリです。
環境によって既にインストールされていたり、されていなかったりなので、インストール時に確認して、インストールされていない場合はインストールするようにしています。

Visual C++ 再頒布可能パッケージをインストールするために、 iss ファイルに以下の内容を記述しています。
vc_redist.x86.exe は MS の web ページからダウンロードしてきたインストーラーです。

Code ブロックに、Visual C++ 再頒布可能パッケージがインストールされているかを確認する関数が定義されています。
VCRedistNeedsInstalltrue のとき、 [Run] にあるインストール処理が実行されます。

[Files]
...
Source: "misc\\vc_redist.x86.exe"; DestDir: "{tmp}"; Flags: dontcopy

[Run]
...
Filename: "{tmp}\\vc_redist.x86.exe"; StatusMsg: "Installing VC++ Redistributables..."; Parameters: "/quiet"; Check: VCRedistNeedsInstall; Flags: waituntilterminated

[Code]
function VCRedistNeedsInstall: Boolean;
var
  Version: String;
begin
  if RegQueryStringValue(HKEY_LOCAL_MACHINE, 'SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\x64', 'Version', Version) then
  begin
    Log('VC Redist Version check : found ' + Version);
    Result := (CompareStr(Version, 'v14.40.33810.0')<0);
  end
  else
  begin
    Result := True;
  end;
  if (Result) then
  begin
    ExtractTemporaryFile('vc_redist.x86.exe');
  end;
end;

おわりに

PKSHA Speech Insight をはじめ、AI SaaS の各プロダクトでは社会実装に取り組んでいく仲間を募集中です。
お客様に快適にプロダクトを使っていただけるように、新機能開発はもちろんのこと、本記事で紹介したようなデリバリープロセスの改善なども積極的に行っています。
興味を持っていただけた方は、ぜひカジュアル面談しましょう!

―INFORMATION―
▼ 中途採用:ソフトウエアエンジニアの募集要項はこちら

▼ 中途採用:全職種の募集要項はこちら

▼ PKSHA 採用サイト

▼ Wantedly はこちら