Merge branch 'ci/github-actions'

This commit is contained in:
hectorqin committed 2026-10-27 10:09:32 +00:00
commit cd158f6785
5 files changed
+261 -7

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
# Build and test: server (Node/TypeScript), web client, UI review, Android shell.
#
# Runs on pull requests and on pushes to any branch except main. Pushes to main and
# release tags run it through release.yml (workflow_call), before any image is
# published, so main is tested exactly once per push.
name: CI
on:
pull_request:
push:
branches-ignore: [main]
workflow_call:
workflow_dispatch:
permissions:
contents: read
concurrency:
# A workflow_call run shares the caller's ref; keep it apart from a direct run.
group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: 24
jobs:
server:
name: Server · typecheck, test, build
runs-on: ubuntu-latest
defaults:
run:
working-directory: server
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: server/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run build
web:
name: Web · typecheck, test, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: |
web/package-lock.json
server/package-lock.json
# The web suite imports server code (search parity), so both are installed.
- run: npm ci --prefix server
- run: npm ci --prefix web
- run: npm run typecheck --prefix web
- run: npm test --prefix web
- run: npm run build --prefix web
- uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist
retention-days: 7
ui-review:
# The production bundle rendered in a real Chromium and measured; see docs/ui.md §7.
name: Web · UI review
runs-on: ubuntu-latest
needs: web
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci --prefix web
- run: npx playwright install --with-deps chromium
working-directory: web
- uses: actions/download-artifact@v4
with:
name: web-dist
path: web/dist
- run: npm run ui:review:only --prefix web
- uses: actions/upload-artifact@v4
if: always()
with:
name: ui-review
path: artifacts/ui-review/react-app
retention-days: 14
android:
name: Android · shell contract, debug APK
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: sh android/scripts/check-shell.sh
- uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: web/package-lock.json
# The APK carries the current web build; verify-web-assets checks it is packed.
- run: sh android/scripts/build-web-assets.sh
- run: sh android/scripts/verify-web-assets.sh
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: gradle
# The hosted runner ships the Android SDK; this only accepts and adds what the
# build asks for (platform 35, build-tools 35.0.1).
- uses: android-actions/setup-android@v3
- run: ./gradlew --no-daemon :app:assembleDebug
working-directory: android
- uses: actions/upload-artifact@v4
with:
name: reader-debug-apk
path: android/app/build/outputs/apk/debug/*.apk
retention-days: 14
+123
View File
@@ -0,0 +1,123 @@
# Release the single-container image (server + web) to Docker Hub for amd64 and arm64.
#
# push to main -> hectorqin/reader-pro:main, :sha-<short>
# tag v1.2.3 -> hectorqin/reader-pro:1.2.3, :1.2, :latest, :sha-<short>
# manual run -> tags for the chosen ref, as above
#
# CI (ci.yml) runs first; nothing is published from a red build. Each architecture
# is built on its own native runner — the arm64 image under QEMU would run npm ci
# and the Vite build emulated, which is many times slower — and pushed by digest,
# then one job stitches the digests into the multi-arch tags.
#
# Needs repository secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a Docker Hub
# access token with read/write on hectorqin/reader-pro).
name: Release
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
IMAGE: hectorqin/reader-pro
jobs:
ci:
uses: ./.github/workflows/ci.yml
build:
name: Image · ${{ matrix.platform }}
needs: ci
strategy:
fail-fast: true
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Platform slug
run: echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
env:
PLATFORM: ${{ matrix.platform }}
- uses: actions/checkout@v5
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- id: build
uses: docker/build-push-action@v6
with:
context: .
file: server/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
- name: Record digest
run: |
mkdir -p "$RUNNER_TEMP/digests"
touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}"
env:
DIGEST: ${{ steps.build.outputs.digest }}
- uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
publish:
name: Image · multi-arch tags
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
flavor: |
latest=${{ startsWith(github.ref, 'refs/tags/v') }}
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
tags=()
while read -r tag; do tags+=(-t "$tag"); done < <(jq -r '.tags[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
refs=()
for digest in *; do refs+=("$IMAGE@sha256:$digest"); done
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
- name: Inspect
run: docker buildx imagetools inspect "$IMAGE:$VERSION"
env:
VERSION: ${{ steps.meta.outputs.version }}
+1 -1
View File
@@ -14,7 +14,7 @@ Docs (mostly Chinese) live in `docs/`; `docs/architecture.md` explains *why* thi
## Commands
Run from repo root (or `cd` into the package). CI (`.cnb.yml`) runs install → typecheck → test → build for each package, only when that package changed.
Run from repo root (or `cd` into the package). CI runs install → typecheck → test → build per package: GitHub Actions `.github/workflows/ci.yml` (PRs and non-main pushes; also server, UI review, Android shell check + debug APK) and CNB `.cnb.yml`. `.github/workflows/release.yml` runs CI on main pushes and `v*` tags, then publishes the multi-arch (amd64 + arm64, native runners) image to Docker Hub `hectorqin/reader-pro` (`main`/`sha-*`, or `x.y.z`/`x.y`/`latest` for tags); it needs the `DOCKERHUB_USERNAME`/`DOCKERHUB_TOKEN` secrets. `android/scripts/check-shell.sh` checks the JS bridge contract: only `@JavascriptInterface` methods count, and each must be declared in `web/src/android-bridge.d.ts` (method syntax, `name?(): T` for optional).
```sh
npm ci --prefix server && npm ci --prefix web
+12 -5
View File
@@ -21,7 +21,8 @@ err() { printf 'FAIL: %s\n' "$1" >&2; fail=1; }
note "==> checking package declarations match directory layout"
for file in $(find "$android_dir/app/src" -name '*.kt'); do
pkg=$(sed -n 's/^package \(.*\)$/\1/p' "$file" | head -1)
rel=${file#"$android_dir"/app/src/main/java/}
# Any source set (main, test, androidTest): the package is the path under its java/.
rel=${file#"$android_dir"/app/src/*/java/}
expected=$(dirname "$rel" | tr '/' '.')
if [ "$pkg" != "$expected" ]; then
err "$file declares '$pkg' but lives in '$expected'"
@@ -58,10 +59,16 @@ bridge_file="$android_dir/app/src/main/java/cool/cnb/reader/bridge/ReaderBridge.
speech_file="$android_dir/app/src/main/java/cool/cnb/reader/bridge/SpeechBridge.kt"
types_file="$repo_dir/web/src/android-bridge.d.ts"
# A method is "implemented" if either bridge class has it at method indentation.
# Both files are searched for both interfaces: the Kotlin class a method happens
# to live in is an implementation detail, not part of the contract.
implemented=$(sed -n 's/^ fun \([A-Za-z][A-Za-z0-9_]*\)(.*/\1/p' "$bridge_file" "$speech_file" | sort -u)
# A method is "implemented" if either bridge class exposes it to the page: a
# method-level `fun` directly under `@JavascriptInterface`. Both files are searched
# for both interfaces: the Kotlin class a method happens to live in is an
# implementation detail, not part of the contract. A `fun` without the annotation
# is Kotlin-only (SpeechBridge.interruptForMedia is called by the media bridge,
# never by the page), and the page cannot call it, so declaring it would be wrong.
implemented=$(awk '
/^ fun [A-Za-z][A-Za-z0-9_]*\(/ && exposed { name = $2; sub(/\(.*/, "", name); print name }
NF { exposed = ($0 ~ /^[[:space:]]*@JavascriptInterface[[:space:]]*$/) }
' "$bridge_file" "$speech_file" | sort -u)
# The declaration side has two shapes, and the difference matters:
#
+1 -1
View File
@@ -11,7 +11,7 @@ export interface AndroidBridge {
/** Version of the native shell, so the client can require a minimum. */
shellVersion(): number;
/** Optional Reader server origin configured by the native APK. */
configuredServerUrl?: () => string;
configuredServerUrl?(): string;
/** Device label recorded on progress rows, e.g. `Pixel 7`. */
deviceLabel(): string;
/** 'online' | 'offline' from the platform connectivity manager. */