Android 自动化发布流程
Google Play Developer API
是谷歌官方提供的 API,包含有两个组件:
Subscriptions and In-App Purchases API
,可以管理应用内购买交易和订阅。Publishing API
,可以上传和发布应用,及其他发布相关任务。
我们需要用到的是 Publishing API
。
初始准备
在使用 API 之前,需要完成一系列环境配置和认证准备工作,具体步骤如下:
- 创建 Google Cloud 项目。
- 开启
Google Play Developer API
服务。 - 创建一个服务账号以便使用 API。
创建 Google Cloud
可以查看官方提供的创建和管理项目文档。
开启 API 服务
创建 Google Cloud 项目后,前往 API 页面。
- 在 API 页面点击 “库” 选项。来到 API 库。
- 搜索
Google Play Developer API
进入详情页开启服务。
配置 OAuth 和服务账号
OAuth 和服务账号的区别在于:
- OAuth 由用户访问 API 。
- 服务账号由软件访问 API 。
我们只需要创建服务账号即可。
- 前往 Google Cloud 服务账号页面。
- 选择先前创建的 Google Cloud 项目。
- 点击创建服务账号按钮,进入创建页面,填写相关信息。
- 创建服务账号后,进入创建的服务账号详情页。
- 点击密钥选项,来到密钥标签页。
- 在密钥标签页点击 “添加键” 按钮创建一个 JSON 密钥。
- 来到 Google Play 管理中心的用户和权限页面。
- 点击邀请新用户,输入服务账号邮箱,并配置相关权限。
- 配置完毕后,点击邀请用户,将服务账号成功添加到开发者账号中。
具体步骤可以查看官方提供的文档。
服务账号密钥格式如下:
{
"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.json
和 client_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