Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
935ef9c68b | ||
|
|
84ffa89623 | ||
|
|
b6837fa35c | ||
|
|
7c9dfbb03e | ||
|
|
9cf8fd30c9 | ||
|
|
101c2cddf9 | ||
|
|
d17e3fc745 | ||
|
|
70c7397e86 | ||
|
|
853233422d | ||
|
|
9a5e7fcdd5 | ||
|
|
02c72d76fe | ||
|
|
cd131e853c | ||
|
|
8a353d6e46 | ||
|
|
d5cf6e9445 | ||
|
|
b9dc4464a6 | ||
|
|
974d2d7566 | ||
|
|
536f822b60 | ||
|
|
d7e6e42414 | ||
|
|
78c9e796b1 | ||
|
|
980a5c4201 | ||
|
|
81a2f1ad1c | ||
|
|
b84eace68d | ||
|
|
4d510d380a | ||
|
|
99b7a1e1d4 | ||
|
|
7cd1379b4f | ||
|
|
b028ed83b3 | ||
|
|
5427bcbe2b | ||
|
|
6a74f079de | ||
|
|
c16321c553 | ||
|
|
7406d2c7a1 | ||
|
|
623184c55d | ||
|
|
91c8f42cf7 | ||
|
|
e00bb8ce1e | ||
|
|
7f42989517 | ||
|
|
6fefbe4681 | ||
|
|
f1479e715c | ||
|
|
7b7381ed55 | ||
|
|
642ba59b8a | ||
|
|
dd8dcab872 | ||
|
|
cf500ad293 | ||
|
|
354ae8f121 | ||
|
|
b36eb787b2 | ||
|
|
c23d6291fa | ||
|
|
8eb5399590 | ||
|
|
51e4495788 | ||
|
|
15ee0081ab |
15
.eslintrc.cjs
Normal file
15
.eslintrc.cjs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint'],
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||||
|
],
|
||||||
|
ignorePatterns: ["lib/*.js", "jest.config.js", "__tests__/**"]
|
||||||
|
};
|
||||||
42
.github/workflows/pr-validation.yml
vendored
Normal file
42
.github/workflows/pr-validation.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# This is a basic workflow to help you get started with Actions
|
||||||
|
|
||||||
|
name: PR Validation
|
||||||
|
|
||||||
|
# Controls when the workflow will run
|
||||||
|
on:
|
||||||
|
# Triggers the workflow on push or pull request events but only for the "master" branch
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "master" ]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
# Set up NPM and deps
|
||||||
|
- name: Set up NPM
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# Do a build
|
||||||
|
- name: Run Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Check whether the compiled library changed
|
||||||
|
- name: Check Build Updated
|
||||||
|
uses: tj-actions/verify-changed-files@v17
|
||||||
|
id: verify-built-lib
|
||||||
|
with:
|
||||||
|
files: lib/index.js
|
||||||
|
|
||||||
|
# If it did change, the authro forgot to build. Fail the action
|
||||||
|
- name: Verify Build Updated
|
||||||
|
if: steps.verify-changed-files.outputs.files_changed == 'true'
|
||||||
|
run: exit 1
|
||||||
|
|
||||||
|
- name: Run Tests
|
||||||
|
run: npm test
|
||||||
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -1,24 +1,16 @@
|
|||||||
name: "Tag & Deploy Action"
|
name: "Update v1 tag"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
release:
|
||||||
branches:
|
types: [released]
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v2.5.0
|
||||||
|
|
||||||
- name: Install
|
- uses: rickstaa/action-create-tag@v1
|
||||||
run: npm ci
|
with:
|
||||||
|
tag: v1
|
||||||
- name: Run Tests
|
force_push_tag: true
|
||||||
run: npm run test
|
|
||||||
|
|
||||||
- name: Update v1 Tag
|
|
||||||
uses: hole19/git-tag-action@master
|
|
||||||
env:
|
|
||||||
TAG: v1
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
155
README.md
155
README.md
@@ -4,123 +4,74 @@ This action will help you upload an Android `.apk` or `.aab` (Android App Bundle
|
|||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
_You must provide one of either `serviceAccountJson` or `serviceAccountJsonPlainText`_
|
| Input | Description | Value | Required |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
### `serviceAccountJson`
|
| releaseFiles | The Android release file(s) to upload (.apk or .aab) | Comma-separated paths. Supports glob via [fast-glob](https://github.com/mrmlnc/fast-glob) | true |
|
||||||
|
| serviceAccountJsonPlainText | The service account json in plain text, provided via a secret, etc | The contents of your `service-account.json` | true (or serviceAccountJson) |
|
||||||
The service account json private key file to authorize the upload request
|
| packageName | The package name, or Application Id, of the app you are uploading | A valid package name, e.g. `com.example.myapp`. The packageName must already exist in the play console account, so make sure you upload a manual apk or aab first through the console | true |
|
||||||
|
| track | The track in which you want to assign the uploaded app. Defaults to `production` | See [APKs and Tracks](https://developers.google.com/android-publisher/tracks#adding_and_modifying_apks) documentation for calculating your target track name | true |
|
||||||
### `serviceAccountJsonPlainText`
|
| releaseName | The release name. Not required to be unique. Default is configured by Google Play Console | A user-friendly update name, e.g. `v1.0.0` | false |
|
||||||
|
| inAppUpdatePriority | In-app update priority of the release. All newly added APKs in the release will be considered at this priority. Defaults to `0` | `[0-5]`, where `5` is the highest priority | false |
|
||||||
The service account json in plain text, provided via a secret, etc.
|
| userFraction | Percentage of users who should get the staged version of the app. | `(0.0-1.0)` | false |
|
||||||
|
| status | Release status. Defaults to `completed`. | One of `completed`, `inProgress`, `halted`, `draft`. Cannot be null. | false |
|
||||||
### `packageName`
|
| whatsNewDirectory | The directory of localized "whats new" files to upload as the release notes. The files contained in the `whatsNewDirectory` MUST use the pattern `whatsnew-<LOCALE>` where `LOCALE` is using the [`BCP 47`](https://tools.ietf.org/html/bcp47) format | A path to a valid `whatsNewDirectory` | false |
|
||||||
|
| mappingFile | The mapping.txt file used to de-obfuscate your stack traces from crash reports | A path to a valid `mapping.txt` file | false |
|
||||||
**Required:** The package name, or Application Id, of the app you are uploading
|
| debugSymbols | The native-debug-symbols.zip file or folder that contains your debug symbols | A path to a valid `native-debug-symbols.zip file` file or a folder | false |
|
||||||
|
| changesNotSentForReview | Indicates that the changes in this edit will not be reviewed until they are explicitly sent for review from the Google Play Console. Defaults to `false` | `true` or `false` | `false` |
|
||||||
### `releaseFile`
|
| serviceAccountJson | The service account json private key file to authorize the upload request. Can be used instead of `serviceAccountJsonPlainText` to specify a file rather than provide a secret | A path to a valid `service-account.json` file | true (or serviceAccountJsonPlainText) |
|
||||||
**DEPRECATED:** Please switch to using `releaseFiles` as this will be removed in the future
|
| existingEditId | The ID of an existing edit that has not been completed. If this is supplied, the action will append information to that rather than creating an edit | A valid, unpublished Edit ID | false |
|
||||||
The Android release file to upload (.apk or .aab)
|
| ~~releaseFile~~ | Please switch to using `releaseFiles` as this will be removed in the future | | false |
|
||||||
|
|
||||||
### `releaseFiles`
|
|
||||||
**CAVEAT:** Either this or `releaseFile` are required
|
|
||||||
|
|
||||||
The Android release file(s) to upload (.apk or .aab). Multiple files are separated by ','. Supports glob.
|
|
||||||
|
|
||||||
### `releaseName`
|
|
||||||
|
|
||||||
The release name. Not required to be unique. If not set, the name is generated from the APK's versionName. If the release contains multiple APKs, the name is generated from the date.
|
|
||||||
|
|
||||||
### `track`
|
|
||||||
|
|
||||||
**Required:** The track in which you want to assign the uploaded app.
|
|
||||||
**Default:** `production`
|
|
||||||
_Values:_ `alpha`, `beta`, `internal`, `production`, `internalsharing`
|
|
||||||
|
|
||||||
### `inAppUpdatePriority`
|
|
||||||
|
|
||||||
In-app update priority of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority.
|
|
||||||
|
|
||||||
**Default:** `0`
|
|
||||||
_Values:_ `[0, 5]`
|
|
||||||
|
|
||||||
### `userFraction`
|
|
||||||
|
|
||||||
Portion of users who should get the staged version of the app. Accepts values between 0.0 and 1.0 (exclusive-exclusive). Omitting this value will execute a full rollout.
|
|
||||||
|
|
||||||
### `status`
|
|
||||||
|
|
||||||
Release status. This can be set to `draft` to complete the release at some other time.
|
|
||||||
|
|
||||||
**Default:** `inProgress` if `userFraction` is specified, otherwise `completed`
|
|
||||||
|
|
||||||
_Values:_ `completed`, `inProgress`, `draft`, `halted`
|
|
||||||
|
|
||||||
### `whatsNewDirectory`
|
|
||||||
|
|
||||||
The directory of localized whats new files to upload as the release notes. The files contained in the `whatsNewDirectory` MUST use the pattern `whatsnew-<LOCALE>` where `LOCALE` is using the [`BCP 47`](https://tools.ietf.org/html/bcp47) format, e.g.
|
|
||||||
* `en-US` - English/America
|
|
||||||
* `de-DE` - German/Germany
|
|
||||||
* `ja-JP` - Japanese/Japan
|
|
||||||
|
|
||||||
and contain plain `utf8` encoded text with no extension on the file. The resulting directory in your project should look something like this:
|
|
||||||
```
|
|
||||||
projectDir/
|
|
||||||
└── whatsNewDirectory/
|
|
||||||
├── whatsnew-en-US
|
|
||||||
├── whatsnew-de-DE
|
|
||||||
└── whatsnew-ja-JP
|
|
||||||
```
|
|
||||||
where `whatsNewDirectory` is the path you pass to the action.
|
|
||||||
|
|
||||||
|
|
||||||
### `mappingFile`
|
|
||||||
|
|
||||||
The mapping.txt file used to de-obfuscate your stack traces from crash reports
|
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|
||||||
### `internalSharingDownloadUrls`
|
| Output | Environment Variable | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
A JSON list containing the download urls for every release file uploaded using the `track` `internalsharing`
|
| internalSharingDownloadUrls | INTERNAL_SHARING_DOWNLOAD_URLS | A JSON list containing the download urls for every release file uploaded using the `internalsharing` track |
|
||||||
|
| internalSharingDownloadUrl | INTERNAL_SHARING_DOWNLOAD_URL | The download url for the last release file uploaded using the `internalsharing` track |
|
||||||
### ENV VAR: `INTERNAL_SHARING_DOWNLOAD_URLS`
|
|
||||||
|
|
||||||
The environment variable that is set when using the `track` `internalsharing`
|
|
||||||
|
|
||||||
### `internalSharingDownloadUrl`
|
|
||||||
|
|
||||||
The download url for the last release file uploaded using the `track` `internalsharing`, useful when a single release file is uploaded
|
|
||||||
|
|
||||||
### ENV VAR: `INTERNAL_SHARING_DOWNLOAD_URL`
|
|
||||||
|
|
||||||
The environment variable that is set when using the `track` `internalsharing`
|
|
||||||
|
|
||||||
## Example usage
|
## Example usage
|
||||||
|
|
||||||
|
The below example publishes `MyApp` to Google Play, targetting 33% (`0.33`) of users with a priority of `2`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
uses: r0adkll/upload-google-play@v1
|
uses: r0adkll/upload-google-play@v1
|
||||||
with:
|
with:
|
||||||
serviceAccountJson: ${{ SERVICE_ACCOUNT_JSON }}
|
serviceAccountJsonPlainText: ${{ SERVICE_ACCOUNT_JSON }}
|
||||||
packageName: com.example.MyApp
|
packageName: com.example.MyApp
|
||||||
releaseFile: ${{ SIGNED_RELEASE_FILE}}
|
releaseFiles: app/build/outputs/bundle/release/app-release.aab
|
||||||
track: production
|
track: production
|
||||||
|
status: inProgress
|
||||||
inAppUpdatePriority: 2
|
inAppUpdatePriority: 2
|
||||||
userFraction: 0.33
|
userFraction: 0.33
|
||||||
whatsNewDirectory: distribution/whatsnew
|
whatsNewDirectory: distribution/whatsnew
|
||||||
mappingFile: app/build/outputs/mapping/release/mapping.txt
|
mappingFile: app/build/outputs/mapping/release/mapping.txt
|
||||||
|
debugSymbols: app/intermediates/merged_native_libs/release/out/lib
|
||||||
```
|
```
|
||||||
|
|
||||||
Using glob to get release files
|
## Configure service account
|
||||||
```yaml
|
1. Configure service account in Google Cloud Platform
|
||||||
uses: r0adkll/upload-google-play@v1
|
1. Navigate to https://cloud.google.com/gcp
|
||||||
with:
|
1. Open IAM and admin > Service accounts > Create service account
|
||||||
serviceAccountJson: ${{ SERVICE_ACCOUNT_JSON }}
|
1. Pick a name and add appropriate permissions (e.g. 'owner)
|
||||||
packageName: com.example.MyApp
|
1. Open the newly created service account, click on `keys` tab and add a new key, JSON type
|
||||||
releaseFiles: app/build/outputs/bundle/release/*.aab
|
1. When successful, a JSON file will be automatically downloaded on your machine
|
||||||
track: production
|
1. Store the content of this file to your GitHub secrets, e.g. SERVICE_ACCOUNT_JSON.
|
||||||
inAppUpdatePriority: 2
|
1. Add user to Google Play Console
|
||||||
userFraction: 0.33
|
1. Open https://play.google.com/console and pick your developer account
|
||||||
whatsNewDirectory: distribution/whatsnew
|
1. Open Users and permissions
|
||||||
mappingFile: app/build/outputs/mapping/release/mapping.txt
|
1. Click invite new user and add the email of the service account created in the previous step
|
||||||
|
1. Grant permissions to the app that you want the service account to deploy in `app permissions`
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
### I get the error "Package not found"
|
||||||
|
Make sure you upload an apk or aab manually first by creating a release through the play console.
|
||||||
|
|
||||||
|
The `whatsNewDirectory` in this example supplies changelogs for English, German and Japanese
|
||||||
|
|
||||||
|
```
|
||||||
|
distribution/
|
||||||
|
└─ whatsnew/
|
||||||
|
├─ whatsnew-en-US
|
||||||
|
├─ whatsnew-de-DE
|
||||||
|
└─ whatsnew-ja-JP
|
||||||
```
|
```
|
||||||
|
|||||||
74
__tests__/input-validation.test.ts
Normal file
74
__tests__/input-validation.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { validateInAppUpdatePriority, validateReleaseFiles, validateStatus, validateUserFraction } from "../src/input-validation"
|
||||||
|
|
||||||
|
test("invalid in-app update priority fails validation", async () => {
|
||||||
|
const testValues = [-1, 6, -1000, 1000]
|
||||||
|
for (const value of testValues) {
|
||||||
|
await expect(validateInAppUpdatePriority(value)).rejects.toThrowError()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("valid in-app update priority passes validation", async () => {
|
||||||
|
const testValues = [0, 1, 2, 3, 4, 5, undefined]
|
||||||
|
for (const value of testValues) {
|
||||||
|
await validateInAppUpdatePriority(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invalid releaseFiles glob fails validation", async () => {
|
||||||
|
const testValues = ['./__tests__/releasefiles/*.null', `./__tests__/releasefiles/nonexistent-release.aab`]
|
||||||
|
await expect(validateReleaseFiles(testValues)).rejects.toThrowError()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("valid releaseFiles glob passes validation", async () => {
|
||||||
|
const testValues = ['./__tests__/releasefiles/*.aab', `./__tests__/releasefiles/release.aab`]
|
||||||
|
await validateReleaseFiles(testValues)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fractionless status without fraction passes validation", async () => {
|
||||||
|
const testValues = ['completed', 'draft']
|
||||||
|
for (const value of testValues) {
|
||||||
|
await validateStatus(value, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fractionless status with fraction fails validation", async () => {
|
||||||
|
const testValues = ['completed', 'draft']
|
||||||
|
for (const value of testValues) {
|
||||||
|
await expect(validateStatus(value, true)).rejects.toThrowError()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fractioned status without fraction fails validation", async () => {
|
||||||
|
const testValues = ['inProgress', 'halted']
|
||||||
|
for (const value of testValues) {
|
||||||
|
await expect(validateStatus(value, false)).rejects.toThrowError()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fractioned status with fraction passes validation", async () => {
|
||||||
|
const testValues = ['inProgress', 'halted']
|
||||||
|
for (const value of testValues) {
|
||||||
|
await validateStatus(value, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invalid status fails validation", async () => {
|
||||||
|
const testValues = ['statusGoBrrr', undefined]
|
||||||
|
for (const value of testValues) {
|
||||||
|
await expect(validateStatus(value, true)).rejects.toThrowError()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("invalid user fraction fails validation", async () => {
|
||||||
|
const testValues = [0, 1, -1, 2, NaN]
|
||||||
|
for (const value of testValues) {
|
||||||
|
await expect(validateUserFraction(value)).rejects.toThrowError()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("valid user fraction passes validation", async () => {
|
||||||
|
const testValues = [0.1, 0.9, 0.5, undefined]
|
||||||
|
for (const value of testValues) {
|
||||||
|
await validateUserFraction(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
109
__tests__/main.test.ts
Normal file
109
__tests__/main.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import {RunOptions, RunTarget} from 'github-action-ts-run-api';
|
||||||
|
|
||||||
|
const target = RunTarget.mainJs('action.yml');
|
||||||
|
|
||||||
|
async function expectRunInitiatesUpload(options: RunOptions) {
|
||||||
|
const result = await target.run(options)
|
||||||
|
expect(result.commands.errors.length).toBe(1)
|
||||||
|
expect(result.commands.errors).toContainEqual("The incoming JSON object does not contain a client_email field")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("correct bare minimum inputs", async () => {
|
||||||
|
const minimumOptions = RunOptions.create()
|
||||||
|
.setInputs({
|
||||||
|
serviceAccountJsonPlainText: "{}",
|
||||||
|
packageName: "com.package.name",
|
||||||
|
releaseFiles: "./__tests__/releasefiles/*.aab",
|
||||||
|
track: "production",
|
||||||
|
})
|
||||||
|
await expectRunInitiatesUpload(minimumOptions)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("correct inputs for complete rollout", async () => {
|
||||||
|
// Run with the bare minimum
|
||||||
|
const minimumOptions = RunOptions.create()
|
||||||
|
.setInputs({
|
||||||
|
serviceAccountJsonPlainText: "{}",
|
||||||
|
packageName: "com.package.name",
|
||||||
|
releaseFiles: "./__tests__/releasefiles/*.aab",
|
||||||
|
track: "production",
|
||||||
|
status: "completed",
|
||||||
|
})
|
||||||
|
await expectRunInitiatesUpload(minimumOptions)
|
||||||
|
|
||||||
|
// Test with optional extras
|
||||||
|
const extraOptions = RunOptions.create()
|
||||||
|
.setInputs({
|
||||||
|
serviceAccountJsonPlainText: "{}",
|
||||||
|
packageName: "com.package.name",
|
||||||
|
releaseFiles: "./__tests__/releasefiles/*.aab",
|
||||||
|
releaseName: "Release name",
|
||||||
|
track: "production",
|
||||||
|
inAppUpdatePriority: "3",
|
||||||
|
status: "completed",
|
||||||
|
whatsNewDir: "./__tests__/whatsnew",
|
||||||
|
changesNotSentForReview: "true",
|
||||||
|
existingEditId: "123"
|
||||||
|
})
|
||||||
|
await expectRunInitiatesUpload(extraOptions)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("correct inputs for inProgress rollout", async () => {
|
||||||
|
// Run with the bare minimum
|
||||||
|
const minimumOptions = RunOptions.create()
|
||||||
|
.setInputs({
|
||||||
|
serviceAccountJsonPlainText: "{}",
|
||||||
|
packageName: "com.package.name",
|
||||||
|
releaseFiles: "./__tests__/releasefiles/*.aab",
|
||||||
|
track: "production",
|
||||||
|
userFraction: "0.99",
|
||||||
|
status: "inProgress",
|
||||||
|
})
|
||||||
|
await expectRunInitiatesUpload(minimumOptions)
|
||||||
|
|
||||||
|
// Test with optional extras
|
||||||
|
const extraOptions = RunOptions.create()
|
||||||
|
.setInputs({
|
||||||
|
serviceAccountJsonPlainText: "{}",
|
||||||
|
packageName: "com.package.name",
|
||||||
|
releaseFiles: "./__tests__/releasefiles/*.aab",
|
||||||
|
releaseName: "Release name",
|
||||||
|
track: "production",
|
||||||
|
inAppUpdatePriority: "3",
|
||||||
|
userFraction: "0.5",
|
||||||
|
status: "inProgress",
|
||||||
|
whatsNewDir: "./__tests__/whatsnew",
|
||||||
|
changesNotSentForReview: "true",
|
||||||
|
existingEditId: "123"
|
||||||
|
})
|
||||||
|
await expectRunInitiatesUpload(extraOptions)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("correct inputs for draft rollout", async () => {
|
||||||
|
// Run with the bare minimum
|
||||||
|
const minimumOptions = RunOptions.create()
|
||||||
|
.setInputs({
|
||||||
|
serviceAccountJsonPlainText: "{}",
|
||||||
|
packageName: "com.package.name",
|
||||||
|
releaseFiles: "./__tests__/releasefiles/*.aab",
|
||||||
|
track: "production",
|
||||||
|
status: "draft",
|
||||||
|
})
|
||||||
|
await expectRunInitiatesUpload(minimumOptions)
|
||||||
|
|
||||||
|
// Test with optional extras
|
||||||
|
const extraOptions = RunOptions.create()
|
||||||
|
.setInputs({
|
||||||
|
serviceAccountJsonPlainText: "{}",
|
||||||
|
packageName: "com.package.name",
|
||||||
|
releaseFiles: "./__tests__/releasefiles/*.aab",
|
||||||
|
releaseName: "Release name",
|
||||||
|
track: "production",
|
||||||
|
inAppUpdatePriority: "3",
|
||||||
|
status: "draft",
|
||||||
|
whatsNewDir: "./__tests__/whatsnew",
|
||||||
|
changesNotSentForReview: "true",
|
||||||
|
existingEditId: "123"
|
||||||
|
})
|
||||||
|
await expectRunInitiatesUpload(extraOptions)
|
||||||
|
})
|
||||||
0
__tests__/releasefiles/release.aab
Normal file
0
__tests__/releasefiles/release.aab
Normal file
@@ -1,12 +1,7 @@
|
|||||||
import * as process from 'process'
|
|
||||||
import * as cp from 'child_process'
|
|
||||||
import * as path from 'path'
|
|
||||||
import {readLocalizedReleaseNotes} from "../src/whatsnew";
|
import {readLocalizedReleaseNotes} from "../src/whatsnew";
|
||||||
import {androidpublisher_v3} from "googleapis";
|
|
||||||
import LocalizedText = androidpublisher_v3.Schema$LocalizedText;
|
|
||||||
|
|
||||||
test("read localized whatsnew files", async () => {
|
test("read localized whatsnew files", async () => {
|
||||||
let texts = await readLocalizedReleaseNotes("./__tests__");
|
let texts = await readLocalizedReleaseNotes("./__tests__/whatsnew");
|
||||||
expect(texts).toHaveLength(6);
|
expect(texts).toHaveLength(6);
|
||||||
expect(texts).toContainEqual({
|
expect(texts).toContainEqual({
|
||||||
language: "en-US",
|
language: "en-US",
|
||||||
@@ -32,4 +27,4 @@ test("read localized whatsnew files", async () => {
|
|||||||
language: "et",
|
language: "et",
|
||||||
text: "test_changelog_file_estonian"
|
text: "test_changelog_file_estonian"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
15
action.yml
15
action.yml
@@ -36,16 +36,27 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
status:
|
status:
|
||||||
description: "Release status. This can be set to 'draft' to complete the release at some other time."
|
description: "Release status. This can be set to 'draft' to complete the release at some other time."
|
||||||
required: false
|
required: true
|
||||||
|
default: "completed"
|
||||||
whatsNewDirectory:
|
whatsNewDirectory:
|
||||||
description: "The directory of localized whats new files"
|
description: "The directory of localized whats new files"
|
||||||
required: false
|
required: false
|
||||||
mappingFile:
|
mappingFile:
|
||||||
description: "The mapping.txt file used to de-obfuscate your stack traces from crash reports"
|
description: "The mapping.txt file used to de-obfuscate your stack traces from crash reports"
|
||||||
required: false
|
required: false
|
||||||
|
debugSymbols:
|
||||||
|
description: "The native-debug-symbols.zip file or folder that contains your debug symbols"
|
||||||
|
required: false
|
||||||
|
changesNotSentForReview:
|
||||||
|
description: "Indicates that the changes in this edit will not be reviewed until they are explicitly sent for review from the Google Play Console"
|
||||||
|
default: 'false'
|
||||||
|
required: false
|
||||||
|
existingEditId:
|
||||||
|
description: "The ID of an existing edit that has not been completed. If this is supplied, the action will append information to that rather than creating an edit"
|
||||||
|
required: false
|
||||||
outputs:
|
outputs:
|
||||||
internalSharingDownloadUrl:
|
internalSharingDownloadUrl:
|
||||||
description: "The internal app sharing download url if track was 'internalsharing'"
|
description: "The internal app sharing download url if track was 'internalsharing'"
|
||||||
runs:
|
runs:
|
||||||
using: 'node12'
|
using: 'node20'
|
||||||
main: 'lib/index.js'
|
main: 'lib/index.js'
|
||||||
|
|||||||
41
lib/index.js
41
lib/index.js
File diff suppressed because one or more lines are too long
15790
package-lock.json
generated
15790
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
27
package.json
27
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "upload-google-play",
|
"name": "upload-google-play",
|
||||||
"version": "1.0.13",
|
"version": "1.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Upload an Android release (.apk or .aab) to Google Play Console",
|
"description": "Upload an Android release (.apk or .aab) to Google Play Console",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
@@ -20,17 +20,22 @@
|
|||||||
"author": "r0adkll",
|
"author": "r0adkll",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/core": "^1.2.6",
|
"@actions/core": "^1.10.1",
|
||||||
"fast-glob": "^3.2.5",
|
"@googleapis/androidpublisher": "^12.1.0",
|
||||||
"googleapis": "^68.0.0"
|
"fast-glob": "^3.3.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"p-timeout": "^6.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^26.0.22",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^14.14.37",
|
"@types/node": "^20.11.17",
|
||||||
"@vercel/ncc": "^0.27.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"jest": "^26.6.3",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
"jest-circus": "^26.6.3",
|
"@vercel/ncc": "^0.38.1",
|
||||||
"ts-jest": "^26.5.4",
|
"eslint": "^8.56.0",
|
||||||
"typescript": "^4.2.3"
|
"github-action-ts-run-api": "^3.0.4",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
350
src/edits.ts
350
src/edits.ts
@@ -1,163 +1,191 @@
|
|||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import {readFileSync} from "fs";
|
import { readFileSync, lstatSync } from "fs";
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import path = require('path');
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
import {google} from 'googleapis';
|
import * as google from '@googleapis/androidpublisher';
|
||||||
import {androidpublisher_v3} from "googleapis";
|
import { androidpublisher_v3 } from "@googleapis/androidpublisher";
|
||||||
|
|
||||||
import AndroidPublisher = androidpublisher_v3.Androidpublisher;
|
import AndroidPublisher = androidpublisher_v3.Androidpublisher;
|
||||||
import AppEdit = androidpublisher_v3.Schema$AppEdit;
|
|
||||||
import Apk = androidpublisher_v3.Schema$Apk;
|
import Apk = androidpublisher_v3.Schema$Apk;
|
||||||
import Bundle = androidpublisher_v3.Schema$Bundle;
|
import Bundle = androidpublisher_v3.Schema$Bundle;
|
||||||
import Track = androidpublisher_v3.Schema$Track;
|
import Track = androidpublisher_v3.Schema$Track;
|
||||||
import InternalAppSharingArtifact = androidpublisher_v3.Schema$InternalAppSharingArtifact;
|
import InternalAppSharingArtifact = androidpublisher_v3.Schema$InternalAppSharingArtifact;
|
||||||
import {Compute} from "google-auth-library/build/src/auth/computeclient";
|
import { GoogleAuth } from "google-auth-library/build/src/auth/googleauth"
|
||||||
import {JWT} from "google-auth-library/build/src/auth/jwtclient";
|
import { readLocalizedReleaseNotes } from "./whatsnew";
|
||||||
import {UserRefreshClient} from "google-auth-library/build/src/auth/refreshclient";
|
|
||||||
import {readLocalizedReleaseNotes} from "./whatsnew";
|
|
||||||
|
|
||||||
const androidPublisher: AndroidPublisher = google.androidpublisher('v3');
|
const androidPublisher: AndroidPublisher = google.androidpublisher('v3');
|
||||||
|
|
||||||
export interface EditOptions {
|
export interface EditOptions {
|
||||||
auth: Compute | JWT | UserRefreshClient;
|
auth: GoogleAuth;
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
track: string;
|
track: string;
|
||||||
inAppUpdatePriority: number;
|
inAppUpdatePriority: number;
|
||||||
userFraction?: number;
|
userFraction?: number;
|
||||||
whatsNewDir?: string;
|
whatsNewDir?: string;
|
||||||
mappingFile?: string;
|
mappingFile?: string;
|
||||||
|
debugSymbols?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
status?: string;
|
status: string;
|
||||||
|
changesNotSentForReview?: boolean;
|
||||||
|
existingEditId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadToPlayStore(options: EditOptions, releaseFiles: string[]): Promise<string | undefined> {
|
export async function runUpload(
|
||||||
|
packageName: string,
|
||||||
|
track: string,
|
||||||
|
inAppUpdatePriority: number | undefined,
|
||||||
|
userFraction: number | undefined,
|
||||||
|
whatsNewDir: string | undefined,
|
||||||
|
mappingFile: string | undefined,
|
||||||
|
debugSymbols: string | undefined,
|
||||||
|
name: string | undefined,
|
||||||
|
changesNotSentForReview: boolean,
|
||||||
|
existingEditId: string | undefined,
|
||||||
|
status: string,
|
||||||
|
validatedReleaseFiles: string[]
|
||||||
|
) {
|
||||||
|
const auth = new google.auth.GoogleAuth({
|
||||||
|
scopes: ['https://www.googleapis.com/auth/androidpublisher']
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await uploadToPlayStore({
|
||||||
|
auth: auth,
|
||||||
|
applicationId: packageName,
|
||||||
|
track: track,
|
||||||
|
inAppUpdatePriority: inAppUpdatePriority || 0,
|
||||||
|
userFraction: userFraction,
|
||||||
|
whatsNewDir: whatsNewDir,
|
||||||
|
mappingFile: mappingFile,
|
||||||
|
debugSymbols: debugSymbols,
|
||||||
|
name: name,
|
||||||
|
changesNotSentForReview: changesNotSentForReview,
|
||||||
|
existingEditId: existingEditId,
|
||||||
|
status: status
|
||||||
|
}, validatedReleaseFiles);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
console.log(`Finished uploading to the Play Store: ${result}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToPlayStore(options: EditOptions, releaseFiles: string[]): Promise<string | void> {
|
||||||
|
const internalSharingDownloadUrls: string[] = []
|
||||||
|
|
||||||
// Check the 'track' for 'internalsharing', if so switch to a non-track api
|
// Check the 'track' for 'internalsharing', if so switch to a non-track api
|
||||||
if (options.track === 'internalsharing') {
|
if (options.track === 'internalsharing') {
|
||||||
core.debug("Track is Internal app sharing, switch to special upload api")
|
core.debug("Track is Internal app sharing, switch to special upload api")
|
||||||
let downloadUrls: string[] = []
|
|
||||||
for (const releaseFile of releaseFiles) {
|
for (const releaseFile of releaseFiles) {
|
||||||
core.debug(`Uploading ${releaseFile}`);
|
core.debug(`Uploading ${releaseFile}`);
|
||||||
await uploadInternalSharingRelease(options, releaseFile)
|
const url = await uploadInternalSharingRelease(options, releaseFile)
|
||||||
.then(downloadUrl => {
|
internalSharingDownloadUrls.push(url)
|
||||||
downloadUrls.push(downloadUrl as string)
|
|
||||||
})
|
|
||||||
.catch(reason => {
|
|
||||||
core.setFailed(reason);
|
|
||||||
return Promise.reject(reason);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
core.setOutput("internalSharingDownloadUrls", downloadUrls);
|
|
||||||
core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URLS", downloadUrls);
|
|
||||||
} else {
|
} else {
|
||||||
// Create a new Edit
|
// Create a new Edit
|
||||||
core.info(`Creating a new Edit for this release`)
|
const appEditId = await getOrCreateEdit(options)
|
||||||
const appEdit = await androidPublisher.edits.insert({
|
|
||||||
auth: options.auth,
|
|
||||||
packageName: options.applicationId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate the given track
|
// Validate the given track
|
||||||
core.info(`Validating track '${options.track}'`)
|
await validateSelectedTrack(appEditId, options)
|
||||||
await validateSelectedTrack(appEdit.data, options).catch(reason => {
|
|
||||||
core.setFailed(reason);
|
|
||||||
return Promise.reject(reason);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upload artifacts to Google Play, and store their version codes
|
// Upload artifacts to Google Play, and store their version codes
|
||||||
const versionCodes = new Array<number>();
|
const versionCodes = await uploadReleaseFiles(appEditId, options, releaseFiles)
|
||||||
for (const releaseFile of releaseFiles) {
|
|
||||||
core.info(`Uploading ${releaseFile}`);
|
// Infer the download URL from the version codes
|
||||||
const versionCode = await uploadRelease(appEdit.data, options, releaseFile).catch(reason => {
|
for (const versionCode of versionCodes) {
|
||||||
core.setFailed(reason);
|
const url = inferInternalSharingDownloadUrl(options.applicationId, versionCode);
|
||||||
return Promise.reject(reason);
|
core.setOutput("internalSharingDownloadUrl", url);
|
||||||
});
|
core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URL", url);
|
||||||
versionCodes.push(versionCode!);
|
internalSharingDownloadUrls.push(url);
|
||||||
}
|
}
|
||||||
core.info(`Successfully uploaded ${versionCodes.length} artifacts`)
|
|
||||||
|
|
||||||
// Add the uploaded artifacts to the Edit track
|
// Add the uploaded artifacts to the Edit track
|
||||||
core.info(`Adding ${versionCodes.length} artifacts to release on '${options.track}' track`)
|
await addReleasesToTrack(appEditId, options, versionCodes);
|
||||||
const track = await addReleasesToTrack(appEdit.data, options, versionCodes);
|
|
||||||
core.debug(`Track: ${track}`);
|
|
||||||
|
|
||||||
// Commit the pending Edit
|
// Commit the pending Edit
|
||||||
core.info(`Committing the Edit`)
|
core.info(`Committing the Edit`)
|
||||||
const res = await androidPublisher.edits.commit({
|
const res = await androidPublisher.edits.commit({
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
editId: appEdit.data.id!,
|
editId: appEditId,
|
||||||
packageName: options.applicationId
|
packageName: options.applicationId,
|
||||||
|
changesNotSentForReview: options.changesNotSentForReview
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simple check to see whether commit was successful
|
// Simple check to see whether commit was successful
|
||||||
if (res.data.id != null) {
|
if (res.data.id) {
|
||||||
core.info(`Successfully committed ${res.data.id}`);
|
core.info(`Successfully committed ${res.data.id}`);
|
||||||
return Promise.resolve(res.data.id!);
|
return res.data.id
|
||||||
} else {
|
} else {
|
||||||
core.setFailed(`Error ${res.status}: ${res.statusText}`);
|
core.setFailed(`Error ${res.status}: ${res.statusText}`);
|
||||||
return Promise.reject(res.status);
|
return Promise.reject(res.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
core.setOutput("internalSharingDownloadUrls", internalSharingDownloadUrls);
|
||||||
|
core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URLS", internalSharingDownloadUrls);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadInternalSharingRelease(options: EditOptions, releaseFile: string): Promise<string | undefined | null> {
|
async function uploadInternalSharingRelease(options: EditOptions, releaseFile: string): Promise<string> {
|
||||||
|
let res: google.androidpublisher_v3.Schema$InternalAppSharingArtifact
|
||||||
if (releaseFile.endsWith('.apk')) {
|
if (releaseFile.endsWith('.apk')) {
|
||||||
const res = await internalSharingUploadApk(options, releaseFile)
|
res = await internalSharingUploadApk(options, releaseFile)
|
||||||
core.setOutput("internalSharingDownloadUrl", res.downloadUrl);
|
|
||||||
core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URL", res.downloadUrl);
|
|
||||||
console.log(`${releaseFile} uploaded to Internal Sharing, download it with ${res.downloadUrl}`)
|
|
||||||
return Promise.resolve(res.downloadUrl)
|
|
||||||
} else if (releaseFile.endsWith('.aab')) {
|
} else if (releaseFile.endsWith('.aab')) {
|
||||||
const res = await internalSharingUploadBundle(options, releaseFile)
|
res = await internalSharingUploadBundle(options, releaseFile)
|
||||||
core.setOutput("internalSharingDownloadUrl", res.downloadUrl);
|
|
||||||
core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URL", res.downloadUrl);
|
|
||||||
console.log(`${releaseFile} uploaded to Internal Sharing, download it with ${res.downloadUrl}`)
|
|
||||||
return Promise.resolve(res.downloadUrl)
|
|
||||||
} else {
|
} else {
|
||||||
return Promise.reject(`${releaseFile} is invalid`)
|
throw Error(`${releaseFile} is invalid (missing or invalid file extension).`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!res.downloadUrl) throw Error('Uploaded file has no download URL.')
|
||||||
|
core.setOutput("internalSharingDownloadUrl", res.downloadUrl);
|
||||||
|
core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URL", res.downloadUrl);
|
||||||
|
console.log(`${releaseFile} uploaded to Internal Sharing, download it with ${res.downloadUrl}`)
|
||||||
|
|
||||||
|
return res.downloadUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadRelease(appEdit: AppEdit, options: EditOptions, releaseFile: string): Promise<number | undefined | null> {
|
async function validateSelectedTrack(appEditId: string, options: EditOptions): Promise<void> {
|
||||||
if (releaseFile.endsWith('.apk')) {
|
core.info(`Validating track '${options.track}'`)
|
||||||
const apk = await uploadApk(appEdit, options, releaseFile);
|
|
||||||
await uploadMappingFile(appEdit, apk.versionCode!, options);
|
|
||||||
return Promise.resolve(apk.versionCode);
|
|
||||||
} else if (releaseFile.endsWith('.aab')) {
|
|
||||||
const bundle = await uploadBundle(appEdit, options, releaseFile);
|
|
||||||
await uploadMappingFile(appEdit, bundle.versionCode!, options);
|
|
||||||
return Promise.resolve(bundle.versionCode);
|
|
||||||
} else {
|
|
||||||
return Promise.reject(`${releaseFile} is invalid`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateSelectedTrack(appEdit: AppEdit, options: EditOptions): Promise<undefined> {
|
|
||||||
const res = await androidPublisher.edits.tracks.list({
|
const res = await androidPublisher.edits.tracks.list({
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
editId: appEdit.id!,
|
editId: appEditId,
|
||||||
packageName: options.applicationId
|
packageName: options.applicationId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If we didn't get status 200, i.e. success, propagate the error with valid text
|
||||||
|
if (res.status != 200) {
|
||||||
|
throw Error(res.statusText)
|
||||||
|
}
|
||||||
|
|
||||||
const allTracks = res.data.tracks;
|
const allTracks = res.data.tracks;
|
||||||
if (allTracks == undefined || allTracks.find(value => value.track == options.track) == undefined) {
|
// Check whether we actually have any tracks
|
||||||
return Promise.reject(`Track "${options.track}" could not be found `);
|
if (!allTracks) {
|
||||||
|
throw Error('No tracks found, unable to validate track.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether the track is valid
|
||||||
|
if (allTracks.find(value => value.track == options.track) == undefined) {
|
||||||
|
const allTrackNames = allTracks.map((track) => { return track.track });
|
||||||
|
throw Error(`Track "${options.track}" could not be found. Available tracks are: ${allTrackNames.toString()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addReleasesToTrack(appEdit: AppEdit, options: EditOptions, versionCodes: number[]): Promise<Track> {
|
async function addReleasesToTrack(appEditId: string, options: EditOptions, versionCodes: number[]): Promise<Track> {
|
||||||
let status: string | undefined = options.status;
|
const status = options.status
|
||||||
if (status == undefined) {
|
|
||||||
if (options.userFraction != undefined) {
|
core.debug(`Creating release for:`);
|
||||||
status = 'inProgress';
|
core.debug(`edit=${appEditId}`)
|
||||||
} else {
|
core.debug(`track=${options.track}`)
|
||||||
status = 'completed';
|
if (options.userFraction) {
|
||||||
}
|
core.debug(`userFraction=${options.userFraction}`)
|
||||||
}
|
}
|
||||||
|
core.debug(`status=${status}`)
|
||||||
|
core.debug(`versionCodes=${versionCodes.toString()}`)
|
||||||
|
|
||||||
core.debug(`Creating Track Release for Edit(${appEdit.id}) for Track(${options.track}) with a UserFraction(${options.userFraction}), Status(${status}), and VersionCodes(${versionCodes})`);
|
|
||||||
const res = await androidPublisher.edits.tracks
|
const res = await androidPublisher.edits.tracks
|
||||||
.update({
|
.update({
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
editId: appEdit.id!,
|
editId: appEditId,
|
||||||
packageName: options.applicationId,
|
packageName: options.applicationId,
|
||||||
track: options.track,
|
track: options.track,
|
||||||
requestBody: {
|
requestBody: {
|
||||||
@@ -178,15 +206,15 @@ async function addReleasesToTrack(appEdit: AppEdit, options: EditOptions, versio
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadMappingFile(appEdit: AppEdit, versionCode: number, options: EditOptions) {
|
async function uploadMappingFile(appEditId: string, versionCode: number, options: EditOptions) {
|
||||||
if (options.mappingFile != undefined && options.mappingFile.length > 0) {
|
if (options.mappingFile != undefined && options.mappingFile.length > 0) {
|
||||||
const mapping = readFileSync(options.mappingFile, 'utf-8');
|
const mapping = readFileSync(options.mappingFile, 'utf-8');
|
||||||
if (mapping != undefined) {
|
if (mapping != undefined) {
|
||||||
core.debug(`[${appEdit.id}, versionCode=${versionCode}, packageName=${options.applicationId}]: Uploading Proguard mapping file @ ${options.mappingFile}`);
|
core.debug(`[${appEditId}, versionCode=${versionCode}, packageName=${options.applicationId}]: Uploading Proguard mapping file @ ${options.mappingFile}`);
|
||||||
await androidPublisher.edits.deobfuscationfiles.upload({
|
await androidPublisher.edits.deobfuscationfiles.upload({
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
packageName: options.applicationId,
|
packageName: options.applicationId,
|
||||||
editId: appEdit.id!,
|
editId: appEditId,
|
||||||
apkVersionCode: versionCode,
|
apkVersionCode: versionCode,
|
||||||
deobfuscationFileType: 'proguard',
|
deobfuscationFileType: 'proguard',
|
||||||
media: {
|
media: {
|
||||||
@@ -198,6 +226,65 @@ async function uploadMappingFile(appEdit: AppEdit, versionCode: number, options:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadDebugSymbolsFile(appEditId: string, versionCode: number, options: EditOptions) {
|
||||||
|
if (options.debugSymbols != undefined && options.debugSymbols.length > 0) {
|
||||||
|
const fileStat = lstatSync(options.debugSymbols);
|
||||||
|
|
||||||
|
let data: Buffer | null = null;
|
||||||
|
if (fileStat.isDirectory()) {
|
||||||
|
data = await createDebugSymbolZipFile(options.debugSymbols);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
data = readFileSync(options.debugSymbols);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data != null) {
|
||||||
|
core.debug(`[${appEditId}, versionCode=${versionCode}, packageName=${options.applicationId}]: Uploading Debug Symbols file @ ${options.debugSymbols}`);
|
||||||
|
await androidPublisher.edits.deobfuscationfiles.upload({
|
||||||
|
auth: options.auth,
|
||||||
|
packageName: options.applicationId,
|
||||||
|
editId: appEditId,
|
||||||
|
apkVersionCode: versionCode,
|
||||||
|
deobfuscationFileType: 'nativeCode',
|
||||||
|
media: {
|
||||||
|
mimeType: 'application/octet-stream',
|
||||||
|
body: Readable.from(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zipFileAddDirectory(root: JSZip | null, dirPath: string, rootPath: string, isRootRoot: boolean) {
|
||||||
|
if (root == null) return root;
|
||||||
|
|
||||||
|
const newRootPath = path.join(rootPath, dirPath);
|
||||||
|
const fileStat = lstatSync(newRootPath);
|
||||||
|
|
||||||
|
if (!fileStat.isDirectory()) {
|
||||||
|
const data = readFileSync(newRootPath);
|
||||||
|
root.file(dirPath, data);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = fs.readdirSync(newRootPath);
|
||||||
|
const zipFolder = isRootRoot ? root : root.folder(dirPath);
|
||||||
|
for (let pathIndex = 0; pathIndex < dir.length; pathIndex++) {
|
||||||
|
const underPath = dir[pathIndex];
|
||||||
|
await zipFileAddDirectory(zipFolder, underPath, newRootPath, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDebugSymbolZipFile(debugSymbolsPath: string) {
|
||||||
|
const zipFile = JSZip();
|
||||||
|
await zipFileAddDirectory(zipFile, ".", debugSymbolsPath, true);
|
||||||
|
|
||||||
|
return zipFile.generateAsync({ type: "nodebuffer" });
|
||||||
|
}
|
||||||
|
|
||||||
async function internalSharingUploadApk(options: EditOptions, apkReleaseFile: string): Promise<InternalAppSharingArtifact> {
|
async function internalSharingUploadApk(options: EditOptions, apkReleaseFile: string): Promise<InternalAppSharingArtifact> {
|
||||||
core.debug(`[packageName=${options.applicationId}]: Uploading Internal Sharing APK @ ${apkReleaseFile}`);
|
core.debug(`[packageName=${options.applicationId}]: Uploading Internal Sharing APK @ ${apkReleaseFile}`);
|
||||||
|
|
||||||
@@ -228,13 +315,13 @@ async function internalSharingUploadBundle(options: EditOptions, bundleReleaseFi
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadApk(appEdit: AppEdit, options: EditOptions, apkReleaseFile: string): Promise<Apk> {
|
async function uploadApk(appEditId: string, options: EditOptions, apkReleaseFile: string): Promise<Apk> {
|
||||||
core.debug(`[${appEdit.id}, packageName=${options.applicationId}]: Uploading APK @ ${apkReleaseFile}`);
|
core.debug(`[${appEditId}, packageName=${options.applicationId}]: Uploading APK @ ${apkReleaseFile}`);
|
||||||
|
|
||||||
const res = await androidPublisher.edits.apks.upload({
|
const res = await androidPublisher.edits.apks.upload({
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
packageName: options.applicationId,
|
packageName: options.applicationId,
|
||||||
editId: appEdit.id!,
|
editId: appEditId,
|
||||||
media: {
|
media: {
|
||||||
mimeType: 'application/vnd.android.package-archive',
|
mimeType: 'application/vnd.android.package-archive',
|
||||||
body: fs.createReadStream(apkReleaseFile)
|
body: fs.createReadStream(apkReleaseFile)
|
||||||
@@ -244,12 +331,12 @@ async function uploadApk(appEdit: AppEdit, options: EditOptions, apkReleaseFile:
|
|||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadBundle(appEdit: AppEdit, options: EditOptions, bundleReleaseFile: string): Promise<Bundle> {
|
async function uploadBundle(appEditId: string, options: EditOptions, bundleReleaseFile: string): Promise<Bundle> {
|
||||||
core.debug(`[${appEdit.id}, packageName=${options.applicationId}]: Uploading App Bundle @ ${bundleReleaseFile}`);
|
core.debug(`[${appEditId}, packageName=${options.applicationId}]: Uploading App Bundle @ ${bundleReleaseFile}`);
|
||||||
const res = await androidPublisher.edits.bundles.upload({
|
const res = await androidPublisher.edits.bundles.upload({
|
||||||
auth: options.auth,
|
auth: options.auth,
|
||||||
packageName: options.applicationId,
|
packageName: options.applicationId,
|
||||||
editId: appEdit.id!,
|
editId: appEditId,
|
||||||
media: {
|
media: {
|
||||||
mimeType: 'application/octet-stream',
|
mimeType: 'application/octet-stream',
|
||||||
body: fs.createReadStream(bundleReleaseFile)
|
body: fs.createReadStream(bundleReleaseFile)
|
||||||
@@ -258,3 +345,70 @@ async function uploadBundle(appEdit: AppEdit, options: EditOptions, bundleReleas
|
|||||||
|
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getOrCreateEdit(options: EditOptions): Promise<string> {
|
||||||
|
// If we already have an ID, just return that
|
||||||
|
if (options.existingEditId) {
|
||||||
|
return options.existingEditId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else attempt to create a new edit. This will throw if there is an issue
|
||||||
|
core.info(`Creating a new Edit for this release`)
|
||||||
|
const insertResult = await androidPublisher.edits.insert({
|
||||||
|
auth: options.auth,
|
||||||
|
packageName: options.applicationId
|
||||||
|
})
|
||||||
|
|
||||||
|
// If we didn't get status 200, i.e. success, propagate the error with valid text
|
||||||
|
if (insertResult.status != 200) {
|
||||||
|
throw Error(insertResult.statusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the result was successful but we have no ID, somethign went horribly wrong
|
||||||
|
if (!insertResult.data.id) {
|
||||||
|
throw Error('New edit has no ID, cannot continue.')
|
||||||
|
}
|
||||||
|
|
||||||
|
core.debug(`This new edit expires at ${String(insertResult.data.expiryTimeSeconds)}`)
|
||||||
|
// Return the new edit ID
|
||||||
|
return insertResult.data.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadReleaseFiles(appEditId: string, options: EditOptions, releaseFiles: string[]): Promise<number[]> {
|
||||||
|
const versionCodes: number[] = []
|
||||||
|
// Upload all release files
|
||||||
|
for (const releaseFile of releaseFiles) {
|
||||||
|
core.info(`Uploading ${releaseFile}`)
|
||||||
|
let versionCode: number
|
||||||
|
if (releaseFile.endsWith('.apk')) {
|
||||||
|
// Upload APK, or throw when something goes wrong
|
||||||
|
const apk = await uploadApk(appEditId, options, releaseFile);
|
||||||
|
if (!apk.versionCode) throw Error('Failed to upload APK.')
|
||||||
|
versionCode = apk.versionCode
|
||||||
|
} else if (releaseFile.endsWith('.aab')) {
|
||||||
|
// Upload AAB, or throw when something goes wrong
|
||||||
|
const bundle = await uploadBundle(appEditId, options, releaseFile);
|
||||||
|
if (!bundle.versionCode) throw Error('Failed to upload bundle.')
|
||||||
|
versionCode = bundle.versionCode
|
||||||
|
} else {
|
||||||
|
// Throw if file extension is not right
|
||||||
|
throw Error(`${releaseFile} is invalid.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload version code
|
||||||
|
await uploadMappingFile(appEditId, versionCode, options)
|
||||||
|
await uploadDebugSymbolsFile(appEditId, versionCode, options)
|
||||||
|
versionCodes.push(versionCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
core.info(`Successfully uploaded ${versionCodes.length} artifacts`)
|
||||||
|
|
||||||
|
return versionCodes
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferInternalSharingDownloadUrl(
|
||||||
|
applicationId: string,
|
||||||
|
versionCode: number
|
||||||
|
) {
|
||||||
|
return `https://play.google.com/apps/test/${applicationId}/${versionCode}`;
|
||||||
|
}
|
||||||
56
src/input-validation.ts
Normal file
56
src/input-validation.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import fg from "fast-glob";
|
||||||
|
|
||||||
|
export async function validateUserFraction(userFraction: number | undefined): Promise<void> {
|
||||||
|
if (userFraction != undefined) {
|
||||||
|
// If userFraction was set, perform basic validation
|
||||||
|
if (isNaN(userFraction)) {
|
||||||
|
return Promise.reject(new Error(`'userFraction' must be a number! Got ${userFraction}`))
|
||||||
|
}
|
||||||
|
if (userFraction >= 1 || userFraction <= 0) {
|
||||||
|
return Promise.reject(new Error(`'userFraction' must be between 0 and 1! Got ${userFraction}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateStatus(status: string | undefined, hasUserFraction: boolean): Promise<void> {
|
||||||
|
// If status was set, perform basic validation
|
||||||
|
if (status != 'completed' && status != 'inProgress' && status != 'halted' && status != 'draft') {
|
||||||
|
return Promise.reject(new Error(`Invalid status provided! Must be one of 'completed', 'inProgress', 'halted', 'draft'. Got ${status ?? "undefined"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate userFraction is correct for the given status
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
case 'draft':
|
||||||
|
if (hasUserFraction) {
|
||||||
|
return Promise.reject(new Error(`Status '${status}' does not support 'userFraction'`))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'halted':
|
||||||
|
case 'inProgress':
|
||||||
|
if (!hasUserFraction) {
|
||||||
|
return Promise.reject(new Error(`Status '${status}' requires a 'userFraction' to be set`))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateInAppUpdatePriority(inAppUpdatePriority: number | undefined): Promise<void> {
|
||||||
|
if (inAppUpdatePriority) {
|
||||||
|
if (inAppUpdatePriority < 0 || inAppUpdatePriority > 5) {
|
||||||
|
return Promise.reject(new Error('inAppUpdatePriority must be between 0 and 5, inclusive-inclusive'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateReleaseFiles(releaseFiles: string[] | undefined): Promise<string[]> {
|
||||||
|
if (!releaseFiles) {
|
||||||
|
return Promise.reject(new Error(`You must provide 'releaseFiles' in your configuration`))
|
||||||
|
} else {
|
||||||
|
const files = await fg(releaseFiles)
|
||||||
|
if (!files.length) {
|
||||||
|
return Promise.reject(new Error(`Unable to find any release file matching ${releaseFiles.join(',')}`))
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/main.ts
181
src/main.ts
@@ -1,14 +1,11 @@
|
|||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core'
|
||||||
import * as fs from "fs";
|
import * as fs from "fs"
|
||||||
import fg from "fast-glob";
|
import { runUpload } from "./edits"
|
||||||
import { uploadToPlayStore } from "./edits";
|
import { validateInAppUpdatePriority, validateReleaseFiles, validateStatus, validateUserFraction } from "./input-validation"
|
||||||
const {google} = require('googleapis');
|
import { unlink, writeFile } from 'fs/promises'
|
||||||
|
import pTimeout from 'p-timeout'
|
||||||
|
|
||||||
const auth = new google.auth.GoogleAuth({
|
export async function run() {
|
||||||
scopes: ['https://www.googleapis.com/auth/androidpublisher']
|
|
||||||
});
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
try {
|
||||||
const serviceAccountJson = core.getInput('serviceAccountJson', { required: false });
|
const serviceAccountJson = core.getInput('serviceAccountJson', { required: false });
|
||||||
const serviceAccountJsonRaw = core.getInput('serviceAccountJsonPlainText', { required: false});
|
const serviceAccountJsonRaw = core.getInput('serviceAccountJsonPlainText', { required: false});
|
||||||
@@ -16,113 +13,113 @@ async function run() {
|
|||||||
const releaseFile = core.getInput('releaseFile', { required: false });
|
const releaseFile = core.getInput('releaseFile', { required: false });
|
||||||
const releaseFiles = core.getInput('releaseFiles', { required: false })
|
const releaseFiles = core.getInput('releaseFiles', { required: false })
|
||||||
?.split(',')
|
?.split(',')
|
||||||
?.filter(x => x !== '') || [];
|
?.filter(x => x !== '');
|
||||||
const releaseName = core.getInput('releaseName', { required: false });
|
const releaseName = core.getInput('releaseName', { required: false });
|
||||||
const track = core.getInput('track', { required: true });
|
const track = core.getInput('track', { required: true });
|
||||||
const inAppUpdatePriority = core.getInput('inAppUpdatePriority', { required: false });
|
const inAppUpdatePriority = core.getInput('inAppUpdatePriority', { required: false });
|
||||||
const userFraction = core.getInput('userFraction', { required: false });
|
const userFraction = core.getInput('userFraction', { required: false })
|
||||||
const status = core.getInput('status', { required: false });
|
const status = core.getInput('status', { required: false });
|
||||||
const whatsNewDir = core.getInput('whatsNewDirectory', { required: false });
|
const whatsNewDir = core.getInput('whatsNewDirectory', { required: false });
|
||||||
const mappingFile = core.getInput('mappingFile', { required: false });
|
const mappingFile = core.getInput('mappingFile', { required: false });
|
||||||
|
const debugSymbols = core.getInput('debugSymbols', { required: false });
|
||||||
|
const changesNotSentForReview = core.getInput('changesNotSentForReview', { required: false }) == 'true';
|
||||||
|
const existingEditId = core.getInput('existingEditId');
|
||||||
|
|
||||||
// Validate that we have a service account json in some format
|
await validateServiceAccountJson(serviceAccountJsonRaw, serviceAccountJson)
|
||||||
if (!serviceAccountJson && !serviceAccountJsonRaw) {
|
|
||||||
console.log("No service account json key provided!");
|
|
||||||
core.setFailed("You must provide one of 'serviceAccountJson' or 'serviceAccountJsonPlainText' to use this action");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user has provided the raw plain text via secrets, or w/e, then write to file and
|
// Validate user fraction
|
||||||
// set appropriate env variable for the auth
|
let userFractionFloat: number | undefined
|
||||||
if (serviceAccountJsonRaw) {
|
if (userFraction) {
|
||||||
const serviceAccountFile = "./serviceAccountJson.json";
|
userFractionFloat = parseFloat(userFraction)
|
||||||
fs.writeFileSync(serviceAccountFile, serviceAccountJsonRaw, {
|
|
||||||
encoding: 'utf8'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insure that the api can find our service account credentials
|
|
||||||
core.exportVariable("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serviceAccountJson) {
|
|
||||||
// Insure that the api can find our service account credentials
|
|
||||||
core.exportVariable("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate user fraction as a number, and within [0.0, 1.0]
|
|
||||||
let userFractionFloat: number | undefined = parseFloat(userFraction);
|
|
||||||
if (!isNaN(userFractionFloat)) {
|
|
||||||
if (userFractionFloat <= 0.0 || userFractionFloat >= 1.0) {
|
|
||||||
core.setFailed('A provided userFraction must be between 0.0 and 1.0, inclusive-inclusive');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
userFractionFloat = undefined;
|
userFractionFloat = undefined
|
||||||
}
|
}
|
||||||
|
await validateUserFraction(userFractionFloat)
|
||||||
|
|
||||||
|
// Validate release status
|
||||||
|
await validateStatus(status, userFractionFloat != undefined && !isNaN(userFractionFloat))
|
||||||
|
|
||||||
// Validate the inAppUpdatePriority to be a valid number in within [0, 5]
|
// Validate the inAppUpdatePriority to be a valid number in within [0, 5]
|
||||||
let inAppUpdatePriorityInt: number | undefined = parseInt(inAppUpdatePriority);
|
let inAppUpdatePriorityInt: number | undefined
|
||||||
if (!isNaN(inAppUpdatePriorityInt)) {
|
if (inAppUpdatePriority) {
|
||||||
if (inAppUpdatePriorityInt < 0 || inAppUpdatePriorityInt > 5) {
|
inAppUpdatePriorityInt = parseInt(inAppUpdatePriority)
|
||||||
core.setFailed('inAppUpdatePriority must be between 0 and 5, inclusive-inclusive');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
inAppUpdatePriorityInt = undefined;
|
inAppUpdatePriorityInt = undefined
|
||||||
}
|
}
|
||||||
|
await validateInAppUpdatePriority(inAppUpdatePriorityInt)
|
||||||
|
|
||||||
// Check release files while maintaining backward compatibility
|
// Check release files while maintaining backward compatibility
|
||||||
let validatedReleaseFiles: string[] = [];
|
if (releaseFile) {
|
||||||
if (releaseFiles.length == 0 && !releaseFile) {
|
core.warning(`WARNING!! 'releaseFile' is deprecated and will be removed in a future release. Please migrate to 'releaseFiles'`)
|
||||||
core.setFailed(`You must provide either 'releaseFile' or 'releaseFiles' in your configuration.`);
|
|
||||||
return;
|
|
||||||
} else if (releaseFiles.length == 0 && releaseFile) {
|
|
||||||
core.warning(`WARNING!! 'releaseFile' is deprecated and will be removed in a future release. Please migrate to 'releaseFiles'.`);
|
|
||||||
core.debug(`Validating ${releaseFile} exists`)
|
|
||||||
if (!fs.existsSync(releaseFile)) {
|
|
||||||
core.setFailed(`Unable to find release file @ ${releaseFile}`);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
validatedReleaseFiles = [releaseFile];
|
|
||||||
}
|
|
||||||
} else if (releaseFiles.length > 0) {
|
|
||||||
core.debug(`Finding files ${releaseFiles.join(',')}`)
|
|
||||||
const files = await fg(releaseFiles);
|
|
||||||
if (!files.length) {
|
|
||||||
core.setFailed(`Unable to find any release file @ ${releaseFiles.join(',')}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
validatedReleaseFiles = files;
|
|
||||||
}
|
}
|
||||||
|
const validatedReleaseFiles: string[] = await validateReleaseFiles(releaseFiles ?? [releaseFile])
|
||||||
|
|
||||||
if (whatsNewDir != undefined && whatsNewDir.length > 0 && !fs.existsSync(whatsNewDir)) {
|
if (whatsNewDir != undefined && whatsNewDir.length > 0 && !fs.existsSync(whatsNewDir)) {
|
||||||
core.setFailed(`Unable to find 'whatsnew' directory @ ${whatsNewDir}`);
|
core.warning(`Unable to find 'whatsnew' directory @ ${whatsNewDir}`);
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mappingFile != undefined && mappingFile.length > 0 && !fs.existsSync(mappingFile)) {
|
if (mappingFile != undefined && mappingFile.length > 0 && !fs.existsSync(mappingFile)) {
|
||||||
core.setFailed(`Unable to find 'mappingFile' @ ${mappingFile}`);
|
core.warning(`Unable to find 'mappingFile' @ ${mappingFile}`);
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authClient = await auth.getClient();
|
if (debugSymbols != undefined && debugSymbols.length > 0 && !fs.existsSync(debugSymbols)) {
|
||||||
|
core.warning(`Unable to find 'debugSymbols' @ ${debugSymbols}`);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await uploadToPlayStore({
|
await pTimeout(
|
||||||
auth: authClient,
|
runUpload(
|
||||||
applicationId: packageName,
|
packageName,
|
||||||
track: track,
|
track,
|
||||||
inAppUpdatePriority: inAppUpdatePriorityInt || 0,
|
inAppUpdatePriorityInt,
|
||||||
userFraction: userFractionFloat,
|
userFractionFloat,
|
||||||
status: status,
|
whatsNewDir,
|
||||||
whatsNewDir: whatsNewDir,
|
mappingFile,
|
||||||
mappingFile: mappingFile,
|
debugSymbols,
|
||||||
name: releaseName
|
releaseName,
|
||||||
}, validatedReleaseFiles);
|
changesNotSentForReview,
|
||||||
|
existingEditId,
|
||||||
console.log(`Finished uploading to the Play Store: ${result}`)
|
status,
|
||||||
} catch (error) {
|
validatedReleaseFiles
|
||||||
core.setFailed(error.message)
|
),
|
||||||
|
{
|
||||||
|
milliseconds: 3.6e+6
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
core.setFailed(error.message)
|
||||||
|
} else {
|
||||||
|
core.setFailed('Unknown error occurred.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (core.getInput('serviceAccountJsonPlainText', { required: false})) {
|
||||||
|
// Cleanup our auth file that we created.
|
||||||
|
core.debug('Cleaning up service account json file');
|
||||||
|
await unlink('./serviceAccountJson.json');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
async function validateServiceAccountJson(serviceAccountJsonRaw: string | undefined, serviceAccountJson: string | undefined): Promise<string | undefined> {
|
||||||
|
if (serviceAccountJson && serviceAccountJsonRaw) {
|
||||||
|
// If the user provided both, print a warning one will be ignored
|
||||||
|
core.warning('Both \'serviceAccountJsonPlainText\' and \'serviceAccountJson\' were provided! \'serviceAccountJson\' will be ignored.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceAccountJsonRaw) {
|
||||||
|
// If the user has provided the raw plain text, then write to file and set appropriate env variable
|
||||||
|
const serviceAccountFile = "./serviceAccountJson.json";
|
||||||
|
await writeFile(serviceAccountFile, serviceAccountJsonRaw, {
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
core.exportVariable("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountFile)
|
||||||
|
} else if (serviceAccountJson) {
|
||||||
|
// If the user has provided the json path, then set appropriate env variable
|
||||||
|
core.exportVariable("GOOGLE_APPLICATION_CREDENTIALS", serviceAccountJson)
|
||||||
|
} else {
|
||||||
|
// If the user provided neither, fail and exit
|
||||||
|
return Promise.reject("You must provide one of 'serviceAccountJsonPlainText' or 'serviceAccountJson' to use this action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as core from '@actions/core';
|
import * as core from '@actions/core';
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import {readFileSync} from "fs";
|
import {androidpublisher_v3} from "@googleapis/androidpublisher";
|
||||||
import {androidpublisher_v3} from "googleapis";
|
|
||||||
import LocalizedText = androidpublisher_v3.Schema$LocalizedText;
|
import LocalizedText = androidpublisher_v3.Schema$LocalizedText;
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
export async function readLocalizedReleaseNotes(whatsNewDir: string | undefined): Promise<LocalizedText[] | undefined> {
|
export async function readLocalizedReleaseNotes(whatsNewDir: string | undefined): Promise<LocalizedText[] | undefined> {
|
||||||
core.debug(`Executing readLocalizedReleaseNotes`);
|
core.debug(`Executing readLocalizedReleaseNotes`);
|
||||||
@@ -12,16 +12,16 @@ export async function readLocalizedReleaseNotes(whatsNewDir: string | undefined)
|
|||||||
.filter(value => /whatsnew-((.*-.*)|(.*))\b/.test(value));
|
.filter(value => /whatsnew-((.*-.*)|(.*))\b/.test(value));
|
||||||
const pattern = /whatsnew-(?<local>(.*-.*)|(.*))/;
|
const pattern = /whatsnew-(?<local>(.*-.*)|(.*))/;
|
||||||
|
|
||||||
let localizedReleaseNotes: LocalizedText[] = [];
|
const localizedReleaseNotes: LocalizedText[] = [];
|
||||||
|
|
||||||
core.debug(`Found files: ${releaseNotes}`);
|
core.debug(`Found files: ${releaseNotes.toString()}`);
|
||||||
releaseNotes.forEach(value => {
|
for (const value of releaseNotes) {
|
||||||
const matches = value.match(pattern);
|
const matches = value.match(pattern);
|
||||||
core.debug(`Matches for ${value} = ${matches}`);
|
if (matches != null && matches.length == 4) {
|
||||||
if (matches != undefined && matches.length == 4) {
|
core.debug(`Matches for ${value} = ${matches.toString()}`);
|
||||||
const lang = matches[1];
|
const lang = matches[1];
|
||||||
const filePath = path.join(whatsNewDir, value);
|
const filePath = path.join(whatsNewDir, value);
|
||||||
const content = readFileSync(filePath, 'utf-8');
|
const content = await readFile(filePath, 'utf-8');
|
||||||
|
|
||||||
if (content != undefined) {
|
if (content != undefined) {
|
||||||
core.debug(`Found localized 'whatsnew-*-*' for Lang(${lang})`);
|
core.debug(`Found localized 'whatsnew-*-*' for Lang(${lang})`);
|
||||||
@@ -33,7 +33,7 @@ export async function readLocalizedReleaseNotes(whatsNewDir: string | undefined)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
return localizedReleaseNotes
|
return localizedReleaseNotes
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user