Android使用Github Actions持续集成并自动上传apk到蒲公英App内测分发平台(含证书密码脱敏)

目录

1.前言

2.Github Actions持续集成

3.上传apk到蒲公英

4.Gradle配合Github Actions的Secret使用

4.1设置Github Actions Secrets

4.2修改app.gradlede的signingConfigs读取设置的Github Actions Secrets

5.YAML、YML在线编辑(校验)器


1.前言

关于持续集成的文章笔者之前已经写过几篇使用Jenkins和Travis CI的案例:

Docker+Jenkins实现Android持续集成(一)

Docker+Jenkins实现Android持续集成(二)

Android基于Travis CI的持续集成实践

Travis CI 原本分为付费和免费两个版本,对于私有项目收费,对于所有自由与开源(OSS)项目则提供免费的 Credits 。2019年 1 月,Travis CI 被 Idera 公司收购,尽管官方当时仍保证将继续为开源项目提供免费的服务,但如今还是改变了经营策略。 看看最新的官方价格方案:

有限的credits,瞬间不香了?

• 10,000 credits

• Compatible with Assembla, Bitbucket, GitHub & GitLab

对比下Circle CI,高下立判。

Jenkins就不用多说了,你有钱任性就可以自己搞个服务器折腾。

再看看Github Actions的提供免费大餐:

支持的运行器和硬件资源
Windows 和 Linux 虚拟机的硬件规格:

2核CPU
7 GB RAM 内存
14 GB SSD 磁盘空间
macOS 虚拟机的硬件规格:

3核CPU
14 GB RAM 内存
14 GB SSD 磁盘空间

以上信息来源于Github Actions官方文档介绍:

About GitHub-hosted runners - GitHub Docs

高性能且免费,就问你Github Actions它香不香?

2.Github Actions持续集成

重复的内容就没必要重新写了,这里介绍两篇写的比较好的文章供参考:

烹茶室【持续集成】Android使用Github Action自动打包并发布Fir.im内测

薛瑄Github Actions 使用指南和Android 持续集成示例

3.上传apk到蒲公英

为啥是蒲公英不是fir.im(betaqr.com)就不多说了,先看android.yml文件内容全貌:

