ci: consolidate web and android workflows into one pipeline

Both subprojects ship from the same commit, so ordinary CI is now a single
ci.yml; only the tag-driven release stands apart. The web app is built twice
per run — once per base path — and every consumer downloads the artifact
instead of rebuilding, replacing three redundant base-"" builds on main.

Nothing deploys unless the test job is green, and android-release runs the
suite before signing (ci.yml does not fire on tags, so it was the only gap).

Shared toolchain setup moves into composite actions, which puts the web build
and the APK on the same Node version for the first time. The Firebase PR path
was still on npm ci against a stale web/package-lock.json that could resolve a
different tree than pnpm-lock.yaml; drop the lockfile and the npm path with it.

Also: least-privilege permissions widened per job, persist-credentials off on
every checkout, concurrency groups that cancel superseded PRs but never a live
deploy, npm caching for android, and the Firebase action pinned by commit SHA
to match how the release actions were already pinned.
This commit is contained in:
tiennm99 authored and Tien Nguyen Minh committed 2026-08-09 15:21:23 +07:00
1 parent dfc9f67e0c
commit c6c839fd95
17 files changed
+321 -8681

No files matched your search

+28
View File
@@ -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
+24
View File
@@ -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
-59
View File
@@ -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
+23 -23
View File
@@ -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:
+181
View File
@@ -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
@@ -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
@@ -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
@@ -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
-41
View File
@@ -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
+24 -8
View File
@@ -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).
+12 -6
View File
@@ -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 `<audio>` element all work offline.
- Capacitor 8 (Android wrapper)
- Web app in `web/`: SvelteKit 2 + Vite 8 + `@sveltejs/adapter-static` + `@vite-pwa/sveltekit`
- minSdk 24 · targetSdk 36 · JDK 21 · Node 22 (Capacitor 8 requires both)
- minSdk 24 · targetSdk 36 · JDK 21 · Node 24 (Capacitor 8 requires the first two)
## Setup
@@ -120,11 +120,17 @@ Workflows live at the repository root in `.github/workflows/`.
| Workflow | Trigger | Result |
|----------|---------|--------|
| `android-build-debug` | push to `main`, any PR touching `web/` or `android/` | unsigned debug APK uploaded as artifact |
| `android-release` | tag `v*.*.*` | signed AAB + APK attached to GH Release |
| `ci` (`android-debug` job) | push to `main`, any PR touching `web/` or `android/` | unsigned debug APK uploaded as artifact |
| `android-release` | tag `v*.*.*` | tests, then signed AAB + APK attached to GH Release |
Both install npm deps in `android/`, build `web/`, `cap sync`, then run Gradle
inside `android/android/`.
Both `cap sync` a web bundle into `android/android/`, then run Gradle there.
They differ in where the bundle comes from: the `android-debug` job downloads
the artifact `ci` already built, so `web/` is never built twice in one run,
while `android-release` builds `web/` itself from the tagged tree. Neither
runs `npm run build` in `android/` — that script would rebuild `web/`.
Toolchain setup is shared through the composite actions
`.github/actions/setup-web` and `.github/actions/setup-android`.
### GitHub Secrets (release only)
+6 -3
View File
@@ -118,9 +118,12 @@ The script auto-discovers all available `vi-*` voices and writes an updated mani
## Deployment
Deployed to GitHub Pages via `.github/workflows/web-deploy-github-pages.yml` on push
to `main`. Build command: `pnpm build:gh` (sets `/loto` base path). Static output in
`build/`.
Deployed to GitHub Pages by the `deploy-pages` job in
`.github/workflows/ci.yml` on push to `main`. Build command: `pnpm build:gh`
(sets `/loto` base path). Static output in `build/`.
The same workflow also deploys the base-`""` build to Firebase Hosting and
bundles it into the debug APK, so `web/` is built once per base path per run.
---
+2 -2
View File
@@ -50,8 +50,8 @@
| `svelte.config.js` | adapter-static (HTML export), dual basePath via BUILD_PROFILE env, SvelteKit PWA plugin config. |
| `vite.config.js` | Tailwind + SvelteKit + PWA plugins. codeserver HMR config (port, allowedHosts, hmr). |
| `package.json` | SvelteKit 2, Svelte 5 (runes), Tailwind 4, Vite, @vite-pwa/sveltekit. Scripts: dev, dev:codeserver, build, build:gh, lint, test, test:watch. |
| `.github/workflows/web-deploy-github-pages.yml` | Canonical deploy. On push to `main`: `npm ci && npm run build:gh`, uploads `build/` as the GitHub Pages artifact, deploys to `https://tiennm99.github.io/loto/`. |
| `.github/workflows/web-verify-build.yml` | CI: on push/PR to main runs `npm test && npm run build` as a regression gate before the deploy job. |
| `.github/workflows/ci.yml` | Repo-root pipeline for push/PR. `test` (`pnpm test`) gates a two-profile `build` matrix; the artifacts feed `deploy-pages` (canonical, `https://tiennm99.github.io/loto/`), `deploy-firebase`, `preview-firebase`, and `android-debug`. |
| `.github/actions/setup-web/` | Composite action: pnpm + Node 24 + `pnpm install --frozen-lockfile` in `web/`. Shared by `ci.yml` and `android-release.yml`. |
| `eslint.config.mjs` | ESLint 9 flat config (@eslint/js + eslint-plugin-svelte). Declares Svelte 5 rune globals. |
| `jsconfig.json` | Path alias `$lib`, no checkJs. |
| `.gitignore` | Excludes node_modules, build, .env.local, etc. |
+19 -9
View File
@@ -15,9 +15,10 @@ either profile without code changes.
## Production Deployment — GitHub Pages
Canonical deploy. Wired via `.github/workflows/web-deploy-github-pages.yml`:
on push to `main`, runs `npm run build:gh`, uploads `build/` as the
GitHub Pages artifact, and deploys.
Canonical deploy. Wired via the `deploy-pages` job in
`.github/workflows/ci.yml`: on push to `main`, the `build (gh)` job runs
`pnpm build:gh` and uploads `build/`; `deploy-pages` downloads that artifact
and publishes it. Both are gated on the `test` job.
One-time setup (already done; documented for restoration):
1. Repo → Settings → Pages → Source: **GitHub Actions**.
@@ -134,12 +135,21 @@ Generates:
## CI/CD Pipeline
Two workflows on `main`:
- **`.github/workflows/web-deploy-github-pages.yml`** — canonical deploy. Builds
with `npm run build:gh` and publishes `build/` to GitHub Pages.
- **`.github/workflows/web-verify-build.yml`** — PR + push gate. Runs
`npm test && npm run build` to catch regressions before they reach the
deploy job.
One workflow, `.github/workflows/ci.yml`, covers PRs and pushes to `main`:
- **`test`** — `pnpm test`. Every other job depends on it, so a red suite
blocks all deploys.
- **`build`** — a two-entry matrix producing the only two web builds in the
run: `pnpm build` (base `""`, artifact `web-build`) and `pnpm build:gh`
(base `/loto`, artifact `web-build-gh`).
- **`deploy-pages`** — canonical deploy; publishes `web-build-gh`.
- **`deploy-firebase`** / **`preview-firebase`** — Firebase live channel on
`main`, preview channel on same-repo PRs; both consume `web-build`.
- **`android-debug`** — syncs `web-build` into the Capacitor project and
assembles an unsigned APK.
Tags run `.github/workflows/android-release.yml` instead, which builds the
web app itself because no `ci` run exists to take artifacts from.
## Performance Checklist
+1 -1
View File
@@ -86,7 +86,7 @@ State is entirely client-side. Each card / panel instance uses a unique localSto
## Deployment
- **Production**: GitHub Pages at `https://tiennm99.github.io/loto/` (canonical, basePath `/loto`). Deployed by `.github/workflows/web-deploy-github-pages.yml` on push to `main`.
- **Production**: GitHub Pages at `https://tiennm99.github.io/loto/` (canonical, basePath `/loto`). Deployed by the `deploy-pages` job in `.github/workflows/ci.yml` on push to `main`.
- **Development**: `npm run dev` (local), `npm run dev:codeserver` (code-server via proxy).
- **Build**: `npm run build:gh` generates static export to `build/` directory with the `/loto` basePath.
+1 -1
View File
@@ -74,7 +74,7 @@ npm run build:gh
basePath="/loto"
Output: build/index.html with /loto prefix
Deploy: https://tiennm99.github.io/loto/
Workflow: .github/workflows/web-deploy-github-pages.yml
Workflow: .github/workflows/ci.yml (build "gh" -> deploy-pages)
```
### Development (Local)
-8423
View File
File diff suppressed because it is too large. Load diff