Android 自动化发布流程

Google Play Developer API 是谷歌官方提供的 API,包含有两个组件:

我们需要用到的是 Publishing API

初始准备

在使用 API 之前,需要完成一系列环境配置和认证准备工作,具体步骤如下:

  1. 创建 Google Cloud 项目。
  2. 开启 Google Play Developer API 服务。
  3. 创建一个服务账号以便使用 API。

创建 Google Cloud

可以查看官方提供的创建和管理项目文档。

开启 API 服务

创建 Google Cloud 项目后,前往 API 页面

  1. 在 API 页面点击 “库” 选项。来到 API 库。
  2. 搜索 Google Play Developer API 进入详情页开启服务。

配置 OAuth 和服务账号

OAuth 和服务账号的区别在于:

  • OAuth 由用户访问 API 。
  • 服务账号由软件访问 API 。

我们只需要创建服务账号即可。

  1. 前往 Google Cloud 服务账号页面
  2. 选择先前创建的 Google Cloud 项目。
  3. 点击创建服务账号按钮,进入创建页面,填写相关信息。
  4. 创建服务账号后,进入创建的服务账号详情页。
  5. 点击密钥选项,来到密钥标签页。
  6. 在密钥标签页点击 “添加键” 按钮创建一个 JSON 密钥。
  7. 来到 Google Play 管理中心的用户和权限页面
  8. 点击邀请新用户,输入服务账号邮箱,并配置相关权限。
  9. 配置完毕后,点击邀请用户,将服务账号成功添加到开发者账号中。

具体步骤可以查看官方提供的文档

服务账号密钥格式如下:

{
  "type": "service_account",
  "project_id": "your-project-id",
  "private_key_id": "密钥唯一标识符",
  "private_key": "-----BEGIN PRIVATE KEY-----\n密钥内容\n-----END PRIVATE KEY-----\n",
  "client_email": "服务账号邮箱@your-project-id.iam.gserviceaccount.com",
  "client_id": "客户端唯一标识",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/服务账号邮箱",
  "universe_domain": "googleapis.com"
}

编写自动化脚本

自动化目标如下:

  • 自动将 Android 应用构建产物上传到 Google Play 中。
  • 自动上传应用截图。
  • 自动修改应用详情。
  • 自动发布版本。

配置文件

// config.json
{
  // 服务账号密钥文件路径
  "secretsFile": "client_secrets.json",
  // 应用包名称
  "packageName": "com.example.android.app",
  // 应用详情多语言支持
  // 要求使用 BCP-47 规范
  "languages": [
    "zh-CN",
    "en-US",
    "de-DE",
    "fr-FR",
    "it-IT",
    "ja-JP",
    "ko-KR",
    "nl-NL",
    "pt-PT",
    "es-ES",
    "ar"
  ],
  // 默认语言
  "defaultLanguage": "en-US",
  // 截图文件夹路径
  "screenshotPaths": "screenshots",
  // 发布的轨道
  // https://developers.google.com/android-publisher/tracks?hl=zh-cn#ff-track-name
  "track": "internal",
  // 请求超时配置 单位 s
  "timeout": 5,
  // 产物路径
  "production": {
    "aab": "src-tauri/gen/android/app/build/outputs/bundle/universalRelease/
    app-universal-release.aab",
    "apk": "src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
  },
  // 应用信息
  "appinfo": {
    "name": "应用名称",
    "description": {
      "short": "简短描述",
      "full": "详细描述"
    }
  }
}

编写脚本

首先我们需要使用 googleapis 这个谷歌官方提供的包。

使用 pnpm add googleapis 安装到项目中。

获取认证

import fs from 'node:fs'
import { google } from 'googleapis'
import path from 'node:path'

// 加载配置文件
const config = JSON.parse(fs.readFileSync('config.json'))
// 定义权限范围
const scopes = ['https://www.googleapis.com/auth/androidpublisher']

// 初始化认证客户端
const auth = new google.auth.GoogleAuth({
  scopes,
  keyFile: config.secretsFile,
})

// 创建 API 客户端
const client = await auth.getClient()

使用 API

const useTrack = config.track
const languages = config.languages
const packageName = config.packageName
const timeout = config.timeout * 60 * 1000
const bundle = config.production['aab']
const screenshotPaths = config.screenshotPaths

const publisher = google.androidpublisher({
  version: 'v3',
  auth: client,
  timeout,
})

获取编辑会话

const editId = await publisher.edits
  .insert({ packageName })
  .then((res) => res.data)
  .then((data) => data.id)

上传构建产物

// 创建一个构建产物
const uploadData = await publisher.edits.bundles
  .upload({
    packageName,
    editId,
    media: {
      mimeType: 'application/octet-stream',
      body: fs.createReadStream(bundle),
    },
    // 可续传上传
    uploadType: 'resumable',
  })
  .then((res) => res.data)
  .catch((reson) => console.error({ reson }))

修改发布详情

项目使用了 googleapis/release-please-action 这个 Github 的 Action 来自动生成 CHANGELOG.md
发布详情可以从这里获取。