name: Android CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: set up JDK 11
      uses: actions/setup-java@v2
      with:
        java-version: '11'
        distribution: 'temurin'
        cache: gradle

    - name: Grant execute permission for gradlew
      run: chmod +x gradlew

    - name: Build with Gradle
      run: ./gradlew app:assembleRelease

    - name: Upload apk to pgyer.com
      run: find ${
   
   { github.workspace }}/app/build/outputs/apk/release/ -name "*.apk*" -type f -exec curl -F "file=@{}" -F "uKey=${
   
   { secrets.PGYER_UKEY }}" -F "_api_key=${
   
   { secrets.PGYER_API_KEY }}" https://upload.pgyer.com/apiv1/app/upload  \;

    - name: Upload apk to artifact
      uses: actions/upload-artifact@master
      if: always()
      with:
        name: lottery_app
        path: ${
   
   { github.workspace }}/app/build/outputs/apk/release/*.apk

以上来源自笔者的开源项目:

https://github.com/xiangang/RecyclerViewLoopScrollAnimation/blob/main/.github/workflows/android.yml

android.yml其他部分不做过多的讲解了,这里主要介绍下如何上传不确定文件名称的apk,网上大多数文章介绍的此部分内容都是写死的apk名称,但实际的项目一般都会给apk名称加上渠道名版本号编译时间等,因此写死apk名称显然不合适。笔者一开始尝试使用*.apk来匹配,但一直提示无法open file,原因是curl 不支持这样使用通配符。

怎么办?*.apk匹配不到,那就先把apk名称找出来吧,通过find命令可以完成。

使用以下命令,就可以很轻松就把apk文件名查询出来了,当然如果有多个apk那肯定要把匹配条件修改的更精确点。

find ./ -name "*.apk*" -type f

文件名找到了,再配合curl命令使用岂不是水到渠成?那是必须的。这里不介绍find命令的用法,有兴趣的可以使用man find命令查看帮助文档。

简单说下以下命令的含义:

run: find ${
   
   { github.workspace }}/app/build/outputs/apk/release/ -name "*.apk*" -type f -exec curl -F "file=@{}" -F "uKey=${
   
   { secrets.PGYER_UKEY }}" -F "_api_key=${
   
   { secrets.PGYER_API_KEY }}" https://upload.pgyer.com/apiv1/app/upload  \;

find ${ { github.workspace }}/app/build/outputs/apk/release/ -name "*.apk*" -type f

查找${ { github.workspace }}/app/build/outputs/apk/release/目录下文件名为*.apk*的文件。其中-type代表指定的文件类型,f代表普通文档类型。

find xxx -exec xxxx {} \; :代表对查找到的文件执行某命令,命令可以使用find查找到的文件集合;

-exec:代表开始执行xxxx命令

{}:代表用find命令查找出的符合条件的文件集合保存到的变量

\; :find命令的作用域于结束

理解了以上用法,以后遇到其它的构建产物需要上传或者发送邮件就都很简单了 。新技能Get!

4.Gradle配合Github Actions的Secret使用

对于一般的商业项目而言,保密措施通常是要做到位的。这意味着我们在编译apk时签名所用到的证书的密码或者其它涉密的内容,都不应该以明文的方式直接提交到代码仓库。在持续集成环境中,通常我们可以使用环境变量来替换所暴露的明文密码,当然如果是公司内部的服务器或者其它有权限的集成环境,我们也可以自己编写加密算法,对明文密码进行加密,然后编译时再解密。

本文仅介绍通过设置环境变量来达到脱敏的效果。这部分内容在《Android基于Travis CI的持续集成实践》也将讲解过,有兴趣可以阅读了解。

4.1设置Github Actions Secrets

进入Github仓库Settings页面,点击左侧Secrets菜单,右上角点击New respository secret,在跳转的页面填写对应的Name Value,点add secret按钮即可。注意Name会自动转成大写。

要修改对应Name的Value也很简单,点击列表里的Update进行修改即可。注意,只能修改Value,不能修改Name,如果Name搞错了,只Remove后重新Add。如果所示,笔者已经设置好了签名所需的三个Secret。

4.2修改app.gradlede的signingConfigs读取设置的Github Actions Secrets

先看看原来的证书密码是怎么读取的:

static def getAppReleaseTime() {
    return new Date().format("yyyyMMdd_HHmm", TimeZone.getTimeZone("Asia/Shanghai"))
}

// Remove private signing information from your project
// 创建一个名为keystorePropertiesFile的变量,并将其初始化为rootProject文件夹中的keystore.properties文件。
def keystorePropertiesFile = rootProject.file("keystore.properties")
// 初始化一个名为keystoreProperties的新Properties()对象
def keystoreProperties = new Properties()
// 将keystore.properties文件加载到keystoreProperties对象中
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))


android {
    compileSdk 31

    defaultConfig {
        applicationId "com.nxg.app"
        minSdk 21
        targetSdk 31
        versionCode 1
        versionName "1.0.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    signingConfigs {
        release {
            storeFile keystoreProperties['keyAlias'] != null ? file(keystoreProperties['storeFile']) : file('../demo.jks')
            storePassword keystoreProperties['storePassword'] != null ? keystoreProperties['storePassword'] : System.getenv("storePassword")
            keyAlias keystoreProperties['keyAlias'] != null ? keystoreProperties['keyAlias'] : System.getenv("keyAlias")
            keyPassword keystoreProperties['keyPassword'] != null ? keystoreProperties['keyPassword'] : System.getenv("keyPassword")
        }
    }

    buildTypes {
        debug {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            shrinkResources true //是否清理无用资源,依赖于minifyEnabled
            zipAlignEnabled true //是否启用zipAlign压缩
            signingConfig signingConfigs.release
            manifestPlaceholders = [RELEASE_TIME: getAppReleaseTime()]
            multiDexEnabled = true
            versionNameSuffix = ''
        }

        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            shrinkResources true //是否清理无用资源,依赖于minifyEnabled
            zipAlignEnabled true //是否启用zipAlign压缩
            signingConfig signingConfigs.release
            manifestPlaceholders = [RELEASE_TIME: getAppReleaseTime()]
            multiDexEnabled = true
            versionNameSuffix = ''
            // 自定义apk名称
            applicationVariants.all { variant ->
                variant.outputs.all { output ->
                    def fileName = "lucky_cube_app_release_${variant.versionName}_${appReleaseTime}.apk"
                    def outFile = output.outputFile
                    if (outFile != null && outFile.name.endsWith('.apk')) {
                        outputFileName = fileName
                    }
                }
            }
        }

    }
}

在项目的根目录中,有一个keystore.properties文件:

# 线上版本持续集成使用环境变量,本地开发时手动填上对应的值
storePassword =
keyAlias = 
keyPassword =
storeFile= 

如果是本地编译,通常的做法是在keystore.properties中填写对应密码和证书文件路径。如果是线上编译,则使用线上编译环境设置的环境变量。

前面我们通过Github Actions Serects设置好了签名所需的Secrets,但怎么用呢?当然是使用环境变量,因为Github Actions Serects实际上保存在Github Actions提供的服务器上我们并没有办法在gradle中直接读取,所以只能间接的通过读取环境变量来读取对应的Github Actions Serects。

直接上代码:

static def getAppReleaseTime() {
    return new Date().format("yyyyMMdd_HHmm", TimeZone.getTimeZone("Asia/Shanghai"))
}

// Remove private signing information from your project
// 创建一个名为keystorePropertiesFile的变量,并将其初始化为rootProject文件夹中的keystore.properties文件。
def keystorePropertiesFile = rootProject.file("keystore.properties")
// 初始化一个名为keystoreProperties的新Properties()对象
def keystoreProperties = new Properties()
// 将keystore.properties文件加载到keystoreProperties对象中
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
println "keystoreProperties->$keystoreProperties"

def getStoreFile = {
    def storeFile = keystoreProperties['storeFile']
    if (storeFile == null || storeFile.isEmpty()) {
        storeFile = '../demo.jks'
    }
    return storeFile
}

def getStorePassword = {
    def storePassword = keystoreProperties['storePassword']
    if (storePassword == null || storePassword.isEmpty()) {
        storePassword = System.getenv("storePassword")
    }
    return storePassword
}

def getKeyAlias = {
    def keyAlias = keystoreProperties['keyAlias']
    if (keyAlias == null || keyAlias.isEmpty()) {
        keyAlias = System.getenv("keyAlias")
    }
    return keyAlias
}

def getKeyPassword = {
    def keyPassword = keystoreProperties['keyPassword']
    if (keyPassword == null || keyPassword.isEmpty()) {
        keyPassword = System.getenv("keyPassword")
    }
    return keyPassword
}

println "storePassword->${System.getenv("storePassword")}"
println "keyAlias->${System.getenv("keyAlias")}"
println "keyPassword->${System.getenv("keyPassword")}"

println "getStoreFile->${getStoreFile()}"
println "getStorePassword->${getStorePassword()}"
println "getKeyAlias->${getKeyAlias()}"
println "getKeyPassword->${getKeyPassword()}"

android {
    compileSdk 31

    defaultConfig {
        applicationId "com.nxg.app"
        minSdk 21
        targetSdk 31
        versionCode 1
        versionName "1.0.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    signingConfigs {

        release {
            storeFile file(getStoreFile())
            storePassword getStorePassword()
            keyAlias getKeyAlias()
            keyPassword getKeyPassword()
        }
    }

    buildTypes {
        debug {
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            manifestPlaceholders = [RELEASE_TIME: getAppReleaseTime()]
            multiDexEnabled = true
        }

        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
            shrinkResources true //是否清理无用资源,依赖于minifyEnabled
            zipAlignEnabled true //是否启用zipAlign压缩
            signingConfig signingConfigs.release
            manifestPlaceholders = [RELEASE_TIME: getAppReleaseTime()]
            multiDexEnabled = true
            versionNameSuffix = ''
            // 自定义apk名称
            applicationVariants.all { variant ->
                variant.outputs.all { output ->
                    def fileName = "lucky_cube_app_release_${variant.versionName}_${appReleaseTime}.apk"
                    def outFile = output.outputFile
                    if (outFile != null && outFile.name.endsWith('.apk')) {
                        outputFileName = fileName
                    }
                }
            }
        }

    }
}

可以看到在app.gradle中是使用System.getenv("ENV_NAME")来获取名称为ENV_NAME的环境变量的值的,当然还有另一种写法System.env.ENV_NAME

这里以storePassword为例,编译时会先获取keystore.properties中定义的storePassword的值,如果为空则获取环境变量中定义的值,所以对应线上编译环境,我们通常不会提交keystore.properties中定义的值,而是只在本地使用,甚至说keystore.properties都不提交到代码仓库中。

def getStorePassword = {
    def storePassword = keystoreProperties['storePassword']
    if (storePassword == null || storePassword.isEmpty()) {
        storePassword = System.getenv("storePassword")
    }
    return storePassword
}

这样一来,在signingConfigs中就可以直接调用对应的方法获取环境变量中的值了。

signingConfigs {
    release {
        storeFile file(getStoreFile())
        storePassword getStorePassword()
        keyAlias getKeyAlias()
        keyPassword getKeyPassword()
    }
}

这样就完了吗?当然没有,别忘了环境变量和Github Actions Serects还没关联上,所以此时读取到的环境变量是没有任何值的。

那环境变量和Github Actions Serects怎么关联?

熟悉使用Linux系统使用的同学立马就会想到export命令,通过export命令我们可以设置临时的环境变量,变量的值对应Github Actions Serects设置的值。

关键android.yml代码如下:

name: Android CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: set up JDK 11
      uses: actions/setup-java@v2
      with:
        java-version: '11'
        distribution: 'temurin'
        cache: gradle

    - name: Grant execute permission for gradlew
      run: chmod +x gradlew

    - name: Build with Gradle
      run: |
        export storePassword=${
   
   { secrets.STOREPASSWORD }}
        export keyAlias=${
   
   { secrets.KEYALIAS }}
        export keyPassword=${
   
   { secrets.KEYPASSWORD }}
        ./gradlew app:assembleRelease


    - name: Upload apk to pgyer.com
      run: find ${
   
   { github.workspace }}/app/build/outputs/apk/release/ -name "*.apk*" -type f -exec curl -F "file=@{}" -F "uKey=${
   
   { secrets.PGYER_UKEY }}" -F "_api_key=${
   
   { secrets.PGYER_API_KEY }}" https://upload.pgyer.com/apiv1/app/upload  \;

    - name: Upload apk to artifact
      uses: actions/upload-artifact@master
      if: always()
      with:
        name: lottery_app
        path: ${
   
   { github.workspace }}/app/build/outputs/apk/release/*.apk

可以看到,在Github Actions中是通过${ { secrets.STOREPASSWORD }}这样的形式来获取设置的secret,然后只需要配合export命令即可在当前运行编译环境中去读取到系统中设置的临时环境变量,为什么是临时,因为编译结束,这个临时的环境变量就读不到了,随时编译随时用,用完也不影响系统,真是有万花丛中过,片叶不沾身的潇洒。

编译apk我们使用的是assembleRelease task,因此设置环境变量要确保在执行assembleRelease task之前才可以保证编译apk时通过System.getenv("ENV_NAME")读取到环境变量中设置的Github Actions Serects。

至此,我们通过Github Actions Serects和环境变量,实现了对证书密码进行脱敏,一定程度上保证了线上编译环境的安全性。 新技能Get!

5.YAML、YML在线编辑(校验)器

YAML、YML在线编辑器(格式化校验)-BeJSON.com

额外奉送,由于涉及android.yml编写,很多时候我们会不小心导致脚本编译出问题,这时候可以通过这个在线网站进行校验了,还是非常方便排查问题的。要知道,你的时间非常值钱!是吧。

写在最后,首先非常感谢您耐心阅读完整篇文章,坚持写原创且基于实战的文章不是件容易的事,如果本文刚好对您有点帮助,欢迎您给文章点赞评论,您的鼓励是笔者坚持不懈的动力。若文章有不对之处也欢迎指正,再次感谢。

猜你喜欢

转载自blog.csdn.net/xiangang12202/article/details/122594984