diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e5f62d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4ffb19c --- /dev/null +++ b/.github/workflows/release.yml @@ -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- +# tag v1.2.3 -> hectorqin/reader-pro:1.2.3, :1.2, :latest, :sha- +# 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 }} diff --git a/CLAUDE.md b/CLAUDE.md index f87562e..dab8d6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/android/scripts/check-shell.sh b/android/scripts/check-shell.sh index c49cfea..3b64a07 100755 --- a/android/scripts/check-shell.sh +++ b/android/scripts/check-shell.sh @@ -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: # diff --git a/web/src/android-bridge.d.ts b/web/src/android-bridge.d.ts index 0b988df..ad95e70 100644 --- a/web/src/android-bridge.d.ts +++ b/web/src/android-bridge.d.ts @@ -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. */