diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..39044cf --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -0,0 +1,91 @@ +name: Build release APK + +on: + pull_request: + types: [closed] + branches: [main] + +concurrency: + group: build-apk + cancel-in-progress: false + +jobs: + build: + # Only when: PR was MERGED, from branch pr/workflow, and title starts with "feat(dev): " + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'pr/workflow' && + startsWith(github.event.pull_request.title, 'feat(dev): ') + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + + # Ubuntu runners already include the Android SDK; pin JDK 17 (cached, fast). + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install JS dependencies + run: npm ci + + - name: Bump app version to 2.0.1 + run: | + node -e " + const fs=require('fs'); + const j=JSON.parse(fs.readFileSync('app.json','utf8')); + j.expo.version='2.0.1'; + fs.writeFileSync('app.json',JSON.stringify(j,null,2)+'\n'); + console.log('version ->', j.expo.version);" + + - name: Create .env from secrets + env: + EXPO_PUBLIC_STRIPE_PK: ${{ secrets.EXPO_PUBLIC_STRIPE_PK }} + EXPO_PUBLIC_API_URL: ${{ secrets.EXPO_PUBLIC_API_URL }} + EXPO_PUBLIC_CLOUDINARY_CLOUD_NAME: ${{ secrets.EXPO_PUBLIC_CLOUDINARY_CLOUD_NAME }} + EXPO_PUBLIC_CLOUDINARY_UPLOAD_PRESET: ${{ secrets.EXPO_PUBLIC_CLOUDINARY_UPLOAD_PRESET }} + run: | + { + echo "EXPO_PUBLIC_STRIPE_PK=$EXPO_PUBLIC_STRIPE_PK" + echo "EXPO_PUBLIC_API_URL=$EXPO_PUBLIC_API_URL" + echo "EXPO_PUBLIC_CLOUDINARY_CLOUD_NAME=$EXPO_PUBLIC_CLOUDINARY_CLOUD_NAME" + echo "EXPO_PUBLIC_CLOUDINARY_UPLOAD_PRESET=$EXPO_PUBLIC_CLOUDINARY_UPLOAD_PRESET" + } > .env + + # /android is git-ignored (generated), so regenerate it. + - name: Expo prebuild (android) + run: npx expo prebuild --platform android --no-install + + - uses: gradle/actions/setup-gradle@v4 + + - name: Build release APK + working-directory: android + run: set -a && source ../.env && set +a && ./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a --no-daemon + + - name: Rename APK + id: apk + run: | + SRC=$(ls android/app/build/outputs/apk/release/*.apk | head -1) + NAME="BStackBank-2.0.1-${{ github.event.pull_request.number }}-$(date +%Y%m%d-%H%M).apk" + cp "$SRC" "$NAME" + echo "name=$NAME" >> "$GITHUB_OUTPUT" + + - name: Upload APK to Google Drive + env: + GDRIVE_SERVICE_ACCOUNT_JSON: ${{ secrets.GDRIVE_SERVICE_ACCOUNT_JSON }} + GDRIVE_FOLDER_ID: ${{ secrets.GDRIVE_FOLDER_ID }} + run: node scripts/upload-to-drive.js "${{ steps.apk.outputs.name }}" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ steps.apk.outputs.name }} + path: ${{ steps.apk.outputs.name }} + retention-days: 7 diff --git a/app.json b/app.json index 98fa812..cf03ff5 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "BStack Bank", "slug": "yash", - "version": "2.0.0", + "version": "2.0.1", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "demobankingapp", diff --git a/scripts/make-pr.sh b/scripts/make-pr.sh new file mode 100755 index 0000000..cb92257 --- /dev/null +++ b/scripts/make-pr.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Usage: ./scripts/make-pr.sh "your commit message" +# Commits your current changes to branch pr/workflow as "feat(dev): " and opens a PR to main. +# Safe to run repeatedly: pr/workflow is always rebuilt from the latest origin/main, +# so leftovers from previously merged PRs never conflict. +set -euo pipefail + +BRANCH="pr/workflow" +BASE="main" +PREFIX="feat(dev): " + +MSG="${*:-}" +if [ -z "$MSG" ]; then + read -r -p "Commit message: " MSG +fi +[ -z "$MSG" ] && { echo "Commit message required"; exit 1; } +MSG="${MSG#"$PREFIX"}" # avoid double prefix +TITLE="${PREFIX}${MSG}" + +command -v gh >/dev/null || { echo "gh CLI required"; exit 1; } + +git fetch origin --prune + +# If an OPEN PR already exists for this branch, just add the commit to it. +OPEN_PR="" +if gh pr list --head "$BRANCH" --state open --json url -q '.[0].url' 2>/dev/null | grep -q http; then + OPEN_PR=$(gh pr list --head "$BRANCH" --state open --json url -q '.[0].url') +fi + +if [ -n "$OPEN_PR" ]; then + # Continue the existing open PR + CUR=$(git rev-parse --abbrev-ref HEAD) + [ "$CUR" != "$BRANCH" ] && git checkout "$BRANCH" + git add -A + git diff --cached --quiet || git commit -m "$TITLE" + git push origin "$BRANCH" + echo "Open PR updated: $OPEN_PR" + echo "Note: the PR title must still start with '$PREFIX' to trigger the build." + exit 0 +fi + +# No open PR: carry current changes onto a fresh branch cut from the latest origin/main. +git add -A +STASHED=0 +if ! git diff --cached --quiet; then + git stash push -q -m "make-pr-wip" + STASHED=1 +fi + +git checkout -B "$BRANCH" "origin/$BASE" + +if [ "$STASHED" = 1 ]; then + git stash pop -q || { echo "Conflict applying your changes onto latest $BASE. Resolve, then re-run."; exit 1; } +fi + +git add -A +if git diff --cached --quiet; then + echo "No changes to commit (nothing differs from origin/$BASE)." + exit 1 +fi +git commit -m "$TITLE" + +# Branch is rebuilt from main each time, so a force push is intended (only this branch). +git push -u --force-with-lease origin "$BRANCH" + +gh pr create --base "$BASE" --head "$BRANCH" --title "$TITLE" --body "$TITLE" diff --git a/scripts/upload-to-drive.js b/scripts/upload-to-drive.js new file mode 100644 index 0000000..bf7be5e --- /dev/null +++ b/scripts/upload-to-drive.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Uploads a file to a Google Drive folder using a service account. +// Usage: node scripts/upload-to-drive.js [remoteName] +// Env: GDRIVE_SERVICE_ACCOUNT_JSON (raw JSON), GDRIVE_FOLDER_ID +// No dependencies: uses Node 18+ fetch and crypto. +const fs = require('fs'); +const crypto = require('crypto'); + +const b64url = (b) => + Buffer.from(b).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); + +async function getToken(sa) { + const now = Math.floor(Date.now() / 1000); + const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const claim = b64url( + JSON.stringify({ + iss: sa.client_email, + scope: 'https://www.googleapis.com/auth/drive', + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600, + }) + ); + const sig = crypto.createSign('RSA-SHA256').update(`${header}.${claim}`).sign(sa.private_key); + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: `${header}.${claim}.${b64url(sig)}`, + }), + }); + if (!res.ok) throw new Error(`Token request failed: ${res.status} ${await res.text()}`); + return (await res.json()).access_token; +} + +async function main() { + const [file, remoteName] = process.argv.slice(2); + if (!file || !fs.existsSync(file)) throw new Error(`File not found: ${file}`); + const folderId = (process.env.GDRIVE_FOLDER_ID || '').trim(); + if (!folderId) throw new Error('GDRIVE_FOLDER_ID is not set'); + let raw = (process.env.GDRIVE_SERVICE_ACCOUNT_JSON || '').trim(); + if (!raw) throw new Error('GDRIVE_SERVICE_ACCOUNT_JSON is not set'); + if (!raw.startsWith('{')) raw = Buffer.from(raw, 'base64').toString('utf8'); // allow base64 too + const sa = JSON.parse(raw); + + const token = await getToken(sa); + const name = remoteName || file.split('/').pop(); + const boundary = 'bndry' + crypto.randomBytes(8).toString('hex'); + const meta = JSON.stringify({ name, parents: [folderId] }); + const body = Buffer.concat([ + Buffer.from( + `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${meta}\r\n` + + `--${boundary}\r\nContent-Type: application/vnd.android.package-archive\r\n\r\n` + ), + fs.readFileSync(file), + Buffer.from(`\r\n--${boundary}--`), + ]); + + const res = await fetch( + 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink', + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': `multipart/related; boundary=${boundary}`, + }, + body, + } + ); + if (!res.ok) throw new Error(`Upload failed: ${res.status} ${await res.text()}`); + const out = await res.json(); + console.log(`Uploaded "${out.name}" (id: ${out.id})`); + if (out.webViewLink) console.log(out.webViewLink); +} + +main().catch((e) => { + console.error(e.message); + process.exit(1); +});