Skip to content

Commit 493f3a4

Browse files
committed
Add branch protections and CI/CD workflows
1 parent 26a34a6 commit 493f3a4

5 files changed

Lines changed: 298 additions & 0 deletions

File tree

.github/workflows/build-apk.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Build APK
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
pull_request:
8+
branches:
9+
- main
10+
11+
concurrency:
12+
group: build-apk-${{ github.ref }}
13+
cancel-in-progress: true
14+
15+
jobs:
16+
build:
17+
name: Assemble Release APK
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- name: Checkout repository
22+
uses: actions/checkout@v4
23+
24+
- name: Set up Java
25+
uses: actions/setup-java@v4
26+
with:
27+
distribution: temurin
28+
java-version: 17
29+
cache: gradle
30+
31+
- name: Set up Android SDK
32+
uses: android-actions/setup-android@v3
33+
with:
34+
packages: |
35+
platforms;android-36
36+
build-tools;35.0.0
37+
38+
- name: Cache Gradle
39+
uses: gradle/actions/setup-gradle@v4
40+
41+
- name: Grant execute permission for Gradle wrapper
42+
run: chmod +x gradlew
43+
44+
- name: Assemble release APK
45+
run: ./gradlew assembleRelease
46+
47+
- name: Upload release APK
48+
uses: actions/upload-artifact@v4
49+
with:
50+
name: app-release-apk
51+
path: app/build/outputs/apk/release/*.apk
52+

.github/workflows/release-apk.yml

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
name: Release APK
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
release_name:
7+
description: Override release name (defaults to tag or ref name).
8+
required: false
9+
push:
10+
tags:
11+
- "v*"
12+
13+
permissions:
14+
contents: write
15+
16+
env:
17+
BUILD_TOOLS_VERSION: 35.0.0
18+
RELEASE_APK_PATH: app/build/outputs/apk/release/app-release.apk
19+
20+
jobs:
21+
release:
22+
name: Build and Publish
23+
runs-on: ubuntu-latest
24+
25+
steps:
26+
- name: Checkout repository
27+
uses: actions/checkout@v4
28+
with:
29+
fetch-depth: 0
30+
31+
- name: Verify ref on main (workflow_dispatch)
32+
if: github.event_name == 'workflow_dispatch'
33+
run: |
34+
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
35+
echo "Workflow must target the main branch. Ref: ${GITHUB_REF}" >&2
36+
exit 1
37+
fi
38+
39+
- name: Verify tag points to main (tag push)
40+
if: startsWith(github.ref, 'refs/tags/')
41+
run: |
42+
git fetch origin main --depth=1
43+
if ! git merge-base --is-ancestor origin/main "$GITHUB_SHA"; then
44+
echo "Tag ${GITHUB_REF} does not point to the main branch." >&2
45+
exit 1
46+
fi
47+
48+
- name: Set up Java
49+
uses: actions/setup-java@v4
50+
with:
51+
distribution: temurin
52+
java-version: 17
53+
cache: gradle
54+
55+
- name: Set up Android SDK
56+
uses: android-actions/setup-android@v3
57+
with:
58+
packages: |
59+
cmdline-tools;latest
60+
platform-tools
61+
platforms;android-36
62+
build-tools;${{ env.BUILD_TOOLS_VERSION }}
63+
64+
- name: Cache Gradle
65+
uses: gradle/actions/setup-gradle@v4
66+
67+
- name: Grant execute permission for Gradle wrapper
68+
run: chmod +x gradlew
69+
70+
- name: Assemble release APK
71+
run: ./gradlew assembleRelease
72+
73+
- name: Decode keystore (if provided)
74+
if: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 != '' }}
75+
run: |
76+
echo "${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }}" | base64 --decode > release.keystore
77+
78+
- name: Sign APK (if secrets present)
79+
if: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 != '' && secrets.ANDROID_SIGNING_KEY_ALIAS != '' && secrets.ANDROID_SIGNING_KEY_PASSWORD != '' && secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD != '' }}
80+
env:
81+
KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }}
82+
KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }}
83+
STORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }}
84+
run: |
85+
APK_PATH=$(ls app/build/outputs/apk/release/*.apk | head -n 1)
86+
if [ -z "$APK_PATH" ]; then
87+
echo "No APK produced at app/build/outputs/apk/release." >&2
88+
exit 1
89+
fi
90+
mv "$APK_PATH" "${APK_PATH%.apk}-unsigned.apk"
91+
"${ANDROID_SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" sign \
92+
--ks release.keystore \
93+
--ks-key-alias "$KEY_ALIAS" \
94+
--ks-pass env:STORE_PASSWORD \
95+
--key-pass env:KEY_PASSWORD \
96+
--out "${APK_PATH%.apk}.apk" \
97+
"${APK_PATH%.apk}-unsigned.apk"
98+
"${ANDROID_SDK_ROOT}/build-tools/${BUILD_TOOLS_VERSION}/apksigner" verify "${APK_PATH%.apk}.apk"
99+
100+
- name: Upload release artifact
101+
uses: actions/upload-artifact@v4
102+
with:
103+
name: release-apk
104+
path: app/build/outputs/apk/release/*.apk
105+
106+
- name: Publish GitHub Release
107+
if: startsWith(github.ref, 'refs/tags/')
108+
uses: softprops/action-gh-release@v2
109+
with:
110+
files: app/build/outputs/apk/release/*.apk
111+
name: ${{ inputs.release_name || github.ref_name }}
112+
tag_name: ${{ github.ref_name }}
113+
generate_release_notes: true
114+

docs/ci-cd.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# CI/CD & Release Guide
2+
3+
This project enforces guarded changes on `main` and publishes release APKs via GitHub Actions. Use this guide to configure repository settings, maintain secrets, and operate the pipelines.
4+
5+
## Branch Protection
6+
7+
Main must remain fast-forward only and receive all changes through reviewed pull requests.
8+
9+
1. **Enable protection in GitHub**
10+
- Go to `Settings → Branches → Branch protection rules → Add rule`.
11+
- Target branch name pattern: `main`.
12+
- Enable these options:
13+
- Require a pull request before merging (minimum one approval, dismiss stale reviews).
14+
- Require status checks to pass (`Build APK`).
15+
- Require branches to be up to date before merging.
16+
- Include administrators (prevents accidental direct pushes).
17+
- Disallow force pushes and deletions.
18+
2. **Automate with GitHub CLI (optional)**
19+
Run `./tools/configure_branch_protection.sh` after authenticating with `gh auth login`. The script applies the settings above and can be re-run to enforce updates.
20+
21+
## Required GitHub Secrets
22+
23+
| Secret | Purpose | Notes |
24+
| --- | --- | --- |
25+
| `ANDROID_SIGNING_KEYSTORE_BASE64` | Base64-encoded `.jks`/`.keystore` file | Optional; omit to produce unsigned release artifacts. |
26+
| `ANDROID_SIGNING_KEYSTORE_PASSWORD` | Password for the keystore | Required for signing. |
27+
| `ANDROID_SIGNING_KEY_ALIAS` | Alias inside the keystore | Required for signing. |
28+
| `ANDROID_SIGNING_KEY_PASSWORD` | Password for the key alias | Required for signing. |
29+
30+
If signing secrets are not provided, releases remain unsigned but still build successfully.
31+
32+
## Workflows
33+
34+
### Build APK (`.github/workflows/build-apk.yml`)
35+
36+
- Triggers on pull requests to `main` and pushes directly to `main`.
37+
- Builds the release variant using JDK 17 and Android SDK.
38+
- Publishes the generated APK as an artifact (`app-release-apk`) for validation.
39+
- Use this workflow as a required status check in the branch protection rule.
40+
41+
### Release APK (`.github/workflows/release-apk.yml`)
42+
43+
- Trigger modes:
44+
- **Tag push (`v*`)**: Builds, optionally signs, and attaches the APK to a GitHub Release created for the tag (requires branch/tag commit on `main`).
45+
- **Manual dispatch**: Select `main` as the branch, optionally override the release name, and obtain the artifact from the run (no release created automatically).
46+
- Signing is automatic when all four secrets are present; otherwise an unsigned artifact is uploaded.
47+
- The workflow aborts if the triggering ref/tag does not point to the latest commit on `main`, guaranteeing releases originate from protected history.
48+
49+
## Release Checklist
50+
51+
1. Merge all changes into `main` via reviewed pull requests.
52+
2. Tag the `main` commit (`git tag vX.Y.Z && git push origin vX.Y.Z`) **or** run the Release workflow manually from `main`.
53+
3. Confirm the workflow succeeds and download the signed APK from the run or resulting GitHub Release.
54+
4. Distribute the APK or upload it to the Play Console as needed.
55+

gradlew

100644100755
File mode changed.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
# -----------------------------------------------------------------------------
6+
# configure_branch_protection.sh
7+
#
8+
# Helper script to configure branch protection for the main branch using
9+
# the GitHub CLI (`gh`). This script is idempotent and can be safely re-run.
10+
# -----------------------------------------------------------------------------
11+
#
12+
# Prerequisites:
13+
# - GitHub CLI installed: https://cli.github.com/
14+
# - Authenticated via `gh auth login` with repo admin permissions.
15+
# - `GH_REPO` environment variable exported as "owner/repo", or run the script
16+
# from within a cloned repository so that `gh` can infer it.
17+
#
18+
# Usage:
19+
# ./tools/configure_branch_protection.sh
20+
# GH_REPO=owner/repo ./tools/configure_branch_protection.sh
21+
#
22+
# This script applies the branch protection settings recommended by our CI plan:
23+
# - Require pull request reviews with at least one approval.
24+
# - Dismiss stale reviews when new commits are pushed.
25+
# - Require status checks to pass before merging (build workflow).
26+
# - Require branches to be up to date before merging.
27+
# - Disallow force pushes and direct pushes to main.
28+
#
29+
# Customize REQUIRED_CHECKS below if workflow names change.
30+
# -----------------------------------------------------------------------------
31+
32+
BRANCH="main"
33+
REQUIRED_CHECKS=(
34+
"Build APK"
35+
)
36+
37+
echo "Configuring branch protection for branch: ${BRANCH}"
38+
39+
REQUIRED_CHECKS_JOINED="$(printf '%s\n' "${REQUIRED_CHECKS[@]}")"
40+
41+
PAYLOAD="$(REQUIRED_CHECKS_JOINED="${REQUIRED_CHECKS_JOINED}" python3 <<'PY'
42+
import json
43+
import os
44+
45+
checks = [c for c in os.environ.get("REQUIRED_CHECKS_JOINED", "").splitlines() if c]
46+
47+
payload = {
48+
"required_status_checks": {
49+
"strict": True,
50+
"contexts": checks,
51+
},
52+
"enforce_admins": True,
53+
"required_pull_request_reviews": {
54+
"dismiss_stale_reviews": True,
55+
"required_approving_review_count": 1,
56+
},
57+
"restrictions": None,
58+
"allow_force_pushes": False,
59+
"allow_deletions": False,
60+
"required_linear_history": True,
61+
"allow_fork_pushes": False,
62+
"allow_fork_syncing": True,
63+
"lock_branch": False,
64+
}
65+
66+
print(json.dumps(payload))
67+
PY
68+
)"
69+
70+
gh api \
71+
--method PUT \
72+
-H "Accept: application/vnd.github+json" \
73+
"/repos/{owner}/{repo}/branches/${BRANCH}/protection" \
74+
--input - <<<"${PAYLOAD}"
75+
76+
echo "Branch protection applied successfully."
77+

0 commit comments

Comments
 (0)