diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index 99a4c56db..b7ef94538 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -42,6 +42,104 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + test-linux-egui: + # Run in parallel with packaging: the tests used + # to serialise ~8 minutes in front of the ~7 minute release build, making this + # workflow the critical path of the whole CI run. + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + CARGO_TARGET_DIR: ${{ github.workspace }}/openless-all/app/target + steps: + - uses: actions/checkout@v6 + with: + submodules: false + ref: ${{ inputs.checkout_ref || github.ref }} + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + if: inputs.scope != 'none' + - uses: actions/setup-node@v4 + with: + node-version: '22' + if: inputs.scope != 'none' + + - name: Install native build and test dependencies + if: inputs.scope != 'none' + # Same list as the packaging job: the fcitx5 plugin build and the + # `pwsh` contract scripts both live here now. + run: | + sudo add-apt-repository -y universe || true + sudo apt-get update + sudo apt-get install -y \ + appstream build-essential cmake cpio desktop-file-utils \ + fcitx5-modules-dev file libasound2-dev libdbus-1-dev \ + libfcitx5config-dev libfcitx5core-dev libfcitx5utils-dev \ + liblzma-dev libpipewire-0.3-dev libpulse-dev libssl-dev \ + libwayland-dev libx11-dev libxkbcommon-dev pkg-config ripgrep rpm + + - uses: swatinem/rust-cache@v2 + if: inputs.scope != 'none' + with: + # Test and release profiles are produced independently; each needs its own cache. + shared-key: test-linux-egui + workspaces: | + openless-all/app -> target + openless-all/app/src-tauri/backend-tests -> ../../target + cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + + - name: Build and test fcitx5 plugin + if: inputs.scope != 'none' + working-directory: openless-all/app/scripts/linux-fcitx5-plugin + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr + cmake --build build --parallel + ctest --test-dir build --output-on-failure + test -s build/libopenless.so + test -s build/openless.conf + + - name: Test Core, Linux host, and platform boundaries + if: inputs.scope != 'none' + working-directory: openless-all/app + run: | + set -euo pipefail + cargo test --locked -p openless-core + # Named re-run of the hotkey edge gate that used to be a standalone + # script spawning its own `cargo test`: the binary is already built by + # the line above, so this is free, and a rename now fails loudly here + # instead of silently dropping the gate. + cargo test --locked -p openless-core --lib \ + api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics -- --exact \ + | tee "$RUNNER_TEMP/openless-hotkey-edge.log" + grep -Fq 'test api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics ... ok' \ + "$RUNNER_TEMP/openless-hotkey-edge.log" + cargo test --locked -p openless-harmony-host + cargo clippy --locked -p openless-core --all-targets -- -D warnings + cargo test --locked -p openless-linux-egui --all-targets + cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings + node scripts/linux-egui-tauri-free-contract.test.mjs + node scripts/linux-egui-release-contract.test.mjs + node scripts/ci-changed-areas.test.mjs + node scripts/ci-cache-usage.test.mjs + node scripts/ci-cache-sharing.test.mjs + node scripts/harmony-release-contract.test.mjs + node scripts/tauri-linux-free-contract.test.mjs + pwsh -NoProfile -File ./scripts/check-core-deps.ps1 + pwsh -NoProfile -File ./scripts/check-core-deps.ps1 openless-linux-egui + pwsh -NoProfile -File ./scripts/check-core-secret-surface.ps1 + pwsh -NoProfile -File ./scripts/check-core-test-isolation.ps1 + pwsh -NoProfile -File ./scripts/check-core-runtime-seam.ps1 + pwsh -NoProfile -File ./scripts/check-linux-public-surface.ps1 + + - name: Test remote input TLS identity and Unix key permissions + if: inputs.scope != 'none' + working-directory: openless-all/app + run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls + build-linux-egui: # PipeWire/libspa in this host needs 24.04. Packages target glibc >= 2.39. runs-on: ubuntu-24.04 @@ -110,6 +208,8 @@ jobs: - uses: swatinem/rust-cache@v2 if: inputs.scope != 'none' with: + # Keep the release cache shared with tag builds, separate from test profiles. + shared-key: build-linux-egui workspaces: | openless-all/app -> target openless-all/app/src-tauri/backend-tests -> ../../target @@ -118,43 +218,15 @@ jobs: cache-on-failure: true save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - - name: Build and test fcitx5 plugin + - name: Build the fcitx5 plugin for packaging if: inputs.scope != 'none' + # The package installs `libopenless.so` + `openless.conf`, so the + # packaging job needs the artifact. The assertions and `ctest` run in the + # parallel `test-linux-egui` job; this is the build only. working-directory: openless-all/app/scripts/linux-fcitx5-plugin run: | cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr cmake --build build --parallel - ctest --test-dir build --output-on-failure - test -s build/libopenless.so - test -s build/openless.conf - - - name: Test Core, Linux host, and platform boundaries - if: inputs.scope != 'none' - working-directory: openless-all/app - run: | - cargo test --locked -p openless-core - cargo test --locked -p openless-harmony-host - cargo clippy --locked -p openless-core --all-targets -- -D warnings - cargo test --locked -p openless-linux-egui --all-targets - cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings - node scripts/linux-egui-tauri-free-contract.test.mjs - node scripts/linux-egui-release-contract.test.mjs - node scripts/ci-changed-areas.test.mjs - node scripts/ci-cache-usage.test.mjs - node scripts/ci-cache-sharing.test.mjs - node scripts/harmony-release-contract.test.mjs - node scripts/tauri-linux-free-contract.test.mjs - pwsh -NoProfile -File ./scripts/check-core-deps.ps1 - pwsh -NoProfile -File ./scripts/check-core-deps.ps1 openless-linux-egui - pwsh -NoProfile -File ./scripts/check-core-secret-surface.ps1 - pwsh -NoProfile -File ./scripts/check-core-test-isolation.ps1 - pwsh -NoProfile -File ./scripts/check-core-runtime-seam.ps1 - pwsh -NoProfile -File ./scripts/check-linux-public-surface.ps1 - - - name: Test remote input TLS identity and Unix key permissions - if: inputs.scope != 'none' - working-directory: openless-all/app - run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls - name: Validate Linux desktop metadata if: inputs.scope != 'none' @@ -217,18 +289,45 @@ jobs: path: openless-all/app/target/linux-egui-packages/* if-no-files-found: error + - name: Upload direct download aliases + if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '') + uses: actions/upload-artifact@v7 + with: + name: openless-linux-egui-direct-download + path: ${{ runner.temp }}/openless-linux-egui-direct-download/* + if-no-files-found: error + + publish-linux-egui: + needs: [test-linux-egui, build-linux-egui] + if: inputs.scope != 'none' && inputs.publish && inputs.release_tag != '' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + steps: + - name: Download verified Linux packages + uses: actions/download-artifact@v8 + with: + name: openless-linux-egui-x86_64 + path: ${{ runner.temp }}/openless-linux-egui-packages + - name: Download direct download aliases + uses: actions/download-artifact@v8 + with: + name: openless-linux-egui-direct-download + path: ${{ runner.temp }}/openless-linux-egui-direct-download - name: Attach Linux packages to the GitHub Release - # Runs only for the release entry point, which pins the tag; PR and push - # builds never publish. - if: inputs.scope != 'none' && inputs.publish && inputs.release_tag != '' - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.release_tag }} - working-directory: openless-all/app run: | set -euo pipefail - # The desktop workflow creates the Release for this tag; wait for it - # instead of racing it, and fail loudly if it never appears. + output="$RUNNER_TEMP/openless-linux-egui-packages" + alias_dir="$RUNNER_TEMP/openless-linux-egui-direct-download" + for file in "$output"/OpenLess-Linux-egui-*.deb "$output"/OpenLess-Linux-egui-*.rpm \ + "$output/SHA256SUMS-linux" "$alias_dir/OpenLess_linux_x86_64.deb" \ + "$alias_dir/OpenLess_linux_x86_64.rpm"; do + [ -s "$file" ] || { echo "::error::missing Linux release asset $file"; exit 1; } + done + # The desktop workflow creates the Release; wait without racing its creation. for attempt in $(seq 1 40); do if gh release view "$RELEASE_TAG" --json isDraft > /dev/null 2>&1; then break @@ -239,21 +338,11 @@ jobs: fi sleep 15 done - output=target/linux-egui-packages - alias_dir="$RUNNER_TEMP/openless-linux-egui-direct-download" gh release upload "$RELEASE_TAG" \ "$output"/OpenLess-Linux-egui-*.deb \ "$output"/OpenLess-Linux-egui-*.rpm \ - "$output"/SHA256SUMS-linux \ - "$alias_dir"/OpenLess_linux_x86_64.deb \ - "$alias_dir"/OpenLess_linux_x86_64.rpm \ + "$output/SHA256SUMS-linux" \ + "$alias_dir/OpenLess_linux_x86_64.deb" \ + "$alias_dir/OpenLess_linux_x86_64.rpm" \ --clobber echo "attached Linux assets to $RELEASE_TAG" - - - name: Upload direct download aliases - if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '') - uses: actions/upload-artifact@v7 - with: - name: openless-linux-egui-direct-download - path: ${{ runner.temp }}/openless-linux-egui-direct-download/* - if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65ab70c5a..2821c2b6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,7 +201,7 @@ jobs: - name: Install Linux check dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential curl file libssl-dev wget + sudo apt-get install -y build-essential curl file libpulse0 libssl-dev wget - name: Install frontend dependencies run: npm ci @@ -239,17 +239,26 @@ jobs: - name: Copy Android production and test scaffolding run: node scripts/copy-android-scaffolding.mjs - - name: Cache Android Rime instrumentation dependencies - uses: actions/cache@v4 + - name: Restore Android Rime instrumentation dependencies + id: rime_cache + uses: actions/cache/restore@v4 with: path: ${{ runner.temp }}/openless-cache/mobile-rime key: mobile-rime-ndk26-x86_64-${{ hashFiles('openless-all/app/scripts/build-mobile-rime.py', 'openless-all/app/mobile-ime/**') }} - - name: Build and stage Rime for Android instrumentation tests env: OPENLESS_CACHE_ROOT: ${{ runner.temp }}/openless-cache run: python3 scripts/build-mobile-rime.py android --abi x86_64 --stage + - name: Save Android Rime dependencies on beta builds + # 400 MB per entry: pull request scopes are never restored again, so only + # the beta branch may write this cache. + if: steps.rime_cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: actions/cache/save@v4 + with: + path: ${{ runner.temp }}/openless-cache/mobile-rime + key: ${{ steps.rime_cache.outputs.cache-primary-key }} + - name: Merge Shizuku manifest run: node scripts/merge-android-shizuku-manifest.mjs @@ -274,21 +283,148 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Restore the pinned Android emulator and system image + id: emulator_cache + uses: actions/cache/restore@v4 + with: + path: | + ${{ env.ANDROID_HOME }}/emulator + ${{ env.ANDROID_HOME }}/system-images/android-35/google_apis/x86_64 + key: android-emulator-v2-16555280-api35-google_apis-x86_64-${{ runner.os }}-${{ runner.arch }} + + - name: Prepare the pinned Android emulator and system image + env: + EMULATOR_CACHE_HIT: ${{ steps.emulator_cache.outputs.cache-hit }} + shell: bash + run: | + set -euo pipefail + image="$ANDROID_HOME/system-images/android-35/google_apis/x86_64" + validate_emulator() { + local version + # Drain the launcher output before grep: an early reader exit causes SIGPIPE. + if version=$("$1" -version 2>&1) && grep -Fq 'build_id 16555280' <<< "$version"; then + return 0 + fi + printf '%s\n' "$version" >&2 + return 1 + } + validate_image() { [ -s "$image/system.img" ] && [ -s "$image/package.xml" ]; } + # Register the pinned package before sdkmanager resolves image dependencies. + register_emulator() { + if [ ! -s "$ANDROID_HOME/emulator/package.xml" ]; then + python3 - "$ANDROID_HOME/emulator" <<'PY' + from pathlib import Path + import sys + import xml.etree.ElementTree as ET + emulator = Path(sys.argv[1]) + props = dict(line.split('=', 1) for line in (emulator / 'source.properties').read_text().splitlines() if '=' in line) + ET.register_namespace('common', 'http://schemas.android.com/repository/android/common/02') + ET.register_namespace('xsi', 'http://www.w3.org/2001/XMLSchema-instance') + root = ET.Element('{http://schemas.android.com/repository/android/common/02}repository', {'xmlns:generic': 'http://schemas.android.com/repository/android/generic/02'}) + package = ET.SubElement(root, 'localPackage', {'path': 'emulator', 'obsolete': 'false'}) + ET.SubElement(package, 'type-details', {'{http://www.w3.org/2001/XMLSchema-instance}type': 'generic:genericDetailsType'}) + revision = ET.SubElement(package, 'revision') + for tag, value in zip(('major', 'minor', 'micro'), props['Pkg.Revision'].strip().split('.')[:3]): + ET.SubElement(revision, tag).text = str(int(value)) + ET.SubElement(package, 'display-name').text = 'Android Emulator' + ET.ElementTree(root).write(emulator / 'package.xml', encoding='utf-8', xml_declaration=True) + PY + fi + } + if [ "$EMULATOR_CACHE_HIT" != 'true' ]; then + work=$(mktemp -d "$RUNNER_TEMP/openless-emulator.XXXXXX") + trap 'rm -rf "$work"' EXIT + for attempt in 1 2 3; do + echo "Install emulator build 16555280 and API 35 image (attempt $attempt)" + if curl -fL --connect-timeout 30 --max-time 180 \ + -o "$work/emulator.zip" https://dl.google.com/android/repository/emulator-linux_x64-16555280.zip \ + && unzip -tq "$work/emulator.zip" \ + && unzip -oq "$work/emulator.zip" -d "$work" \ + && validate_emulator "$work/emulator/emulator"; then + rm -rf "$ANDROID_HOME/emulator" + mv "$work/emulator" "$ANDROID_HOME/emulator" + register_emulator + if sdkmanager 'system-images;android-35;google_apis;x86_64' && validate_image; then + break + fi + rm -rf "$ANDROID_HOME/emulator" + fi + rm -rf "$work/emulator" "$image" + if [ "$attempt" -eq 3 ]; then + echo '::error::could not install the pinned emulator and system image after 3 attempts' + exit 1 + fi + sleep 15 + done + fi + validate_emulator "$ANDROID_HOME/emulator/emulator" \ + || { echo '::error::cached emulator does not match build 16555280'; exit 1; } + validate_image || { echo '::error::Android API 35 system image is incomplete'; exit 1; } + register_emulator + + - name: Save the Android emulator on beta builds + if: steps.emulator_cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: actions/cache/save@v4 + with: + path: | + ${{ env.ANDROID_HOME }}/emulator + ${{ env.ANDROID_HOME }}/system-images/android-35/google_apis/x86_64 + key: ${{ steps.emulator_cache.outputs.cache-primary-key }} + - name: Run Android Keystore instrumentation tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 35 - target: google_apis - arch: x86_64 - # The suite exercises AndroidKeyStore and the real packaged Rime JNI - # without launching Tauri. Rime is staged for this emulator ABI above. - script: >- - cd openless-all/app && - src-tauri/gen/android/gradlew - --project-dir src-tauri/gen/android - :app:connectedX86_64DebugAndroidTest - -x :app:rustBuildX86_64Debug - --no-daemon + shell: bash + run: | + set -euo pipefail + # SDK tools and the pinned emulator must share the same AVD registry. + export ANDROID_AVD_HOME="$RUNNER_TEMP/openless-ci-avds" + mkdir -p "$ANDROID_AVD_HOME" + avd="$RUNNER_TEMP/openless-ci.avd" + printf 'no\n' | avdmanager create avd --force --name openless-ci \ + --package 'system-images;android-35;google_apis;x86_64' --path "$avd" + sed '/^hw[.]keyboard[ =]/d' "$avd/config.ini" > "$avd/config.ini.tmp" + mv "$avd/config.ini.tmp" "$avd/config.ini" + echo 'hw.keyboard=no' >> "$avd/config.ini" + log="$RUNNER_TEMP/openless-android-emulator.log" + emulator_pid='' + cleanup() { + status=$? + trap - EXIT + if [ -n "$emulator_pid" ]; then + if kill -0 "$emulator_pid" 2>/dev/null; then + timeout 10s adb -s emulator-5554 emu kill >/dev/null 2>&1 || true + kill "$emulator_pid" 2>/dev/null || true + fi + wait "$emulator_pid" 2>/dev/null || true + fi + if [ "$status" -ne 0 ]; then cat "$log" >&2 || true; fi + exit "$status" + } + trap cleanup EXIT + "$ANDROID_HOME/emulator/emulator" -avd openless-ci -port 5554 -no-window \ + -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim > "$log" 2>&1 & + emulator_pid=$! + export ANDROID_SERIAL=emulator-5554 + timeout 600s bash -c ' + until [ "$(adb -s emulator-5554 shell getprop sys.boot_completed 2>/dev/null | tr -d "\r")" = 1 ]; do + kill -0 "$1" 2>/dev/null || exit 1 + sleep 2 + done + ' bash "$emulator_pid" + adb -s emulator-5554 shell input keyevent 82 + for setting in window_animation_scale transition_animation_scale animator_duration_scale; do + adb -s emulator-5554 shell settings put global "$setting" 0 + done + # Rime is staged for this emulator ABI; do not launch the Tauri Rust build. + src-tauri/gen/android/gradlew --project-dir src-tauri/gen/android \ + :app:connectedX86_64DebugAndroidTest -x :app:rustBuildX86_64Debug --no-daemon + + - name: Upload Android emulator failure log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-instrumentation-failure + path: ${{ runner.temp }}/openless-android-emulator.log + if-no-files-found: ignore # HarmonyOS covers three layers, in three places: # * the Rust host is compiled and tested by the Linux job @@ -301,7 +437,7 @@ jobs: harmony-check: needs: [changes] if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.harmony != 'false' - name: Harmony (ArkTS contracts + ArkWeb bundle, HAP when the SDK is set) + name: Harmony (ArkTS contracts, Rust host, HAP) runs-on: ubuntu-latest timeout-minutes: 20 defaults: @@ -333,12 +469,10 @@ jobs: node scripts/harmony-clipboard-history.test.mjs node scripts/harmony-symbol-gesture.test.mjs node scripts/harmony-rime-startup.test.mjs - - name: Build the ArkWeb bundle - env: - VITE_OPENLESS_PLATFORM: harmony - # The frontend half of the HAP: same sources, harmony platform flag, - # relative base for the rawfile origin. - run: npm run build -- --base=./ --outDir=dist-harmony + # The ArkWeb bundle is not built here: `package-harmony-hap.sh` -> + # `build-harmony-frontend.sh` runs the same + # `npm run build -- --base=./ --outDir=…`, so building it twice only + # doubled the vite pass. - uses: dtolnay/rust-toolchain@stable with: # Tier-2 OHOS target; `rustup target list` above lists it for stable. @@ -351,14 +485,10 @@ jobs: key: harmony-host save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - - name: Cache the HarmonyOS toolchain - # The official command-line tools, downloaded from the public mirror that - # `prepare-harmony-sdk.sh` pins by checksum. No Huawei account is involved, - # so this runs on every pull request. - uses: actions/cache@v4 - with: - path: ~/.local/share/harmony/current - key: harmony-tools-6.0.1.251-${{ runner.os }} + # No toolchain cache. The download is 2.1 GB at ~100 MB/s plus ~70 s to + # unpack, while the extracted tree is 1.77 GB of the repository's 10 GB + # cache budget - and `actions/cache` wrote one copy per pull request + # scope. Downloading is both cheaper and shorter than restoring it. - name: Build the HAP (compiles ArkTS and the Rust host) env: OPENLESS_HARMONY_CHANNEL: none @@ -389,6 +519,94 @@ jobs: # 散文改动跳过整条 Linux 构建与打包链;输出缺失/未知时退回 full。 scope: ${{ needs.changes.outputs.linux == 'false' && 'none' || 'full' }} + windows-frontend: + # Split out of `cross-platform`: the job used to serialise 6 minutes of + # frontend work behind 15 minutes of Rust compiling on the same runner, and + # the Windows runner is the slowest of the three. Running them in parallel + # costs one extra setup but removes ~6 minutes from the critical path. + needs: [changes] + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.tauri != 'false' + name: Windows frontend and contracts + runs-on: windows-latest + timeout-minutes: 40 + env: + CARGO_PROFILE_TEST_DEBUG: 0 + defaults: + run: + working-directory: openless-all/app + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Cache npm from beta + uses: ./.github/actions/cache-npm + - name: Install frontend dependencies + run: npm ci + - name: Check Windows prerequisites + shell: pwsh + run: ./scripts/windows-preflight.ps1 -Toolchain msvc + - name: Check PowerShell scripts + shell: pwsh + run: | + foreach ($script in @("./scripts/windows-preflight.ps1", "./scripts/windows-build-gnu.ps1", "./scripts/windows-runtime-smoke.ps1", "./scripts/prepare-windows-sherpa.ps1")) { + $errors = $null + [System.Management.Automation.PSParser]::Tokenize((Get-Content -Raw $script), [ref]$errors) | Out-Null + if ($errors) { + $errors | Format-List + exit 1 + } + } + - name: Build frontend + run: npm run build + - name: Run the platform-sensitive contracts + # `process.platform` branches and the `windows-*` contracts only mean + # anything here; everything else runs on macOS (full suite) and Linux. + run: node scripts/frontend-test-runner.mjs --platform-only windows + + native-capsule: + # The capsule crates compile into the desktop shells, so a break there would + # otherwise only surface inside the long Tauri matrix. They are checked on + # both desktop platforms inside this workflow rather than in a second + # workflow: one run per pull request, one `Changed areas` gate, one cache + # policy. Being a `push` job on the default branches is also what keeps the + # Rust cache warm - a pull-request-only workflow can never save a tree the + # next pull request is allowed to restore. + needs: [changes] + if: needs.changes.outputs.tauri != 'false' + name: Native capsule (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '["macos-latest"]' || '["macos-latest","windows-latest"]') }} + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + defaults: + run: + working-directory: openless-all/app + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + # Stable prefix per platform instead of the job name, so renaming or + # splitting the job keeps the tree. + shared-key: native-capsule + workspaces: openless-all/app -> target + # Pull requests restore the beta tree and never write their own copy: + # `refs/pull//merge` scopes are never restored again and only evict + # the branch caches (see docs/ci-trigger-and-cache-policy.md). + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + - run: cargo check --locked -p openless-native-capsule + - run: cargo test --locked -p openless-capsule --features ui,host + - run: cargo clippy --locked -p openless-native-capsule -p openless-capsule --features openless-capsule/ui,openless-capsule/host -- -D warnings + cross-platform: needs: [changes] # 失败即运行:只有判定脚本明确说“够不到”才跳过。 @@ -428,8 +646,10 @@ jobs: python-version: "3.x" # macOS MSRV is checked in a separate parallel job; Windows keeps the original check order. + # Same rule as the macOS MSRV job: a second toolchain is only worth the + # install + full rebuild while the declared MSRV is behind stable. - uses: dtolnay/rust-toolchain@1.99.0 - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.changes.outputs.msrv_needed == 'true' - uses: dtolnay/rust-toolchain@stable @@ -467,11 +687,6 @@ jobs: - name: Install frontend dependencies run: npm ci - - name: Check Windows prerequisites - if: matrix.preflight - shell: pwsh - run: ./scripts/windows-preflight.ps1 -Toolchain msvc - - name: Check PowerShell scripts if: matrix.preflight shell: pwsh @@ -485,7 +700,18 @@ jobs: } } + - name: Build the frontend for the Tauri context + # `tauri::generate_context!()` embeds `../dist` at compile time, so the + # Rust compile below needs the frontend built even on Windows, where the + # contract suite runs in its own job. + if: runner.os == 'Windows' + run: npm run build + - name: Build frontend and run frontend/contract tests + # Windows runs the platform-sensitive subset in its own job: spawning 144 + # node processes there costs ~3x the macOS time, and the rest of the suite + # is already covered here. The frontend build it needs is the step above. + if: runner.os == 'macOS' run: npm test # Direct Cargo checks do not execute Tauri beforeBuildCommand. @@ -511,10 +737,6 @@ jobs: OPENLESS_CODEX_TEST_EXE: codex run: cargo test --locked -p openless-core hardening_actually_narrows_the_writable_roots -- --ignored --nocapture --test-threads=1 - - name: Check Tauri backend (cargo check) - if: runner.os == 'Windows' - run: cargo check --locked --manifest-path src-tauri/Cargo.toml - # test compiles and runs lib/bin, covering the production binary paths that plain cargo # check misses. Avoids a metadata-only check followed by another codegen pass over the same # dependency graph. @@ -528,7 +750,10 @@ jobs: # the cfg/link coverage here; the shared Core's public compatibility contract is actually # executed in the next step. if: runner.os == 'Windows' - run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --no-run + # `--bins` keeps the binary coverage that the removed `cargo check` used to + # provide: `cargo check` writes metadata, so the following codegen pass + # recompiled the whole dependency graph anyway (measured: 6.4 min of it). + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --bins --no-run - name: Run Rust-only backend unit tests (Windows) # The standalone test crate does not link the full Tauri app lib; it verifies the public @@ -543,11 +768,11 @@ jobs: run: python scripts/test-recorder-module.py - name: Check Tauri backend with Rust 1.99 MSRV - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.changes.outputs.msrv_needed == 'true' run: cargo +1.99.0 check --locked --manifest-path src-tauri/Cargo.toml - name: Compile backend tests with Rust 1.99 MSRV - if: runner.os == 'Windows' + if: runner.os == 'Windows' && needs.changes.outputs.msrv_needed == 'true' run: cargo +1.99.0 test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --no-run - name: Build the Windows TSF input method diff --git a/.github/workflows/native-capsule.yml b/.github/workflows/native-capsule.yml deleted file mode 100644 index f9a72fc3b..000000000 --- a/.github/workflows/native-capsule.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Native capsule -on: - pull_request: - branches: [main, beta] - workflow_dispatch: -jobs: - native-capsule: - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - defaults: - run: - working-directory: openless-all/app - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - workspaces: openless-all/app - - run: cargo check --locked -p openless-native-capsule - - run: cargo test --locked -p openless-capsule --features ui,host - - run: cargo clippy --locked -p openless-native-capsule -p openless-capsule --features openless-capsule/ui,openless-capsule/host -- -D warnings diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index 6d26a02b2..053d64ac9 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -22,11 +22,16 @@ on: required: true type: string channel: - description: 'release = AppGallery production signing, test = AppGallery test channel' + # `none` is the repository distribution path: build the HAP, attach the + # unsigned package to the GitHub Release, never sign, never touch the + # store. `release` / `test` add the AppGallery signing on top, and only + # the store upload ever sees the signed file. + description: 'none = unsigned (GitHub Release only), release = AppGallery production, test = AppGallery test channel' required: false type: choice - default: release + default: none options: + - none - release - test publish: @@ -60,15 +65,35 @@ jobs: # Device-independent: the SDK path can be overridden per runner with a # repository variable instead of patching this file. OPENLESS_HARMONY_TOOLCHAIN: ${{ vars.HARMONY_TOOLCHAIN }} - # Empty until the signing step resolves `$RUNNER_TEMP`; the `runner` - # context is not available in a job-level `env`, only inside a step. - OPENLESS_HARMONY_SIGNING_DIR: "" + # `OPENLESS_HARMONY_SIGNING_DIR` is deliberately *not* declared here: the + # `runner` context is unavailable at job level, so the signing step writes + # it to `$GITHUB_ENV` - and GitHub resolves YAML `env` above `$GITHUB_ENV`, + # so an empty placeholder here would shadow it and the build would look for + # signing material in `~/.local/share/harmony/signing/release`. GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ inputs.tag }} # Signing material: files are base64 secrets, passwords are plain secrets # and never touch the workspace. - HARMONY_KEYSTORE_PASSWORD: ${{ secrets.HARMONY_KEYSTORE_PASSWORD }} - HARMONY_KEY_PASSWORD: ${{ secrets.HARMONY_KEY_PASSWORD }} + # + # Two naming schemes are accepted. The channel-specific one + # (`HARMONY_RELEASE_*` / `HARMONY_TEST_*`) lets production and test material + # coexist in the repository; the plain names remain for a repo that only + # ever signs one channel. + HARMONY_RELEASE_CERT_BASE64: ${{ secrets.HARMONY_RELEASE_CERT_BASE64 }} + HARMONY_RELEASE_PROFILE_BASE64: ${{ secrets.HARMONY_RELEASE_PROFILE_BASE64 }} + HARMONY_RELEASE_KEYSTORE_BASE64: ${{ secrets.HARMONY_RELEASE_KEYSTORE_BASE64 }} + HARMONY_RELEASE_KEYSTORE_PASSWORD: ${{ secrets.HARMONY_RELEASE_KEYSTORE_PASSWORD }} + HARMONY_RELEASE_KEY_PASSWORD: ${{ secrets.HARMONY_RELEASE_KEY_PASSWORD }} + HARMONY_TEST_CERT_BASE64: ${{ secrets.HARMONY_TEST_CERT_BASE64 }} + HARMONY_TEST_PROFILE_BASE64: ${{ secrets.HARMONY_TEST_PROFILE_BASE64 }} + HARMONY_TEST_KEYSTORE_BASE64: ${{ secrets.HARMONY_TEST_KEYSTORE_BASE64 }} + HARMONY_TEST_KEYSTORE_PASSWORD: ${{ secrets.HARMONY_TEST_KEYSTORE_PASSWORD }} + HARMONY_TEST_KEY_PASSWORD: ${{ secrets.HARMONY_TEST_KEY_PASSWORD }} + HARMONY_CERT_BASE64: ${{ secrets.HARMONY_CERT_BASE64 }} + HARMONY_PROFILE_BASE64: ${{ secrets.HARMONY_PROFILE_BASE64 }} + HARMONY_KEYSTORE_BASE64: ${{ secrets.HARMONY_KEYSTORE_BASE64 }} + HARMONY_KEYSTORE_PASSWORD: ${{ inputs.channel == 'release' && secrets.HARMONY_RELEASE_KEYSTORE_PASSWORD || inputs.channel == 'test' && secrets.HARMONY_TEST_KEYSTORE_PASSWORD || secrets.HARMONY_KEYSTORE_PASSWORD }} + HARMONY_KEY_PASSWORD: ${{ inputs.channel == 'release' && secrets.HARMONY_RELEASE_KEY_PASSWORD || inputs.channel == 'test' && secrets.HARMONY_TEST_KEY_PASSWORD || secrets.HARMONY_KEY_PASSWORD }} HARMONY_KEY_ALIAS: ${{ vars.HARMONY_KEY_ALIAS }} APPGALLERY_CLIENT_ID: ${{ secrets.APPGALLERY_CLIENT_ID }} APPGALLERY_CLIENT_SECRET: ${{ secrets.APPGALLERY_CLIENT_SECRET }} @@ -88,11 +113,10 @@ jobs: with: node-version: '22' - - name: Cache the HarmonyOS toolchain - uses: actions/cache@v4 - with: - path: ~/.local/share/harmony/current - key: harmony-tools-6.0.1.251-${{ runner.os }} + # No toolchain cache here either: `prepare-harmony-sdk.sh` downloads the + # checksum-pinned official tools in under two minutes, and a 1.77 GB entry + # that a tag build rarely restores would sit in the 10 GB budget and evict + # the caches the pull request path depends on. - name: Prepare the HarmonyOS toolchain env: @@ -118,6 +142,7 @@ jobs: fi - name: Write the signing material for this channel + if: inputs.channel != 'none' # One secret set per channel: a `test` dispatch installs the test profile # under `test.*`, a `release` dispatch installs the production profile # under `release.*`. The packaging script picks the prefix by channel. @@ -125,29 +150,78 @@ jobs: set -euo pipefail dir="$RUNNER_TEMP/harmony-signing" prefix="${{ inputs.channel }}" + upper=$(printf '%s' "$prefix" | tr '[:lower:]' '[:upper:]') rm -rf "$dir" mkdir -p "$dir" umask 077 echo "OPENLESS_HARMONY_SIGNING_DIR=$dir" >> "$GITHUB_ENV" + + # Prefer `HARMONY__*` (so release and test material can both + # live in the repository), fall back to the channel-agnostic names. + # Secret names put the channel first: HARMONY_RELEASE_CERT_BASE64, + # HARMONY_TEST_CERT_BASE64. The channel-agnostic fallback is the plain + # name without that segment. + resolve() { + local base=$1 specific + specific="HARMONY_${upper}_${base#HARMONY_}" + if [ -n "${!specific:-}" ]; then printf '%s' "$specific"; return 0; fi + printf '%s' "$base" + } + missing=() for pair in "HARMONY_CERT_BASE64:$prefix.cer" \ "HARMONY_PROFILE_BASE64:$prefix.p7b" \ "HARMONY_KEYSTORE_BASE64:$prefix.p12"; do - name="${pair%%:*}" + base="${pair%%:*}" file="${pair##*:}" + name=$(resolve "$base") value="${!name:-}" if [ -z "$value" ]; then - echo "::error::missing secret $name (base64 of $file)" - exit 1 + missing+=("$name") + continue fi printf '%s' "$value" | base64 -d > "$dir/$file" done + if [ "${#missing[@]}" -gt 0 ]; then + echo "::error::missing signing secrets for channel '$prefix': ${missing[*]} (base64 of the .cer/.p7b/.p12 downloaded from AppGallery Connect)" + exit 1 + fi + + # Passwords follow the same scheme. They are written next to the + # keystore instead of re-exported through `$GITHUB_ENV`: YAML `env` + # outranks `$GITHUB_ENV`, so the job-level plain-name secret would + # shadow the resolved channel-specific one. `sign-harmony-hap.py` + # reads these files when the environment has nothing. + keystore_password=$(resolve HARMONY_KEYSTORE_PASSWORD) + if [ -n "${!keystore_password:-}" ]; then + printf '%s' "${!keystore_password}" > "$dir/keystore-password" + fi + key_password=$(resolve HARMONY_KEY_PASSWORD) + if [ -n "${!key_password:-}" ]; then + printf '%s' "${!key_password}" > "$dir/key-password" + fi + chmod 600 "$dir/keystore-password" "$dir/key-password" 2>/dev/null || true ls -l "$dir" + - name: Validate the signing material before building + if: inputs.channel != 'none' + # Cheap and early: a certificate that does not match the keystore, a + # profile issued for another certificate, a profile for a different + # bundleName or an expired profile would otherwise surface after a + # six-minute build, or only when a device refuses the install. + working-directory: openless-all/app + run: bash scripts/check-harmony-signing.sh "$OPENLESS_HARMONY_SIGNING_DIR" "${{ inputs.channel }}" + - name: Build, sign and stage the HAP working-directory: openless-all/app env: OPENLESS_HARMONY_CHANNEL: ${{ inputs.channel }} - run: bash scripts/package-harmony-hap.sh + run: | + set -euo pipefail + if [ "$OPENLESS_HARMONY_CHANNEL" != "none" ] && [ -z "${OPENLESS_HARMONY_SIGNING_DIR:-}" ]; then + echo "::error::OPENLESS_HARMONY_SIGNING_DIR is unset; the signing step must export it" + exit 1 + fi + bash scripts/package-harmony-hap.sh - name: Verify the packaged HAP working-directory: openless-all/app @@ -164,8 +238,14 @@ jobs: # additionally prove its signature. bash scripts/verify-harmony-hap.sh "$output/OpenLess_harmony_arm64.hap" \ "$toolchain" "$version_name" "$version_code" --allow-unsigned - bash scripts/verify-harmony-hap.sh \ - "$output/OpenLess_harmony_arm64-signed.hap" "$toolchain" + if [ "${{ inputs.channel }}" != "none" ]; then + # AppGallery takes the App Pack; a bare HAP is rejected with + # 204144641 "The suffix name of the software package is incorrect". + bash scripts/verify-harmony-hap.sh \ + "$output/OpenLess_harmony_arm64-signed.app" "$toolchain" + else + echo "channel=none: unsigned package only, no signature to verify" + fi - name: Upload the HAP as a workflow artifact uses: actions/upload-artifact@v4 @@ -203,7 +283,8 @@ jobs: echo "attached HarmonyOS assets to $RELEASE_TAG" - name: Upload to HUAWEI AppGallery Connect - if: inputs.upload_to_appgallery + # Nothing to upload for the unsigned distribution path. + if: inputs.upload_to_appgallery && inputs.channel != 'none' working-directory: openless-all/app # A fork without AGC credentials still gets the GitHub Release; the step # reports that it skipped instead of failing the whole release. @@ -212,5 +293,5 @@ jobs: # GitHub Release distributes. node scripts/upload-appgallery.mjs \ --channel "${{ inputs.channel }}" \ - --file target/harmony-packages/OpenLess_harmony_arm64-signed.hap \ + --file target/harmony-packages/OpenLess_harmony_arm64-signed.app \ --skip-if-unconfigured diff --git a/RELEASING.md b/RELEASING.md index 6e74345d7..d142901fd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -79,11 +79,18 @@ redistribution, which is also why it is fetched at job time rather than vendored | AppGallery 正式版 | `OpenLess_harmony_arm64-signed.hap` | `release.*` material | AppGallery only, never a Release asset | | 测试渠道 | `OpenLess_harmony_arm64-signed.hap` | `test.*` material | AppGallery test channel | -Dispatch the workflow with `channel: release` (default) or `channel: test`; the -input selects both the signing prefix (`release.cer/p7b/p12` vs -`test.cer/p7b/p12`) and the AppGallery upload label. The workflow artifact -bundle carries both packages, the Release step uploads only the unsigned pair, -and the AppGallery step uploads only the signed one. +Dispatch the workflow with `channel: none` (default), `channel: release` or +`channel: test`: + +- `none` is the repository path. It builds the HAP, attaches the **unsigned** + package to the GitHub Release and touches neither the signing step nor the + store, so it needs no secret at all. +- `release` / `test` select the signing prefix (`release.cer/p7b/p12` vs + `test.cer/p7b/p12`), sign, and upload to AppGallery. + +The workflow artifact bundle carries both packages; the Release step uploads only +the unsigned pair, and the AppGallery step uploads only the signed one. CI never +signs: `ci.yml` always builds with `OPENLESS_HARMONY_CHANNEL=none`. A local machine without store certificates can still produce a package: `OPENLESS_HARMONY_CHANNEL=none` builds and ships the unsigned HAP only, and @@ -167,6 +174,69 @@ Before dispatching the Linux release build, additionally require all of the foll The HAP build reads everything from the environment; no key material is ever committed, cached or printed. +### Where each piece comes from + +Everything below is done once, on a machine you trust, with your own HUAWEI +developer account. Nothing here can be automated: AppGallery issues the material +only to a verified developer, and it will not accept a self-signed substitute. + +| Step | Where | What you get | +|---|---|---| +| 1 | → 注册 + 实名认证(个人或企业) | an account that may create certificates | +| 2 | AppGallery Connect → 我的项目 → 创建项目 → 添加应用(HarmonyOS,包名必须是 `openless.hm.com`,与 `harmony/AppScope/app.json5` 一致) | `APPGALLERY_APP_ID`(应用信息里的 APP ID) | +| 3 | 本地:生成密钥库与证书请求 | `release.p12` + `release.csr` + 两个密码 | +| 4 | AGC → 用户与访问 → 证书管理 → 申请证书(**发布证书**)→ 上传 `release.csr` | `release.cer` | +| 5 | AGC → Profile 管理 → 申请 Profile(**发布 Profile**,绑定上面的证书与包名) | `release.p7b` | +| 6 | AGC → 用户与访问 → API 密钥 → 创建客户端(勾选发布相关权限) | `APPGALLERY_CLIENT_ID` / `APPGALLERY_CLIENT_SECRET` | + +Step 3, without DevEco Studio: + +```bash +keytool -genkeypair -keystore release.p12 -storetype PKCS12 \ + -alias harmony-release -keyalg EC -groupname secp256r1 \ + -sigalg SHA256withECDSA -validity 9125 \ + -dname "CN=OpenLess, O=<你的组织>, C=CN" +keytool -certreq -keystore release.p12 -alias harmony-release -file release.csr +``` + +Keep `release.p12` and both passwords offline; they are the only copy of the +private key that AppGallery will trust. + +Before uploading anything, dry-run the material: + +```bash +bash openless-all/app/scripts/check-harmony-signing.sh \ + ~/.local/share/harmony/signing/release release +``` + +It proves the four things that fail late otherwise: the certificate belongs to +the keystore key, the profile was issued for that certificate, `bundle-info.bundle-name` +equals `harmony/AppScope/app.json5`, and nothing has expired. The workflow runs the +same check before it starts building. + +### Storing them in the repository + +```bash +base64 -w0 release.cer # → HARMONY_RELEASE_CERT_BASE64 +base64 -w0 release.p7b # → HARMONY_RELEASE_PROFILE_BASE64 +base64 -w0 release.p12 # → HARMONY_RELEASE_KEYSTORE_BASE64 +``` + +`HARMONY__*` names let production and test material coexist. The +channel-agnostic `HARMONY_CERT_BASE64` / `HARMONY_PROFILE_BASE64` / +`HARMONY_KEYSTORE_BASE64` are still accepted and are used when no +channel-specific secret is set. + +### What the workflow does, and what it cannot do + +Automated: build → sign → verify the signature → upload as the AppGallery +*candidate* package → attach the **unsigned** HAP to the GitHub Release. + +Manual, always: AGC requires a human to fill in the store listing (icon, +screenshots, privacy-policy URL, 软件著作权, 分类与年龄分级) and to press +提交审核. `scripts/upload-appgallery.mjs` deliberately stops at replacing the +candidate package. + | Kind | Name | Purpose | |---|---|---| | Secret | `HARMONY_KEYSTORE_BASE64` | base64 of the release `.p12` keystore | @@ -174,8 +244,8 @@ committed, cached or printed. | Secret | `HARMONY_PROFILE_BASE64` | base64 of the release `.p7b` provisioning profile | | Secret | `HARMONY_KEYSTORE_PASSWORD` | keystore password | | Secret | `HARMONY_KEY_PASSWORD` | key password (falls back to the keystore password) | -| Variable | `HARMONY_KEY_ALIAS` | key alias inside the keystore | -| Variable | `HARMONY_TOOLCHAIN` | absolute path of the DevEco / HarmonyOS SDK on a self-hosted runner | +| Variable (optional) | `HARMONY_KEY_ALIAS` | key alias inside the keystore; defaults to `harmony-` | +| Variable (optional) | `HARMONY_TOOLCHAIN` | absolute path of the DevEco / HarmonyOS SDK on a self-hosted runner | | Variable (optional) | `HARMONY_SDK_URL` | override the tools archive URL | | Variable (optional) | `HARMONY_SDK_SHA256` | SHA-256 of that archive | @@ -187,6 +257,13 @@ time matches the channel being built. | Secret | `APPGALLERY_CLIENT_SECRET` | AppGallery Connect API client secret | | Secret | `APPGALLERY_APP_ID` | AppGallery Connect app id of the HarmonyOS app | +Nothing else has to be configured: the build downloads the official command-line +tools from the checksum-pinned mirror, so a hosted runner needs no variable, no +self-hosted machine and no Huawei account. The signing directory is exported by +the workflow itself (`$RUNNER_TEMP/harmony-signing`) and must not be redeclared as +an `env:` key - YAML `env` outranks `$GITHUB_ENV`, so an empty placeholder would +shadow it and the build would fall back to a directory that does not exist. + The upload replaces the candidate package in AppGallery Connect; it never submits the app for review, because AGC requires a human submission. Without the `APPGALLERY_*` secrets the workflow still attaches the HAP to the GitHub Release diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index 55e6dcf3a..f27e47478 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -51,34 +51,46 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job | job | 缓存 | 命中收益 | 性价比 | |---|---|---|---| -| macOS Rust 1.99 MSRV | 0.72 GB | −13.1 min | 1102 s/GB | -| macOS stable | 0.88 GB | −11.4 min | 775 s/GB | -| Linux egui | 1.34 GB | −10.8 min | 483 s/GB | -| Android | 0.75 GB | −2.4 min | 188 s/GB | -| Windows | 1.62 GB | −4.6 min | 169 s/GB | +| macOS stable | 0.87 GB | −11.4 min | 786 s/GB | +| Linux egui | 0.75 GB(+ 旧前缀 1.28 GB 待回收) | −10.8 min | 864 s/GB | +| macOS Rust 1.99 MSRV | 0.72 GB | −13.1 min | 1102 s/GB(与 stable 同号时整个 job 跳过,见 §2) | +| Android | 0.85 GB | −2.4 min | 169 s/GB | +| Windows | 1.57 GB | −4.6 min | 176 s/GB | +| Harmony Rust host | 0.28 GB | −2–4 min | ~600 s/GB | +| Android Rime NDK | 0.40 GB | −3 min | 450 s/GB | +| Playwright 浏览器 | 0.37 GB | −1.5 min | 243 s/GB | + +**不缓存鸿蒙命令行工具链**:解压后 7.1 GB,缓存条目 1.77 GB,而下载 2.1 GB 只要 ~18 s(108 MB/s)、解压 ~70 s。恢复缓存本身就要几十秒,还要占掉 18% 的配额,性价比为负。`prepare-harmony-sdk.sh` 按 SHA-256 固定校验和,每次重下反而是更可靠的选择。 分配原则:**按平台配额 + 只有基线分支/tag/迭代中的 job 允许写。** | 归属 | 目标 | 谁写 | 谁读 | |---|---|---|---| -| Windows 依赖树 | 1.2–1.6 GB | `push`(beta/main)、tag | 所有 PR、发版 | -| macOS(stable 0.88 + MSRV 0.72 + MLX 0.30) | ~1.9 GB | 同上 | 同上 | -| Linux(egui + core) | ~1.7 GB | `push`、tag,**以及 PR 里的 `build-linux-egui`** | 同上 | -| Android | 0.75 GB | `push`、tag | PR | +| Windows 依赖树 | ~1.6 GB | `push`(beta)、tag | 所有 PR、发版 | +| macOS(stable 0.87 + MSRV 0.72) | ~1.6 GB | 同上 | 同上 | +| Linux(egui 0.75 + core) | ~1.7 GB | `push`、tag,**以及 PR 里的 `build-linux-egui`** | 同上 | +| Android(Rust 0.85 + Rime 0.40 + gradle 0.31) | ~1.6 GB | `push`、tag | PR | +| Harmony Rust host | 0.28 GB | `push`、tag | PR | +| Playwright 浏览器 | 0.37 GB | `push`、tag | PR | +| 鸿蒙工具链 | **0(不缓存)** | — | — | | 发版(tag 作用域) | ~1.0 GB | tag / 发版前预热 | — | -| 余量 | ≥2 GB | — | — | +| 余量 | ≥1.5 GB | — | — | 对应的机制: -- 重量级 job 用 `save-if: ${{ github.event_name != 'pull_request' }}`:PR 只从基线分支恢复,不再各自复制一份 1.6 GB 依赖树(此前两个 PR 就占了 10.87 GB,把基线缓存挤掉)。 +- 重量级 job 用 `save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}`:PR 只从基线分支恢复,不再各自复制一份 1.6 GB 依赖树(此前两个 PR 就占了 10.87 GB,把基线缓存挤掉)。 +- **禁止 `uses: actions/cache@v4`**:它会在每次运行时自动保存,包括 `refs/pull//merge` 这种此后再也不会被恢复的作用域。鸿蒙工具链(1.77 GB)和 Rime NDK(0.40 GB)都这样泄漏过——两个 PR 推送就能把 beta 的依赖树挤掉。规则统一成 `actions/cache/restore@v4` 无条件恢复 + 独立的 `actions/cache/save@v4` 且 `if:` 限定 beta。 +- `playwright` 与 `mobile-rime` 使用 restore/save 拆分;`swatinem/rust-cache` 用 `save-if` 达到同样效果。 - `build-linux-egui` 保留 PR 内自写并开 `cache-on-failure: true`:失败的运行也会把依赖缓存存下来,下一次迭代仍然热;同时关掉 dev/test debug info,缓存更小、链接更快。 - key 结构为 `v0-rust---<环境哈希>-`:环境哈希含工具链与 `CARGO_PROFILE_*`,所以 stable / MSRV / release 天然分开,不要用跨平台 `shared-key` 混合。 +- 原生胶囊(`native-capsule` job)原本是一个独立的 `pull_request`-only workflow:缓存写在 `refs/pull//merge`,这个作用域此后再不会被恢复;而 beta 上没有任何 job 产出同 key 的树,所以 PR 永远冷编译。现在它已并入 `ci.yml`(同一个 `Changed areas` 门禁、同一次运行),用 `shared-key: native-capsule` + beta 门控的 `save-if`。 - `Cargo.lock` 变化时精确键失效,但 `restore-keys` 前缀回退会拿到同前缀的旧缓存,所以“锁变了”不等于“全冷”——前提是那份没被淘汰,这正是要守预算的原因。 ## 4. 维护 - 报告与清理:`bash openless-all/app/scripts/ci-cache-usage.sh [--prune] [--limit-gb 8] [--repo OWNER/NAME]`。`--prune` 只删已关闭/已合并 PR 的 `refs/pull/*` 作用域(先查 PR 状态,状态读不到就保留),永不触碰分支与 tag 作用域;用量超过预算时退出码为 1。 - 自动维护:`.github/workflows/cache-maintenance.yml` 每周一 03:17 UTC 运行并支持手动触发,预算 8 GB。 +- 契约测试 `scripts/ci-cache-sharing.test.mjs` 会拒绝任何 `uses: actions/cache@v4`(自动保存)以及任何鸿蒙工具链缓存键,防止上面两条规则被无意改回去。 - 合并/关闭 PR 后可手动立即回收:`gh api -X DELETE repos/Open-Less/openless/actions/caches?ref=refs/pull//merge`。 ## 5. 实测对照 diff --git a/openless-all/README.md b/openless-all/README.md index 01ac24df2..893675bd0 100644 --- a/openless-all/README.md +++ b/openless-all/README.md @@ -125,7 +125,6 @@ coordinator `handle_pressed` / `handle_released` path, asserts the log contains ```powershell cd app -npm run check:hotkey-injection ``` ### Windows Runtime Notes diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index dee261df4..b4f334f26 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -140 +141 diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 7d4cfc0e1..2f63e8a71 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -24,7 +24,6 @@ "copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs", "check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs", "check:macos-audio-usage-description": "node scripts/macos-audio-usage-description-contract.test.mjs", - "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs", "check:hotkey-side-modifiers": "tsx src/lib/hotkeySideModifiers.test.ts", "check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs", "check:windows-ui-config": "node scripts/windows-ui-config.test.mjs", diff --git a/openless-all/app/scripts/build-harmony-frontend.sh b/openless-all/app/scripts/build-harmony-frontend.sh index be9a056de..637413e3c 100755 --- a/openless-all/app/scripts/build-harmony-frontend.sh +++ b/openless-all/app/scripts/build-harmony-frontend.sh @@ -103,9 +103,20 @@ if [ -n "${HVIGOR_JS:-}" ]; then else HVIGOR=(hvigorw) fi +# AppGallery only accepts an App Pack (`.app`), never a bare `.hap`. hvigor +# cannot write a signingConfig from CI: `storePassword`/`keyPassword` must be the +# IDE-encrypted material (>= 32 chars), so the pack stays unsigned here and +# `scripts/sign-harmony-hap.py` signs it afterwards with `hap-sign-tool`. "${HVIGOR[@]}" --mode module -p product=default -p module=entry@default -p buildMode=debug assembleHap --no-daemon printf '\nHAP output: %s/entry/build/default/outputs/default/\n' "$BUILD_PROJECT" +if [ -n "${OPENLESS_HARMONY_STORE_SIGNING:-}" ]; then + # `--mode project`: `assembleApp` is a project-level task and is invisible in + # module mode ("Task [ 'assembleApp' ] was not found in the project"). + "${HVIGOR[@]}" --mode project -p product=default -p buildMode=release assembleApp --no-daemon + printf 'App Pack output: %s/build/outputs/default/\n' "$BUILD_PROJECT" +fi + if [[ -f "$SIGNING_DIR/debug.cer" && -f "$SIGNING_DIR/debug.p7b" ]]; then python3 "$APP_ROOT/scripts/sign-harmony-hap.py" "$HARMONY_TOOLCHAIN" "$SIGNING_DIR" "$BUILD_PROJECT/entry/build/default/outputs/default" fi diff --git a/openless-all/app/scripts/check-harmony-signing.sh b/openless-all/app/scripts/check-harmony-signing.sh new file mode 100755 index 000000000..097d7626b --- /dev/null +++ b/openless-all/app/scripts/check-harmony-signing.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Validate a HarmonyOS signing directory before a build or a dispatch. +# +# bash scripts/check-harmony-signing.sh [release|test|debug] +# +# Checks the three files Huawei issues/downloads against each other: +# * .p12 opens with the stored password and holds an alias +# * .cer belongs to that alias (the public keys must match), otherwise +# AppGallery accepts the certificate and the device rejects the HAP +# * .p7b names the same bundleName as harmony/AppScope/app.json5, was +# issued for that certificate, has a type that matches the channel, and both +# the profile and the certificate are inside their validity window +# +# Read-only: nothing is written outside a temporary directory and no secret is +# printed. +set -euo pipefail + +APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +signing=${1:-} +prefix=${2:-release} +[ -n "$signing" ] || { + echo "usage: $0 [release|test|debug]" >&2 + exit 2 +} +[ -d "$signing" ] || { + echo "error: $signing is not a directory" >&2 + exit 2 +} + +failures=0 +ok() { echo " ok $*"; } +bad() { + echo " FAIL $*" >&2 + failures=$((failures + 1)) +} +warn() { echo " warn $*"; } + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +echo "signing directory: $signing (channel: $prefix)" + +# 1. Files ------------------------------------------------------------------- +for extension in p12 cer p7b; do + file="$signing/$prefix.$extension" + if [ -s "$file" ]; then ok "$prefix.$extension present"; else bad "missing or empty $file"; fi +done +[ "$failures" -eq 0 ] || { + echo "cannot continue without the three files" >&2 + exit 1 +} + +# 2. Password ---------------------------------------------------------------- +password=${HARMONY_KEYSTORE_PASSWORD:-} +if [ -z "$password" ] && [ -f "$signing/keystore-password" ]; then + password=$(< "$signing/keystore-password") +fi +[ -n "$password" ] || { + bad "no password: set HARMONY_KEYSTORE_PASSWORD or write $signing/keystore-password" + exit 1 +} + +# 3. Keystore and aliases ---------------------------------------------------- +if keytool -list -keystore "$signing/$prefix.p12" -storetype PKCS12 \ + -storepass "$password" > "$work/aliases.txt" 2> "$work/keytool.err"; then + ok "keystore opens with the stored password" +else + bad "keystore rejects the password ($(tail -1 "$work/keytool.err" | tr -d '\r'))" + echo >&2 + echo "FAILED: cannot read the keystore" >&2 + exit 1 +fi +aliases=$(sed -n 's/^\([^,]*\),.*/\1/p' "$work/aliases.txt" | sed 's/^[[:space:]]*//') +requested=${HARMONY_KEY_ALIAS:-harmony-$prefix} +if printf '%s\n' "$aliases" | grep -qx "$requested"; then + ok "alias '$requested' is in the keystore" + alias=$requested +elif [ "$(printf '%s\n' "$aliases" | wc -l)" -eq 1 ]; then + alias=$(printf '%s\n' "$aliases" | head -1) + warn "alias '$requested' not found, using the only alias present: '$alias'" +else + alias=$(printf '%s\n' "$aliases" | head -1) + bad "alias '$requested' not in the keystore (found: $(printf '%s' "$aliases" | tr '\n' ' '))" +fi + +# 4. Certificate belongs to the key, profile belongs to the certificate ------ +# `release.cer` / `debug.cer` from AppGallery Connect are PEM chains (Huawei root +# CA + leaf). The leaf is identified by its public key, because the first +# certificate in the file is the CA. +keytool -exportcert -rfc -keystore "$signing/$prefix.p12" -storetype PKCS12 \ + -storepass "$password" -alias "$alias" > "$work/keystore.pem" 2> /dev/null || + bad "cannot export the certificate of alias '$alias'" + +if [ -s "$work/keystore.pem" ]; then + # The helper reads a certificate chain and reports what it found; it never + # exits non-zero, so the bash-level failure counter stays authoritative. + python3 "$APP_ROOT/scripts/harmony-signing-report.py" \ + "$signing/$prefix.cer" "$work/keystore.pem" "$signing/$prefix.p7b" || + failures=$((failures + 1)) +fi + +# 5. Profile matches the application ----------------------------------------- +if openssl cms -verify -noverify -inform DER -in "$signing/$prefix.p7b" \ + -out "$work/profile.json" 2> "$work/cms.err"; then + ok "profile opens" + python3 "$APP_ROOT/scripts/harmony-profile-report.py" \ + "$work/profile.json" "$APP_ROOT/harmony/AppScope/app.json5" "$prefix" || + failures=$((failures + 1)) +else + bad "cannot read $prefix.p7b ($(tail -1 "$work/cms.err" | tr -d '\r'))" +fi + +echo +if [ "$failures" -gt 0 ]; then + echo "FAILED: $failures problem(s) in $signing" >&2 + exit 1 +fi +echo "PASS: $signing/$prefix.{p12,cer,p7b} are consistent and current" diff --git a/openless-all/app/scripts/check-hotkey-injection.mjs b/openless-all/app/scripts/check-hotkey-injection.mjs deleted file mode 100644 index 3b7d866f0..000000000 --- a/openless-all/app/scripts/check-hotkey-injection.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import { spawnSync } from 'node:child_process'; - -const result = spawnSync( - 'cargo', - [ - 'test', - '--locked', - '-p', - 'openless-core', - 'shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics', - '--', - '--nocapture', - ], - { - env: process.env, - encoding: 'utf8', - }, -); - -const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; -for (const chunk of (result.stdout ?? '').match(/[\s\S]{1,8192}/g) ?? []) - process.stdout.write(chunk); -for (const chunk of (result.stderr ?? '').match(/[\s\S]{1,8192}/g) ?? []) - process.stderr.write(chunk); - -if (result.status !== 0) { - if (result.error) console.error(result.error); - process.exit(result.status ?? 1); -} - -if ( - !output.includes( - 'test api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics ... ok', - ) -) { - console.error('Core hotkey edge gate did not execute the expected test.'); - process.exit(1); -} - -console.log('Core hotkey edge gate passed.'); diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index d4e847b00..bd9d40766 100644 --- a/openless-all/app/scripts/ci-cache-sharing.test.mjs +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -10,7 +10,7 @@ // Runs without `npm ci` (the Linux contract job has no node_modules), so the // workflows are read as text. import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -22,20 +22,52 @@ const release = read('release-tauri.yml'); const androidApk = read('android-apk.yml'); const linux = read('check-linux-egui.yml'); const linuxRelease = read('release-linux-egui.yml'); +const releaseHarmony = read('release-harmony.yml'); const sharedKeys = (yaml) => [...yaml.matchAll(/shared-key:\s*([^\s#]+)/g)].map((match) => match[1].trim()); +// Every write path must be gated on the beta branch. An ungated `actions/cache` +// writes one copy per pull request scope (`refs/pull//merge`), and those +// copies are never restored again - they just occupy the 10 GB budget and evict +// the branch trees. The HarmonyOS toolchain cache (1.77 GB) and the Rime NDK +// cache (400 MB) both leaked this way before this contract existed. +// +// `actions/cache@v4` saves on every run it executes, including pull request +// scopes (`refs/pull//merge`) that are never restored again. The rule is: +// restore unconditionally, save only from beta. +for (const [yaml, label] of [ + [ci, 'ci.yml'], + [release, 'release-tauri.yml'], + [androidApk, 'android-apk.yml'], + [linux, 'check-linux-egui.yml'], + [linuxRelease, 'release-linux-egui.yml'], + [releaseHarmony, 'release-harmony.yml'], +]) { + assert.ok( + !/uses: actions\/cache@v4/.test(yaml), + `${label} must not use actions/cache@v4 (auto-save); pair cache/restore@v4 with a beta-gated cache/save@v4`, + ); +} +// The extracted toolchain is 1.77 GB for a 2.1 GB download, so neither workflow +// may cache it. +for (const [yaml, label] of [ + [ci, 'ci.yml'], + [releaseHarmony, 'release-harmony.yml'], +]) { + assert.ok( + !yaml.includes('harmony-tools-'), + `${label} must not cache the HarmonyOS toolchain: the download is shorter than restoring 1.77 GB`, + ); +} + // 1. The baseline job must own a stable, job-name-independent key per platform. for (const key of ['tauri-windows', 'tauri-macos']) { assert.ok( sharedKeys(ci).includes(key), `ci.yml must declare shared-key ${key} so the release workflow can reuse it`, ); - assert.ok( - sharedKeys(release).includes(key), - `release-tauri.yml must restore shared-key ${key}`, - ); + assert.ok(sharedKeys(release).includes(key), `release-tauri.yml must restore shared-key ${key}`); } // 2. The release workflow must not keep a private key: that is what made tag @@ -43,7 +75,9 @@ for (const key of ['tauri-windows', 'tauri-macos']) { assert.doesNotMatch(release, /key:\s*macos-release/); // 3. Baseline writes stay gated to the default branch, releases only read it. -const saveIfs = [...ci.matchAll(/save-if:\s*\$\{\{\s*github\.ref == 'refs\/heads\/beta'[^}]*\}\}/g)]; +const saveIfs = [ + ...ci.matchAll(/save-if:\s*\$\{\{\s*github\.ref == 'refs\/heads\/beta'[^}]*\}\}/g), +]; assert.ok(saveIfs.length >= 3, 'every shared cache in ci.yml must be beta-only for writes'); // 4. The APK build keeps its per-ABI key (it builds a different target triple @@ -60,4 +94,54 @@ assert.match(linux, /cargo build --locked --release -p openless-linux-egui/); assert.match(linuxRelease, /release_tag: \$\{\{ inputs\.tag \}\}/); assert.match(linuxRelease, /checkout_ref: \$\{\{ inputs\.tag \}\}/); +// The capsule crates used to run in their own pull-request-only workflow: it +// wrote a dependency tree under `refs/pull//merge` (never restored again) +// while beta produced nothing to restore. It now lives here, behind the same +// `Changed areas` verdict, and saves only from beta. +{ + const capsuleJob = ci.match(/\n native-capsule:[\s\S]*?(?=\n [a-z-]+:\n)/)?.[0] ?? ''; + assert.ok(capsuleJob, 'ci.yml must run the capsule crates as the native-capsule job'); + assert.match(capsuleJob, /shared-key: native-capsule/); + assert.match(capsuleJob, /save-if: \$\{\{ github\.ref == 'refs\/heads\/beta'/); + assert.ok( + !existsSync(join(root, '.github/workflows/native-capsule.yml')), + 'native-capsule.yml must stay merged into ci.yml', + ); +} + +const linuxJob = (name) => + linux.match(new RegExp('\\n ' + name + ':[\\s\\S]*?(?=\\n [a-z-]+:|$)'))?.[0] ?? ''; +assert.match(linuxJob('test-linux-egui'), /shared-key: test-linux-egui/); +assert.match(linuxJob('build-linux-egui'), /shared-key: build-linux-egui/); +const before = (producer, save) => { + const producerIndex = ci.indexOf('- name: ' + producer); + const saveIndex = ci.indexOf('- name: ' + save); + assert.ok( + producerIndex >= 0 && producerIndex < saveIndex, + save + ' must follow its successful producer', + ); +}; +before( + 'Build and stage Rime for Android instrumentation tests', + 'Save Android Rime dependencies on beta builds', +); +before( + 'Prepare the pinned Android emulator and system image', + 'Save the Android emulator on beta builds', +); +assert.doesNotMatch( + ci, + /reactivecircus\/android-emulator-runner|~\/\.android\/avd|android-emulator-37\.2\.12/, +); +assert.match(ci, /android-emulator-v2-16555280-api35-google_apis-x86_64-/); +for (const id of ['rime_cache', 'emulator_cache']) { + assert.match( + ci, + new RegExp( + 'if: steps\\.' + + id + + "\\.outputs\\.cache-hit != 'true' && github\\.ref == 'refs/heads/beta' && \\(github\\.event_name == 'push' \\|\\| github\\.event_name == 'workflow_dispatch'\\)", + ), + ); +} console.log('ci-cache-sharing contract test passed'); diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 8bd7489b2..23619fb16 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -9,6 +9,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; const scriptPath = resolve(dirname(fileURLToPath(import.meta.url)), 'ci-changed-areas.sh'); const git = (cwd, args) => @@ -73,7 +74,10 @@ try { // HarmonyOS work touches the shared bundle and the Rust host, never the Linux // package chain or the Tauri desktop build. - commit({ 'openless-all/app/harmony/entry/src/main/ets/pages/Index.ets': 'export {};\n' }, 'arkts'); + commit( + { 'openless-all/app/harmony/entry/src/main/ets/pages/Index.ets': 'export {};\n' }, + 'arkts', + ); check('arkts source: harmony', decide('harmony'), 'true'); check('arkts source: tauri', decide('tauri'), 'true'); reset(); @@ -181,6 +185,27 @@ check( workflow.includes('msrv_needed: ${{ steps.msrv.outputs.needed }}'), true, ); +// Windows spends ~10 minutes on its own MSRV pass; it must follow the same rule +// as the macOS job instead of running unconditionally. +check( + 'windows msrv steps follow the comparison', + workflow.includes("if: runner.os == 'Windows' && needs.changes.outputs.msrv_needed == 'true'"), + true, +); +// `cargo check` immediately followed by `cargo test --no-run` compiles the graph +// twice; the no-run pass now covers bins itself. +check( + 'windows has one codegen pass, not check plus no-run', + !/run: cargo check --locked --manifest-path src-tauri\/Cargo\.toml$/m.test(workflow), + true, +); +check( + 'windows codegen pass covers lib and bins', + workflow.includes( + 'cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --bins --no-run', + ), + true, +); check( 'android gate is fail-open', /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.tauri != 'false'/.test(workflow), @@ -207,6 +232,108 @@ check( true, ); +// Wall-clock guards: the Windows runner is the slowest one, so the frontend +// suite must not serialise behind the Rust compile, and the Linux tests must not +// serialise in front of the deb/rpm build. +check( + 'windows frontend runs in its own job', + / windows-frontend:[\s\S]*?runs-on: windows-latest/.test(workflow), + true, +); +check( + 'windows only runs the platform-sensitive contracts', + workflow.includes('--platform-only windows'), + true, +); +check( + 'macOS still runs the full contract suite', + /if: runner\.os == 'macOS'\n\s+run: npm test/.test(workflow), + true, +); + +// `tauri::generate_context!()` embeds ../dist at compile time, so any job that +// compiles the Tauri lib must build the frontend first — including the matrix +// job on Windows, where the contract suite moved to its own job. +check( + 'windows builds the frontend before compiling the Tauri lib', + /if: runner\.os == 'Windows'\n\s+run: npm run build/.test(workflow), + true, +); + +const windowsJob = workflow.match(/\n windows-frontend:[\s\S]*?(?=\n [a-z-]+:)/)?.[0] ?? ''; +const capsuleJob = workflow.match(/\n native-capsule:[\s\S]*?(?=\n [a-z-]+:)/)?.[0] ?? ''; +const windowsGate = windowsJob.match(/\n if: (.+)/)?.[1]; +const capsuleMatrix = capsuleJob.match(/\n os: \$\{\{ (.+) \}\}/)?.[1]; +for (const [event, platform, tauri, expectedWindows, expectedPlatforms] of [ + ['workflow_dispatch', 'macos', 'true', false, ['macos-latest']], + ['workflow_dispatch', 'all', 'true', true, ['macos-latest', 'windows-latest']], + ['pull_request', '', 'true', true, ['macos-latest', 'windows-latest']], + ['push', '', undefined, true, ['macos-latest', 'windows-latest']], + ['pull_request', '', 'false', false, ['macos-latest', 'windows-latest']], +]) { + const context = { + github: { event_name: event }, + inputs: { platform }, + needs: { changes: { outputs: { tauri } } }, + fromJSON: JSON.parse, + }; + check( + event + '/' + platform + ': Windows frontend gate', + Boolean(vm.runInNewContext(windowsGate ?? 'false', context)), + expectedWindows, + ); + check( + event + '/' + platform + ': capsule platforms', + JSON.stringify(vm.runInNewContext(capsuleMatrix ?? '[]', context)), + JSON.stringify(expectedPlatforms), + ); +} + +// Execute the real wiring checks with changed text, without spawning Git again. +const source = readFileSync(fileURLToPath(import.meta.url), 'utf8'); +const wiringStart = source.indexOf('const areas ='); +const wiringEnd = source.indexOf('// Execute the real wiring checks'); +const failureStart = source.lastIndexOf('\nif (failures > 0) {'); +const wiring = source.slice(wiringStart, wiringEnd) + source.slice(failureStart); +for (const [label, changed] of [ + [ + 'missing Windows frontend build', + workflow.replace( + /if: runner\.os == 'Windows'\n\s+run: npm run build/, + "if: runner.os == 'Never'\n run: npm run build", + ), + ], + [ + 'missing separate frontend job', + workflow.replace('\n windows-frontend:', '\n removed-frontend:'), + ], +]) { + check(label + ': mutation applied', changed !== workflow, true); + let exitCode = 0; + try { + vm.runInNewContext( + 'let failures = 0; const check = (label, actual, expected) => { if (actual !== expected) failures++; }; ' + + wiring, + { + workflow: changed, + reusable, + vm, + check: undefined, + console: { log() {}, error() {} }, + process: { + exit(code) { + exitCode = code; + throw new Error('expected contract failure'); + }, + }, + }, + ); + } catch (error) { + if (!exitCode) throw error; + } + check(label + ': contract rejects the mutation', exitCode, 1); +} + if (failures > 0) { console.error(`ci-changed-areas contract test failed (${failures})`); process.exit(1); diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs new file mode 100644 index 000000000..f9c1b8959 --- /dev/null +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -0,0 +1,399 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; +import { load } from 'js-yaml'; + +const workflow = (name) => + load(readFileSync(new URL(`../../../.github/workflows/${name}`, import.meta.url), 'utf8')); +const python = process.platform === 'win32' ? 'python' : 'python3'; +const ci = workflow('ci.yml'); +const linux = workflow('check-linux-egui.yml'); +const harmony = workflow('release-harmony.yml'); +const androidSteps = ci.jobs['android-check'].steps; +const runtimeDependencies = androidSteps.find( + (step) => step.name === 'Install Linux check dependencies', +); +assert.match(runtimeDependencies.run, /\blibpulse0\b/, 'the emulator requires libpulse.so.0'); +assert.ok( + androidSteps.indexOf(runtimeDependencies) < + androidSteps.findIndex( + (step) => step.name === 'Prepare the pinned Android emulator and system image', + ), +); +const install = androidSteps.find( + (step) => step.name === 'Prepare the pinned Android emulator and system image', +).run; +const boot = androidSteps.find( + (step) => step.name === 'Run Android Keystore instrumentation tests', +).run; +const coreTests = linux.jobs['test-linux-egui'].steps.find( + (step) => step.name === 'Test Core, Linux host, and platform boundaries', +).run; +const hotkey = + 'set -euo pipefail\n' + + coreTests.slice( + coreTests.indexOf('cargo test --locked -p openless-core --lib'), + coreTests.indexOf('cargo test --locked -p openless-harmony-host'), + ); +const publish = linux.jobs['publish-linux-egui'].steps.find((step) => + step.run?.includes('gh release upload'), +).run; +for (const script of [install, boot, hotkey, publish]) { + const result = spawnSync('bash', ['-n'], { input: script, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); +} + +const scratch = mkdtempSync(join(tmpdir(), 'openless-ci-behavior-')); +assert.equal(dirname(resolve(scratch)), resolve(tmpdir())); +assert.ok(basename(scratch).startsWith('openless-ci-behavior-')); +const shellPath = (path) => + process.platform === 'win32' + ? path.replaceAll('\\', '/').replace(/^([a-z]):/i, (_, drive) => '/' + drive.toLowerCase()) + : path; +const executable = (path, source) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, '#!/usr/bin/env bash\nset -eu\n' + source); + chmodSync(path, 0o755); +}; +const emulatorSource = ` +if [ "\${1:-}" = '-version' ]; then + echo "Android emulator version 37.3.4.0 (build_id \${FAKE_EMULATOR_BUILD:-16555280})" + if [ "\${FAKE_VERSION_DETAILS:-0}" = 1 ]; then + "$TEST_SLEEP" 0.02 + printf '%8192s\n' 'emulator license details' + fi + exit "\${FAKE_VERSION_EXIT:-0}" +fi +[ -s "$ANDROID_AVD_HOME/openless-ci.ini" ] || { echo 'Unknown AVD name [openless-ci]' >&2; exit 1; } +echo "$$" > "$RUNNER_TEMP/emulator.pid" +echo 'emulator started' +trap 'echo stopped >> "$RUNNER_TEMP/trace"; exit 0' TERM +while :; do "$TEST_SLEEP" 0.02; done +`; +const stubs = ` +name=$(basename "$0") +printf '%s %s\\n' "$name" "$*" >> "$RUNNER_TEMP/trace" +case "$name" in + curl) + count=0; [ ! -f "$RUNNER_TEMP/curl-count" ] || count=$(cat "$RUNNER_TEMP/curl-count") + count=$((count + 1)); echo "$count" > "$RUNNER_TEMP/curl-count" + [ "$count" -gt "\${FAKE_CURL_FAILURES:-0}" ] || exit 22 + while [ "$#" -gt 0 ]; do + if [ "$1" = '-o' ]; then printf archive > "$2"; break; fi + shift + done + ;; + unzip) + if [ "$1" = '-tq' ]; then + [ "$(cat "$RUNNER_TEMP/curl-count")" -gt "\${FAKE_BAD_ZIPS:-0}" ] || exit 1 + else + mkdir -p "$4/emulator" + cp "$FAKE_EMULATOR_SOURCE" "$4/emulator/emulator" + echo Pkg.Revision=37.3.4 > "$4/emulator/source.properties" + fi + ;; + sdkmanager) + [ -s "$ANDROID_HOME/emulator/package.xml" ] || { echo 'SDK would download an unregistered emulator dependency' >&2; exit 1; } + count=0; [ ! -f "$RUNNER_TEMP/sdk-count" ] || count=$(cat "$RUNNER_TEMP/sdk-count") + count=$((count + 1)); echo "$count" > "$RUNNER_TEMP/sdk-count" + [ "$count" -gt "\${FAKE_SDK_FAILURES:-0}" ] || exit 1 + image="$ANDROID_HOME/system-images/android-35/google_apis/x86_64" + mkdir -p "$image"; echo package > "$image/package.xml" + if [ "\${FAKE_INCOMPLETE_IMAGE:-0}" != 1 ]; then echo system > "$image/system.img"; fi + ;; + sed) + if [ "$1" = '-i' ]; then echo 'BSD sed requires a backup suffix for -i' >&2; exit 1; fi + exec "$TEST_SED" "$@" + ;; + python3) exec "$TEST_PYTHON" "$@" ;; + sleep) ;; + avdmanager) + [ -n "$ANDROID_AVD_HOME" ] || { echo 'SDK tools and emulator require an explicit shared AVD registry' >&2; exit 1; } + [ -s "$ANDROID_HOME/emulator/package.xml" ] || { echo 'Error: "emulator" package must be installed!' >&2; exit 1; } + while [ "$1" != '--path' ]; do shift; done + mkdir -p "$2"; echo hw.keyboard=yes > "$2/config.ini" + printf 'path=%s\n' "$2" > "$ANDROID_AVD_HOME/openless-ci.ini" + ;; + timeout) + if [ "$1" = 10s ]; then shift; exec "$@"; fi + [ "$1" = 600s ] || exit 99 + until [ -s "$RUNNER_TEMP/emulator.pid" ]; do "$TEST_SLEEP" 0.01; done + if [ "\${FAKE_BOOT_TIMEOUT:-0}" = 1 ]; then exit 124; fi + shift; exec "$@" + ;; + adb) + case "$*" in + *getprop*) echo 1 ;; + *'emu kill'*) kill -TERM "$(cat "$RUNNER_TEMP/emulator.pid")" 2>/dev/null || true ;; + esac + ;; + cargo) + [ "\${FAKE_CARGO_EXIT:-0}" = 0 ] || exit "$FAKE_CARGO_EXIT" + if [ "\${FAKE_HOTKEY_MATCH:-1}" = 1 ]; then + echo 'test api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics ... ok' + else + echo 'test result: ok. 0 passed; 1381 filtered out' + fi + ;; + gh) [ "$GH_REPO" = 'example/openless' ] ;; +esac +`; +function fixture(name) { + const dir = join(scratch, name); + const bin = join(dir, 'bin'); + mkdirSync(bin, { recursive: true }); + for (const command of [ + 'curl', + 'unzip', + 'sdkmanager', + 'sleep', + 'python3', + 'sed', + 'avdmanager', + 'timeout', + 'adb', + 'cargo', + 'gh', + ]) + executable(join(bin, command), stubs); + executable(join(dir, 'emulator-source'), emulatorSource); + executable( + join(dir, 'src-tauri/gen/android/gradlew'), + 'echo "gradle $*" >> "$RUNNER_TEMP/trace"\nexit "${FAKE_GRADLE_EXIT:-0}"\n', + ); + const sdk = join(dir, 'sdk'); + mkdirSync(sdk); + const env = { + ...process.env, + TEST_BIN: shellPath(bin), + TEST_PYTHON_COMMAND: python, + RUNNER_TEMP: shellPath(dir), + ANDROID_HOME: shellPath(sdk), + ANDROID_AVD_HOME: '', + FAKE_EMULATOR_SOURCE: shellPath(join(dir, 'emulator-source')), + EMULATOR_CACHE_HIT: 'false', + GH_REPO: 'example/openless', + RELEASE_TAG: 'v2.0.2-tauri', + }; + const run = (script, extra = {}) => { + const guarded = + 'export TEST_SLEEP="$(command -v sleep)" TEST_SED="$(command -v sed)" TEST_PYTHON="$(command -v "$TEST_PYTHON_COMMAND")"\n' + + 'export PATH="$TEST_BIN:$PATH"\n[ "$(command -v curl)" = "$TEST_BIN/curl" ] || exit 99\n' + + script; + const result = spawnSync('bash', ['-c', guarded], { + cwd: dir, + env: { ...env, ...extra }, + encoding: 'utf8', + timeout: 15000, + }); + assert.ifError(result.error, result.stdout + result.stderr); + return result; + }; + const trace = () => + existsSync(join(dir, 'trace')) ? readFileSync(join(dir, 'trace'), 'utf8') : ''; + const count = (command) => + trace() + .split('\n') + .filter((line) => line.startsWith(command + ' ')).length; + const seed = () => { + executable(join(sdk, 'emulator/emulator'), emulatorSource); + writeFileSync(join(sdk, 'emulator/source.properties'), 'Pkg.Revision=37.3.4\n'); + const image = join(sdk, 'system-images/android-35/google_apis/x86_64'); + mkdirSync(image, { recursive: true }); + writeFileSync(join(image, 'system.img'), 'system'); + writeFileSync(join(image, 'package.xml'), 'package'); + }; + return { dir, sdk, run, trace, count, seed }; +} +try { + for (const [name, env, expected, curls, sdkCalls] of [ + ['cold', {}, 0, 1, 1], + ['verbose-version', { FAKE_VERSION_DETAILS: '1' }, 0, 1, 1], + ['download-retry', { FAKE_CURL_FAILURES: '2' }, 0, 3, 1], + ['bad-zip-retry', { FAKE_BAD_ZIPS: '2' }, 0, 3, 1], + ['sdk-retry', { FAKE_SDK_FAILURES: '2' }, 0, 3, 3], + ['download-failure', { FAKE_CURL_FAILURES: '3' }, 1, 3, 0], + ['incomplete-image', { FAKE_INCOMPLETE_IMAGE: '1' }, 1, 3, 3], + ]) { + const test = fixture(name); + const result = test.run(install, env); + assert.equal(result.status, expected, name + ': ' + result.stdout + result.stderr); + assert.equal(test.count('curl'), curls, name); + assert.equal(test.count('sdkmanager'), sdkCalls, name); + assert.equal(existsSync(join(test.sdk, 'emulator/emulator')), expected === 0, name); + assert.equal( + existsSync(join(test.sdk, 'emulator/package.xml')), + expected === 0, + name + ': emulator must be registered in the SDK', + ); + } + for (const [name, env, removeImage, expected] of [ + ['warm', {}, false, 0], + ['wrong-build', { FAKE_EMULATOR_BUILD: 'wrong' }, false, 1], + ['version-command-failure', { FAKE_VERSION_EXIT: '3' }, false, 1], + ['missing-image', {}, true, 1], + ]) { + const test = fixture(name); + test.seed(); + if (removeImage) + rmSync(join(test.sdk, 'system-images/android-35/google_apis/x86_64/system.img')); + const result = test.run(install, { EMULATOR_CACHE_HIT: 'true', ...env }); + assert.equal(result.status, expected, result.stdout + result.stderr); + assert.equal(test.count('curl'), 0, name + ' must not download on a cache hit'); + assert.equal(test.count('sdkmanager'), 0, name); + } + for (const [name, env, expected] of [ + ['boot-success', {}, 0], + ['boot-timeout', { FAKE_BOOT_TIMEOUT: '1' }, 124], + ['gradle-failure', { FAKE_GRADLE_EXIT: '17' }, 17], + ]) { + const test = fixture(name); + test.seed(); + assert.equal(test.run(install, { EMULATOR_CACHE_HIT: 'true' }).status, 0); + const result = test.run(boot, env); + assert.equal(result.status, expected, name + ': ' + result.stdout + result.stderr); + assert.match(test.trace(), /adb -s emulator-5554 emu kill/, name); + assert.match(test.trace(), /stopped/, name + ': owned emulator must exit'); + assert.equal(test.count('gradle'), name === 'boot-timeout' ? 0 : 1, name); + const registry = readFileSync(join(test.dir, 'openless-ci-avds/openless-ci.ini'), 'utf8'); + assert.ok(registry.includes('path=' + shellPath(join(test.dir, 'openless-ci.avd')))); + const config = readFileSync(join(test.dir, 'openless-ci.avd/config.ini'), 'utf8'); + assert.match(config, /^hw.keyboard=no$/m); + assert.doesNotMatch(config, /hw.keyboard=yes/); + if (expected) assert.match(result.stderr, /emulator started/, 'preserve failure log'); + if (name !== 'boot-timeout') + assert.match( + test.trace(), + /gradle .*:app:connectedX86_64DebugAndroidTest -x :app:rustBuildX86_64Debug --no-daemon/, + ); + } + for (const [name, env, expected] of [ + ['hotkey-match', {}, 0], + ['hotkey-missing', { FAKE_HOTKEY_MATCH: '0' }, 1], + ['hotkey-cargo-failure', { FAKE_CARGO_EXIT: '23' }, 23], + ]) { + const test = fixture(name); + assert.equal(test.run(hotkey, env).status, expected, name); + } + { + const test = fixture('publish-assets'); + const packages = join(test.dir, 'openless-linux-egui-packages'); + const aliases = join(test.dir, 'openless-linux-egui-direct-download'); + for (const [dir, names] of [ + [ + packages, + ['OpenLess-Linux-egui-test.deb', 'OpenLess-Linux-egui-test.rpm', 'SHA256SUMS-linux'], + ], + [aliases, ['OpenLess_linux_x86_64.deb', 'OpenLess_linux_x86_64.rpm']], + ]) { + mkdirSync(dir); + for (const name of names) writeFileSync(join(dir, name), 'fake verified asset'); + } + assert.equal(test.run(publish).status, 0); + const upload = test + .trace() + .split('\n') + .find((line) => line.startsWith('gh release upload')); + for (const asset of [ + 'OpenLess-Linux-egui-test.deb', + 'OpenLess-Linux-egui-test.rpm', + 'SHA256SUMS-linux', + 'OpenLess_linux_x86_64.deb', + 'OpenLess_linux_x86_64.rpm', + ]) + assert.ok(upload.includes(asset)); + assert.ok(upload.endsWith('--clobber')); + rmSync(join(aliases, 'OpenLess_linux_x86_64.rpm')); + const uploadsBefore = test + .trace() + .split('\n') + .filter((line) => line.startsWith('gh release upload')).length; + assert.equal(test.run(publish).status, 1, 'missing asset must not publish'); + assert.equal( + test + .trace() + .split('\n') + .filter((line) => line.startsWith('gh release upload')).length, + uploadsBefore, + ); + } + { + const test = fixture('signing-passwords'); + const signing = join(test.dir, 'signing'); + const output = join(test.dir, 'output'); + mkdirSync(signing); + mkdirSync(output); + for (const channel of ['release', 'test']) + for (const suffix of ['cer', 'p7b', 'p12']) + writeFileSync(join(signing, channel + '.' + suffix), 'fake'); + writeFileSync(join(signing, 'keystore-password'), 'local-file-store'); + writeFileSync(join(signing, 'key-password'), 'local-file-key'); + writeFileSync(join(output, 'entry-default-unsigned.hap'), 'fake'); + const jar = join(test.sdk, 'sdk/default/openharmony/toolchains/lib/hap-sign-tool.jar'); + mkdirSync(dirname(jar), { recursive: true }); + writeFileSync(jar, 'fake'); + const script = fileURLToPath(new URL('./sign-harmony-hap.py', import.meta.url)); + const source = ` +import contextlib, io, json, os, runpy, sys +from types import SimpleNamespace +from unittest.mock import patch +script, toolchain, signing, output, channel, values = sys.argv[1:] +with patch.dict(os.environ, json.loads(values), clear=True), patch('sys.argv', [script, toolchain, signing, output, '--prefix', channel]), patch('subprocess.run', return_value=SimpleNamespace(returncode=0, stdout='', stderr='')) as run, contextlib.redirect_stdout(io.StringIO()): + runpy.run_path(script, run_name='__main__') + command = run.call_args.args[0] +print(json.dumps([command[command.index('-keystorePwd') + 1], command[command.index('-keyPwd') + 1]])) +`; + const runSign = (channel, env) => { + const result = spawnSync( + python, + ['-B', '-c', source, script, test.sdk, signing, output, channel, JSON.stringify(env)], + { encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); + }; + for (const channel of ['release', 'test']) { + const secrets = { + HARMONY_KEYSTORE_PASSWORD: 'old-store', + HARMONY_KEY_PASSWORD: 'old-key', + HARMONY_RELEASE_KEYSTORE_PASSWORD: 'release-store', + HARMONY_RELEASE_KEY_PASSWORD: 'release-key', + HARMONY_TEST_KEYSTORE_PASSWORD: 'test-store', + HARMONY_TEST_KEY_PASSWORD: 'test-key', + }; + const env = Object.fromEntries( + ['HARMONY_KEYSTORE_PASSWORD', 'HARMONY_KEY_PASSWORD'].map((name) => [ + name, + vm.runInNewContext(harmony.jobs['build-harmony-hap'].env[name].slice(3, -2), { + inputs: { channel }, + secrets, + }), + ]), + ); + assert.deepEqual(runSign(channel, env), [channel + '-store', channel + '-key']); + } + assert.deepEqual(runSign('release', {}), ['local-file-store', 'local-file-key']); + rmSync(join(signing, 'key-password')); + assert.deepEqual(runSign('release', { HARMONY_KEYSTORE_PASSWORD: 'resolved-store' }), [ + 'resolved-store', + 'resolved-store', + ]); + } +} finally { + rmSync(scratch, { recursive: true, force: true }); +} +console.log( + 'ci-workflow-behavior: install retries/cache hits, emulator cleanup, exact-test guard, assets and signing passwords passed', +); diff --git a/openless-all/app/scripts/frontend-test-runner.mjs b/openless-all/app/scripts/frontend-test-runner.mjs index 976dcf95f..bce8a86f7 100644 --- a/openless-all/app/scripts/frontend-test-runner.mjs +++ b/openless-all/app/scripts/frontend-test-runner.mjs @@ -1,4 +1,4 @@ -import { readdirSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { dirname, join, relative, resolve, sep } from 'node:path'; import process from 'node:process'; @@ -69,9 +69,33 @@ export function runTestFiles( return 0; } +/** + * The full suite is platform-independent except for a handful of files that read + * `process.platform` or whose name names a platform. Running all 140 scripts on + * Windows costs ~3x the macOS time (process spawn + filesystem), so Windows runs + * only the platform-sensitive subset and the rest is covered by macOS and Linux. + */ +export function selectPlatformSensitive(appRoot, testFiles, platform) { + const marked = new RegExp(`(^|[/-])${platform}([-.]|$)`, 'i'); + return testFiles.filter((file) => { + if (marked.test(file)) return true; + const source = readFileSync(join(appRoot, file), 'utf8'); + return /process\.platform/.test(source); + }); +} + const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; if (invokedPath === import.meta.url) { - const testFiles = discoverTestFiles(); + const platformOnlyIndex = process.argv.indexOf('--platform-only'); + const platformOnly = platformOnlyIndex > -1 ? process.argv[platformOnlyIndex + 1] : null; + let testFiles = discoverTestFiles(); + if (platformOnly) { + const all = testFiles; + testFiles = selectPlatformSensitive(DEFAULT_APP_ROOT, all, platformOnly); + console.log( + `[frontend-tests] --platform-only ${platformOnly}: ${testFiles.length}/${all.length} tests`, + ); + } if (testFiles.length === 0) { console.error('[frontend-tests] no tests discovered'); process.exitCode = 1; diff --git a/openless-all/app/scripts/frontend-test-runner.test.mjs b/openless-all/app/scripts/frontend-test-runner.test.mjs index 4b6d5d06e..577ad6d8d 100644 --- a/openless-all/app/scripts/frontend-test-runner.test.mjs +++ b/openless-all/app/scripts/frontend-test-runner.test.mjs @@ -7,7 +7,6 @@ import { discoverTestFiles, runTestFiles } from './frontend-test-runner.mjs'; const discovered = discoverTestFiles(); for (const expected of [ 'scripts/check-android-updater-pubkey.mjs', - 'scripts/check-hotkey-injection.mjs', 'scripts/less-computer-opencode-contract.test.mjs', 'scripts/macos-capsule-spaces-contract.test.mjs', 'scripts/macos-audio-usage-description-contract.test.mjs', diff --git a/openless-all/app/scripts/harmony-profile-report.py b/openless-all/app/scripts/harmony-profile-report.py new file mode 100755 index 000000000..2dae33dfb --- /dev/null +++ b/openless-all/app/scripts/harmony-profile-report.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Report whether a HarmonyOS profile matches the application being packaged. + + harmony-profile-report.py + +`openssl cms -verify -noverify` (or `hap-sign-tool verify-profile`) gives the +profile as JSON. What matters before a release: + + * `bundle-info.bundle-name` equals the `bundleName` in AppScope/app.json5 + * `type` matches the channel - a `release` dispatch needs a release profile + * `validity` covers today + * a debug profile lists the device UDIDs it may be installed on + +Exits 1 when a check failed. +""" +import json +import re +import sys +import time + + +def main(): + profile_path, app_json5_path, channel = sys.argv[1:4] + failures = 0 + + def ok(message): + print(f' ok {message}') + + def bad(message): + nonlocal failures + failures += 1 + print(f' FAIL {message}', file=sys.stderr) + + def warn(message): + print(f' warn {message}') + + profile = json.load(open(profile_path, encoding='utf-8')) + bundle_info = profile.get('bundle-info', {}) + bundle = bundle_info.get('bundle-name', '') + + source = open(app_json5_path, encoding='utf-8').read() + expected = re.search(r'"bundleName"\s*:\s*"([^"]+)"', source) + expected = expected.group(1) if expected else '' + + if expected and bundle == expected: + ok(f'profile bundleName matches app.json5 ({bundle})') + elif expected: + bad(f'profile is for "{bundle}", app.json5 says "{expected}"') + else: + warn('app.json5 bundleName not found; profile not compared') + + profile_type = profile.get('type', '') + if channel == 'release' and profile_type != 'release': + bad(f'profile type is "{profile_type}", a release dispatch needs a release profile') + elif channel == 'debug' and profile_type != 'debug': + warn(f'profile type is "{profile_type}", debug signing usually uses a debug profile') + else: + ok(f'profile type is "{profile_type}"') + + validity = profile.get('validity', {}) + start = int(validity.get('not-before', 0) or 0) + end = int(validity.get('not-after', 0) or 0) + now = int(time.time()) + stamp = lambda value: time.strftime('%Y-%m-%d', time.gmtime(value)) # noqa: E731 + if start and end: + if start <= now <= end: + ok(f'profile valid {stamp(start)} → {stamp(end)}') + else: + bad(f'profile outside its window: {stamp(start)} → {stamp(end)}') + else: + warn('profile has no validity window') + + devices = profile.get('debug-info', {}).get('device-ids') or [] + if profile_type == 'debug': + if devices: + ok(f'{len(devices)} device UDID(s) authorised for this debug profile') + else: + bad('debug profile lists no device UDID; installation will be refused') + + version_name = profile.get('version-name') + print(f' note profile versionName is {version_name!r}; the packager stamps the HAP' + ' from package.json, so keep them compatible when AppGallery rejects a build') + + return failures + + +if __name__ == '__main__': + sys.exit(1 if main() else 0) diff --git a/openless-all/app/scripts/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs index c77bb73d9..ceacf3491 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const workflow = readFileSync(join(appRoot, '../../.github/workflows/release-harmony.yml'), 'utf8'); @@ -42,18 +43,32 @@ assert.match(packaging, /@ohos\/hvigor\/bin\/hvigor\.js/); // Distribution policy: the repository only ever carries the unsigned package, // AppGallery gets the signed one, and the test channel signs with its own // material. -assert.match(workflow, /channel:[\s\S]{0,400}default: release/); +// The default dispatch is the unsigned repository path: signing only happens for +// a store channel, and a plain release build must not need any secret. +assert.match(workflow, /channel:[\s\S]{0,600}default: none/); +assert.match(workflow, /options:[\s\S]{0,200}- none[\s\S]{0,80}- release[\s\S]{0,80}- test/); +assert.match( + workflow, + /Write the signing material for this channel\n\s+if: inputs\.channel != 'none'/, + 'the signing material step must be skipped for channel=none', +); assert.match(workflow, /HARMONY_KEYSTORE_BASE64[\s\S]{0,400}prefix\.p12/); assert.match(workflow, /OPENLESS_HARMONY_CHANNEL: \$\{\{ inputs\.channel \}\}/); assert.match(workflow, /--channel "\$\{\{ inputs\.channel \}\}"/); const attachStart = workflow.indexOf('gh release upload'); -const releaseAttach = workflow.slice(attachStart, workflow.indexOf('attached HarmonyOS assets', attachStart)); +const releaseAttach = workflow.slice( + attachStart, + workflow.indexOf('attached HarmonyOS assets', attachStart), +); assert.ok(!releaseAttach.includes('-signed.hap'), 'the Release must not carry the signed HAP'); assert.match(releaseAttach, /OpenLess_harmony_arm64\.hap/); assert.match(packaging, /OpenLess_harmony_arm64-signed\.hap/); assert.match(packaging, /OPENLESS_HARMONY_CHANNEL/); assert.match(readFileSync(join(appRoot, 'scripts/sign-harmony-hap.py'), 'utf8'), /--prefix/); -assert.match(readFileSync(join(appRoot, 'scripts/verify-harmony-hap.sh'), 'utf8'), /--allow-unsigned/); +assert.match( + readFileSync(join(appRoot, 'scripts/verify-harmony-hap.sh'), 'utf8'), + /--allow-unsigned/, +); // Credentials arrive as Secrets/Variables, never as literals. for (const secret of [ @@ -69,7 +84,11 @@ for (const secret of [ assert.ok(workflow.includes(secret), `release-harmony.yml must consume ${secret}`); assert.ok(releasing.includes(secret), `RELEASING.md must document ${secret}`); } -assert.doesNotMatch(workflow, /password\s*:\s*['"][^'"]+['"]/i, 'no literal passwords in the workflow'); +assert.doesNotMatch( + workflow, + /password\s*:\s*['"][^'"]+['"]/i, + 'no literal passwords in the workflow', +); // Packaging and signing are separate, testable scripts. assert.ok(existsSync(join(appRoot, 'scripts/package-harmony-hap.sh'))); @@ -93,4 +112,96 @@ assert.match(workflow, /upload-appgallery\.mjs/); assert.match(workflow, /gh release upload/); assert.match(workflow, /OpenLess_harmony_arm64\.hap/); +// `env:` at job level outranks `$GITHUB_ENV`, so a placeholder key here would +// shadow the value the signing step exports and the build would look for +// signing material in `~/.local/share/harmony/signing/release`. +{ + const jobEnv = workflow.match(/\n env:\n((?: [A-Z_]+:.*\n)+)/)?.[1] ?? ''; + assert.doesNotMatch( + jobEnv, + /OPENLESS_HARMONY_SIGNING_DIR/, + 'release-harmony.yml must not declare OPENLESS_HARMONY_SIGNING_DIR as job-level env', + ); + assert.match( + workflow, + /OPENLESS_HARMONY_SIGNING_DIR=\$dir.*GITHUB_ENV|echo "OPENLESS_HARMONY_SIGNING_DIR=\$dir" >> "\$GITHUB_ENV"/, + 'the signing step must export OPENLESS_HARMONY_SIGNING_DIR through $GITHUB_ENV', + ); +} + +// The signing material must be validated before the build: a mismatched +// certificate or an expired profile would otherwise only surface after a +// six-minute build, or when a device refuses the install. +assert.match( + workflow, + /Validate the signing material before building[\s\S]*check-harmony-signing\.sh/, + 'release-harmony.yml must run check-harmony-signing.sh before building', +); +assert.ok( + existsSync(join(appRoot, 'scripts/check-harmony-signing.sh')), + 'scripts/check-harmony-signing.sh must exist', +); + +// Secret names carry the channel first (`HARMONY_RELEASE_CERT_BASE64`), so the +// resolver must insert the channel after `HARMONY_` rather than appending it. +assert.match( + workflow, + /specific="HARMONY_\$\{upper\}_\$\{base#HARMONY_\}"/, + 'channel-specific secret lookup must build HARMONY__', +); +assert.match( + workflow, + /HARMONY_RELEASE_CERT_BASE64: \$\{\{ secrets\.HARMONY_RELEASE_CERT_BASE64 \}\}/, + 'the release channel secret must be wired into the job environment', +); + +const fields = ['KEYSTORE_PASSWORD', 'KEY_PASSWORD']; +const passwordExpression = (field) => { + const line = workflow.split('\n').find((line) => line.startsWith(' HARMONY_' + field + ':')); + assert.ok(line, 'missing resolved ' + field); + return line + .slice(line.indexOf(':') + 1) + .trim() + .slice(3, -2) + .trim(); +}; +for (const channel of ['release', 'test']) { + const own = 'HARMONY_' + channel.toUpperCase() + '_'; + const other = 'HARMONY_' + (channel === 'release' ? 'TEST' : 'RELEASE') + '_'; + for (const mode of ['specific', 'generic', 'both', 'missing-key']) { + const secrets = {}; + for (const prefix of ['HARMONY_', own, other]) + for (const field of fields) secrets[prefix + field] = ''; + for (const field of fields) { + secrets[other + field] = 'wrong-channel-' + field; + if (mode !== 'specific' && mode !== 'missing-key') + secrets['HARMONY_' + field] = 'generic-' + field; + if (mode !== 'generic' && !(mode === 'missing-key' && field === 'KEY_PASSWORD')) + secrets[own + field] = channel + '-' + field; + const actual = vm.runInNewContext(passwordExpression(field), { + inputs: { channel }, + secrets, + }); + const expected = + mode === 'generic' + ? 'generic-' + field + : mode === 'missing-key' && field === 'KEY_PASSWORD' + ? '' + : channel + '-' + field; + assert.equal(actual, expected, channel + '/' + mode + '/' + field); + } + } +} +for (const field of fields) { + const secrets = Object.fromEntries( + ['HARMONY_', 'HARMONY_RELEASE_', 'HARMONY_TEST_'].map((prefix) => [prefix + field, '']), + ); + assert.equal( + vm.runInNewContext(passwordExpression(field), { inputs: { channel: 'none' }, secrets }), + '', + ); +} +// Workflow resolution changes, not the local environment-over-file contract. +assert.match(signing, /password = os\.environ\.get\('HARMONY_KEYSTORE_PASSWORD'/); +assert.match(signing, /key_password = key_password or password/); console.log('harmony-release contract test passed'); diff --git a/openless-all/app/scripts/harmony-signing-report.py b/openless-all/app/scripts/harmony-signing-report.py new file mode 100755 index 000000000..60d18e62f --- /dev/null +++ b/openless-all/app/scripts/harmony-signing-report.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Report whether a Huawei certificate chain belongs to a keystore key. + + harmony-signing-report.py + +`release.cer` / `debug.cer` downloaded from AppGallery Connect are PEM chains +(Huawei root CA + leaf), so the certificate that matters is the one whose public +key equals the keystore's - not the first certificate in the file, which is the +issuing CA. The profile (`.p7b`) embeds the certificate it was issued for; that +is compared verbatim (whitespace-insensitive) against the leaf. + +Prints `ok`/`warn`/`FAIL` lines for the caller and exits 1 when a check failed. +Nothing secret is printed. +""" +import json +import re +import subprocess +import sys + + +def run(args, stdin=None): + return subprocess.run(args, input=stdin, capture_output=True) + + +def pem_blocks(raw): + blocks = re.findall(rb'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----', raw, re.S) + if blocks: + return blocks + # A single DER certificate. + converted = run(['openssl', 'x509', '-inform', 'DER', '-outform', 'PEM'], raw).stdout + return [converted] if converted else [] + + +def describe(pem): + out = run(['openssl', 'x509', '-noout', '-subject'], pem).stdout.decode().strip() + return out.removeprefix('subject=').strip() + + +def pubkey(pem): + return run(['openssl', 'x509', '-noout', '-pubkey'], pem).stdout + + +def normalise(text): + return re.sub(r'\s+', '', text) + + +def main(): + chain_path, keystore_path, profile_path = sys.argv[1:4] + blanks = [] + failures = 0 + + def ok(message): + print(f' ok {message}') + + def bad(message): + nonlocal failures + failures += 1 + print(f' FAIL {message}', file=sys.stderr) + + with open(chain_path, 'rb') as handle: + blocks = pem_blocks(handle.read()) + if not blocks: + bad(f'cannot read any certificate from {chain_path}') + return 1 + + with open(keystore_path, 'rb') as handle: + keystore_pem = handle.read() + keystore_key = pubkey(keystore_pem) + + leaf = next((pem for pem in blocks if pubkey(pem) == keystore_key), None) + if not leaf: + subjects = '; '.join(describe(pem) for pem in blocks) or 'unreadable' + bad(f'no certificate in the chain belongs to the keystore key (chain: {subjects})') + return failures + + ok(f'certificate matches the keystore key ({describe(leaf)})') + if len(blocks) > 1: + ok(f'chain carries {len(blocks) - 1} issuing certificate(s)') + if blanks: + ok(blanks[0]) + + end = run(['openssl', 'x509', '-noout', '-enddate'], leaf).stdout.decode().strip() + end = end.removeprefix('notAfter=').removeprefix('enddate=').strip() + if run(['openssl', 'x509', '-noout', '-checkend', '0'], leaf).returncode == 0: + ok(f'certificate valid until {end}') + else: + bad(f'certificate expired on {end}') + + # The profile from `hap-sign-tool`/`openssl cms` is JSON; a profile shipped + # next to the material is already unwrapped. + issued = '' + try: + if profile_path.endswith('.json'): + profile = json.load(open(profile_path, encoding='utf-8')) + else: + unwrapped = run( + ['openssl', 'cms', '-verify', '-noverify', '-inform', 'DER', '-in', profile_path] + ).stdout + profile = json.loads(unwrapped) + bundle_info = profile.get('bundle-info', {}) + issued = ( + bundle_info.get('distribution-certificate') + or bundle_info.get('development-certificate') + or '' + ) + except Exception as error: # noqa: BLE001 - report, never raise + print(f' warn could not read the profile for comparison ({error})') + + if not issued: + print(' warn profile embeds no certificate; cannot compare') + elif normalise(issued) == normalise(leaf.decode()): + ok('profile was issued for this certificate') + else: + bad('profile was issued for a different certificate; re-apply the profile') + + return failures + + +if __name__ == '__main__': + sys.exit(1 if main() else 0) diff --git a/openless-all/app/scripts/linux-egui-release-contract.test.mjs b/openless-all/app/scripts/linux-egui-release-contract.test.mjs index f3084ce25..804e647c1 100644 --- a/openless-all/app/scripts/linux-egui-release-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -123,14 +123,20 @@ for (const dep of ['fcitx5', 'libpipewire-0.3-0', 'libvulkan1', 'liblzma5', 'lib assert.ok(debDeps.includes(dep), `deb must require ${dep}`); } for (const removed of ['libegl1', 'libgl1', 'libwayland-egl1']) { - assert.ok(!debDeps.includes(removed), `deb must not require ${removed} after the Vulkan migration`); + assert.ok( + !debDeps.includes(removed), + `deb must not require ${removed} after the Vulkan migration`, + ); } const rpmDeps = pack.match(/^Requires: ([^\n]+)/m)?.[1].split(/,\s*/) ?? []; for (const dep of ['fcitx5', 'pipewire-libs', 'vulkan-loader', 'libwayland-client']) { assert.ok(rpmDeps.includes(dep), `rpm must require ${dep}`); } for (const removed of ['libglvnd-egl', 'libwayland-egl.so.1()(64bit)']) { - assert.ok(!rpmDeps.includes(removed), `rpm must not require ${removed} after the Vulkan migration`); + assert.ok( + !rpmDeps.includes(removed), + `rpm must not require ${removed} after the Vulkan migration`, + ); } assert.match(pack, /^Recommends: mesa-vulkan-drivers$/m, 'packages must recommend a Vulkan driver'); for (const [file, size] of [ @@ -152,4 +158,31 @@ assert.doesNotMatch( check, /cargo (?:build|check|test) --locked --manifest-path src-tauri\/Cargo\.toml/, ); + +// The package installs the fcitx5 plugin, so the packaging job must build it +// even though `ctest` lives in the parallel test job: dropping it made +// `package-linux-egui.sh` fail on an empty `test -s` with no output at all. +{ + const buildJob = check.match(/ build-linux-egui:[\s\S]*$/)?.[0] ?? ''; + assert.match( + buildJob, + /cmake --build build --parallel/, + 'build-linux-egui must compile the fcitx5 plugin that the package installs', + ); + assert.match( + pack, + /require_file "\$PLUGIN_ROOT\/libopenless\.so"/, + 'package-linux-egui.sh must report a missing plugin artifact instead of a bare test -s', + ); +} + +assert.match( + check, + /cargo test --locked -p openless-core --lib \\\n\s+api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics -- --exact/, +); +assert.match( + check, + /grep -Fq 'test api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics \.\.\. ok'/, +); +assert.match(check, /needs: \[test-linux-egui, build-linux-egui\]/); console.log('linux-egui-release-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/package-harmony-hap.sh b/openless-all/app/scripts/package-harmony-hap.sh index 5977d365c..c8674c214 100755 --- a/openless-all/app/scripts/package-harmony-hap.sh +++ b/openless-all/app/scripts/package-harmony-hap.sh @@ -21,15 +21,21 @@ TOOLCHAIN="${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}" # Channels, matching how the artifacts are distributed: # release production build for HUAWEI AppGallery (signed with release.*) # test beta build for the AppGallery test channel (signed with test.*) -# none no store signing; the debug material is used when it is present -# The GitHub Release only ever carries the *unsigned* HAP. +# debug local development build (signed with debug.*, installable only on the +# devices listed in that debug profile); never published +# none no store signing (the GitHub Release only ships the unsigned HAP) CHANNEL="${OPENLESS_HARMONY_CHANNEL:-release}" case "$CHANNEL" in release | test) SIGN_PREFIX="$CHANNEL" ;; + debug) + SIGN_PREFIX="debug" + # Local material lives beside the store channels but in its own directory. + SIGNING_DIR="${OPENLESS_HARMONY_SIGNING_DIR:-$HOME/.local/share/harmony/signing/local-test}" + ;; none) SIGN_PREFIX="" ;; - *) echo 'OPENLESS_HARMONY_CHANNEL must be release, test or none' >&2; exit 1 ;; + *) echo 'OPENLESS_HARMONY_CHANNEL must be release, test, debug or none' >&2; exit 1 ;; esac -SIGNING_DIR="${OPENLESS_HARMONY_SIGNING_DIR:-$HOME/.local/share/harmony/signing/release}" +SIGNING_DIR="${SIGNING_DIR:-${OPENLESS_HARMONY_SIGNING_DIR:-$HOME/.local/share/harmony/signing/release}}" CACHE_ROOT="${OPENLESS_CACHE_ROOT:-$APP_ROOT/target/harmony-cache}" OUTPUT="${OPENLESS_HARMONY_OUTPUT:-$APP_ROOT/target/harmony-packages}" @@ -95,6 +101,8 @@ HVIGOR_JS="$HVIGOR_BIN" \ OPENLESS_CACHE_ROOT="$CACHE_ROOT" \ OPENLESS_HARMONY_TOOLCHAIN="$TOOLCHAIN" \ OPENLESS_HARMONY_SIGNING_DIR="$SIGNING_DIR" \ +OPENLESS_HARMONY_STORE_SIGNING="${SIGN_PREFIX:+1}" \ +OPENLESS_HARMONY_SIGN_PREFIX="$SIGN_PREFIX" \ bash "$APP_ROOT/scripts/build-harmony-frontend.sh" BUILD_PROJECT="$CACHE_ROOT/harmony/openless-project" @@ -103,11 +111,27 @@ UNSIGNED=$(ls "$OUT_DIR"/*-unsigned.hap 2>/dev/null | head -1) [ -n "$UNSIGNED" ] || { echo "hvigor produced no unsigned HAP in $OUT_DIR" >&2; exit 1; } SIGNED="" +APP_PACK="" if [ -n "$SIGN_PREFIX" ]; then python3 "$APP_ROOT/scripts/sign-harmony-hap.py" \ "$TOOLCHAIN" "$SIGNING_DIR" "$OUT_DIR" --prefix "$SIGN_PREFIX" + # The App Pack is signed the same way; `hap-sign-tool sign-app` accepts `.app`. + if [ -d "$CACHE_ROOT/harmony/openless-project/build/outputs/default" ]; then + python3 "$APP_ROOT/scripts/sign-harmony-hap.py" \ + "$TOOLCHAIN" "$SIGNING_DIR" "$CACHE_ROOT/harmony/openless-project/build/outputs/default" \ + --prefix "$SIGN_PREFIX" --skip-hap + fi SIGNED="$OUT_DIR/entry-default-signed.hap" [ -f "$SIGNED" ] || { echo "expected a signed HAP at $SIGNED" >&2; exit 1; } + + # AppGallery rejects a bare HAP ("The suffix name of the software package is + # incorrect", code 204144641), so the store channels ship the signed App Pack. + APP_OUT="$BUILD_PROJECT/build/outputs/default" + APP_PACK=$(ls "$APP_OUT"/*-signed.app 2>/dev/null | head -1 || true) + [ -n "$APP_PACK" ] || { + echo "store channel produced no signed .app App Pack in $APP_OUT (AppGallery requirement)" >&2 + exit 1 + } else echo "no store signing: packaging the unsigned HAP only" fi @@ -118,6 +142,11 @@ mkdir -p "$OUTPUT" # download never carries someone else's signing identity. cp "$UNSIGNED" "$OUTPUT/OpenLess_harmony_arm64.hap" cp "$UNSIGNED" "$OUTPUT/OpenLess_harmony.hap" +if [ -n "$APP_PACK" ]; then + # Store-only artifact: AppGallery takes the App Pack, and it never enters the + # GitHub Release. + cp "$APP_PACK" "$OUTPUT/OpenLess_harmony_arm64-signed.app" +fi if [ -n "$SIGNED" ]; then # Store-only artifact: the workflow uploads it to AppGallery and keeps it out # of the Release. diff --git a/openless-all/app/scripts/package-linux-egui.sh b/openless-all/app/scripts/package-linux-egui.sh index f7990a3cd..6566a88fe 100644 --- a/openless-all/app/scripts/package-linux-egui.sh +++ b/openless-all/app/scripts/package-linux-egui.sh @@ -33,15 +33,40 @@ HICOLOR_ICONS=( "512x512:icon.png" ) -test -x "$BINARY" -test -d "$ICON_DIR" -for spec in "${HICOLOR_ICONS[@]}"; do test -s "$ICON_DIR/${spec#*:}"; done -test -s "$PLUGIN_ROOT/libopenless.so" -test -s "$PLUGIN_ROOT/openless.conf" -test -s "$PACKAGING/openless.desktop" -test -s "$PACKAGING/top.openless.OpenLess.metainfo.xml" -command -v dpkg-deb >/dev/null -command -v rpmbuild >/dev/null +# Never fail silently: a bare `test -s` exits 1 with no output, which in CI looks +# like "the packaging step failed for no reason". +require_file() { + [ -s "$1" ] || { + echo "missing or empty $1" >&2 + case "$1" in + */linux-fcitx5-plugin/build/*) echo " build it with: (cd scripts/linux-fcitx5-plugin && cmake -S . -B build && cmake --build build)" >&2 ;; + */release/openless-linux-egui) echo " build it with: cargo build --release -p openless-linux-egui" >&2 ;; + esac + exit 1 + } +} +require_command() { + command -v "$1" > /dev/null || { + echo "missing build tool: $1" >&2 + exit 1 + } +} + +[ -x "$BINARY" ] || { + echo "missing release binary $BINARY (cargo build --release -p openless-linux-egui)" >&2 + exit 1 +} +[ -d "$ICON_DIR" ] || { + echo "missing icon directory $ICON_DIR" >&2 + exit 1 +} +for spec in "${HICOLOR_ICONS[@]}"; do require_file "$ICON_DIR/${spec#*:}"; done +require_file "$PLUGIN_ROOT/libopenless.so" +require_file "$PLUGIN_ROOT/openless.conf" +require_file "$PACKAGING/openless.desktop" +require_file "$PACKAGING/top.openless.OpenLess.metainfo.xml" +require_command dpkg-deb +require_command rpmbuild mkdir -p "$OUTPUT" diff --git a/openless-all/app/scripts/pin-persistence-security-contract.test.mjs b/openless-all/app/scripts/pin-persistence-security-contract.test.mjs index 9fc5781e7..ddd75cd35 100644 --- a/openless-all/app/scripts/pin-persistence-security-contract.test.mjs +++ b/openless-all/app/scripts/pin-persistence-security-contract.test.mjs @@ -61,7 +61,9 @@ assert.match( ); assert.match( ci, - /if: runner\.os == 'Windows'[\s\S]*cargo test --locked --manifest-path src-tauri\/Cargo\.toml --lib --no-run/, + // `--bins` was folded in when the duplicate `cargo check` pass was removed; + // the requirement is only that Windows compiles the owning module's tests. + /if: runner\.os == 'Windows'[\s\S]*cargo test --locked --manifest-path src-tauri\/Cargo\.toml --lib(?: --bins)? --no-run/, 'Windows CI must at least compile the owning Tauri module tests when native runtime DLLs prevent execution', ); diff --git a/openless-all/app/scripts/release-publication-contract.test.mjs b/openless-all/app/scripts/release-publication-contract.test.mjs index 2e48a669e..b8d3e21cb 100644 --- a/openless-all/app/scripts/release-publication-contract.test.mjs +++ b/openless-all/app/scripts/release-publication-contract.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import yaml from 'js-yaml'; +import vm from 'node:vm'; const workflow = (name) => yaml.load(readFileSync(new URL(`../../../.github/workflows/${name}`, import.meta.url), 'utf8')); @@ -42,4 +43,55 @@ for (const name of [ `${name}: a draft must not change the public cask`, ); } + +const linux = workflow('check-linux-egui.yml'); +const publish = linux.jobs['publish-linux-egui']; +assert.deepEqual(publish.needs, ['test-linux-egui', 'build-linux-egui']); +assert.equal(linux.jobs['test-linux-egui'].needs, undefined, 'testing must remain parallel'); +assert.equal(linux.jobs['build-linux-egui'].needs, undefined, 'packaging must remain parallel'); +assert.equal(publish.permissions, undefined, 'the reusable job must inherit its caller permission'); +assert.equal(publish.env.GH_REPO, '${{ github.repository }}'); +assert.equal(publish.env.GH_TOKEN, '${{ github.token }}'); +assert.equal(publish.env.RELEASE_TAG, '${{ inputs.release_tag }}'); +assert.doesNotMatch(publish.if, /always\(|failure\(|cancelled\(/); +for (const [results, inputs, expected] of [ + [['success', 'success'], { scope: 'full', publish: true, release_tag: 'v2.0.2-tauri' }, true], + [['failure', 'success'], { scope: 'full', publish: true, release_tag: 'v2.0.2-tauri' }, false], + [['success', 'failure'], { scope: 'full', publish: true, release_tag: 'v2.0.2-tauri' }, false], + [['cancelled', 'success'], { scope: 'full', publish: true, release_tag: 'v2.0.2-tauri' }, false], + [['success', 'skipped'], { scope: 'full', publish: true, release_tag: 'v2.0.2-tauri' }, false], + [['success', 'success'], { scope: 'none', publish: true, release_tag: 'v2.0.2-tauri' }, false], + [['success', 'success'], { scope: 'full', publish: false, release_tag: 'v2.0.2-tauri' }, false], + [['success', 'success'], { scope: 'full', publish: true, release_tag: '' }, false], +]) { + const enabled = + results.every((result) => result === 'success') && + Boolean(vm.runInNewContext(publish.if, { inputs })); + assert.equal(enabled, expected, JSON.stringify({ results, inputs })); +} +const downloads = publish.steps.filter((step) => step.uses === 'actions/download-artifact@v8'); +assert.deepEqual( + downloads.map((step) => step.with.name), + ['openless-linux-egui-x86_64', 'openless-linux-egui-direct-download'], +); +const uploads = linux.jobs['build-linux-egui'].steps.filter((step) => + step.uses?.startsWith('actions/upload-artifact@'), +); +for (const step of downloads) { + assert.ok(uploads.some((upload) => upload.with.name === step.with.name)); + assert.equal(step.with['run-id'], undefined, 'download only this run'); + assert.equal(step.with['github-token'], undefined, 'do not enable cross-run downloads'); +} +assert.equal( + publish.steps.some((step) => step.uses?.startsWith('actions/checkout@')), + false, +); +for (const [name, job] of Object.entries(linux.jobs)) { + if (name !== 'publish-linux-egui') + assert.ok((job.steps ?? []).every((step) => !step.run?.includes('gh release upload'))); +} +const attach = publish.steps.find((step) => step.run?.includes('gh release upload')); +assert.match(attach.run, /seq 1 40/); +assert.match(attach.run, /sleep 15/); +assert.match(attach.run, /--clobber/); console.log('release-publication-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/sign-harmony-hap.py b/openless-all/app/scripts/sign-harmony-hap.py index 7b056b1ec..3d2001288 100644 --- a/openless-all/app/scripts/sign-harmony-hap.py +++ b/openless-all/app/scripts/sign-harmony-hap.py @@ -23,6 +23,7 @@ release = '--release' in sys.argv[1:] prefix_override = '' +skip_hap = False args = [] rest = sys.argv[1:] while rest: @@ -31,6 +32,9 @@ if not rest: sys.exit('--prefix needs a value') prefix_override = rest.pop(0) + elif argument == '--skip-hap': + # The second pass only signs the App Pack in its own output directory. + skip_hap = True elif argument.startswith('--'): continue else: @@ -48,7 +52,12 @@ if not password_file.is_file(): sys.exit(f'missing signing password: set HARMONY_KEYSTORE_PASSWORD or {password_file}') password = password_file.read_text().strip() -key_password = os.environ.get('HARMONY_KEY_PASSWORD', '').strip() or password +key_password = os.environ.get('HARMONY_KEY_PASSWORD', '').strip() +if not key_password: + key_password_file = signing / 'key-password' + if key_password_file.is_file(): + key_password = key_password_file.read_text().strip() +key_password = key_password or password required = [signing / f'{prefix}.cer', signing / f'{prefix}.p7b', signing / f'{prefix}.p12'] missing = [str(path) for path in required if not path.is_file()] @@ -59,29 +68,43 @@ if not jar.is_file(): sys.exit(f'missing hap-sign-tool.jar under {toolchain}') -candidates = sorted(output.glob('*-unsigned.hap')) -if len(candidates) != 1: - sys.exit(f'expected exactly one unsigned HAP in {output}, found {len(candidates)}') -unsigned = candidates[0] +# `hap-sign-tool sign-app` also signs an App Pack (`.app`), which is what +# AppGallery accepts: a bare HAP is rejected with 204144641 "The suffix name of +# the software package is incorrect". Both inputs are signed the same way. +hap_candidates = [] if skip_hap else sorted(output.glob('*-unsigned.hap')) +app_candidates = sorted(output.glob('*-unsigned.app')) +if not skip_hap and len(hap_candidates) != 1: + sys.exit(f'expected exactly one unsigned HAP in {output}, found {len(hap_candidates)}') +if skip_hap and not app_candidates: + sys.exit(f'expected an unsigned App Pack in {output}') +def sign_package(source, target): + """`hap-sign-tool sign-app` handles both a `.hap` and an `.app`.""" + if target.exists(): + target.unlink() + argv = ['java', '-jar', str(jar), 'sign-app', '-mode', 'localSign', + '-keyAlias', key_alias, + '-appCertFile', str(signing / f'{prefix}.cer'), + '-profileFile', str(signing / f'{prefix}.p7b'), + '-inFile', str(source), + '-signAlg', 'SHA256withECDSA', + '-keystoreFile', str(signing / f'{prefix}.p12'), + '-keystorePwd', password, '-keyPwd', key_password, + '-outFile', str(target), + '-compatibleVersion', '12', '-signCode', '1'] + result = subprocess.run(argv, capture_output=True, text=True) + # Never echo the passwords back, even on failure. + print((result.stdout + result.stderr).replace(password, '[redacted]').replace(key_password, '[redacted]')) + if result.returncode: + sys.exit(result.returncode) + print('Signed:', target) + + # Keep the name the build scripts already reference; `--release` only swaps the # signing material, not the artifact name. -signed = output / 'entry-default-signed.hap' -if signed.exists(): - signed.unlink() +if hap_candidates: + signed = output / 'entry-default-signed.hap' + sign_package(hap_candidates[0], signed) -command = ['java', '-jar', str(jar), 'sign-app', '-mode', 'localSign', - '-keyAlias', key_alias, - '-appCertFile', str(signing / f'{prefix}.cer'), - '-profileFile', str(signing / f'{prefix}.p7b'), - '-inFile', str(unsigned), - '-signAlg', 'SHA256withECDSA', - '-keystoreFile', str(signing / f'{prefix}.p12'), - '-keystorePwd', password, '-keyPwd', key_password, - '-outFile', str(signed), - '-compatibleVersion', '12', '-signCode', '1'] -result = subprocess.run(command, capture_output=True, text=True) -# Never echo the passwords back, even on failure. -print((result.stdout + result.stderr).replace(password, '[redacted]').replace(key_password, '[redacted]')) -if result.returncode: - sys.exit(result.returncode) -print('Signed HAP:', signed) +for candidate in app_candidates: + app_signed = candidate.with_name(candidate.name.replace('-unsigned', '-signed')) + sign_package(candidate, app_signed) diff --git a/openless-all/app/scripts/upload-appgallery.mjs b/openless-all/app/scripts/upload-appgallery.mjs index 65bcc883a..aa677ac2e 100644 --- a/openless-all/app/scripts/upload-appgallery.mjs +++ b/openless-all/app/scripts/upload-appgallery.mjs @@ -4,9 +4,17 @@ // node scripts/upload-appgallery.mjs --file path/to/hap [--channel release|test] // [--release-type N] [--release-phase N] [--dry-run] [--skip-if-unconfigured] // -// Environment (GitHub Secrets in CI, never the repository): -// APPGALLERY_CLIENT_ID AGC API client id -// APPGALLERY_CLIENT_SECRET AGC API client secret +// Environment (GitHub Secrets in CI, never the repository). Two credential +// styles are accepted: +// +// A. API client (OAuth2 client credentials) +// APPGALLERY_CLIENT_ID AGC API client id +// APPGALLERY_CLIENT_SECRET AGC API client secret +// B. Service account (the `*private.json` AGC hands out when you create one) +// APPGALLERY_SERVICE_ACCOUNT_JSON path to that file, or +// APPGALLERY_SERVICE_ACCOUNT_BASE64 base64 of it +// B wins when both are present: it is what AGC shows for new projects. +// // APPGALLERY_APP_ID AGC app id of the HarmonyOS app // // Flow (matches the documented Publishing API sequence): @@ -17,7 +25,7 @@ // // The app is *not* submitted for review: step 4 only replaces the candidate // package, so a release still needs the human submission that AGC requires. -import { createHash } from 'node:crypto'; +import { createHash, createSign } from 'node:crypto'; import { readFileSync, statSync } from 'node:fs'; import { basename } from 'node:path'; @@ -41,9 +49,13 @@ const clientId = process.env.APPGALLERY_CLIENT_ID ?? ''; const clientSecret = process.env.APPGALLERY_CLIENT_SECRET ?? ''; const appId = process.env.APPGALLERY_APP_ID ?? ''; +const serviceAccountPath = process.env.APPGALLERY_SERVICE_ACCOUNT_JSON ?? ''; +const serviceAccountBase64 = process.env.APPGALLERY_SERVICE_ACCOUNT_BASE64 ?? ''; +const hasServiceAccount = Boolean(serviceAccountPath || serviceAccountBase64); + const missing = []; -if (!clientId) missing.push('APPGALLERY_CLIENT_ID'); -if (!clientSecret) missing.push('APPGALLERY_CLIENT_SECRET'); +if (!hasServiceAccount && !clientId) missing.push('APPGALLERY_CLIENT_ID'); +if (!hasServiceAccount && !clientSecret) missing.push('APPGALLERY_CLIENT_SECRET'); if (!appId) missing.push('APPGALLERY_APP_ID'); if (!file) missing.push('--file'); @@ -68,7 +80,8 @@ if (dryRun) { } const request = async (path, init = {}) => { - const response = await fetch(`${ORIGIN}${path}`, init); + const url = init.urlOverride ?? `${ORIGIN}${path}`; + const response = await fetch(url, init); const text = await response.text(); let body = null; try { @@ -79,18 +92,73 @@ const request = async (path, init = {}) => { return { status: response.status, body, text }; }; -const token = await request('/api/oauth2/v1/token', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'cache-control': 'no-cache' }, - body: JSON.stringify({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret }), -}); -const accessToken = token.body?.access_token; -if (!accessToken) { - console.error(`[appgallery] token request failed: HTTP ${token.status} ${token.text.slice(0, 400)}`); - process.exit(1); +// `headerFromServiceAccount` signs a JWT assertion with the RSA key AGC bundled +// into `*private.json`; `getAccessToken` then exchanges it at `token_uri`. +const loadServiceAccount = () => { + const text = serviceAccountBase64 + ? Buffer.from(serviceAccountBase64, 'base64').toString('utf8') + : readFileSync(serviceAccountPath, 'utf8'); + const account = JSON.parse(text); + for (const field of ['key_id', 'sub_account', 'private_key', 'token_uri']) { + if (!account[field]) { + console.error(`[appgallery] service account file is missing ${field}`); + process.exit(1); + } + } + return account; +}; + +const base64url = (value) => Buffer.from(value).toString('base64url'); + +const serviceAccountAssertion = (account, audience) => { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT', kid: account.key_id })); + const payload = base64url( + JSON.stringify({ iss: account.sub_account, aud: audience ?? account.token_uri, iat: now, exp: now + 3600 }), + ); + const signer = createSign('RSA-SHA256'); + signer.update(`${header}.${payload}`); + signer.end(); + const signature = signer.sign(account.private_key).toString('base64url'); + return `${header}.${payload}.${signature}`; +}; + +let accessToken; +let clientHeader; +if (hasServiceAccount) { + const account = loadServiceAccount(); + clientHeader = account.sub_account; + const assertion = serviceAccountAssertion(account); + const token = await request('', { + method: 'POST', + urlOverride: account.token_uri, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion, + }).toString(), + }); + accessToken = token.body?.access_token; + if (!accessToken) { + console.error(`[appgallery] service account token failed: HTTP ${token.status} ${token.text.slice(0, 400)}`); + process.exit(1); + } + console.log(`[appgallery] authenticated with the service account for project ${account.project_id}`); +} else { + clientHeader = clientId; + const token = await request('/api/oauth2/v1/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'cache-control': 'no-cache' }, + body: JSON.stringify({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret }), + }); + accessToken = token.body?.access_token; + if (!accessToken) { + console.error(`[appgallery] token request failed: HTTP ${token.status} ${token.text.slice(0, 400)}`); + process.exit(1); + } } -const authHeaders = { Authorization: `Bearer ${accessToken}`, client_id: clientId }; +const authHeaders = { Authorization: `Bearer ${accessToken}`, client_id: clientHeader }; const uploadUrl = await request( `/api/publish/v2/upload-url/for-obs?appId=${encodeURIComponent(appId)}&fileName=${encodeURIComponent(fileName)}&contentLength=${size}`, diff --git a/openless-all/app/scripts/verify-harmony-hap.sh b/openless-all/app/scripts/verify-harmony-hap.sh index a93466685..3ce6dfad1 100755 --- a/openless-all/app/scripts/verify-harmony-hap.sh +++ b/openless-all/app/scripts/verify-harmony-hap.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # -# Verify a packaged HarmonyOS HAP without a device. +# Verify a packaged HarmonyOS HAP or App Pack without a device. # -# scripts/verify-harmony-hap.sh [toolchain] [versionName] [versionCode] [--allow-unsigned] +# scripts/verify-harmony-hap.sh [toolchain] [versionName] [versionCode] [--allow-unsigned] # # Checks the things that silently go wrong in a HAP build: a missing ArkWeb # bundle, a stale version stamp, a missing native library, and - when the Huawei -# signing tool is available - an invalid signature. +# signing tool is available - an invalid signature. An App Pack (`.app`, what +# AppGallery accepts) is unwrapped first and checked the same way. set -euo pipefail hap=${1:?usage: verify-harmony-hap.sh [toolchain] [versionName] [versionCode]} @@ -16,6 +17,22 @@ version_code=${4:-} [ -f "$hap" ] || { echo "missing HAP: $hap" >&2; exit 1; } +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# An App Pack wraps one HAP; check the inner package, and check the pack's own +# signature when the signing tool is available. +if [ "${hap##*.}" = "app" ]; then + inner=$(unzip -Z1 "$hap" | grep -E '\.hap$' | head -1) + [ -n "$inner" ] || { echo "App Pack contains no HAP" >&2; exit 1; } + unzip -q -o "$hap" "$inner" -d "$work" + echo "App Pack: $hap (inner $inner)" + # The pack carries the signature; sign-app was run on the `.app`, not on the + # HAP it wraps, so the signature check below targets the shipped file. + signature_target=$hap + hap="$work/$inner" +fi + entries=$(unzip -Z1 "$hap") require_entry() { grep -qx "$1" <<< "$entries" || { echo "HAP is missing $1" >&2; exit 1; } @@ -51,7 +68,7 @@ if [ "${5:-}" = '--allow-unsigned' ]; then elif [ -f "$jar" ] && command -v java > /dev/null 2>&1; then # verify-app exits non-zero on a tampered or unsigned package; keep its output # terse because a valid run prints several informational lines. - if out=$(java -jar "$jar" verify-app -inFile "$hap" -outCertChain "$hap.cer" \ + if out=$(java -jar "$jar" verify-app -inFile "${signature_target:-$hap}" -outCertChain "$hap.cer" \ -outProfile "$hap.p7b" 2>&1); then echo "signature verified" else