diff --git a/.github/actions/setup-android/action.yml b/.github/actions/setup-android/action.yml new file mode 100644 index 0000000..e161800 --- /dev/null +++ b/.github/actions/setup-android/action.yml @@ -0,0 +1,28 @@ +name: Set up Android toolchain +description: > + Installs Node, JDK and Gradle, then restores android/ dependencies from the + lockfile. Covers the Capacitor CLI only — the web bundle it syncs is built + separately and must already exist at web/build. + +runs: + using: composite + steps: + # Keep in lockstep with .github/actions/setup-web. + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: npm + cache-dependency-path: android/package-lock.json + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v6 + with: + cache-provider: basic + + - run: npm ci + shell: bash + working-directory: android diff --git a/.github/actions/setup-web/action.yml b/.github/actions/setup-web/action.yml new file mode 100644 index 0000000..7d87ad2 --- /dev/null +++ b/.github/actions/setup-web/action.yml @@ -0,0 +1,24 @@ +name: Set up web toolchain +description: > + Installs pnpm and Node, then restores web/ dependencies from the lockfile. + Node stays in lockstep with .github/actions/setup-android so the SvelteKit + app is tested and bundled into the APK on the same runtime. + +runs: + using: composite + steps: + # Must precede setup-node: `cache: pnpm` needs the pnpm binary to + # resolve the store path. + - uses: pnpm/action-setup@v6 + with: + package_json_file: web/package.json + + - uses: actions/setup-node@v7 + with: + node-version: '24' + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + shell: bash + working-directory: web diff --git a/.github/workflows/android-build-debug.yml b/.github/workflows/android-build-debug.yml deleted file mode 100644 index 3e98a87..0000000 --- a/.github/workflows/android-build-debug.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: android / build debug - -on: - pull_request: - paths: - - 'web/**' - - 'android/**' - - '.github/workflows/android-build-debug.yml' - push: - branches: [main] - paths: - - 'web/**' - - 'android/**' - - '.github/workflows/android-build-debug.yml' - -jobs: - build: - runs-on: ubuntu-latest - - defaults: - run: - working-directory: android - - steps: - - uses: actions/checkout@v7 - - - name: Set up Node 22 - uses: actions/setup-node@v7 - with: - node-version: '22' - - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - with: - cache-provider: basic - - - name: Install wrapper deps - run: npm ci - - - name: Build web + sync into Android - run: npm run build - - - name: Assemble debug APK - working-directory: android/android - run: ./gradlew :app:assembleDebug - - - name: Upload debug APK - uses: actions/upload-artifact@v7 - with: - name: app-debug.apk - path: android/android/app/build/outputs/apk/debug/*.apk - archive: false - retention-days: 7 diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 9383f4a..81589b8 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -1,3 +1,7 @@ +# Tag-driven release. Builds standalone rather than consuming ci.yml +# artifacts: a tag run has no upstream run to pull from, and a release +# should be reproducible from the tagged tree alone. + name: android / release on: @@ -7,40 +11,36 @@ on: permissions: contents: write -jobs: - build: - runs-on: ubuntu-latest +concurrency: + group: android-release-${{ github.ref }} + cancel-in-progress: false - defaults: - run: - working-directory: android +jobs: + release: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - - name: Set up Node 22 - uses: actions/setup-node@v7 - with: - node-version: '22' + - uses: ./.github/actions/setup-web - - name: Set up JDK 21 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: '21' + # ci.yml does not run on tags, so this is the only gate before signing. + - name: Test web + run: pnpm test + working-directory: web - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v6 - with: - cache-provider: basic + # Base "" — the APK loads the bundle from the domain root. + - name: Build web + run: pnpm build + working-directory: web - - name: Install wrapper deps - run: npm ci + - uses: ./.github/actions/setup-android - - name: Build web + sync into Android - run: npm run build + - name: Sync web bundle into the native project + run: npm run sync + working-directory: android - name: Decode keystore env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3839b48 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,181 @@ +# One pipeline for both subprojects, because they ship from the same commit. +# +# The web app is built twice and only twice — once per base path — and every +# consumer downloads an artifact instead of rebuilding: +# +# build (root, base "") -> deploy-firebase, preview-firebase, android-debug +# build (gh, base /loto) -> deploy-pages +# +# Tags are handled by android-release.yml, which builds standalone. + +name: ci + +# Actions' parser rejects YAML anchors, so the two path lists are duplicated +# by necessity — keep them identical. +on: + pull_request: + paths: + - 'web/**' + - 'android/**' + - '.github/actions/**' + - '.github/workflows/ci.yml' + push: + branches: [main] + paths: + - 'web/**' + - 'android/**' + - '.github/actions/**' + - '.github/workflows/ci.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + # Superseded PRs are safe to kill; a main run may be mid-deploy. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup-web + - run: pnpm test + working-directory: web + + build: + # Nothing is built, shipped or signed unless the suite is green. + needs: test + runs-on: ubuntu-latest + strategy: + matrix: + include: + # Firebase and the APK serve from a domain root. + - profile: root + script: build + artifact: web-build + # GitHub Pages serves under /loto. + - profile: gh + script: 'build:gh' + artifact: web-build-gh + name: build (${{ matrix.profile }}) + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup-web + - run: pnpm ${{ matrix.script }} + working-directory: web + - uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: web/build + # Hand-off within this run only; releases keep their own copies. + retention-days: 1 + + deploy-pages: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + pages: write + id-token: write + # Guards the shared Pages environment against runs of other workflows. + concurrency: + group: github-pages + cancel-in-progress: true + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/download-artifact@v7 + with: + name: web-build-gh + path: build + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: build + - id: deployment + uses: actions/deploy-pages@v5 + + deploy-firebase: + if: github.event_name != 'pull_request' + needs: build + runs-on: ubuntu-latest + concurrency: + group: firebase-hosting-live + cancel-in-progress: false + steps: + # entryPoint below reads web/firebase.json and web/.firebaserc. + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + name: web-build + # firebase.json declares `public: "build"`, relative to entryPoint. + path: web/build + - uses: FirebaseExtended/action-hosting-deploy@500ac625ca2dd40cbd15f7659af953801858032a # v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LOOTOO }} + channelId: live + projectId: lootoo + entryPoint: web + + preview-firebase: + # Forks cannot read the service account, so skip rather than fail. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + needs: build + runs-on: ubuntu-latest + permissions: + checks: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + name: web-build + path: web/build + - uses: FirebaseExtended/action-hosting-deploy@500ac625ca2dd40cbd15f7659af953801858032a # v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LOOTOO }} + projectId: lootoo + entryPoint: web + + android-debug: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/download-artifact@v7 + with: + name: web-build + # capacitor.config.json declares `webDir: "../web/build"`. + path: web/build + - uses: ./.github/actions/setup-android + # `npm run build` would rebuild web/; the bundle is already here. + - name: Sync web bundle into the native project + run: npm run sync + working-directory: android + - name: Assemble debug APK + run: ./gradlew :app:assembleDebug + working-directory: android/android + - uses: actions/upload-artifact@v7 + with: + name: app-debug.apk + path: android/android/app/build/outputs/apk/debug/*.apk + archive: false + retention-days: 7 diff --git a/.github/workflows/web-deploy-github-pages.yml b/.github/workflows/web-deploy-github-pages.yml deleted file mode 100644 index 9f444f4..0000000 --- a/.github/workflows/web-deploy-github-pages.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: web / deploy to GitHub Pages - -on: - push: - branches: [main] - paths: - - 'web/**' - - '.github/workflows/web-deploy-github-pages.yml' - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: github-pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: web - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - package_json_file: web/package.json - - uses: actions/setup-node@v7 - with: - node-version: '24' - cache: pnpm - cache-dependency-path: web/pnpm-lock.yaml - - run: pnpm install --frozen-lockfile - - run: pnpm build:gh - - uses: actions/configure-pages@v6 - - uses: actions/upload-pages-artifact@v5 - with: - path: web/build - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/web-firebase-hosting-merge.yml b/.github/workflows/web-firebase-hosting-merge.yml deleted file mode 100644 index 19a3a43..0000000 --- a/.github/workflows/web-firebase-hosting-merge.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Originally generated by the Firebase CLI -# https://github.com/firebase/firebase-tools -# entryPoint points the action at web/, where firebase.json and .firebaserc live. - -name: web / deploy to Firebase Hosting on merge -on: - push: - branches: - - main - paths: - - 'web/**' - - '.github/workflows/web-firebase-hosting-merge.yml' -jobs: - build_and_deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - run: npm ci && npm run build - working-directory: web - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LOOTOO }} - channelId: live - projectId: lootoo - entryPoint: web diff --git a/.github/workflows/web-firebase-hosting-pull-request.yml b/.github/workflows/web-firebase-hosting-pull-request.yml deleted file mode 100644 index 3f50dd8..0000000 --- a/.github/workflows/web-firebase-hosting-pull-request.yml +++ /dev/null @@ -1,28 +0,0 @@ -# Originally generated by the Firebase CLI -# https://github.com/firebase/firebase-tools -# entryPoint points the action at web/, where firebase.json and .firebaserc live. - -name: web / deploy to Firebase Hosting on PR -on: - pull_request: - paths: - - 'web/**' - - '.github/workflows/web-firebase-hosting-pull-request.yml' -permissions: - checks: write - contents: read - pull-requests: write -jobs: - build_and_preview: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - run: npm ci && npm run build - working-directory: web - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_LOOTOO }} - projectId: lootoo - entryPoint: web diff --git a/.github/workflows/web-verify-build.yml b/.github/workflows/web-verify-build.yml deleted file mode 100644 index 8ceade4..0000000 --- a/.github/workflows/web-verify-build.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: web / verify build - -on: - pull_request: - branches: [main] - paths: - - 'web/**' - - '.github/workflows/web-verify-build.yml' - push: - branches: [main] - paths: - - 'web/**' - - '.github/workflows/web-verify-build.yml' - -permissions: - contents: read - -concurrency: - group: web-verify-build-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - working-directory: web - -jobs: - verify: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - with: - package_json_file: web/package.json - - uses: actions/setup-node@v7 - with: - node-version: '24' - cache: pnpm - cache-dependency-path: web/pnpm-lock.yaml - - run: pnpm install --frozen-lockfile - - run: pnpm test - - run: pnpm build diff --git a/README.md b/README.md index 49e1931..093c536 100644 --- a/README.md +++ b/README.md @@ -31,17 +31,33 @@ Per-project detail lives in [`web/README.md`](web/README.md) and ## CI -Workflows live at the repository root in `.github/workflows/`; each is prefixed -with the subproject it serves and filtered on the paths it cares about. +Workflows live at the repository root in `.github/workflows/`. Because both +subprojects ship from the same commit, ordinary CI is a single `ci.yml`; +only the tag-driven release stands apart. | Workflow | Trigger | Result | |----------|---------|--------| -| `web-verify-build` | push/PR touching `web/` | `pnpm test && pnpm build` | -| `web-deploy-github-pages` | push to `main` touching `web/` | publishes to GitHub Pages | -| `web-firebase-hosting-merge` | push to `main` touching `web/` | deploys to Firebase Hosting | -| `web-firebase-hosting-pull-request` | PR touching `web/` | Firebase preview channel | -| `android-build-debug` | push/PR touching `web/` or `android/` | unsigned debug APK artifact | -| `android-release` | tag `v*.*.*` | signed AAB + APK on the GH Release, plus Play Store internal-track upload | +| `ci` | push to `main` or PR touching `web/` or `android/` | see the job graph below | +| `android-release` | tag `v*.*.*` | tests, then a signed AAB + APK on the GH Release, plus Play Store internal-track upload | + +`ci` builds the web app exactly twice — once per base path — and every +consumer downloads the artifact rather than rebuilding it: + +``` +test (pnpm test) +└── build (root, base "") ── deploy-firebase push to main + │ ├─ preview-firebase PR from this repo + │ └─ android-debug unsigned APK artifact + └── build (gh, base /loto) ── deploy-pages push to main +``` + +Nothing deploys unless `test` is green. Deploy jobs are skipped on pull +requests, and the Firebase preview is skipped for PRs from forks, which +cannot read the service-account secret. + +Shared toolchain setup lives in `.github/actions/setup-web` and +`.github/actions/setup-android` — composite actions used by both workflows, +so Node stays on one version across the web build and the APK. Release/secret setup for the Play Store pipeline is documented in [`docs/play-store-publishing.md`](docs/play-store-publishing.md). diff --git a/android/README.md b/android/README.md index d3d80e1..dea41fe 100644 --- a/android/README.md +++ b/android/README.md @@ -1,6 +1,6 @@ # Lô tô — Android (Capacitor wrapper) -![android-build-debug](https://github.com/tiennm99/loto/actions/workflows/android-build-debug.yml/badge.svg) +![ci](https://github.com/tiennm99/loto/actions/workflows/ci.yml/badge.svg) Fully-offline Android wrapper around the [`web/`](../web) SvelteKit PWA. All assets — HTML, JS, CSS, and 184 voice MP3s — are bundled into the APK at build @@ -31,7 +31,7 @@ and the `