GitHub Actions
Complete, copy-pasteable workflows for GitHub Actions. Each workflow is self-contained -- copy it into .github/workflows/ and configure secrets.
Prerequisites
- A Google Cloud service account with Play Console permissions
- The service account JSON stored as a GitHub Actions secret named
GPC_SERVICE_ACCOUNT - Your app's package name (e.g.,
com.example.myapp)
# Store service account as a secret (via GitHub CLI)
gh secret set GPC_SERVICE_ACCOUNT < service-account.json2
Basic: Upload on Tag Push
Triggers on version tags. Builds the AAB and uploads to the internal testing track.
# .github/workflows/release-internal.yml
name: Release to Internal
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Build AAB
run: ./gradlew bundleRelease
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Upload to Internal Track
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
GPC_APP: com.example.myapp
run: |
gpc releases upload \
app/build/outputs/bundle/release/app-release.aab \
--track internal \
--notes "Build from ${{ github.ref_name }}" \
--json2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Full Pipeline: Build, Upload, Promote
Three-stage pipeline with manual track selection. Use workflow_dispatch to trigger from the GitHub UI with a track dropdown.
# .github/workflows/release-pipeline.yml
name: Release Pipeline
on:
workflow_dispatch:
inputs:
track:
description: "Target track"
type: choice
options:
- internal
- beta
- production
default: internal
rollout_percentage:
description: "Rollout percentage (production only)"
type: number
default: 10
release_notes:
description: "Release notes (en-US)"
type: string
default: "Bug fixes and performance improvements"
# Security: do not set GPC_SERVICE_ACCOUNT at workflow level.
# Scope it to individual steps that need it (see upload step below).
jobs:
build:
runs-on: ubuntu-latest
outputs:
aab-path: ${{ steps.build.outputs.aab_path }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Build release AAB
id: build
run: |
./gradlew bundleRelease
AAB_PATH="app/build/outputs/bundle/release/app-release.aab"
echo "aab_path=$AAB_PATH" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: release-bundle
path: app/build/outputs/bundle/release/app-release.aab
retention-days: 7
upload:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: release-bundle
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Preflight compliance check
run: gpc preflight app-release.aab --fail-on error --json
- name: Upload to internal track
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
GPC_APP: com.example.myapp
run: |
gpc releases upload app-release.aab \
--track internal \
--notes "${{ inputs.release_notes }}" \
--json | tee upload-result.json
- name: Write step summary
run: |
echo "## Upload Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
gpc releases status --track internal --output markdown >> $GITHUB_STEP_SUMMARY
promote:
needs: upload
if: inputs.track != 'internal'
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Promote to target track
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
GPC_APP: com.example.myapp
run: |
ROLLOUT_FLAG=""
if [[ "${{ inputs.track }}" == "production" ]]; then
ROLLOUT_FLAG="--rollout ${{ inputs.rollout_percentage }}"
fi
gpc releases promote \
--from internal \
--to ${{ inputs.track }} \
$ROLLOUT_FLAG \
--json
- name: Write step summary
run: |
echo "## Promotion Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Promoted to **${{ inputs.track }}**" >> $GITHUB_STEP_SUMMARY
gpc releases status --track ${{ inputs.track }} --output markdown >> $GITHUB_STEP_SUMMARY2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
Staged Rollout Automation
Runs on a cron schedule (weekdays at 10am UTC). Checks vitals before each rollout increase. Follows the progression: 10% -> 25% -> 50% -> 100%.
# .github/workflows/staged-rollout.yml
name: Staged Rollout
on:
schedule:
- cron: "0 10 * * 1-5" # Weekdays at 10am UTC
workflow_dispatch: # Manual trigger
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
GPC_APP: com.example.myapp
CRASH_THRESHOLD: "2.0"
ANR_THRESHOLD: "0.5"
jobs:
rollout:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Check current rollout status
id: status
run: |
STATUS=$(gpc releases status --track production --json)
CURRENT=$(echo "$STATUS" | jq -r '.data.rolloutPercentage // "100"')
RELEASE_STATUS=$(echo "$STATUS" | jq -r '.data.status // "completed"')
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
echo "release_status=$RELEASE_STATUS" >> "$GITHUB_OUTPUT"
echo "Current rollout: ${CURRENT}% (status: $RELEASE_STATUS)"
- name: Check vitals before increasing
id: vitals
if: steps.status.outputs.release_status == 'inProgress'
run: |
CRASH_RATE=$(gpc vitals crashes --json | jq -r '.data.crashRate')
ANR_RATE=$(gpc vitals anr --json | jq -r '.data.anrRate')
echo "crash_rate=$CRASH_RATE" >> "$GITHUB_OUTPUT"
echo "anr_rate=$ANR_RATE" >> "$GITHUB_OUTPUT"
echo "Crash rate: ${CRASH_RATE}%, ANR rate: ${ANR_RATE}%"
- name: Gate on vitals
if: steps.status.outputs.release_status == 'inProgress'
run: |
CRASH="${{ steps.vitals.outputs.crash_rate }}"
ANR="${{ steps.vitals.outputs.anr_rate }}"
if (( $(echo "$CRASH > $CRASH_THRESHOLD" | bc -l) )); then
echo "::error::Crash rate ${CRASH}% exceeds threshold ${CRASH_THRESHOLD}%"
gpc releases rollout halt --track production --json
echo "## Rollout Halted" >> $GITHUB_STEP_SUMMARY
echo "Crash rate **${CRASH}%** exceeded threshold **${CRASH_THRESHOLD}%**" >> $GITHUB_STEP_SUMMARY
exit 1
fi
if (( $(echo "$ANR > $ANR_THRESHOLD" | bc -l) )); then
echo "::error::ANR rate ${ANR}% exceeds threshold ${ANR_THRESHOLD}%"
gpc releases rollout halt --track production --json
echo "## Rollout Halted" >> $GITHUB_STEP_SUMMARY
echo "ANR rate **${ANR}%** exceeded threshold **${ANR_THRESHOLD}%**" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Vitals healthy. Crash: ${CRASH}%, ANR: ${ANR}%"
- name: Increase rollout
if: steps.status.outputs.release_status == 'inProgress'
run: |
CURRENT="${{ steps.status.outputs.current }}"
case $CURRENT in
10) NEXT=25 ;;
25) NEXT=50 ;;
50) NEXT=100 ;;
*)
echo "Rollout at ${CURRENT}%, no scheduled increase"
echo "## No Action" >> $GITHUB_STEP_SUMMARY
echo "Rollout is at **${CURRENT}%** -- no scheduled increase." >> $GITHUB_STEP_SUMMARY
exit 0
;;
esac
echo "Increasing rollout from ${CURRENT}% to ${NEXT}%"
if [[ "$NEXT" == "100" ]]; then
gpc releases rollout complete --track production --json
else
gpc releases rollout increase --track production --to $NEXT --json
fi
echo "## Rollout Increased" >> $GITHUB_STEP_SUMMARY
echo "Rollout increased from **${CURRENT}%** to **${NEXT}%**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value | Threshold |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|-----------|" >> $GITHUB_STEP_SUMMARY
echo "| Crash rate | ${{ steps.vitals.outputs.crash_rate }}% | ${CRASH_THRESHOLD}% |" >> $GITHUB_STEP_SUMMARY
echo "| ANR rate | ${{ steps.vitals.outputs.anr_rate }}% | ${ANR_THRESHOLD}% |" >> $GITHUB_STEP_SUMMARY
- name: Skip if not in progress
if: steps.status.outputs.release_status != 'inProgress'
run: |
echo "Release status is '${{ steps.status.outputs.release_status }}', not 'inProgress'. Skipping."
echo "## No Active Rollout" >> $GITHUB_STEP_SUMMARY
echo "No staged rollout in progress. Status: **${{ steps.status.outputs.release_status }}**" >> $GITHUB_STEP_SUMMARY2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
Vitals Monitoring
Scheduled vitals check every 6 hours. Writes a dashboard to the step summary and fails the workflow if critical thresholds are breached (useful for triggering alerts via GitHub Actions notifications).
# .github/workflows/vitals-monitor.yml
name: Vitals Monitor
on:
schedule:
- cron: "0 */6 * * *" # Every 6 hours
workflow_dispatch:
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
GPC_APP: com.example.myapp
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Collect vitals
id: vitals
run: |
echo "## Vitals Dashboard" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Value |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
CRASHES=$(gpc vitals crashes --json)
ANR=$(gpc vitals anr --json)
STARTUP=$(gpc vitals startup --json)
CRASH_RATE=$(echo "$CRASHES" | jq -r '.data.crashRate')
ANR_RATE=$(echo "$ANR" | jq -r '.data.anrRate')
COLD_START=$(echo "$STARTUP" | jq -r '.data.coldStartTime // "N/A"')
echo "| Crash rate | ${CRASH_RATE}% |" >> $GITHUB_STEP_SUMMARY
echo "| ANR rate | ${ANR_RATE}% |" >> $GITHUB_STEP_SUMMARY
echo "| Cold start | ${COLD_START}ms |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "crash_rate=$CRASH_RATE" >> "$GITHUB_OUTPUT"
echo "anr_rate=$ANR_RATE" >> "$GITHUB_OUTPUT"
- name: Check for anomalies
run: |
ANOMALIES=$(gpc vitals anomalies --json | jq -r '.data.anomalies // []')
COUNT=$(echo "$ANOMALIES" | jq 'length')
if [[ "$COUNT" -gt 0 ]]; then
echo "### Anomalies Detected" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "$ANOMALIES" | jq -r '.[] | "- **\(.metric)**: \(.description)"' >> $GITHUB_STEP_SUMMARY
fi
- name: Week-over-week comparison
run: |
echo "### Trend Comparison (7-day)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
gpc vitals compare crashes --days 7 --output markdown >> $GITHUB_STEP_SUMMARY
- name: Alert on critical thresholds
run: |
CRASH="${{ steps.vitals.outputs.crash_rate }}"
ANR="${{ steps.vitals.outputs.anr_rate }}"
ALERT=false
if (( $(echo "$CRASH > 3.0" | bc -l) )); then
echo "::error::Critical crash rate: ${CRASH}% (threshold: 3.0%)"
ALERT=true
fi
if (( $(echo "$ANR > 1.0" | bc -l) )); then
echo "::error::Critical ANR rate: ${ANR}% (threshold: 1.0%)"
ALERT=true
fi
if [[ "$ALERT" == "true" ]]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ALERT: Critical thresholds breached" >> $GITHUB_STEP_SUMMARY
exit 1
fi2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
Metadata Sync on PR
Validates metadata changes on pull request (dry-run). Pushes metadata to Play Console when the PR merges.
# .github/workflows/metadata-sync.yml
name: Metadata Sync
on:
pull_request:
paths: ["metadata/**"]
push:
branches: [main]
paths: ["metadata/**"]
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
GPC_APP: com.example.myapp
jobs:
validate:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Dry-run metadata push
run: |
echo "## Metadata Validation (dry-run)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
gpc listings push --dir metadata/ --dry-run --json | tee dry-run.json
# Show what would change
echo "### Changes Preview" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
jq -r '.data.changes[]? | "- **\(.language)**: \(.field) changed"' dry-run.json >> $GITHUB_STEP_SUMMARY || echo "No changes detected" >> $GITHUB_STEP_SUMMARY
sync:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Push metadata to Play Console
run: |
gpc listings push --dir metadata/ --json
echo "## Metadata Synced" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Metadata from \`metadata/\` pushed to Play Console." >> $GITHUB_STEP_SUMMARY2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
Multi-App Monorepo Release
Release multiple apps from a monorepo with a matrix strategy.
# .github/workflows/multi-app-release.yml
name: Multi-App Release
on:
push:
tags: ["v*"]
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
jobs:
release:
runs-on: ubuntu-latest
strategy:
matrix:
app:
- package: com.example.app1
module: app1
- package: com.example.app2
module: app2
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Build ${{ matrix.app.module }}
run: ./gradlew :${{ matrix.app.module }}:bundleRelease
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Upload ${{ matrix.app.package }}
env:
GPC_APP: ${{ matrix.app.package }}
run: |
gpc releases upload \
${{ matrix.app.module }}/build/outputs/bundle/release/${{ matrix.app.module }}-release.aab \
--track internal \
--notes "Release ${{ github.ref_name }}" \
--json2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Flutter Release
Workflow for Flutter apps. Builds the AAB using flutter build appbundle and uploads with GPC.
# .github/workflows/flutter-release.yml
name: Flutter Release
on:
push:
tags: ["v*"]
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
GPC_APP: com.example.flutterapp
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.24.0"
channel: stable
- name: Build AAB
run: flutter build appbundle --release
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Upload to Internal
run: |
gpc releases upload \
build/app/outputs/bundle/release/app-release.aab \
--track internal \
--notes "Flutter build ${{ github.ref_name }}" \
--json2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Generate GitHub Release Notes
Auto-generate the release notes body from your local git log when cutting a tag. gpc changelog generate (v0.9.61+) clusters related commits, lints subjects against project voice, and emits canonical markdown that pipes straight into gh release create -F -.
# .github/workflows/release.yml
name: Cut Release
on:
push:
tags: ["v*"]
permissions:
contents: write # required for gh release create
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required so gpc can read the full git history
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install GPC
run: npm install -g @gpc-cli/cli
- name: Enforce voice in release notes
run: gpc changelog generate --strict
# Fails the workflow if any commit subject contains internal jargon
# (e.g., "mutex", "token bucket", "homedir"). Forces consistency.
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gpc changelog generate | gh release create ${{ github.ref_name }} -F -2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Play Store per-locale "What's new" (v0.9.62+)
Generate per-locale Play Store release notes in the same workflow. gpc changelog generate --target play-store --locales auto reads your live listing's locale list and emits a character-budget meter for each.
- name: Generate Play Store changelog
run: |
gpc changelog generate --target play-store \
--locales auto \
--format json \
--app com.example.app \
--strict > play-store-notes.json
# --strict exits 1 if any locale overflows 500 chars.2
3
4
5
6
7
8
For opt-in AI translation of the [needs translation] placeholders, add --ai (v0.9.63+). To write translated notes directly into a draft release, add --apply (v0.9.64+). The full one-command pipeline: gpc changelog generate --target play-store --locales auto --ai --apply.
For uploading pre-written release notes alongside an AAB, see gpc publish --notes-from-git or gpc releases create --notes-from-git.
CI Security Hardening
Scope secrets to individual steps
Never set GPC_SERVICE_ACCOUNT at the workflow or job level. Scoping it to the step that needs it limits exposure if a step is compromised by a supply chain attack.
# Good: secret scoped to the step that needs it
- name: Upload to Play Console
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
run: gpc releases upload app.aab --track internal --json
# Avoid: secret visible to every step in the job
env:
GPC_SERVICE_ACCOUNT: ${{ secrets.GPC_SERVICE_ACCOUNT }}
jobs:
upload:
steps:
- name: Some other step # can read GPC_SERVICE_ACCOUNT2
3
4
5
6
7
8
9
10
11
12
13
deepsec scan (v0.9.74+)
GPC's own CI runs deepsec on every PR. You can run the same scanner against your GPC-based workflow files:
# .github/workflows/security.yml
name: Security
on: [push, pull_request]
jobs:
deepsec:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run deepsec
run: npx deepsec scan .github/workflows/2
3
4
5
6
7
8
9
10
11
Lockfile verification
Always run pnpm install --frozen-lockfile (or npm ci) in CI to prevent unexpected dependency upgrades. Combined with min-release-age=7 in .npmrc, this blocks packages published within the last 7 days from being installed.
Related
- Vitals Gates -- gate deployments on crash and ANR rates
gpc status-- post-deploy health snapshotgpc changelog generate-- full feature guide- Migrate from Fastlane -- CI workflow migration guide
