ved_Rony
article thumbnail

GameCI를 사용한 apk 빌드 자동화

1) 라이센스 activation

  1. unity pro license인 경우
    • 먼저, 유니티의 serial key를 받아야 한다. → XX-XXXX-XXXX-XXXX-XXXX-XXXX 와 같은 형식
    • repository의 settings에 secrets로 들어간다. 다음 그림 처럼 secrets를 생성해준다. 이름은 UNITY_SERIAL/UNITY_EMAIL/UNITY_PASSWORD 로 지어준다. 각각의 secret에는 각 이름에 맞는 값을 넣어준다. UNITY_SERIAL → 아까받은 serial 값 UNITY_EMAIL → unity 이메일 UNITY_PASSWORD → unity 패스워드

2. unity personal license인 경우 pro와 케이스가 거의 비슷하다. 다만, step이 더욱 길어진다.

  • repository에 .github/workflows/activation.yml 파일을 생성해준다.
  • 파일 내용 → workflow_dispatch 쓰기 싫으면 push 혹은 다른 형태의 git.event를 사용하면 된다.
name: Acquire activation file
on:
  workflow_dispatch: {}
jobs:
  activation:
    name: Request manual activation file 🔑
    runs-on: ubuntu-latest
    steps:
      # Request manual activation file
      - name: Request manual activation file
        id: getManualLicenseFile
        uses: game-ci/unity-request-activation-file@v2
      # Upload artifact (Unity_v20XX.X.XXXX.alf)
      - name: Expose as artifact
        uses: actions/upload-artifact@v2
        with:
          name: ${{ steps.getManualLicenseFile.outputs.filePath }}
          path: ${{ steps.getManualLicenseFile.outputs.filePath }}
  • github에서 actions를 실행해주면 다음과 같은 파일이 action tab에 생성된다.
  • 유니티 라이센스 사이트 (license.unity3d.com)에서 alf 파일을 업로드 하면 ulf 파일이 나올텐데 이 ulf 파일의 내용을 UNITY_LICENSE 시크릿의 값으로 넣어주자. 그리고 pro에서 했던것 처럼 secret들을 넣어준다.

2) 빌드

  1. main.yml을 생성(personal license의 예시)
name: Build project

on: [ push ]