解析 CHANGELOG 获得发布详情
import fs from 'node:fs'
import readline from 'node:readline'

export const CHANGELOG = 'CHANGELOG.md'

export async function parseChangeLog(filepath = CHANGELOG) {
  const stream = fs.createReadStream(filepath)
  const rl = readline.createInterface({
    input: stream,
    crlfDelay: Infinity,
  })

  const updateContent = []
  /** @type { Record<string, Record<string, string[]>> } */
  const record = {}
  let version = ''

  let flag = false
  for await (const line of rl) {
    if (line.startsWith('# ')) continue

    if (line.startsWith('## ')) {
      if (flag) {
        break
      }
      flag = true
    }

    if (line) updateContent.push(line)
  }

  if (updateContent.length) {
    const [head, ...lines] = updateContent
    version = head.match(/^## \[(.*)\].*/)[1]
    let cursor = ''

    for (const line of lines) {
      if (line.startsWith('### ')) {
        cursor = line.match(/^### (.*)/)[1]
      }

      if (cursor) record[cursor] ??= {}

      if (line.startsWith('- ') || line.startsWith('* ')) {
        const change = line.match(/[\*\-] (.*) ?/i)[1].replace(/(\(\[.*\]\(.*\)\))/, '')
        const scope = change.match(/^\*\*(.*):+\*\* /)
        const key = scope ? scope[1] : 'basic'
        const val = change.replace(/^(\*\*.*\*\* )/, '')

        record[cursor][key] = Array.from(new Set([...(record[cursor][key] ?? []), val]))
      }
    }
  }

  return { record, version }
}

输出格式如下:

type Result = {
  record: Record<string, Record<string, string[]>>
  version: `${number}.${number}.${number}`
}
{
  "record": {
    "Bug Fixes": {
      "core": [
        "修复应用闪退",
        "修复登录失败问题",
        "修复登录跳转问题"
      ],
    },
    "Features": {
      "page": [
        "增加用户页"
      ]
    }
  },
  "version": "0.1.0"
}
发布详情
const { record, version } = await parseChangeLog()

let text = ''

for (const [k, v] of Object.entries(record)) {
  text += `${k}\n`
  for (const contents of Object.values(v)) {
    text += contents.map((content) => `  ${content}`).join('\n')
  }
}

const releaseNotes = await Promise.all(
  languages.map(async (language) => {
    return {
      language,
      text,
    }
  }),
)

await publisher.edits.tracks.update({
  packageName,
  editId,
  track: useTrack,
  requestBody: {
    releases: [
      {
        name: 'auto release v' + version,
        // 发布状态
        // 这里是完全发布
        // https://developers.google.com/android-publisher/api-ref/rest/v3/edits.tracks?hl=zh-cn#status
        status: 'completed',
        releaseNotes,
        versionCodes: [uploadData.versionCode.toString()],
      },
    ],
  },
})

修改应用信息

/**
 * 翻译内容到目标语言
 *
 * @param { string } content - 原始内容
 * @param { string } language - 翻译目标语言
 * @returns { string } 翻译结果
 */
const translate = (content, language = '') => {
  if (languages.includes(language)) {
    // do something...
    return content
  }
  return content
}

for (const language of languages) {
  await publisher.edits.listings.update({
    packageName,
    editId,
    language,
    requestBody: {
      title: translate(config['appinfo']['name'], language),
      shortDescription: translate(config['appinfo']['description']['short'], language),
      fullDescription: translate(config['appinfo']['description']['full'], language),
    },
  })
}

上传应用截图

for (const language of languages) {
  const currentLanguageScreenshotDir = path.join(screenshotPaths, language)

  if (fs.existsSync(currentLanguageScreenshotDir)) {
    const screenshots = fs
      .readdirSync(currentLanguageScreenshotDir)
      .filter((file) => /\.(png|jpg|jpeg)$/i.test(file))
      .map((p) => path.join(currentLanguageScreenshotDir, p))

    if (screenshots.length) {
      await publisher.edits.images.deleteall({
        packageName,
        editId,
        language,
        imageType: 'phoneScreenshots',
      })

      await Promise.all(
        screenshots.map(async (p) => {
          const extension = path.extname(p).toLowerCase()
          const mimeType = extension === '.png' ? 'image/png' : 'image/jpeg'

          return await publisher.edits.images.upload({
            packageName,
            editId,
            media: {
              mimeType,
              body: fs.createReadStream(p),
            },
          })
        }),
      )
    }
  }
}

提交编辑会话

await publisher.edits
  .commit({
    packageName,
    editId,
    // 确保在 Google Play 管理中心手动将更改送审前
    // 系统不会对本次更改进行审核
    // 更改将与其他尚未送审的更改添加到一起
    changesNotSentForReview: true
  })
  .then((res) => res.data)
  .then(print)

完整代码

import fs from 'node:fs'
import { google } from 'googleapis'
import path from 'node:path'
import { parseChangeLog } from './parse-change-log.js'

/** @type { Record<string, string } */
const config = JSON.parse(fs.readFileSync('config/config.json'))
/**
 * @type { string }
 * @see https://developers.google.com/android-publisher/tracks?hl=zh-cn#ff-track-name
 */
const useTrack = config.track
/** @type { string[] } */
const languages = config.languages
/** @type { string } */
const packageName = config.packageName
const timeout = config.timeout * 60 * 1000
/** @type { string } */
const bundle = config.production['aab']
/** @type { string } */
const screenshotPaths = config.screenshotPaths
/** @type { string } */
// const defaultLanguage = config.defaultLanguage
const scopes = ['https://www.googleapis.com/auth/androidpublisher']

const STEP_UPLOAD = 'upload'
const STEP_DETAIL = 'detail'
const steps = [STEP_UPLOAD, STEP_DETAIL]

const print = (...data) => {
  if (typeof data === 'object') {
    console.dir(data, { depth: 20 })
  } else {
    console.log(...data)
  }
}

/**
 * translate content to target language
 *
 * @param { string } content - raw content
 * @param { string } language - translate target language
 * @returns { string } translate result
 */
const translate = (content, language = '') => {
  if (languages.includes(language)) {
    // TODO
    // do something...
    return content
  }
  return content
}

async function main() {
  const auth = new google.auth.GoogleAuth({
    scopes,
    keyFile: config.secretsFile,
  })

  const client = await auth.getClient()

  const publisher = google.androidpublisher({
    version: 'v3',
    auth: client,
    timeout,
  })

  const editId = await publisher.edits
    .insert({ packageName })
    .then((res) => res.data)
    .then((data) => data.id)

  if (steps.includes(STEP_UPLOAD)) {
    const uploadData = await publisher.edits.bundles
      .upload({
        packageName,
        editId,
        media: {
          mimeType: 'application/octet-stream',
          body: fs.createReadStream(bundle),
        },

        uploadType: 'resumable',
      })
      .then((res) => res.data)
      .catch((reson) => print({ reson }))

    print(uploadData)

    if (!uploadData) throw new EvalError('not found upload data')

    const { record, version } = await parseChangeLog()

    let text = ''

    for (const [k, v] of Object.entries(record)) {
      text += `${k}\n`
      for (const contents of Object.values(v)) {
        text += contents.map((content) => `  ${content}`).join('\n')
      }
    }

    const releaseNotes = await Promise.all(
      languages.map(async (language) => {
        return {
          language,
          text,
        }
      }),
    )

    print({ releaseNotes })

    await publisher.edits.tracks.update({
      packageName,
      editId,
      track: useTrack,
      requestBody: {
        releases: [
          {
            name: 'auto release v' + version,
            status: 'completed',
            releaseNotes,
            versionCodes: [uploadData.versionCode.toString()],
          },
        ],
      },
    })
  }

  if (steps.includes(STEP_DETAIL)) {
    for (const language of languages) {
      await publisher.edits.listings.update({
        packageName,
        editId,
        language,
        requestBody: {
          title: translate(config['appinfo']['name'], language),
          shortDescription: translate(config['appinfo']['description']['short'], language),
          fullDescription: translate(config['appinfo']['description']['full'], language),
        },
      })

      const currentLanguageScreenshotDir = path.join(screenshotPaths, language)

      if (fs.existsSync(currentLanguageScreenshotDir)) {
        const screenshots = fs
          .readdirSync(currentLanguageScreenshotDir)
          .filter((file) => /\.(png|jpg|jpeg)$/i.test(file))
          .map((p) => path.join(currentLanguageScreenshotDir, p))

        if (screenshots.length) {
          await publisher.edits.images.deleteall({
            packageName,
            editId,
            language,
            imageType: 'phoneScreenshots',
          })

          await Promise.all(
            screenshots.map(async (p) => {
              const extension = path.extname(p).toLowerCase()
              const mimeType = extension === '.png' ? 'image/png' : 'image/jpeg'

              return await publisher.edits.images.upload({
                packageName,
                editId,
                media: {
                  mimeType,
                  body: fs.createReadStream(p),
                },
              })
            }),
          )
        }
      }
    }
  }

  if (steps.length) {
    await publisher.edits
      .commit({ packageName, editId, changesNotSentForReview: true })
      .then((res) => res.data)
      .then(print)
  }
}

main()

集成配置

首先将 config.jsonclient_secrets.json 两个文件上传到仓库的 secrets 中。

name: 构建并发布 Android 应用
description: 构建并发布 Android 应用

on:
  workflow_dispatch:
  push:
    branches:
      - main

jobs:
  # Android-build:
  #  - ...

  upload-google-play:
    needs:
      - Android-build # 构建完毕 android 产物后执行
    runs-on: ubuntu-latest
    steps:
      - name: 写入配置文件
        run: |
          echo '${{secrets.ANDROID_PUBLISH_CONFIG}}' >> ${{github.workspace}}/config.json
          echo '${{secrets.ANDROID_CLIENT_SECRETS}}' >> ${{github.workspace}}/client_secrets.json
      - name: 执行上传脚本
        run: |
          node publish.js

Android 自动化发布流程
http://www.inksha.com/archives/android-zi-dong-hua-fa-bu-liu-cheng
作者
inksha
发布于
2025年06月17日
许可协议