46 Commits

Author SHA1 Message Date
Jack Boswell
935ef9c68b Update dependencies & target node20 (#212)
* Update dependencies & target Node 20

* Bump version
2024-02-10 00:01:23 +13:00
dependabot[bot]
84ffa89623 Bump tj-actions/verify-changed-files in /.github/workflows (#206)
Bumps [tj-actions/verify-changed-files](https://github.com/tj-actions/verify-changed-files) from 9.2 to 17.
- [Release notes](https://github.com/tj-actions/verify-changed-files/releases)
- [Changelog](https://github.com/tj-actions/verify-changed-files/blob/main/HISTORY.md)
- [Commits](https://github.com/tj-actions/verify-changed-files/compare/v9.2...v17)

---
updated-dependencies:
- dependency-name: tj-actions/verify-changed-files
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-03 11:18:09 +13:00
Emil Tabakov
b6837fa35c Add instructions to configure service account in README (#201) 2023-11-13 22:57:13 +13:00
dependabot[bot]
7c9dfbb03e Bump @babel/traverse from 7.19.6 to 7.23.2 (#198)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.19.6 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-19 11:57:55 +13:00
Jack Boswell
9cf8fd30c9 Update documentation to link to Google's track name information (#191) 2023-09-19 16:44:00 +12:00
Jack Boswell
101c2cddf9 Update all dependencies and bump version (#192) 2023-09-19 16:43:48 +12:00
dependabot[bot]
d17e3fc745 Bump word-wrap from 1.2.3 to 1.2.4 (#184)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-19 10:14:15 +12:00
Jack Boswell
70c7397e86 Fix deprecated releaseFile input not being considered (#174) 2023-02-11 12:10:44 +13:00
Jack Boswell
853233422d Fix a bug where our new timeout caused the action to stay "alive" (#173) 2023-02-11 12:06:30 +13:00
Jack Boswell
9a5e7fcdd5 Add an upload timeout of 60 minutes (#171)
* Add an upload timeout of 60 minutes

* Remove now defunct tests
2023-02-10 22:59:20 +13:00
Sindre Moen
02c72d76fe Remove status default from README (#164)
* Remove status default from README

* Default status to `completed`
2023-02-10 22:00:07 +13:00
Shi Chen
cd131e853c feature: Add support for App Bundle Explorer's Internal app sharing link. (#166)
Updated json5 dependency to patch vulnerability.
2023-01-13 16:55:12 +13:00
Saalihou Ndiaye
8a353d6e46 Add doc about packageName needing to exist first (#168) 2023-01-13 15:31:31 +13:00
dependabot[bot]
d5cf6e9445 Bump json5 from 2.2.1 to 2.2.3 (#167)
Bumps [json5](https://github.com/json5/json5) from 2.2.1 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.1...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-13 15:25:47 +13:00
Ivan Milisavljevic
b9dc4464a6 Dont fail the action if optional files are not present on the dics (#162) 2022-12-07 15:19:47 +13:00
Keisuke Yamaguchi
974d2d7566 Update userFraction reuqired to false in README (#157)
* Update userFraction reuqired to false

* Update userFraction to exclusive-exclusive and delete default value
2022-11-23 17:13:43 +13:00
Jack Boswell
536f822b60 Update release workflow (#155) 2022-11-03 19:22:11 +13:00
Jack Boswell
d7e6e42414 fix: Bring back the default release status (#154)
* Bring back the default release status

* Create a test for the bare minimum inputs
2022-11-03 19:11:20 +13:00
Jack Boswell (boswelja)
78c9e796b1 Update dependencies + prepare for release 2022-10-24 08:17:46 +00:00
Jack Boswell
980a5c4201 Write edit expiry time to debug console (#152) 2022-10-24 21:11:26 +13:00
Jack Boswell
81a2f1ad1c Print track names when invalid track provided (#151)
* Print track names when invalid track provided

* Remove redundant safe call
2022-10-24 21:06:38 +13:00
Jack Boswell
b84eace68d Set up full input testing (#150)
* Extract main loop to something testable

* Revert "Extract main loop to something testable"

This reverts commit 35a78459698fe267933c554283212c2d06ed4bf0.

* Extract code that interacts with Google Play
We can now test our input flow as a whole

* Create some basic tests for `completed` status publishing

* Fix applying inputs
We don't want to be keeping values between runs

* Drop some redundant tests

* Create basic tests for draft rollout

* Run build

* Fix userFraction bug

* Improve readability of initialising inputs

* Fix runUpload mocking

* Migrate to github-action-ts-run-api
2022-10-24 14:18:11 +13:00
Jack Boswell (boswelja)
4d510d380a Attempt at fixing status validation
Our validation tests are A-OK, but inputs are not :(
2022-09-26 15:37:05 +13:00
Jack Boswell
99b7a1e1d4 Fix issues parsing Int and Float (#145)
Undefined was transformed into NaN, which is not undefined and thus failed validation
2022-09-24 16:51:04 +12:00
Jack Boswell
7cd1379b4f refactor: Centralized input validation (#142)
* Initial implementaion of service account json, user fraction and status validation

* Move validation to input-validation
Also created some more validators

* Rearrange whatsnew tests to clear out test folder

* Return error messages for input validation

* Create tests for some validation code

* Separate user fraction and status validation

* Write more tests and fix some bugs

* Refactor to use promises

* Fix release file loading from glob
2022-09-21 17:15:54 +12:00
Jack Boswell
b028ed83b3 Set up ESLint (#138)
* Add eslint and config

* Refactor edits.ts to comply with eslint

* Refactor whatsnew.ts to comply with eslint

* Fix final ESLint issues

* Run build
2022-09-12 20:37:51 +12:00
Jack Boswell
5427bcbe2b Ignore userFraction if status is draft (#140) 2022-09-12 20:23:41 +12:00
Thomas Aunvik
6a74f079de Add Debug Symbol File/Folder Upload (#130)
* Add Debug Symbol File/Folder Upload

Uploads Deobfuscation File type 'nativeCode'.

Inputs allowed in 'debugSymbols', a .zip file, or a folder that contains the symbols.

* Space Formatting

* Zip Directory Function higher level, and formatting
2022-09-12 00:21:02 +12:00
Jack Boswell
c16321c553 feature: Infer status from provided parameters (#122)
* Mark status as deprecated

* Make userFraction required & provide a default
We'll use this to infer `inProgress` or `completed`

* Infer status from userFraction

* Fix workflow

* Update README

* Run build

* Add support for drafts again

* Pass in isDraft

* Warn about setting draft and userFraction simultaneously

* Expose isDraft

* Do a build

* Allow passing status in again
This will be required for draft/halted
It is optional though, if it's not specified, we have updated logic for inferring it

* Update userFraction description

* Revert "Fix workflow"

This reverts commit ad98b3d449f0f36c9d4f0a3d0b2eb49a4c3e1aa9.

* Remove deprecated tag from status

* Move status back

* Allow 0 userFraction

* Run build
2022-09-11 20:53:18 +12:00
Jack Boswell
7406d2c7a1 Update deps (#137) 2022-09-11 19:51:48 +12:00
Jack Boswell
623184c55d Validate library is up to date in workflow (#121) 2022-06-20 21:17:01 +12:00
Jack Boswell
91c8f42cf7 feature: Allow editing existing Edits (#120)
* Only take edit ID for track validation

* Only take edit ID for adding releases

* Only take edit ID for mapping file upload

* Only take edit ID for uploading artifacts

* Only use app edit ID for uploading releases

* Rebuild

* Allow passing in an existing edit ID

* Expose existingEditId

* Run build
2022-06-20 20:59:52 +12:00
Jack Boswell
e00bb8ce1e feature: Add support for changesNotSentForReview (#117)
Credits to @princefr for the original implementation
2022-06-18 19:50:39 +12:00
Jack Boswell (boswelja)
7f42989517 Ensure deps are up to date and do a build 2022-06-18 18:57:34 +12:00
Jack Boswell (boswelja)
6fefbe4681 Prep for release 2022-06-18 18:54:49 +12:00
Jack Boswell
f1479e715c Chore: Update readme (#115)
* Update README formatting

* Sort inputs by required

* Sort track values by priority

* Table-ify inputs in README

* Table-ify outputs too

* Bring back the `whatsNewDirectory` example
2022-06-18 18:52:48 +12:00
Jack Boswell
7b7381ed55 Added cleanup to generated service account file (#116)
Co-authored-by: Drew Heavner <veedubusc@gmail.com>
2022-06-18 18:32:34 +12:00
Jack Boswell
642ba59b8a Create pr-validation.yml (#114) 2022-06-15 17:30:19 +12:00
Jack Boswell
dd8dcab872 Chore: Update dependencies (#113)
* Bump minor versions

* Fix error not having a type

* Remove jest-circus

* Bump ncc

* Remove ts-jest

* Drop googleapis for androidpublisher in particular
Way smaller compiled size, and way shorter compile time

* Bump jest

* Bring back ts-jest

* Bump @types/node
2022-06-15 17:13:36 +12:00
dependabot[bot]
cf500ad293 Bump tmpl from 1.0.4 to 1.0.5 (#110)
Bumps [tmpl](https://github.com/daaku/nodejs-tmpl) from 1.0.4 to 1.0.5.
- [Release notes](https://github.com/daaku/nodejs-tmpl/releases)
- [Commits](https://github.com/daaku/nodejs-tmpl/commits/v1.0.5)

---
updated-dependencies:
- dependency-name: tmpl
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-15 13:53:24 +12:00
dependabot[bot]
354ae8f121 Bump ansi-regex from 5.0.0 to 5.0.1 (#107)
Bumps [ansi-regex](https://github.com/chalk/ansi-regex) from 5.0.0 to 5.0.1.
- [Release notes](https://github.com/chalk/ansi-regex/releases)
- [Commits](https://github.com/chalk/ansi-regex/compare/v5.0.0...v5.0.1)

---
updated-dependencies:
- dependency-name: ansi-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-15 13:52:46 +12:00
dependabot[bot]
b36eb787b2 Bump node-fetch from 2.6.1 to 2.6.7 (#108)
Bumps [node-fetch](https://github.com/node-fetch/node-fetch) from 2.6.1 to 2.6.7.
- [Release notes](https://github.com/node-fetch/node-fetch/releases)
- [Commits](https://github.com/node-fetch/node-fetch/compare/v2.6.1...v2.6.7)

---
updated-dependencies:
- dependency-name: node-fetch
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-15 13:52:27 +12:00
dependabot[bot]
c23d6291fa Bump lodash from 4.17.20 to 4.17.21 (#106)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.20 to 4.17.21.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.20...4.17.21)

---
updated-dependencies:
- dependency-name: lodash
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-15 13:52:13 +12:00
dependabot[bot]
8eb5399590 Bump minimist from 1.2.5 to 1.2.6 (#99)
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-06-15 13:51:01 +12:00
Tobias Schürg
51e4495788 docs: add status property to examples (#94) 2022-06-14 20:09:37 -04:00
Doug Keen
15ee0081ab fix: default behavior when status is unspecified (#80) (#87) 2022-06-14 20:03:26 -04:00
22 changed files with 6544 additions and 10362 deletions

15
.eslintrc.cjs Normal file
View 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
View 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

View File

@@ -1,24 +1,16 @@
name: "Tag & Deploy Action"
name: "Update v1 tag"
on:
push:
branches:
- master
release:
types: [released]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v2.5.0
- name: Install
run: npm ci
- name: Run Tests
run: npm run test
- name: Update v1 Tag
uses: hole19/git-tag-action@master
env:
TAG: v1
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: rickstaa/action-create-tag@v1
with:
tag: v1
force_push_tag: true

155
README.md
View File

@@ -4,123 +4,74 @@ This action will help you upload an Android `.apk` or `.aab` (Android App Bundle
## Inputs
_You must provide one of either `serviceAccountJson` or `serviceAccountJsonPlainText`_
### `serviceAccountJson`
The service account json private key file to authorize the upload request
### `serviceAccountJsonPlainText`
The service account json in plain text, provided via a secret, etc.
### `packageName`
**Required:** The package name, or Application Id, of the app you are uploading
### `releaseFile`
**DEPRECATED:** Please switch to using `releaseFiles` as this will be removed in the future
The Android release file to upload (.apk or .aab)
### `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
| Input | Description | Value | Required |
| --- | --- | --- | --- |
| 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) |
| 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 |
| 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 |
| 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 |
| 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 |
| 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` |
| 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) |
| 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 |
| ~~releaseFile~~ | Please switch to using `releaseFiles` as this will be removed in the future | | false |
## Outputs
### `internalSharingDownloadUrls`
A JSON list containing the download urls for every release file uploaded using the `track` `internalsharing`
### 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`
| Output | Environment Variable | Description |
| --- | --- | --- |
| 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 |
## Example usage
The below example publishes `MyApp` to Google Play, targetting 33% (`0.33`) of users with a priority of `2`.
```yaml
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: ${{ SERVICE_ACCOUNT_JSON }}
serviceAccountJsonPlainText: ${{ SERVICE_ACCOUNT_JSON }}
packageName: com.example.MyApp
releaseFile: ${{ SIGNED_RELEASE_FILE}}
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: production
status: inProgress
inAppUpdatePriority: 2
userFraction: 0.33
whatsNewDirectory: distribution/whatsnew
mappingFile: app/build/outputs/mapping/release/mapping.txt
debugSymbols: app/intermediates/merged_native_libs/release/out/lib
```
Using glob to get release files
```yaml
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJson: ${{ SERVICE_ACCOUNT_JSON }}
packageName: com.example.MyApp
releaseFiles: app/build/outputs/bundle/release/*.aab
track: production
inAppUpdatePriority: 2
userFraction: 0.33
whatsNewDirectory: distribution/whatsnew
mappingFile: app/build/outputs/mapping/release/mapping.txt
## Configure service account
1. Configure service account in Google Cloud Platform
1. Navigate to https://cloud.google.com/gcp
1. Open IAM and admin > Service accounts > Create service account
1. Pick a name and add appropriate permissions (e.g. 'owner)
1. Open the newly created service account, click on `keys` tab and add a new key, JSON type
1. When successful, a JSON file will be automatically downloaded on your machine
1. Store the content of this file to your GitHub secrets, e.g. SERVICE_ACCOUNT_JSON.
1. Add user to Google Play Console
1. Open https://play.google.com/console and pick your developer account
1. Open Users and permissions
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
```

View 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
View 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)
})

View File

View 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 {androidpublisher_v3} from "googleapis";
import LocalizedText = androidpublisher_v3.Schema$LocalizedText;
test("read localized whatsnew files", async () => {
let texts = await readLocalizedReleaseNotes("./__tests__");
let texts = await readLocalizedReleaseNotes("./__tests__/whatsnew");
expect(texts).toHaveLength(6);
expect(texts).toContainEqual({
language: "en-US",
@@ -32,4 +27,4 @@ test("read localized whatsnew files", async () => {
language: "et",
text: "test_changelog_file_estonian"
});
});
});

View File

@@ -36,16 +36,27 @@ inputs:
required: false
status:
description: "Release status. This can be set to 'draft' to complete the release at some other time."
required: false
required: true
default: "completed"
whatsNewDirectory:
description: "The directory of localized whats new files"
required: false
mappingFile:
description: "The mapping.txt file used to de-obfuscate your stack traces from crash reports"
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:
internalSharingDownloadUrl:
description: "The internal app sharing download url if track was 'internalsharing'"
runs:
using: 'node12'
using: 'node20'
main: 'lib/index.js'

File diff suppressed because one or more lines are too long

15790
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "upload-google-play",
"version": "1.0.13",
"version": "1.1.3",
"private": true,
"description": "Upload an Android release (.apk or .aab) to Google Play Console",
"main": "lib/index.js",
@@ -20,17 +20,22 @@
"author": "r0adkll",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.2.6",
"fast-glob": "^3.2.5",
"googleapis": "^68.0.0"
"@actions/core": "^1.10.1",
"@googleapis/androidpublisher": "^12.1.0",
"fast-glob": "^3.3.2",
"jszip": "^3.10.1",
"p-timeout": "^6.1.2"
},
"devDependencies": {
"@types/jest": "^26.0.22",
"@types/node": "^14.14.37",
"@vercel/ncc": "^0.27.0",
"jest": "^26.6.3",
"jest-circus": "^26.6.3",
"ts-jest": "^26.5.4",
"typescript": "^4.2.3"
"@types/jest": "^29.5.12",
"@types/node": "^20.11.17",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vercel/ncc": "^0.38.1",
"eslint": "^8.56.0",
"github-action-ts-run-api": "^3.0.4",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3"
}
}

View File

@@ -1,163 +1,191 @@
import * as core from '@actions/core';
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 {androidpublisher_v3} from "googleapis";
import * as google from '@googleapis/androidpublisher';
import { androidpublisher_v3 } from "@googleapis/androidpublisher";
import AndroidPublisher = androidpublisher_v3.Androidpublisher;
import AppEdit = androidpublisher_v3.Schema$AppEdit;
import Apk = androidpublisher_v3.Schema$Apk;
import Bundle = androidpublisher_v3.Schema$Bundle;
import Track = androidpublisher_v3.Schema$Track;
import InternalAppSharingArtifact = androidpublisher_v3.Schema$InternalAppSharingArtifact;
import {Compute} from "google-auth-library/build/src/auth/computeclient";
import {JWT} from "google-auth-library/build/src/auth/jwtclient";
import {UserRefreshClient} from "google-auth-library/build/src/auth/refreshclient";
import {readLocalizedReleaseNotes} from "./whatsnew";
import { GoogleAuth } from "google-auth-library/build/src/auth/googleauth"
import { readLocalizedReleaseNotes } from "./whatsnew";
const androidPublisher: AndroidPublisher = google.androidpublisher('v3');
export interface EditOptions {
auth: Compute | JWT | UserRefreshClient;
auth: GoogleAuth;
applicationId: string;
track: string;
inAppUpdatePriority: number;
userFraction?: number;
whatsNewDir?: string;
mappingFile?: string;
debugSymbols?: 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
if (options.track === 'internalsharing') {
core.debug("Track is Internal app sharing, switch to special upload api")
let downloadUrls: string[] = []
for (const releaseFile of releaseFiles) {
core.debug(`Uploading ${releaseFile}`);
await uploadInternalSharingRelease(options, releaseFile)
.then(downloadUrl => {
downloadUrls.push(downloadUrl as string)
})
.catch(reason => {
core.setFailed(reason);
return Promise.reject(reason);
});
const url = await uploadInternalSharingRelease(options, releaseFile)
internalSharingDownloadUrls.push(url)
}
core.setOutput("internalSharingDownloadUrls", downloadUrls);
core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URLS", downloadUrls);
} else {
// Create a new Edit
core.info(`Creating a new Edit for this release`)
const appEdit = await androidPublisher.edits.insert({
auth: options.auth,
packageName: options.applicationId
});
const appEditId = await getOrCreateEdit(options)
// Validate the given track
core.info(`Validating track '${options.track}'`)
await validateSelectedTrack(appEdit.data, options).catch(reason => {
core.setFailed(reason);
return Promise.reject(reason);
});
await validateSelectedTrack(appEditId, options)
// Upload artifacts to Google Play, and store their version codes
const versionCodes = new Array<number>();
for (const releaseFile of releaseFiles) {
core.info(`Uploading ${releaseFile}`);
const versionCode = await uploadRelease(appEdit.data, options, releaseFile).catch(reason => {
core.setFailed(reason);
return Promise.reject(reason);
});
versionCodes.push(versionCode!);
const versionCodes = await uploadReleaseFiles(appEditId, options, releaseFiles)
// Infer the download URL from the version codes
for (const versionCode of versionCodes) {
const url = inferInternalSharingDownloadUrl(options.applicationId, versionCode);
core.setOutput("internalSharingDownloadUrl", url);
core.exportVariable("INTERNAL_SHARING_DOWNLOAD_URL", url);
internalSharingDownloadUrls.push(url);
}
core.info(`Successfully uploaded ${versionCodes.length} artifacts`)
// Add the uploaded artifacts to the Edit track
core.info(`Adding ${versionCodes.length} artifacts to release on '${options.track}' track`)
const track = await addReleasesToTrack(appEdit.data, options, versionCodes);
core.debug(`Track: ${track}`);
await addReleasesToTrack(appEditId, options, versionCodes);
// Commit the pending Edit
core.info(`Committing the Edit`)
const res = await androidPublisher.edits.commit({
auth: options.auth,
editId: appEdit.data.id!,
packageName: options.applicationId
editId: appEditId,
packageName: options.applicationId,
changesNotSentForReview: options.changesNotSentForReview
});
// Simple check to see whether commit was successful
if (res.data.id != null) {
if (res.data.id) {
core.info(`Successfully committed ${res.data.id}`);
return Promise.resolve(res.data.id!);
return res.data.id
} else {
core.setFailed(`Error ${res.status}: ${res.statusText}`);
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')) {
const 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)
res = await internalSharingUploadApk(options, releaseFile)
} else if (releaseFile.endsWith('.aab')) {
const 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)
res = await internalSharingUploadBundle(options, releaseFile)
} 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> {
if (releaseFile.endsWith('.apk')) {
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> {
async function validateSelectedTrack(appEditId: string, options: EditOptions): Promise<void> {
core.info(`Validating track '${options.track}'`)
const res = await androidPublisher.edits.tracks.list({
auth: options.auth,
editId: appEdit.id!,
editId: appEditId,
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;
if (allTracks == undefined || allTracks.find(value => value.track == options.track) == undefined) {
return Promise.reject(`Track "${options.track}" could not be found `);
// Check whether we actually have any tracks
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> {
let status: string | undefined = options.status;
if (status == undefined) {
if (options.userFraction != undefined) {
status = 'inProgress';
} else {
status = 'completed';
}
}
async function addReleasesToTrack(appEditId: string, options: EditOptions, versionCodes: number[]): Promise<Track> {
const status = options.status
core.debug(`Creating release for:`);
core.debug(`edit=${appEditId}`)
core.debug(`track=${options.track}`)
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
.update({
auth: options.auth,
editId: appEdit.id!,
editId: appEditId,
packageName: options.applicationId,
track: options.track,
requestBody: {
@@ -178,15 +206,15 @@ async function addReleasesToTrack(appEdit: AppEdit, options: EditOptions, versio
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) {
const mapping = readFileSync(options.mappingFile, 'utf-8');
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({
auth: options.auth,
packageName: options.applicationId,
editId: appEdit.id!,
editId: appEditId,
apkVersionCode: versionCode,
deobfuscationFileType: 'proguard',
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> {
core.debug(`[packageName=${options.applicationId}]: Uploading Internal Sharing APK @ ${apkReleaseFile}`);
@@ -228,13 +315,13 @@ async function internalSharingUploadBundle(options: EditOptions, bundleReleaseFi
return res.data;
}
async function uploadApk(appEdit: AppEdit, options: EditOptions, apkReleaseFile: string): Promise<Apk> {
core.debug(`[${appEdit.id}, packageName=${options.applicationId}]: Uploading APK @ ${apkReleaseFile}`);
async function uploadApk(appEditId: string, options: EditOptions, apkReleaseFile: string): Promise<Apk> {
core.debug(`[${appEditId}, packageName=${options.applicationId}]: Uploading APK @ ${apkReleaseFile}`);
const res = await androidPublisher.edits.apks.upload({
auth: options.auth,
packageName: options.applicationId,
editId: appEdit.id!,
editId: appEditId,
media: {
mimeType: 'application/vnd.android.package-archive',
body: fs.createReadStream(apkReleaseFile)
@@ -244,12 +331,12 @@ async function uploadApk(appEdit: AppEdit, options: EditOptions, apkReleaseFile:
return res.data
}
async function uploadBundle(appEdit: AppEdit, options: EditOptions, bundleReleaseFile: string): Promise<Bundle> {
core.debug(`[${appEdit.id}, packageName=${options.applicationId}]: Uploading App Bundle @ ${bundleReleaseFile}`);
async function uploadBundle(appEditId: string, options: EditOptions, bundleReleaseFile: string): Promise<Bundle> {
core.debug(`[${appEditId}, packageName=${options.applicationId}]: Uploading App Bundle @ ${bundleReleaseFile}`);
const res = await androidPublisher.edits.bundles.upload({
auth: options.auth,
packageName: options.applicationId,
editId: appEdit.id!,
editId: appEditId,
media: {
mimeType: 'application/octet-stream',
body: fs.createReadStream(bundleReleaseFile)
@@ -258,3 +345,70 @@ async function uploadBundle(appEdit: AppEdit, options: EditOptions, bundleReleas
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
View 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
}
}

View File

@@ -1,14 +1,11 @@
import * as core from '@actions/core';
import * as fs from "fs";
import fg from "fast-glob";
import { uploadToPlayStore } from "./edits";
const {google} = require('googleapis');
import * as core from '@actions/core'
import * as fs from "fs"
import { runUpload } from "./edits"
import { validateInAppUpdatePriority, validateReleaseFiles, validateStatus, validateUserFraction } from "./input-validation"
import { unlink, writeFile } from 'fs/promises'
import pTimeout from 'p-timeout'
const auth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/androidpublisher']
});
async function run() {
export async function run() {
try {
const serviceAccountJson = core.getInput('serviceAccountJson', { required: false });
const serviceAccountJsonRaw = core.getInput('serviceAccountJsonPlainText', { required: false});
@@ -16,113 +13,113 @@ async function run() {
const releaseFile = core.getInput('releaseFile', { required: false });
const releaseFiles = core.getInput('releaseFiles', { required: false })
?.split(',')
?.filter(x => x !== '') || [];
?.filter(x => x !== '');
const releaseName = core.getInput('releaseName', { required: false });
const track = core.getInput('track', { required: true });
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 whatsNewDir = core.getInput('whatsNewDirectory', { 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
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;
}
await validateServiceAccountJson(serviceAccountJsonRaw, serviceAccountJson)
// If the user has provided the raw plain text via secrets, or w/e, then write to file and
// set appropriate env variable for the auth
if (serviceAccountJsonRaw) {
const serviceAccountFile = "./serviceAccountJson.json";
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;
}
// Validate user fraction
let userFractionFloat: number | undefined
if (userFraction) {
userFractionFloat = parseFloat(userFraction)
} 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]
let inAppUpdatePriorityInt: number | undefined = parseInt(inAppUpdatePriority);
if (!isNaN(inAppUpdatePriorityInt)) {
if (inAppUpdatePriorityInt < 0 || inAppUpdatePriorityInt > 5) {
core.setFailed('inAppUpdatePriority must be between 0 and 5, inclusive-inclusive');
return;
}
let inAppUpdatePriorityInt: number | undefined
if (inAppUpdatePriority) {
inAppUpdatePriorityInt = parseInt(inAppUpdatePriority)
} else {
inAppUpdatePriorityInt = undefined;
inAppUpdatePriorityInt = undefined
}
await validateInAppUpdatePriority(inAppUpdatePriorityInt)
// Check release files while maintaining backward compatibility
let validatedReleaseFiles: string[] = [];
if (releaseFiles.length == 0 && !releaseFile) {
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;
if (releaseFile) {
core.warning(`WARNING!! 'releaseFile' is deprecated and will be removed in a future release. Please migrate to 'releaseFiles'`)
}
const validatedReleaseFiles: string[] = await validateReleaseFiles(releaseFiles ?? [releaseFile])
if (whatsNewDir != undefined && whatsNewDir.length > 0 && !fs.existsSync(whatsNewDir)) {
core.setFailed(`Unable to find 'whatsnew' directory @ ${whatsNewDir}`);
return
core.warning(`Unable to find 'whatsnew' directory @ ${whatsNewDir}`);
}
if (mappingFile != undefined && mappingFile.length > 0 && !fs.existsSync(mappingFile)) {
core.setFailed(`Unable to find 'mappingFile' @ ${mappingFile}`);
return
core.warning(`Unable to find 'mappingFile' @ ${mappingFile}`);
}
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({
auth: authClient,
applicationId: packageName,
track: track,
inAppUpdatePriority: inAppUpdatePriorityInt || 0,
userFraction: userFractionFloat,
status: status,
whatsNewDir: whatsNewDir,
mappingFile: mappingFile,
name: releaseName
}, validatedReleaseFiles);
console.log(`Finished uploading to the Play Store: ${result}`)
} catch (error) {
core.setFailed(error.message)
await pTimeout(
runUpload(
packageName,
track,
inAppUpdatePriorityInt,
userFractionFloat,
whatsNewDir,
mappingFile,
debugSymbols,
releaseName,
changesNotSentForReview,
existingEditId,
status,
validatedReleaseFiles
),
{
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();

View File

@@ -1,9 +1,9 @@
import * as core from '@actions/core';
import * as fs from "fs";
import * as path from "path";
import {readFileSync} from "fs";
import {androidpublisher_v3} from "googleapis";
import {androidpublisher_v3} from "@googleapis/androidpublisher";
import LocalizedText = androidpublisher_v3.Schema$LocalizedText;
import { readFile } from 'fs/promises';
export async function readLocalizedReleaseNotes(whatsNewDir: string | undefined): Promise<LocalizedText[] | undefined> {
core.debug(`Executing readLocalizedReleaseNotes`);
@@ -12,16 +12,16 @@ export async function readLocalizedReleaseNotes(whatsNewDir: string | undefined)
.filter(value => /whatsnew-((.*-.*)|(.*))\b/.test(value));
const pattern = /whatsnew-(?<local>(.*-.*)|(.*))/;
let localizedReleaseNotes: LocalizedText[] = [];
const localizedReleaseNotes: LocalizedText[] = [];
core.debug(`Found files: ${releaseNotes}`);
releaseNotes.forEach(value => {
core.debug(`Found files: ${releaseNotes.toString()}`);
for (const value of releaseNotes) {
const matches = value.match(pattern);
core.debug(`Matches for ${value} = ${matches}`);
if (matches != undefined && matches.length == 4) {
if (matches != null && matches.length == 4) {
core.debug(`Matches for ${value} = ${matches.toString()}`);
const lang = matches[1];
const filePath = path.join(whatsNewDir, value);
const content = readFileSync(filePath, 'utf-8');
const content = await readFile(filePath, 'utf-8');
if (content != undefined) {
core.debug(`Found localized 'whatsnew-*-*' for Lang(${lang})`);
@@ -33,7 +33,7 @@ export async function readLocalizedReleaseNotes(whatsNewDir: string | undefined)
)
}
}
});
}
return localizedReleaseNotes
}