Skip to content
190 changes: 190 additions & 0 deletions .github/workflows/build_and_sign.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Builds a release AAB for the given flavor at the given source ref, signs it using jarsigner
# with a production keystore provisioned via GCP Secret Manager, and uploads the signed binary
# to the flavor-specific GCS archive bucket. Signing is handled by the _sign_and_rename_aab
# Bazel rule in oppia_android_application.bzl via the //config:keystore_* Bazel flags.
#
# This workflow must be triggered manually by a release coordinator. It requires approval from
# a required reviewer via the oppia-android-release-env GitHub environment before any steps
# execute. Only one build+sign run per flavor is allowed at a time to prevent GCS archive
# conflicts (cancel-in-progress is disabled so in-flight builds are never interrupted).

name: Build and Sign Release

on:
workflow_dispatch:
inputs:
flavor:
description: 'App flavor to build and sign'
required: true
type: choice
options:
- alpha
- beta
- ga
source_ref:
description: "Git ref to build from. Must be 'latest-alpha' or a release branch in the form 'release-X.Y' (e.g. 'release-0.17')."
required: true
type: string

concurrency:
# One build+sign run per flavor at a time. cancel-in-progress=false ensures an in-flight
# signing run is never cancelled mid-way (which could leave a corrupt AAB in GCS).
group: build-and-sign-${{ github.event.inputs.flavor }}
cancel-in-progress: false

jobs:
build_and_sign:
name: Build and Sign (${{ github.event.inputs.flavor }})
runs-on: ubuntu-24.04
environment: oppia-android-release-env
permissions:
id-token: write
contents: read
env:
CACHE_DIRECTORY: ~/.bazel_cache
steps:
- name: Validate inputs
run: |
FLAVOR="${{ github.event.inputs.flavor }}"
SOURCE_REF="${{ github.event.inputs.source_ref }}"

# Flavor is enforced by the 'choice' type above, but we re-validate here so that
# future workflow_call usages (e.g. auto_release_alpha.yml) also get this check.
if [[ "$FLAVOR" != "alpha" && "$FLAVOR" != "beta" && "$FLAVOR" != "ga" ]]; then
echo "::error::Invalid flavor '$FLAVOR'. Must be one of: alpha, beta, ga."
exit 1
fi

# source_ref must be 'latest-alpha' or match the 'release-X.Y' pattern exactly.
if [[ "$SOURCE_REF" != "latest-alpha" && ! "$SOURCE_REF" =~ ^release-[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid source_ref '$SOURCE_REF'."
echo "::error::Must be 'latest-alpha' or match 'release-X.Y' (e.g. 'release-0.17')."
exit 1
fi

echo "Input validation passed: flavor=$FLAVOR, source_ref=$SOURCE_REF"

- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.source_ref }}
fetch-depth: 0

- name: Set up Bazel
uses: abhinavsingh/setup-bazel@v3
with:
version: 6.5.0

- name: Set up build environment
uses: ./.github/actions/set-up-android-bazel-build-environment

- uses: actions/cache@v4
id: cache
with:
path: ${{ env.CACHE_DIRECTORY }}
key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-binary-${{ github.sha }}
restore-keys: |
${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-binary-
${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-tests-
${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-

- name: Ensure cache size
env:
BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }}
run: |
EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}"
CACHE_SIZE_MB=$(du -smc $EXPANDED_BAZEL_CACHE_PATH | grep total | cut -f1)
echo "Total size of Bazel cache (rounded up to MBs): $CACHE_SIZE_MB"
if [[ "$CACHE_SIZE_MB" -gt 4500 ]]; then
echo "Cache exceeds cut-off; resetting it (will result in a slow build)"
rm -rf $EXPANDED_BAZEL_CACHE_PATH
fi

- name: Configure Bazel to use a local cache
env:
BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }}
run: |
EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}"
echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path"
echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc
shell: bash

- name: Authenticate to GCP via Workload Identity Federation
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.GCP_RELEASE_SERVICE_ACCOUNT }}

- name: Set up Google Cloud SDK
uses: google-github-actions/setup-gcloud@v2

# Retrieves the release keystore and its credentials from GCP Secret Manager.
# The keystore is stored base64-encoded; the password and alias are plain text.
# Files are written into the workspace (config/) so they can be referenced as
# Bazel labels (//config:release_keystore.jks etc.) required by the label_flag
# types in config/BUILD.bazel (introduced in PR #6265).
- name: Provision release keystore from Secret Manager
run: |
gcloud secrets versions access latest \
--secret="oppia-android-release-keystore" \
--format='get(payload.data)' \
| tr '_-' '/+' | base64 -d > config/release_keystore.jks

gcloud secrets versions access latest \
--secret="oppia-android-release-keystore-password" \
> config/keystore_password.txt

gcloud secrets versions access latest \
--secret="oppia-android-release-key-alias" \
> /tmp/key_alias.txt

# Expose keystore files as Bazel targets for the label_flags.
printf '\nexports_files(["release_keystore.jks", "keystore_password.txt"], visibility = ["//:oppia_binary_visibility"])\n' >> config/BUILD.bazel
echo "Keystore provisioned from Secret Manager."

- name: Build signed AAB
run: |
KEY_ALIAS=$(cat /tmp/key_alias.txt)
bazel build --compilation_mode=opt \
--//config:keystore_file=//config:release_keystore.jks \
--//config:keystore_password_file=//config:keystore_password.txt \
--//config:key_alias="$KEY_ALIAS" \
-- //:oppia_${{ github.event.inputs.flavor }}

# The _sign_and_rename_aab Bazel rule produces a signed AAB named:
# oppia-android-{MAJOR}.{MINOR}-rc{RC}-{flavor}-{hash10}.aab
# This step locates it and extracts the version/RC for the GCS archive path.
- name: Locate signed AAB
run: |
FLAVOR="${{ github.event.inputs.flavor }}"
# Use workspace-relative bazel-bin symlink — 'find bazel-bin' can fail in CI
# because bazel-bin is a symlink that may not resolve in a separate step.
AAB_PATH=$(ls bazel-bin/oppia_${FLAVOR}_release/oppia-android-*.aab 2>/dev/null | head -1)
if [[ -z "$AAB_PATH" ]]; then
echo "::error::Signed AAB not found in bazel-bin/oppia_${FLAVOR}_release/. Build may have failed."
exit 1
fi
AAB_NAME=$(basename "$AAB_PATH")
VERSION=$(echo "$AAB_NAME" | grep -oP '(?<=oppia-android-)\d+\.\d+')
RC_NUM=$(echo "$AAB_NAME" | grep -oP '(?<=rc)\d+')
echo "AAB_PATH=$AAB_PATH" >> $GITHUB_ENV
echo "AAB_NAME=$AAB_NAME" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "RC_NUM=$RC_NUM" >> $GITHUB_ENV
echo "Found: $AAB_NAME (version=$VERSION, RC=$RC_NUM)"


- name: Upload signed AAB to GCS archive
run: |
FLAVOR="${{ github.event.inputs.flavor }}"
GCS_PATH="gs://oppia-android-${FLAVOR}-releases/${VERSION}/RC${RC_NUM}/${AAB_NAME}"

# Fail if binary already exists — enforces the build-once guarantee.
if gsutil ls "$GCS_PATH" 2>/dev/null; then
echo "::error::Binary already exists at $GCS_PATH."
echo "::error::Re-running build_and_sign for the same ref is not allowed."
echo "::error::If this is a new RC, ensure the release branch has a new commit."
exit 1
fi

gsutil cp "$AAB_PATH" "$GCS_PATH"
echo "Uploaded to: $GCS_PATH"
Loading