jobs:
  build:
    name: Build for ${{ matrix.targetPlatform }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        targetPlatform:
          - Android # Build an Android .apk standalone app.
    steps:
      - uses: actions/checkout@v2
      - uses: actions/cache@v2
        with:
          path: Library
          key: Library-${{ matrix.targetPlatform }}
          restore-keys: Library-
      - uses: game-ci/unity-builder@v2
        env:
          UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
          UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
          UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
        with:
          targetPlatform: ${{ matrix.targetPlatform }}
#  Upload an Android .apk standalone app.
      - uses: actions/upload-artifact@v2
        with:
          name: Build-${{ matrix.targetPlatform }}
          path: build/${{ matrix.targetPlatform }}

일반 빌드 같은 경우, 이정도의 세팅에서 마무리 가능하다.

2. 사전 설정 추가 - android key store 등등

  • git secrets에 keystore 값을 추가 해줘야하는 데, 내용은 이전과 같이 해당하는 내용 값을 넣어줘야한다.

  • base 64의 경우 커맨드를 사용해 얻을수 있다.
openssl base64 -in [keystore file path] -out [base64 file path]

cd /project_path
openssl base64 -in release.keystore -out temp.txt

프로젝트 폴더 내부 temp 생성

  • yml을 다음과 같이 수정한다. (pro license의 예시)
name: Build project

on: [ push ]

jobs:
  build:
    name: Build for ${{ matrix.targetPlatform }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        targetPlatform:
          - Android # Build an Android .apk standalone app.
    steps:
      - name: Free Disk Space for Android
        if: matrix.targetPlatform == 'Android'
        run: |
          df -h
          sudo swapoff -a
          sudo rm -f /swapfile
          sudo rm -rf /usr/share/dotnet
          sudo rm -rf /opt/ghc
          sudo rm -rf "/usr/local/share/boost"
          sudo rm -rf "$AGENT_TOOLSDIRECTORY"
          sudo apt clean
          docker rmi $(docker image ls -aq)
          df -h
      - name: Checkout Repository
        uses: actions/checkout@v2
        with:
          lfs: true
      - name: Cache Library
        uses: actions/cache@v2
        with:
          path: Library
          key: Library-${{ matrix.targetPlatform }}
          restore-keys: Library-
      - uses: game-ci/unity-builder@v2
        env:
          UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
          UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
          UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
        with:
          targetPlatform: ${{ matrix.targetPlatform }}
          androidAppBundle: false
          androidKeystoreName: user.keystore
          androidKeystoreBase64: ${{ secrets.KEY_BASE_64_RELEASE }}
          androidKeystorePass: ${{ secrets.KEYSTORE_PASSWORD }}
          androidKeyaliasName: ${{ secrets.KEY_ALIAS }}
          androidKeyaliasPass: ${{ secrets.KEY_PASSWORD }}
      - name: Upload Build File
        uses: actions/upload-artifact@v2
        with:
          name: Build-${{ matrix.targetPlatform }}
          path: build/${{ matrix.targetPlatform }}

안드로이드는 disk 용량 부족 문제가 종종 나타나기 때문에 free disk step을 추가해주었다.

3. 어드레서블 빌드 → custom player 빌드 빌드 단계에서 buildmethod 단계를 추가해준다. customparameter가 필요하다면 추가해주자.

name: Build project

on: [ push ]

jobs:
  build:
    name: Build for ${{ matrix.targetPlatform }}
    runs-on: ubuntu-latest
    strategy:
      matrix:
        targetPlatform:
          - Android # Build an Android .apk standalone app.
    steps:
      - name: Free Disk Space for Android
        if: matrix.targetPlatform == 'Android'
        run: |
          df -h
          sudo swapoff -a
          sudo rm -f /swapfile
          sudo rm -rf /usr/share/dotnet
          sudo rm -rf /opt/ghc
          sudo rm -rf "/usr/local/share/boost"
          sudo rm -rf "$AGENT_TOOLSDIRECTORY"
          sudo apt clean
          docker rmi $(docker image ls -aq)
          df -h
      - name: Checkout Repository
        uses: actions/checkout@v2
        with:
          lfs: true
      - name: Cache Library
        uses: actions/cache@v2
        with:
          path: Library
          key: Library-${{ matrix.targetPlatform }}
          restore-keys: Library-
      - uses: game-ci/unity-builder@v2
        env:
          UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
          UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
          UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
        with:
          targetPlatform: ${{ matrix.targetPlatform }}
          androidAppBundle: false
          androidKeystoreName: user.keystore
          androidKeystoreBase64: ${{ secrets.KEY_BASE_64_RELEASE }}
          androidKeystorePass: ${{ secrets.KEYSTORE_PASSWORD }}
          androidKeyaliasName: ${{ secrets.KEY_ALIAS }}
          androidKeyaliasPass: ${{ secrets.KEY_PASSWORD }}
          buildMethod: UnityBuilderAction.Builder.Build
          customParameters: -devBuild ${{ github.event_name == 'push' }}
      - name: Upload Build File
        uses: actions/upload-artifact@v2
        with:
          name: Build-${{ matrix.targetPlatform }}
          path: build/${{ matrix.targetPlatform }}
      - name: Upload Addressable Content State data file
        uses: actions/upload-artifact@v2
        with:
          name: addressables_content_state_Android
          path: Assets/AddressableAssetsData/Android

빌드 메소드를 쓴다고 말해줬으니 unity에서 빌드 메소드를 만들어주자. Editor 영역이기 때문에 Editor 폴더안에 static 클래스로 무.조.건 생성해줘야한다.

커맨드를 읽어서 속성값을 읽어와서 buildsetting을 사전에 정의해준다. 현재는 단순히 scene과 buildpath, buildtarget만 정의 해주고 있다. keystore나 어드레서블의 설정은 정해져 있지않다.

Builder.cs 수정 이전

using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;

namespace UnityBuilder {
    public static class Builder {
        private static readonly string Eol = Environment.NewLine;

        public static void Build() {
            Console.WriteLine("Build is Called!!");

            ParseCommandLineArguments(out Dictionary<string, string> validatedOptions);

            var scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(s => s.path).ToArray();

            string path = validatedOptions["customBuildPath"];
            Boolean.TryParse(validatedOptions["devBuild"], out bool isDev);
            string profileName = isDev ? "develop" : "product";
            
            BuildAddressables(profileName);
            BuildPipeline.BuildPlayer(scenes, path, BuildTarget.Android, BuildOptions.None);
        }

        private static void ParseCommandLineArguments(out Dictionary<string, string> providedArguments) {
            providedArguments = new Dictionary<string, string>();
            string[] args = Environment.GetCommandLineArgs();

            Console.WriteLine(
                $"{Eol}" +
                $"###########################{Eol}" +
                $"#    Parsing settings     #{Eol}" +
                $"###########################{Eol}" +
                $"{Eol}"
            );

            // Extract flags with optional values
            for (int current = 0, next = 1; current < args.Length; current++, next++) {
                // Parse flag
                bool isFlag = args[current].StartsWith("-");
                if (!isFlag) continue;
                string flag = args[current].TrimStart('-');

                // Parse optional value
                bool flagHasValue = next < args.Length && !args[next].StartsWith("-");
                string value = flagHasValue ? args[next].TrimStart('-') : "";

                string displayValue = "\\"" + value + "\\"";

                // Assign
                Console.WriteLine($"Found flag \\"{flag}\\" with value {displayValue}.");
                providedArguments.Add(flag, value);
            }
        }
        
        private static void BuildAddressables(string addressablesProfileName)
        {
            Console.WriteLine("Cleaning player content.");
            AddressableAssetSettings.CleanPlayerContent();

            AddressableAssetProfileSettings profileSettings =
                AddressableAssetSettingsDefaultObject.Settings.profileSettings;
            Console.WriteLine("Using active databuilder: " +
                              AddressableAssetSettingsDefaultObject.Settings.ActivePlayerDataBuilder.Name);

            Console.WriteLine("Setting profile to: " + addressablesProfileName);
            string profileId = profileSettings.GetProfileId(addressablesProfileName);
            AddressableAssetSettingsDefaultObject.Settings.activeProfileId = profileId;

            Console.WriteLine("Starting addressables content build.");
            // Build addressable content
            AddressableAssetSettings.BuildPlayerContent();

            Console.WriteLine("Building player content finished.");
        }
    }
}

Builder.cs keystore/addressable 수정 이후결과

using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;

namespace UnityBuilderAction {
    internal static class Builder {
        private static readonly string Eol = Environment.NewLine;

        private static readonly string[] Secrets =
            {"androidKeystorePass", "androidKeyaliasName", "androidKeyaliasPass"};

        public static void Build() {
            Dictionary<string, string> options = GetValidatedOptions();

            // Set version for this build
            PlayerSettings.bundleVersion = options["buildVersion"];
            PlayerSettings.macOS.buildNumber = options["buildVersion"];
            PlayerSettings.Android.bundleVersionCode = int.Parse(options["androidVersionCode"]);

            // Enable development build
            Boolean.TryParse(options["devBuild"], out bool isDev);

            EditorUserBuildSettings.development = isDev;
            EditorUserBuildSettings.allowDebugging = isDev;
            EditorUserBuildSettings.waitForManagedDebugger = isDev;

            // Apply build target
            //var buildTarget = (BuildTarget)Enum.Parse(typeof(BuildTarget), options["buildTarget"]);

            EditorUserBuildSettings.buildAppBundle = options["customBuildPath"].EndsWith(".aab");

            if (options.TryGetValue("androidKeystoreName", out string keystoreName) &&
                !string.IsNullOrEmpty(keystoreName))
                PlayerSettings.Android.keystoreName = keystoreName;

            if (options.TryGetValue("androidKeystorePass", out string keystorePass) &&
                !string.IsNullOrEmpty(keystorePass))
                PlayerSettings.Android.keystorePass = keystorePass;

            if (options.TryGetValue("androidKeyaliasName", out string keyaliasName) &&
                !string.IsNullOrEmpty(keyaliasName))
                PlayerSettings.Android.keyaliasName = keyaliasName;

            if (options.TryGetValue("androidKeyaliasPass", out string keyaliasPass) &&
                !string.IsNullOrEmpty(keyaliasPass))
                PlayerSettings.Android.keyaliasPass = keyaliasPass;

            string profileName = isDev ? "develop" : "product";

            // Build addressables content
            BuildAddressables($"MAKE_{profileName}");

            var scenes = EditorBuildSettings.scenes.Where(scene => scene.enabled).Select(s => s.path).ToArray();
            string path = options["customBuildPath"]; // yml path와 항상 맞춰줘야함
            Console.WriteLine("Building!");
            BuildPipeline.BuildPlayer(scenes, path, BuildTarget.Android, BuildOptions.None);
            Console.WriteLine("Building done");
        }

        private static Dictionary<string, string> GetValidatedOptions() {
            ParseCommandLineArguments(out Dictionary<string, string> validatedOptions);

            if (!validatedOptions.TryGetValue("projectPath", out string _)) {
                Console.WriteLine("Missing argument -projectPath");
                EditorApplication.Exit(110);
            }

            if (!validatedOptions.TryGetValue("buildTarget", out string buildTarget)) {
                Console.WriteLine("Missing argument -buildTarget");
                EditorApplication.Exit(120);
            }

            if (!Enum.IsDefined(typeof(BuildTarget), buildTarget ?? string.Empty)) {
                EditorApplication.Exit(121);
            }

            if (!validatedOptions.TryGetValue("customBuildPath", out string _)) {
                Console.WriteLine("Missing argument -customBuildPath");
                EditorApplication.Exit(130);
            }

            const string defaultEnableDevBuildValue = "true";

            if (!validatedOptions.TryGetValue("devBuild", out var isDevBuild)) {
                Console.WriteLine($"Missing argument -devBuild, defaulting to {defaultEnableDevBuildValue}.");
                validatedOptions.Add("devBuild", defaultEnableDevBuildValue);
            }

            const string defaultCustomBuildName = "TestBuild";

            if (!validatedOptions.TryGetValue("customBuildName", out string customBuildName)) {
                Console.WriteLine($"Missing argument -customBuildName, defaulting to {defaultCustomBuildName}.");
                validatedOptions.Add("customBuildName", defaultCustomBuildName);
            } else if (customBuildName == "") {
                Console.WriteLine($"Invalid argument -customBuildName, defaulting to {defaultCustomBuildName}.");
                validatedOptions.Add("customBuildName", defaultCustomBuildName);
            }

            return validatedOptions;
        }

        private static void ParseCommandLineArguments(out Dictionary<string, string> providedArguments) {
            providedArguments = new Dictionary<string, string>();
            string[] args = Environment.GetCommandLineArgs();

            Console.WriteLine(
                $"{Eol}" +
                $"###########################{Eol}" +
                $"#    Parsing settings     #{Eol}" +
                $"###########################{Eol}" +
                $"{Eol}"
            );

            for (int current = 0, next = 1; current < args.Length; current++, next++) {
                // Parse flag
                bool isFlag = args[current].StartsWith("-");
                if (!isFlag) continue;
                string flag = args[current].TrimStart('-');
                // Parse optional value
                bool flagHasValue = next < args.Length && !args[next].StartsWith("-");
                string value = flagHasValue ? args[next].TrimStart('-') : "";
                bool secret = Secrets.Contains(flag);
                string displayValue = secret ? "*HIDDEN*" : "\"" + value + "\"";

                // Assign
                Console.WriteLine($"Found flag \"{flag}\" with value {displayValue}.");
                providedArguments.Add(flag, value);
            }
        }

        private static void BuildAddressables(string addressablesProfileName) {
            Console.WriteLine("Cleaning player content.");
            AddressableAssetSettings.CleanPlayerContent();

            AddressableAssetProfileSettings profileSettings =
                AddressableAssetSettingsDefaultObject.Settings.profileSettings;

            Console.WriteLine("Using active databuilder: " +
                              AddressableAssetSettingsDefaultObject.Settings.ActivePlayerDataBuilder.Name);

            Console.WriteLine("Setting profile to: " + addressablesProfileName);
            string profileId = profileSettings.GetProfileId(addressablesProfileName);
            AddressableAssetSettingsDefaultObject.Settings.activeProfileId = profileId;

            Console.WriteLine("Starting addressables content build.");
            // Build addressable content
            AddressableAssetSettings.BuildPlayerContent();

            Console.WriteLine("Building player content finished.");
        }
    }
}

결과

지금 나온 예시는 push 했을 때, 자동으로 빌드가 실행되는 예시이다. 하지만, push 이외에 내가 직접 action의 trigger를 설정 해줄수 있다. workflow_dispatch이다. 

workflow_dispatch:
    inputs:
      logLevel:
        description: 'isDev'     
        required: true
        default: 'false'

이런식으로 설정해주면 true로 trigger가 invoke 되면 그actions의 workflow가 실행되도록 할수 있다.

 

profile

ved_Rony

@Rony_chan

검색 태그