From 611736e627675f087646b82e0ab7af57b6334950 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 20 Oct 2026 23:00:40 +0800 Subject: [PATCH 01/22] =?UTF-8?q?ci(harmony):=20HAP=20=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E7=9C=9F=E7=BC=96=E4=B9=8B=E5=90=8E=E6=94=B6=E5=B0=BE=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8A=8A=E7=BC=93=E5=AD=98=E9=85=8D=E9=A2=9D=E8=BF=98?= =?UTF-8?q?=E7=BB=99=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 鸿蒙一侧(合并后遗留的三件事): - job 名字还写着 “HAP when the SDK is set”,而公开镜像已经内置 → 改为 「Harmony (ArkTS contracts, Rust host, HAP)」; - `Build the ArkWeb bundle` 这一步是重复劳动:`package-harmony-hap.sh` → `build-harmony-frontend.sh` 里的 `npm run build -- --base=./` 同时设了 `VITE_OPENLESS_PLATFORM=harmony`,删掉这一步只省一次 vite; - 工具链缓存删掉(ci + release 各一处):解压后 7.1 GB、缓存条目 1.77 GB, 而下载 2.1 GB 只要 ~18 s、解压 ~70 s —— 恢复缓存比重新下载更慢,还占掉 仓库 10 GB 配额的 18%。 缓存一侧(真正的泄漏): - `actions/cache@v4` 会在**每次运行**自动保存,包括 `refs/pull//merge` 这种此后再不会被恢复的作用域。鸿蒙工具链 1.77 GB 每 PR 一份、Rime NDK 0.40 GB 每 PR 一份——两次 PR 推送就能把 beta 的依赖树挤掉; - Android Rime 改成 `cache/restore@v4` + beta 门控的 `cache/save@v4`; - 契约测试新增:任何 workflow 里出现 `uses: actions/cache@v4` 直接失败, 出现 `harmony-tools-` 缓存键也失败; - `docs/ci-trigger-and-cache-policy.md` 的配额表按实测体积重写,补上鸿蒙、 Rime、Playwright 三行,并写清「工具链不缓存」的性价比计算。 预期:缓存占用 9.42 GB → ~7.6 GB(预算 8 GB 之内),且 PR 不再写大缓存。 --- .github/workflows/ci.yml | 37 ++++++++++--------- .github/workflows/release-harmony.yml | 9 ++--- docs/ci-trigger-and-cache-policy.md | 33 +++++++++++------ .../app/scripts/ci-cache-sharing.test.mjs | 35 ++++++++++++++++++ 4 files changed, 81 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 700adcb31..771673c16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -239,11 +239,20 @@ 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: 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: Build and stage Rime for Android instrumentation tests env: @@ -301,7 +310,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 +342,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 +358,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 diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index 6d26a02b2..482ee62a9 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -88,11 +88,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: diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index 55e6dcf3a..01e0b6d34 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -51,26 +51,36 @@ 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` 混合。 - `Cargo.lock` 变化时精确键失效,但 `restore-keys` 前缀回退会拿到同前缀的旧缓存,所以“锁变了”不等于“全冷”——前提是那份没被淘汰,这正是要守预算的原因。 @@ -79,6 +89,7 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job - 报告与清理:`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/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index d4e847b00..170fb7223 100644 --- a/openless-all/app/scripts/ci-cache-sharing.test.mjs +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -22,10 +22,45 @@ 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( From f3daaeb1f3875a3b038db74550927c40b08cb7e0 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 20 Oct 2026 22:42:11 +0800 Subject: [PATCH 02/22] =?UTF-8?q?ci(windows):=20=E7=A0=8D=E6=8E=89=2016=20?= =?UTF-8?q?=E5=88=86=E9=92=9F=E9=87=8D=E5=A4=8D=E7=BC=96=E8=AF=91=EF=BC=8C?= =?UTF-8?q?Windows=20=E4=B8=8D=E5=86=8D=E6=98=AF=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用上一轮真实数据定位(run 39337856392,Windows job 35.7 min): 7.6 min Compile Rust backend unit tests (Windows) 6.5 min Check Tauri backend with Rust 1.99 MSRV ← MSRV 6.4 min Check Tauri backend (cargo check) 5.9 min Build frontend and run frontend/contract tests 3.6 min Run Rust-only backend unit tests (Windows) 3.1 min Compile backend tests with Rust 1.99 MSRV ← MSRV 两处浪费: 1. Windows 也在跑 MSRV,而 MSRV(1.99) 与 stable(1.99.x) 同号——和 macOS 那次 一样的冗余,只是当初只条件化了 macOS。现在 Windows 的 1.99 工具链与两条 MSRV 步骤都跟随 `changes.outputs.msrv_needed`,省 ~9.6 min; 2. `cargo check` 之后紧跟 `cargo test --lib --no-run`:check 只产出 metadata, 后面的代码生成会把整张依赖图重编一遍(实测就是那 6.4 min)。去掉 check, 把 bins 覆盖并进 `--lib --bins --no-run`,省 ~6.4 min。 契约测试补了三条断言防止回退(Windows MSRV 跟随判断、不再有独立的 cargo check、 no-run 同时覆盖 lib 与 bins)。 预期:Windows 35.7 → ~20 min,整条 CI 的墙钟从 ~36 min 降到 ~20 min (Linux 19.8 min 成为新的关键路径)。 --- .github/workflows/ci.yml | 17 +++++++++-------- .../app/scripts/ci-changed-areas.test.mjs | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771673c16..4e83e9f96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -431,8 +431,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 @@ -510,10 +512,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. @@ -527,7 +525,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 @@ -542,11 +543,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/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 8bd7489b2..f797cf12b 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -181,6 +181,25 @@ 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), From 594b98e351897995e91789638a6be224620495f3 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 20 Oct 2026 23:13:36 +0800 Subject: [PATCH 03/22] =?UTF-8?q?test:=20pin=20=E5=A5=91=E7=BA=A6=E6=8E=A5?= =?UTF-8?q?=E5=8F=97=20Windows=20=E7=9A=84=20--lib=20--bins=20--no-run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉重复 `cargo check` 时把 bins 覆盖并进了 Windows 的 no-run 编译,但 `pin-persistence-security-contract` 里的正则写死了 `--lib --no-run`, macOS 的契约步骤因此失败。正则放宽成 `(?: --bins)?`——要求本身(Windows 至少 要编过 owning module 的测试)没有变化。 本地把 `grep -l ci.yml scripts/*.test.mjs` 的 7 个文件与 `frontend-test-runner` 全量(83 个脚本测试 + src 下所有 .test.ts)都跑过,全绿。 --- .../app/scripts/pin-persistence-security-contract.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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', ); From dde81630965cb1989f283c1e7177e085d7c58838 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 20 Oct 2026 23:52:21 +0800 Subject: [PATCH 04/22] =?UTF-8?q?ci:=20=E6=8A=8A=20Windows=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=B8=8E=20Linux=20=E6=B5=8B=E8=AF=95=E4=BB=8E?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E8=B7=AF=E5=BE=84=E4=B8=8A=E6=8B=86=E5=87=BA?= =?UTF-8?q?=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测(run 39446467236,各 job 启动间隔 <15 s,没有排队,墙钟 = 最长 job): Windows 21.7 min ← 原关键路径 Linux 16.1 min Harmony 6.4 min UI motion 6.3 min macOS 3.9 min - Windows 的 144 个契约测试要 spawn 144 个 node 进程,在这台 runner 上是 macOS 的 3 倍(6.3 vs 1.9 min),而且它们排在 15 分钟的 Rust 编译后面串行执行。 新增 `windows-frontend` job 并行跑,且只跑平台相关子集 (`--platform-only windows` → 14/144,其余由 macOS 全量覆盖); - Linux 的 8 分钟测试排在 7 分钟打包前面。`check-linux-egui.yml` 拆出 `test-linux-egui` 并行 job,两个 job 显式声明同一个 `shared-key`(值等于原 job 名,缓存键不变),否则 rust-cache 会用 job 名做键,测试 job 每次全冷。 预期墙钟 21.7 → ~15 min(Windows Rust 或 Linux 打包之一成为最长)。 --- .github/workflows/check-linux-egui.yml | 132 +++++++++++++----- .github/workflows/ci.yml | 56 +++++++- .../app/scripts/ci-changed-areas.test.mjs | 21 ++- .../app/scripts/frontend-test-runner.mjs | 28 +++- 4 files changed, 191 insertions(+), 46 deletions(-) diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index 99a4c56db..78dd5dc62 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -42,6 +42,97 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + test-linux-egui: + # Same runner and cache as the packaging job, run in parallel: 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: + # `shared-key` replaces the job name in the cache key, so this job and + # the packaging job (and the tag release, which calls the same workflow + # as `build-linux-egui`) all share one tree. Without it the key would be + # `…-test-linux-egui-…` and every test run would start cold. + shared-key: build-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: | + 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 + build-linux-egui: # PipeWire/libspa in this host needs 24.04. Packages target glibc >= 2.39. runs-on: ubuntu-24.04 @@ -110,6 +201,9 @@ jobs: - uses: swatinem/rust-cache@v2 if: inputs.scope != 'none' with: + # Identical to the job name, so the key is unchanged; declaring it keeps + # the parallel test job on the same tree (see `test-linux-egui`). + shared-key: build-linux-egui workspaces: | openless-all/app -> target openless-all/app/src-tauri/backend-tests -> ../../target @@ -118,44 +212,6 @@ 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 - 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: | - 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' run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e83e9f96..a7ab267bb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -392,6 +392,53 @@ 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: 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 + cross-platform: needs: [changes] # 失败即运行:只有判定脚本明确说“够不到”才跳过。 @@ -472,11 +519,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 @@ -491,6 +533,10 @@ jobs: } - 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. + if: runner.os == 'macOS' run: npm test - name: Install pinned Codex CLI for sandbox contract diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index f797cf12b..516c4cb39 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -227,7 +227,26 @@ check( ); if (failures > 0) { - console.error(`ci-changed-areas contract test failed (${failures})`); + console.error(`// 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, +); + +ci-changed-areas contract test failed (${failures})`); process.exit(1); } console.log('ci-changed-areas contract test 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; From f4adbfc1768f638512827ea8bcf8c9e1c0ac00b0 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 20 Oct 2026 23:54:52 +0800 Subject: [PATCH 05/22] =?UTF-8?q?ci(android):=20=E5=9B=BA=E5=AE=9A=20emula?= =?UTF-8?q?tor=20=E6=9E=84=E5=BB=BA=E5=B9=B6=E7=BC=93=E5=AD=98=EF=BC=8C?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E3=80=8C=E6=9C=80=E5=90=8E=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮 Android 最后结束(+22.9 min 起跑,10.4 min),不是因为它慢,而是 `sdkmanager --install emulator` 在 GitHub 的镜像上拿到截断的包: Warning: An error occurred while preparing SDK package Android Emulator: Error on ZipFile unknown archive job 因此失败,重跑一次才绿——重跑排在 22 分钟之后,于是它「最后完成」。 - 固定 `emulator-build: 16555280`(repository2-3.xml 里 emulator 37.2.12 的 linux archive 就是这个 build),不再解析 latest; - emulator + system-images + AVD 走 restore/save 缓存(~400 MB),命中后下载 与建 AVD 都不在关键路径上,保存只在 beta 且仅未命中时发生; - 自装步骤带 3 次重试,失败先删半截目录再重下。 Android 本体只要 10.4 min(3.1 emulator 测试 + 2.3 Rime 编译 + 1.1 gradle), 本来就比 Windows 21.7 min 短得多。 --- .github/workflows/ci.yml | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7ab267bb..2115e315d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,12 +283,62 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Restore the Android emulator and AVD + # ~400 MB of SDK packages plus the created AVD. Restoring them shortens + # the job and, more importantly, keeps the flaky download out of the + # critical path. The build is pinned, so the key is stable: once one run + # has saved the tree, later runs restore it and skip the save. + id: emulator_cache + uses: actions/cache/restore@v4 + with: + path: | + ${{ env.ANDROID_HOME }}/emulator + ${{ env.ANDROID_HOME }}/system-images + ~/.android/avd + key: android-emulator-37.2.12-${{ runner.os }}-${{ runner.arch }} + - 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/avd + key: android-emulator-37.2.12-${{ runner.os }}-${{ runner.arch }} + + - name: Install the Android emulator with retries + # `sdkmanager --install emulator` regularly returns a truncated archive on + # this image ("Error on ZipFile unknown archive"), which used to fail the + # whole job and force a rerun - that is why Android was the last job to + # finish even though it is one of the shortest. Pin the build and retry. + shell: bash + run: | + set -uo pipefail + installed="$ANDROID_HOME/emulator/package.xml" + if [ -f "$installed" ] && grep -q '37' "$installed"; then + echo "emulator 37.x already present" + exit 0 + fi + for attempt in 1 2 3; do + echo "sdkmanager --install emulator (attempt $attempt)" + if sdkmanager "emulator" "system-images;android-35;google_apis;x86_64"; then + exit 0 + fi + rm -rf "$ANDROID_HOME/emulator" + sleep 15 + done + echo "::error::could not install the Android emulator after 3 attempts" + exit 1 + - name: Run Android Keystore instrumentation tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: 35 target: google_apis arch: x86_64 + # Pinned instead of "latest": the resolved build is the one cached + # above, so the action does not re-download 400 MB per run. + emulator-build: 16555280 # The suite exercises AndroidKeyStore and the real packaged Rime JNI # without launching Tauri. Rime is staged for this emulator ABI above. script: >- From 4776c58fa415beb07d099f69ededac172689fe01 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 20 Oct 2026 23:59:14 +0800 Subject: [PATCH 06/22] =?UTF-8?q?test:=20=E5=88=A0=E6=8E=89=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E4=BC=AA=E8=A3=85=E6=88=90=E5=A5=91=E7=BA=A6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=9A=84=20cargo=20=E8=B0=83=E7=94=A8=EF=BC=88?= =?UTF-8?q?=E6=95=B4=E5=A5=97=E5=A5=91=E7=BA=A6=2065.9s=20=E2=86=92=2013.2?= =?UTF-8?q?s=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/check-hotkey-injection.mjs` 是 40 行的 `spawnSync('cargo', ['test','-p', 'openless-core', <一个测试名>])`。它在每个跑 `npm test` 的平台上都触发一次 **独立的 cargo 编译**: - 它跑在 workspace `app` 下 → 写 `app/target`,而 macOS/Windows 的 Tauri 测试 写 `src-tauri/target`,两棵树互不共享,所以每一个平台都要冷编 openless-core; - Linux job 本来就 `cargo test -p openless-core`(整 crate),这条用例早已被跑。 实测(本机,全 83 个脚本测试逐个计时): 53.58 s check-hotkey-injection.mjs ← 81% 的套件时间 2.29 s locale-lifecycle.test.mjs 其余 81 个都 < 1 s 删掉它,把那句用例名改成 Linux job 里的 `--exact` 重跑:二进制已经由上一行 `cargo test -p openless-core` 编好,成本≈0,但名字被改掉时会在这里直接报错, 不会静默丢失这道门。同时清掉 `npm run check:hotkey-injection` 与 README 里的 引用。 结果:脚本契约套件 65.9 s → **13.2 s**(−80%),且不再有第二条 cargo 构建树。 --- .github/workflows/check-linux-egui.yml | 6 +++ openless-all/README.md | 1 - openless-all/app/package.json | 1 - .../app/scripts/check-hotkey-injection.mjs | 40 ------------------- .../app/scripts/frontend-test-runner.test.mjs | 1 - 5 files changed, 6 insertions(+), 43 deletions(-) delete mode 100644 openless-all/app/scripts/check-hotkey-injection.mjs diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index 78dd5dc62..f45a782c2 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -110,6 +110,12 @@ jobs: working-directory: openless-all/app run: | 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 \ + shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics -- --exact 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 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/package.json b/openless-all/app/package.json index 905b54e71..a34326cf3 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/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/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', From 6969389bbd08e048b2e2e4ec0617e6251c9b7b5a Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 00:06:06 +0800 Subject: [PATCH 07/22] =?UTF-8?q?fix(release):=20=E9=B8=BF=E8=92=99?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E7=9B=AE=E5=BD=95=E8=A2=AB=20job=20=E7=BA=A7?= =?UTF-8?q?=20env=20=E9=81=AE=E8=94=BD=EF=BC=8C=E6=AD=A3=E5=BC=8F=E5=8F=91?= =?UTF-8?q?=E7=89=88=E7=AD=BE=E4=B8=8D=E5=87=BA=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `release-harmony.yml` 的 job `env:` 里有 OPENLESS_HARMONY_SIGNING_DIR: "" 注释说「等签名步骤解析出 $RUNNER_TEMP 再填」,可是 GitHub 的环境变量优先级是 step env > job env > workflow env > $GITHUB_ENV,所以签名步骤写进 $GITHUB_ENV 的那个值会被这个空串盖掉。`package-harmony-hap.sh` 于是走 `${VAR:-默认}` 回落到 `~/.local/share/harmony/signing/release` —— 那里没有密钥材料,通道不是 none 时直接报 "missing signing directory"。 删掉这个占位(脚本各处都用 `:-默认值`,不定义是安全的),并在构建步骤加一条 显式守卫:通道不是 none 而 SIGNING_DIR 为空时立刻以明确信息失败。 顺带核对所有 release 的变量需求: - 鸿蒙:现在零配置——工具链走 checksum 固定的公开镜像,HARMONY_KEY_ALIAS 其实 可选(sign-harmony-hap.py 默认 harmony-),文档改成 「Variable (optional)」;HARMONY_TOOLCHAIN 只在自托管 runner 需要; - 安卓:ANDROID_KEYSTORE_BASE64/PASSWORD、ANDROID_KEY_ALIAS/KEY_PASSWORD、 TAURI_SIGNING_* 都在用,无需新增; - Tauri 桌面:Apple 证书/密码 + TAURI_SIGNING_*,无需新增; - Linux:只用 GITHUB_TOKEN,无 secrets; - Homebrew:HOMEBREW_CASK_ADMIN_TOKEN。 契约测试新增一条:job 级 env 里不允许再出现 OPENLESS_HARMONY_SIGNING_DIR。 --- .github/workflows/release-harmony.yml | 16 ++++++++++++---- RELEASING.md | 11 +++++++++-- .../scripts/harmony-release-contract.test.mjs | 17 +++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index 482ee62a9..aff823d12 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -60,9 +60,11 @@ 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 @@ -146,7 +148,13 @@ jobs: 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 diff --git a/RELEASING.md b/RELEASING.md index 6e74345d7..d6849980f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -174,8 +174,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 +187,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/openless-all/app/scripts/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs index c77bb73d9..fe056756f 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -94,3 +94,20 @@ assert.match(workflow, /gh release upload/); assert.match(workflow, /OpenLess_harmony_arm64\.hap/); console.log('harmony-release contract test passed'); + +// `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', + ); +} From d847f0e5876a6382531643998439b8561f253ae9 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 00:16:35 +0800 Subject: [PATCH 08/22] =?UTF-8?q?feat(harmony):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=B8=82=E5=9C=BA=E5=8F=91=E5=B8=83=E9=93=BE?= =?UTF-8?q?=EF=BC=88=E9=80=9A=E9=81=93=E5=8C=96=E5=87=AD=E6=8D=AE=20+=20de?= =?UTF-8?q?bug=20=E6=9C=AC=E5=9C=B0=E7=AD=BE=E5=90=8D=20+=20=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E6=AD=A5=E9=AA=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户问的是「release 到应用市场还缺什么、怎么拿」。核下来缺的不是代码,是 **凭据的组织方式和获取路径**,另外本地调试签名没有入口。 1. \`package-harmony-hap.sh\` 新增 \`debug\` 通道:\`release|test|none\` 之外, \`debug\` 直接把签名目录指向 \`~/.local/share/harmony/signing/local-test\`, 用 \`debug.cer/.p7b/.p12\` 签,产物只能装到该 Profile 授权的设备上。 此前 \`sign-harmony-hap.py\` 支持 \`--prefix debug\` 但脚本没有入口,本地 材料(bundle-name 正是 \`openless.hm.com\`,有效期到 2027-10-12)用不上。 2. \`release-harmony.yml\` 支持 **通道化 secrets**:优先读 \`HARMONY_RELEASE_*\` / \`HARMONY_TEST_*\`,没有再回落到 \`HARMONY_CERT_BASE64\` 等旧名。这样正式版与测试渠道的材料可以同时存在, 不必每次 dispatch 前手动替换 secrets。 3. 密码不再经 \`$GITHUB_ENV\` 回传(YAML \`env\` 优先级高于 \`$GITHUB_ENV\`, 会被 job 级的同名 secret 盖掉),改为写进签名目录的 \`keystore-password\` / \`key-password\`(600 权限),\`sign-harmony-hap.py\` 增加对后者的读取。 4. 缺 secret 时错误信息会列出**该通道**缺的具体名字。 5. \`RELEASING.md\` 补一节「Where each piece comes from」:六步从注册实名认证到 API 密钥,附不带 DevEco 的 \`keytool\` 命令、base64 存 secret 的写法,并写清 自动到「替换候选包」为止、上架资料与提交审核必须人工。 --- .github/workflows/release-harmony.yml | 55 ++++++++++++++++++- RELEASING.md | 51 +++++++++++++++++ openless-all/app/harmony/AppScope/app.json5 | 4 +- openless-all/app/linux-egui/package-revision | 2 +- .../app/scripts/package-harmony-hap.sh | 14 +++-- openless-all/app/scripts/sign-harmony-hap.py | 7 ++- 6 files changed, 122 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index aff823d12..4d4b828ea 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -69,6 +69,24 @@ jobs: RELEASE_TAG: ${{ inputs.tag }} # Signing material: files are base64 secrets, passwords are plain secrets # and never touch the workspace. + # + # 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: ${{ secrets.HARMONY_KEYSTORE_PASSWORD }} HARMONY_KEY_PASSWORD: ${{ secrets.HARMONY_KEY_PASSWORD }} HARMONY_KEY_ALIAS: ${{ vars.HARMONY_KEY_ALIAS }} @@ -126,22 +144,53 @@ 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. + resolve() { + local base=$1 specific + specific="${base}_${upper}" + 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: Build, sign and stage the HAP diff --git a/RELEASING.md b/RELEASING.md index d6849980f..beb6fe6a2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -167,6 +167,57 @@ 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. + +### 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 | diff --git a/openless-all/app/harmony/AppScope/app.json5 b/openless-all/app/harmony/AppScope/app.json5 index ae396b3d6..a8332d4b5 100644 --- a/openless-all/app/harmony/AppScope/app.json5 +++ b/openless-all/app/harmony/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "openless.hm.com", "vendor": "OpenLess", - "versionCode": 1, - "versionName": "0.1.0", + "versionCode": 141, + "versionName": "2.0.2", "icon": "$media:openless_layered_icon", "label": "$string:app_name" } 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/scripts/package-harmony-hap.sh b/openless-all/app/scripts/package-harmony-hap.sh index 5977d365c..bdd0a6924 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}" diff --git a/openless-all/app/scripts/sign-harmony-hap.py b/openless-all/app/scripts/sign-harmony-hap.py index 7b056b1ec..de30aa0b2 100644 --- a/openless-all/app/scripts/sign-harmony-hap.py +++ b/openless-all/app/scripts/sign-harmony-hap.py @@ -48,7 +48,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()] From 7536a786cdef878645ae9a84fbf31d188cab14fe Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 00:27:18 +0800 Subject: [PATCH 09/22] =?UTF-8?q?feat(harmony):=20=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E6=9D=90=E6=96=99=E8=87=AA=E6=A3=80=E8=84=9A=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E6=8A=8A=E3=80=8C=E7=AD=BE=E4=B8=8D=E5=87=BA=E6=9D=A5=E3=80=8D?= =?UTF-8?q?=E7=9A=84=E5=9B=9B=E4=B8=AA=E5=9D=91=E6=8C=A1=E5=9C=A8=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E4=B9=8B=E5=89=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要上架应用市场,真正容易翻车的不是构建,是拿到华为材料之后才知道对不上: 1. `release.cer` 是 **PEM 证书链**(Huawei root CA + 叶子证书),拿第一张去比 公钥永远不相等——这正是本机 debug 材料一开始被判失败的原因; 2. 证书与私钥库里的别名不匹配(CSR 传错账号 / 重新申请过证书); 3. Profile 是签给**另一张证书**的,或 bundleName 不是 `openless.hm.com`; 4. Profile 过期,或调试 Profile 没有授权设备 UDID。 新增 `scripts/check-harmony-signing.sh`(+ 两个 python 助手),按叶子证书比对 公钥、比对 Profile 内嵌证书、比对 `harmony/AppScope/app.json5` 的 bundleName、 检查 Profile 与证书有效期、统计调试 UDID。只读,不打印任何密钥。 `release-harmony.yml` 在写材料之后、**构建之前**跑它(6 分钟构建前失败,而不是 构建完才发现装不上)。 本机验证(debug 材料,bundleName 正是 openless.hm.com): ok certificate matches the keystore key (…,Development) ok chain carries 2 issuing certificate(s) ok certificate valid until Oct 12 14:46:43 2027 GMT ok profile was issued for this certificate ok profile bundleName matches app.json5 (openless.hm.com) ok profile valid 2026-10-12 → 2027-10-12 ok 1 device UDID(s) authorised for this debug profile PASS --- .github/workflows/release-harmony.yml | 8 ++ RELEASING.md | 12 ++ openless-all/app/harmony/AppScope/app.json5 | 4 +- .../app/scripts/check-harmony-signing.sh | 118 +++++++++++++++++ .../app/scripts/harmony-profile-report.py | 88 +++++++++++++ .../scripts/harmony-release-contract.test.mjs | 13 ++ .../app/scripts/harmony-signing-report.py | 120 ++++++++++++++++++ 7 files changed, 361 insertions(+), 2 deletions(-) create mode 100755 openless-all/app/scripts/check-harmony-signing.sh create mode 100755 openless-all/app/scripts/harmony-profile-report.py create mode 100755 openless-all/app/scripts/harmony-signing-report.py diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index 4d4b828ea..7409badc7 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -193,6 +193,14 @@ jobs: chmod 600 "$dir/keystore-password" "$dir/key-password" 2>/dev/null || true ls -l "$dir" + - name: Validate the signing material before building + # 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: diff --git a/RELEASING.md b/RELEASING.md index beb6fe6a2..9339d6e48 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -195,6 +195,18 @@ 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 diff --git a/openless-all/app/harmony/AppScope/app.json5 b/openless-all/app/harmony/AppScope/app.json5 index a8332d4b5..ae396b3d6 100644 --- a/openless-all/app/harmony/AppScope/app.json5 +++ b/openless-all/app/harmony/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "openless.hm.com", "vendor": "OpenLess", - "versionCode": 141, - "versionName": "2.0.2", + "versionCode": 1, + "versionName": "0.1.0", "icon": "$media:openless_layered_icon", "label": "$string:app_name" } 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/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 fe056756f..8bc430274 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -111,3 +111,16 @@ console.log('harmony-release contract test passed'); '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', +); 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) From 29bcfe040223096bd94b78749e28ce1d769e45d9 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 00:32:03 +0800 Subject: [PATCH 10/22] =?UTF-8?q?feat(harmony):=20release=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=8F=AA=E5=8F=91=E6=9C=AA=E7=AD=BE=E5=90=8D=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=E7=AD=BE=E5=90=8D=E4=BB=85=E7=94=A8=E4=BA=8E=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=B8=82=E5=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 `Release HarmonyOS` 的 channel 只有 release 与 test,默认 release——意味着 **任何一次 release dispatch 都必须有签名材料**,哪怕只想把未签名 HAP 附到 GitHub Release 上也跑不通,会卡在 "missing signing secrets"。 分发策略改成三条路,默认走仓库那条: - `channel: none`(**新默认**):构建 → 附未签名包到 Release → 结束。 写材料、校验材料、签名的三步全部跳过,不需要任何 secret; - `channel: release`:AppGallery 正式版,用 `release.*` 材料签名并上传; - `channel: test`:AppGallery 测试渠道,用 `test.*` 材料。 细节: - 「Write the signing material」「Validate the signing material」都加了 `if: inputs.channel != 'none'`; - 校验步骤在 none 时只跑 `--allow-unsigned` 的未签名校验,并打印原因; - AppGallery 上传步骤要求 `channel != none`(未签名包没有可上传的东西); - Release attach 一直只传未签名的 `OpenLess_harmony_arm64.hap` / `.hap`, 签名包只留在 workflow artifact 里,仓库永不发布; - CI 侧本来就一直是 `OPENLESS_HARMONY_CHANNEL: none`(`ci.yml:417`,且不引用 任何签名脚本),这次在文档里写明。 契约测试同步:默认值断言改为 `default: none`,并新增「签名步骤在 none 下必须跳过」。 --- .github/workflows/release-harmony.yml | 22 ++++++++++++++----- RELEASING.md | 17 +++++++++----- .../scripts/harmony-release-contract.test.mjs | 10 ++++++++- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index 7409badc7..f43261f36 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: @@ -137,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. @@ -194,6 +200,7 @@ jobs: 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 @@ -228,8 +235,12 @@ 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 + bash scripts/verify-harmony-hap.sh \ + "$output/OpenLess_harmony_arm64-signed.hap" "$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 @@ -267,7 +278,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. diff --git a/RELEASING.md b/RELEASING.md index 9339d6e48..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 diff --git a/openless-all/app/scripts/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs index 8bc430274..ff789fe68 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -42,7 +42,15 @@ 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 \}\}"/); From e72771ef71a6ec2be48442f8f71d51ce3dc28649 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 00:43:43 +0800 Subject: [PATCH 11/22] =?UTF-8?q?fix(linux-egui):=20=E6=89=93=E5=8C=85=20j?= =?UTF-8?q?ob=20=E5=BF=85=E9=A1=BB=E8=87=AA=E5=B7=B1=E7=BC=96=20fcitx5=20?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=EF=BC=88=E6=88=91=E6=8B=86=E8=BF=87=E5=A4=B4?= =?UTF-8?q?=E4=BA=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮 `Package deb and rpm` 失败,日志里**一行输出都没有**,原因是我把 「Build and test fcitx5 plugin」整步挪进并行的 `test-linux-egui` 之后, 打包 job 里就没有 `scripts/linux-fcitx5-plugin/build/libopenless.so` 了。 而 `package-linux-egui.sh` 开头是一串裸的 test -s "$PLUGIN_ROOT/libopenless.so" `test` 不打印任何东西 → exit 1 → CI 只显示 "Process completed with exit code 1"。 两处修: 1. `build-linux-egui` 增加「Build the fcitx5 plugin for packaging」:只 cmake 编译,`ctest` 与那几条断言仍留在并行 job,重复成本约 20 秒; 2. `package-linux-egui.sh` 的裸 `test` 全部换成 `require_file` / `require_command`, 缺什么就说什么,并在缺失 fcitx5 产物或 release 二进制时直接给出构建命令。 契约测试同步:`build-linux-egui` 里必须出现 `cmake --build build --parallel`, `package-linux-egui.sh` 里必须用 `require_file` 检查插件产物。 本地复现验证:`OPENLESS_LINUX_VERSION=2.0.2-Beta.1-141 bash scripts/package-linux-egui.sh` → deb / rpm / SHA256SUMS 三件产出正常。 --- .github/workflows/check-linux-egui.yml | 10 +++++ .../linux-egui-release-contract.test.mjs | 17 ++++++++ .../app/scripts/package-linux-egui.sh | 43 +++++++++++++++---- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index f45a782c2..6a3ab313a 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -218,6 +218,16 @@ jobs: cache-on-failure: true save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + - 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 + - name: Validate Linux desktop metadata if: inputs.scope != 'none' run: | 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..3419bf5e1 100644 --- a/openless-all/app/scripts/linux-egui-release-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -153,3 +153,20 @@ assert.doesNotMatch( /cargo (?:build|check|test) --locked --manifest-path src-tauri\/Cargo\.toml/, ); console.log('linux-egui-release-contract.test.mjs passed'); + +// 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', + ); +} diff --git a/openless-all/app/scripts/package-linux-egui.sh b/openless-all/app/scripts/package-linux-egui.sh index 3b700c8c2..fa6f383d9 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" From b2be02f565a6ce844f9d5fc37c2266bf67859b3d Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 00:54:39 +0800 Subject: [PATCH 12/22] =?UTF-8?q?fix(ci):=20Windows=20=E7=9A=84=20Rust=20j?= =?UTF-8?q?ob=20=E4=BB=8D=E8=A6=81=E5=85=88=E6=9E=84=E5=BB=BA=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=EF=BC=88generate=5Fcontext!=20=E8=A6=81=20dist?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Windows checks` 挂在 `Compile Rust backend unit tests`: error: proc macro panicked --> src\lib.rs:1028:16 | .build(tauri::generate_context!()) = help: message: The `frontendDist` configuration is set to "../dist" but this path doesn't exist 我把 `npm test`(`pretest` 先 `npm run build`)限制到 macOS 之后,Windows 的 Rust 编译就失去了 `dist/`——而 `tauri::generate_context!()` 是在**编译期**把 `../dist` 嵌进去的。 补一步 `if: runner.os == 'Windows'` 的 `npm run build`。这样 Windows 侧前端 会被构建两次(这里一次、并行的 `windows-frontend` 一次),但两次并行发生, 墙钟仍比把契约套件重新塞回这个 job 短。 契约测试新增:Windows 下必须先出现 `run: npm run build` 再编 Tauri lib。 --- .github/workflows/ci.yml | 9 ++++++++- openless-all/app/scripts/ci-changed-areas.test.mjs | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2115e315d..43fdd41ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -582,10 +582,17 @@ 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. + # is already covered here. The frontend build it needs is the step above. if: runner.os == 'macOS' run: npm test diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 516c4cb39..7dc8d4282 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -250,3 +250,12 @@ ci-changed-areas contract test failed (${failures})`); process.exit(1); } console.log('ci-changed-areas contract test passed'); + +// `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, +); From 9c8a4a97a91379756cddf4d3ac0a275ecab1f796 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 01:21:00 +0800 Subject: [PATCH 13/22] =?UTF-8?q?fix(release):=20=E9=80=9A=E9=81=93?= =?UTF-8?q?=E5=8C=96=20secret=20=E5=90=8D=E7=A7=B0=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E9=94=99=E4=BD=8D=EF=BC=8Crelease=20=E9=80=9A=E9=81=93?= =?UTF-8?q?=E6=B0=B8=E8=BF=9C=E8=AF=BB=E4=B8=8D=E5=88=B0=E6=9D=90=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve()` 把后缀拼在末尾(`HARMONY_CERT_BASE64_RELEASE`),而 secret 实际命名 是通道在前(`HARMONY_RELEASE_CERT_BASE64`)。于是 dispatch channel=release 时永远 回落到通道无关的名字,报: missing signing secrets for channel 'release': HARMONY_CERT_BASE64 … 修正为 `HARMONY_${upper}_${base#HARMONY_}`,并加两条契约断言(解析形式 + job env 里 RELEASE 组 secret 的接线)。 --- .github/workflows/release-harmony.yml | 5 ++++- .../app/scripts/harmony-release-contract.test.mjs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index f43261f36..001e39324 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -158,9 +158,12 @@ jobs: # 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="${base}_${upper}" + specific="HARMONY_${upper}_${base#HARMONY_}" if [ -n "${!specific:-}" ]; then printf '%s' "$specific"; return 0; fi printf '%s' "$base" } diff --git a/openless-all/app/scripts/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs index ff789fe68..a238f73f2 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -132,3 +132,16 @@ 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', +); From b52c31698490588a69bf77cebc9f92f74b7d5c5a Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 23:29:47 +0800 Subject: [PATCH 14/22] =?UTF-8?q?feat(harmony):=20=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=B8=82=E5=9C=BA=E8=A6=81=E7=9A=84=E6=98=AF=20.app=EF=BC=88Ap?= =?UTF-8?q?p=20Pack=EF=BC=89=EF=BC=8C=E4=B8=8D=E6=98=AF=20.hap=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=B7=B2=E7=9C=9F=E6=9C=BA=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=88=90=E5=8A=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 AGC 的 Connect API 客户端传签名 HAP 时被拒: 204144641 [AppGalleryConnectPublishService]The suffix name of the software package is incorrect 华为应用市场只收 **App Pack(`.app`)**。原流程只产 `.hap`,所以「上传到应用市场」 这一步从来不可能成功,手动在网页上传也一样会被拒。 改动: - `build-harmony-frontend.sh` 在商店通道额外跑 `assembleApp`(**必须是 `--mode project`**,module 模式下会报 "Task [ 'assembleApp' ] was not found"); - hvigor 的 `signingConfigs` 用不了:它要求 `storePassword`/`keyPassword` 是 IDE 加密后的密文(≥32 字符),CI 无法提供。改为**先产无签名 `.app`,再用 `hap-sign-tool sign-app` 签**(实测它同时支持 `.hap` 与 `.app`); - `sign-harmony-hap.py` 抽出 `sign_package()`,新增 `--skip-hap`,一次构建可以 同时签 HAP 与 App Pack; - `verify-harmony-hap.sh` 支持 `.app`:解包校验内层 HAP 的内容与版本号,签名则 校验外层 pack(HAP 本身不再单独签名); - workflow 的校验与 AppGallery 上传都改成 `.app`。 本地实测(release 材料,2.0.2 / 141): OpenLess_harmony_arm64-signed.app 24,653,341 B verify-app success App Pack: …signed.app (inner entry-default.hap) signature verified 并且真的传到了 AGC(app 6917618883555881703): `uploaded OpenLess_harmony_arm64-signed.app … submit it for review` --- .github/workflows/release-harmony.yml | 6 +- .../app/scripts/build-harmony-frontend.sh | 11 ++ .../app/scripts/package-harmony-hap.sh | 22 ++++ openless-all/app/scripts/sign-harmony-hap.py | 64 +++++++---- .../app/scripts/upload-appgallery.mjs | 102 +++++++++++++++--- .../app/scripts/verify-harmony-hap.sh | 25 ++++- 6 files changed, 184 insertions(+), 46 deletions(-) diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index 001e39324..885eb8ae1 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -239,8 +239,10 @@ jobs: bash scripts/verify-harmony-hap.sh "$output/OpenLess_harmony_arm64.hap" \ "$toolchain" "$version_name" "$version_code" --allow-unsigned 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.hap" "$toolchain" + "$output/OpenLess_harmony_arm64-signed.app" "$toolchain" else echo "channel=none: unsigned package only, no signature to verify" fi @@ -291,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/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/package-harmony-hap.sh b/openless-all/app/scripts/package-harmony-hap.sh index bdd0a6924..d850b66ff 100755 --- a/openless-all/app/scripts/package-harmony-hap.sh +++ b/openless-all/app/scripts/package-harmony-hap.sh @@ -101,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" @@ -112,8 +114,23 @@ SIGNED="" 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 @@ -124,6 +141,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/sign-harmony-hap.py b/openless-all/app/scripts/sign-harmony-hap.py index de30aa0b2..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: @@ -64,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 From f20848b36bd48b61a2c4df54aecb37b0f9334934 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 23:44:00 +0800 Subject: [PATCH 15/22] =?UTF-8?q?fix(harmony):=20channel=3Dnone=20?= =?UTF-8?q?=E6=97=B6=20APP=5FPACK=20=E6=9C=AA=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=EF=BC=8Cset=20-u=20=E7=9B=B4=E6=8E=A5=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 我在把 App Pack 逻辑挪到签名之后时删掉了 `APP_PACK=""`,而 `none` 通道不进入 签名分支,后面的 `[ -n "$APP_PACK" ]` 在 `set -u` 下报 unbound variable: scripts/package-harmony-hap.sh: line 144: APP_PACK: unbound variable CI 的 Harmony job 就是挂在这里。补回初始化。 --- openless-all/app/scripts/package-harmony-hap.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/openless-all/app/scripts/package-harmony-hap.sh b/openless-all/app/scripts/package-harmony-hap.sh index d850b66ff..c8674c214 100755 --- a/openless-all/app/scripts/package-harmony-hap.sh +++ b/openless-all/app/scripts/package-harmony-hap.sh @@ -111,6 +111,7 @@ 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" From e377f55c526a0adbb1ea3245413686cb0da39777 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Wed, 21 Oct 2026 23:52:11 +0800 Subject: [PATCH 16/22] =?UTF-8?q?ci:=20=E5=8E=9F=E7=94=9F=E8=83=B6?= =?UTF-8?q?=E5=9B=8A=E5=B9=B6=E8=BF=9B=20ci.yml=EF=BC=88=E5=B9=B6=E5=85=A5?= =?UTF-8?q?=20#1239=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8D=95=E5=BC=80=20PR?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 而不是再加一个 workflow:`native-capsule` 之前在独立的 `native-capsule.yml` 里, 只在 `pull_request` 上触发,`rust-cache` 既没有 `save-if` 也没有 `shared-key`。 - 每次 PR 都往 `refs/pull//merge` 写一份 macOS + Windows 依赖树,这个作用域 此后再也不会被恢复,只占配额、按 LRU 挤掉 beta 的缓存; - beta 上没有任何 job 产出同 key 的树(workflow 不跑 push),所以 PR 侧也永远 命中不了,每次都是冷编译。 并入 `ci.yml` 后:同一个 `Changed areas` 门禁(`needs.changes.outputs.tauri`), 一次 PR 一条状态;缓存用 `shared-key: native-capsule` + 只在 beta 写;顺带补 `components: clippy`、`permissions`、`timeout-minutes`、并发组。删除独立 workflow。 契约测试:任何 workflow 不得出现 `uses: actions/cache@v4`(自动保存), `ci.yml` 里必须有带 shared-key/save-if 的 `native-capsule` job,且独立文件不得复活。 --- .github/workflows/ci.yml | 41 +++++++++++++++++++ .github/workflows/native-capsule.yml | 24 ----------- docs/ci-trigger-and-cache-policy.md | 1 + .../app/scripts/ci-cache-sharing.test.mjs | 34 ++++++++++++++- 4 files changed, 75 insertions(+), 25 deletions(-) delete mode 100644 .github/workflows/native-capsule.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 421626e37..ddc5ea372 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -489,6 +489,47 @@ jobs: # 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: [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] # 失败即运行:只有判定脚本明确说“够不到”才跳过。 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/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index 01e0b6d34..f27e47478 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -83,6 +83,7 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job - `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. 维护 diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index 170fb7223..1219ac469 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'; @@ -96,3 +96,35 @@ assert.match(linuxRelease, /release_tag: \$\{\{ inputs\.tag \}\}/); assert.match(linuxRelease, /checkout_ref: \$\{\{ inputs\.tag \}\}/); console.log('ci-cache-sharing contract test passed'); + +// `actions/cache@v4` saves on every run it executes, including pull request +// scopes (`refs/pull//merge`) that are never restored again. 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 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', + ); +} From c9a86a1d7324c1b177ec9043b04e19bbc1790e8b Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 22 Oct 2026 10:50:33 +0800 Subject: [PATCH 17/22] fix(ci): restore release gates and cache correctness --- .github/workflows/check-linux-egui.yml | 81 ++-- .github/workflows/ci.yml | 152 +++++--- .github/workflows/release-harmony.yml | 4 +- .../app/scripts/ci-cache-sharing.test.mjs | 65 ++-- .../app/scripts/ci-changed-areas.test.mjs | 98 ++++- .../app/scripts/ci-workflow-behavior.test.mjs | 358 ++++++++++++++++++ .../scripts/harmony-release-contract.test.mjs | 70 +++- .../linux-egui-release-contract.test.mjs | 22 +- .../release-publication-contract.test.mjs | 52 +++ 9 files changed, 771 insertions(+), 131 deletions(-) create mode 100644 openless-all/app/scripts/ci-workflow-behavior.test.mjs diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index 6a3ab313a..b7ef94538 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -43,7 +43,7 @@ concurrency: jobs: test-linux-egui: - # Same runner and cache as the packaging job, run in parallel: the tests used + # 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 @@ -84,11 +84,8 @@ jobs: - uses: swatinem/rust-cache@v2 if: inputs.scope != 'none' with: - # `shared-key` replaces the job name in the cache key, so this job and - # the packaging job (and the tag release, which calls the same workflow - # as `build-linux-egui`) all share one tree. Without it the key would be - # `…-test-linux-egui-…` and every test run would start cold. - shared-key: build-linux-egui + # 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 @@ -109,13 +106,17 @@ jobs: 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 \ - shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics -- --exact + 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 @@ -207,8 +208,7 @@ jobs: - uses: swatinem/rust-cache@v2 if: inputs.scope != 'none' with: - # Identical to the job name, so the key is unchanged; declaring it keeps - # the parallel test job on the same tree (see `test-linux-egui`). + # Keep the release cache shared with tag builds, separate from test profiles. shared-key: build-linux-egui workspaces: | openless-all/app -> target @@ -289,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 @@ -311,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 ddc5ea372..11245c51f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -245,6 +245,11 @@ jobs: 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. @@ -254,11 +259,6 @@ jobs: path: ${{ runner.temp }}/openless-cache/mobile-rime key: ${{ steps.rime_cache.outputs.cache-primary-key }} - - 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: Merge Shizuku manifest run: node scripts/merge-android-shizuku-manifest.mjs @@ -283,71 +283,111 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Restore the Android emulator and AVD - # ~400 MB of SDK packages plus the created AVD. Restoring them shortens - # the job and, more importantly, keeps the flaky download out of the - # critical path. The build is pinned, so the key is stable: once one run - # has saved the tree, later runs restore it and skip the save. + - 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/avd - key: android-emulator-37.2.12-${{ runner.os }}-${{ runner.arch }} + ${{ 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() { "$1" -version 2>&1 | grep -Fq 'build_id 16555280'; } + validate_image() { [ -s "$image/system.img" ] && [ -s "$image/package.xml" ]; } + 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" \ + && sdkmanager 'system-images;android-35;google_apis;x86_64' \ + && validate_image; then + rm -rf "$ANDROID_HOME/emulator" + mv "$work/emulator" "$ANDROID_HOME/emulator" + break + 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; } + - 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/avd - key: android-emulator-37.2.12-${{ runner.os }}-${{ runner.arch }} - - - name: Install the Android emulator with retries - # `sdkmanager --install emulator` regularly returns a truncated archive on - # this image ("Error on ZipFile unknown archive"), which used to fail the - # whole job and force a rerun - that is why Android was the last job to - # finish even though it is one of the shortest. Pin the build and retry. + ${{ 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 shell: bash run: | - set -uo pipefail - installed="$ANDROID_HOME/emulator/package.xml" - if [ -f "$installed" ] && grep -q '37' "$installed"; then - echo "emulator 37.x already present" - exit 0 - fi - for attempt in 1 2 3; do - echo "sdkmanager --install emulator (attempt $attempt)" - if sdkmanager "emulator" "system-images;android-35;google_apis;x86_64"; then - exit 0 + set -euo pipefail + 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 -i '/^hw.keyboard[ =]/d' "$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 - rm -rf "$ANDROID_HOME/emulator" - sleep 15 + 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 - echo "::error::could not install the Android emulator after 3 attempts" - exit 1 + # 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: Run Android Keystore instrumentation tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 35 - target: google_apis - arch: x86_64 - # Pinned instead of "latest": the resolved build is the one cached - # above, so the action does not re-download 400 MB per run. - emulator-build: 16555280 - # 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 + - 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 @@ -448,7 +488,7 @@ jobs: # 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: needs.changes.outputs.tauri != 'false' + 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 @@ -503,7 +543,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest] + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '["macos-latest"]' || '["macos-latest","windows-latest"]') }} runs-on: ${{ matrix.os }} timeout-minutes: 25 defaults: diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index 885eb8ae1..053d64ac9 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -92,8 +92,8 @@ jobs: HARMONY_CERT_BASE64: ${{ secrets.HARMONY_CERT_BASE64 }} HARMONY_PROFILE_BASE64: ${{ secrets.HARMONY_PROFILE_BASE64 }} HARMONY_KEYSTORE_BASE64: ${{ secrets.HARMONY_KEYSTORE_BASE64 }} - HARMONY_KEYSTORE_PASSWORD: ${{ secrets.HARMONY_KEYSTORE_PASSWORD }} - HARMONY_KEY_PASSWORD: ${{ secrets.HARMONY_KEY_PASSWORD }} + 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 }} diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index 1219ac469..bd9d40766 100644 --- a/openless-all/app/scripts/ci-cache-sharing.test.mjs +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -67,10 +67,7 @@ for (const key of ['tauri-windows', 'tauri-macos']) { 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 @@ -78,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 @@ -95,25 +94,6 @@ 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 \}\}/); -console.log('ci-cache-sharing contract test passed'); - -// `actions/cache@v4` saves on every run it executes, including pull request -// scopes (`refs/pull//merge`) that are never restored again. 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 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 @@ -128,3 +108,40 @@ for (const [yaml, label] of [ '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 7dc8d4282..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(); @@ -197,7 +201,9 @@ check( ); check( 'windows codegen pass covers lib and bins', - workflow.includes('cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --bins --no-run'), + workflow.includes( + 'cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --bins --no-run', + ), true, ); check( @@ -226,8 +232,7 @@ check( true, ); -if (failures > 0) { - console.error(`// Wall-clock guards: the Windows runner is the slowest one, so the frontend +// 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( @@ -246,11 +251,6 @@ check( true, ); -ci-changed-areas contract test failed (${failures})`); - process.exit(1); -} -console.log('ci-changed-areas contract test passed'); - // `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. @@ -259,3 +259,83 @@ check( /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); +} +console.log('ci-changed-areas contract test passed'); 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..dd93fa882 --- /dev/null +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -0,0 +1,358 @@ +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 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 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})" + exit 0 +fi +echo "$$" > "$RUNNER_TEMP/emulator.pid" +echo 'emulator started' +trap 'echo stopped >> "$RUNNER_TEMP/trace"; exit 0' TERM +while :; do /usr/bin/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" + fi + ;; + sdkmanager) + 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 + ;; + sleep) ;; + avdmanager) + while [ "$1" != '--path' ]; do shift; done + mkdir -p "$2"; echo hw.keyboard=yes > "$2/config.ini" + ;; + timeout) + if [ "$1" = 10s ]; then shift; exec "$@"; fi + [ "$1" = 600s ] || exit 99 + until [ -s "$RUNNER_TEMP/emulator.pid" ]; do /usr/bin/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', + '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), + RUNNER_TEMP: shellPath(dir), + ANDROID_HOME: shellPath(sdk), + 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 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); + 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], + ['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); + } + for (const [name, env, removeImage, expected] of [ + ['warm', {}, false, 0], + ['wrong-build', { FAKE_EMULATOR_BUILD: 'wrong' }, 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(); + 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 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 python = process.platform === 'win32' ? 'python' : 'python3'; + 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/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs index a238f73f2..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'); @@ -55,13 +56,19 @@ 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 [ @@ -77,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'))); @@ -101,8 +112,6 @@ assert.match(workflow, /upload-appgallery\.mjs/); assert.match(workflow, /gh release upload/); assert.match(workflow, /OpenLess_harmony_arm64\.hap/); -console.log('harmony-release contract test passed'); - // `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`. @@ -145,3 +154,54 @@ assert.match( /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/linux-egui-release-contract.test.mjs b/openless-all/app/scripts/linux-egui-release-contract.test.mjs index 3419bf5e1..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,7 +158,6 @@ assert.doesNotMatch( check, /cargo (?:build|check|test) --locked --manifest-path src-tauri\/Cargo\.toml/, ); -console.log('linux-egui-release-contract.test.mjs passed'); // 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 @@ -170,3 +175,14 @@ console.log('linux-egui-release-contract.test.mjs passed'); '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/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'); From 60f2be483d050c68ff75f8f9c35a97adfeb07f75 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 22 Oct 2026 11:20:13 +0800 Subject: [PATCH 18/22] fix(ci): drain emulator version output and support BSD sed --- .github/workflows/ci.yml | 13 +++++++++++-- .../app/scripts/ci-workflow-behavior.test.mjs | 13 ++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11245c51f..07b2e55df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -299,7 +299,15 @@ jobs: run: | set -euo pipefail image="$ANDROID_HOME/system-images/android-35/google_apis/x86_64" - validate_emulator() { "$1" -version 2>&1 | grep -Fq 'build_id 16555280'; } + 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" ]; } if [ "$EMULATOR_CACHE_HIT" != 'true' ]; then work=$(mktemp -d "$RUNNER_TEMP/openless-emulator.XXXXXX") @@ -345,7 +353,8 @@ jobs: 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 -i '/^hw.keyboard[ =]/d' "$avd/config.ini" + 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='' diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs index dd93fa882..bcdae2f89 100644 --- a/openless-all/app/scripts/ci-workflow-behavior.test.mjs +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -59,7 +59,11 @@ const executable = (path, source) => { const emulatorSource = ` if [ "\${1:-}" = '-version' ]; then echo "Android emulator version 37.3.4.0 (build_id \${FAKE_EMULATOR_BUILD:-16555280})" - exit 0 + if [ "\${FAKE_VERSION_DETAILS:-0}" = 1 ]; then + /usr/bin/sleep 0.02 + printf '%8192s\n' 'emulator license details' + fi + exit "\${FAKE_VERSION_EXIT:-0}" fi echo "$$" > "$RUNNER_TEMP/emulator.pid" echo 'emulator started' @@ -95,6 +99,10 @@ case "$name" in 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 /usr/bin/sed "$@" + ;; sleep) ;; avdmanager) while [ "$1" != '--path' ]; do shift; done @@ -133,6 +141,7 @@ function fixture(name) { 'unzip', 'sdkmanager', 'sleep', + 'sed', 'avdmanager', 'timeout', 'adb', @@ -188,6 +197,7 @@ function fixture(name) { 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], @@ -204,6 +214,7 @@ try { 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); From 68ef056ba4234a809bd9fb84983996966684c920 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 22 Oct 2026 11:36:31 +0800 Subject: [PATCH 19/22] fix(ci): install emulator runtime and resolve fixture tool paths --- .github/workflows/ci.yml | 2 +- .../app/scripts/ci-workflow-behavior.test.mjs | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07b2e55df..c72ca6ae0 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 diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs index bcdae2f89..10634861d 100644 --- a/openless-all/app/scripts/ci-workflow-behavior.test.mjs +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -21,6 +21,16 @@ 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; @@ -60,7 +70,7 @@ 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 - /usr/bin/sleep 0.02 + "$TEST_SLEEP" 0.02 printf '%8192s\n' 'emulator license details' fi exit "\${FAKE_VERSION_EXIT:-0}" @@ -68,7 +78,7 @@ fi echo "$$" > "$RUNNER_TEMP/emulator.pid" echo 'emulator started' trap 'echo stopped >> "$RUNNER_TEMP/trace"; exit 0' TERM -while :; do /usr/bin/sleep 0.02; done +while :; do "$TEST_SLEEP" 0.02; done `; const stubs = ` name=$(basename "$0") @@ -101,7 +111,7 @@ case "$name" in ;; sed) if [ "$1" = '-i' ]; then echo 'BSD sed requires a backup suffix for -i' >&2; exit 1; fi - exec /usr/bin/sed "$@" + exec "$TEST_SED" "$@" ;; sleep) ;; avdmanager) @@ -111,7 +121,7 @@ case "$name" in timeout) if [ "$1" = 10s ]; then shift; exec "$@"; fi [ "$1" = 600s ] || exit 99 - until [ -s "$RUNNER_TEMP/emulator.pid" ]; do /usr/bin/sleep 0.01; done + until [ -s "$RUNNER_TEMP/emulator.pid" ]; do "$TEST_SLEEP" 0.01; done if [ "\${FAKE_BOOT_TIMEOUT:-0}" = 1 ]; then exit 124; fi shift; exec "$@" ;; @@ -168,6 +178,7 @@ function fixture(name) { }; const run = (script, extra = {}) => { const guarded = + 'export TEST_SLEEP="$(command -v sleep)" TEST_SED="$(command -v sed)"\n' + 'export PATH="$TEST_BIN:$PATH"\n[ "$(command -v curl)" = "$TEST_BIN/curl" ] || exit 99\n' + script; const result = spawnSync('bash', ['-c', guarded], { From 2ae806ccda68ff90b07057e74816c59162cb9916 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 22 Oct 2026 11:59:21 +0800 Subject: [PATCH 20/22] fix(ci): register pinned emulator as an installed SDK package --- .github/workflows/ci.yml | 20 +++++++++++++++++++ .../app/scripts/ci-workflow-behavior.test.mjs | 16 +++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c72ca6ae0..a36e98749 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -336,6 +336,26 @@ jobs: 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; } + # Raw emulator archives need local-package metadata for avdmanager. + 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 - 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') diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs index 10634861d..dc67aaad0 100644 --- a/openless-all/app/scripts/ci-workflow-behavior.test.mjs +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -17,6 +17,7 @@ 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'); @@ -99,6 +100,7 @@ case "$name" in else mkdir -p "$4/emulator" cp "$FAKE_EMULATOR_SOURCE" "$4/emulator/emulator" + echo Pkg.Revision=37.3.4 > "$4/emulator/source.properties" fi ;; sdkmanager) @@ -113,8 +115,10 @@ case "$name" in 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) + [ -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" ;; @@ -151,6 +155,7 @@ function fixture(name) { 'unzip', 'sdkmanager', 'sleep', + 'python3', 'sed', 'avdmanager', 'timeout', @@ -169,6 +174,7 @@ function fixture(name) { const env = { ...process.env, TEST_BIN: shellPath(bin), + TEST_PYTHON_COMMAND: python, RUNNER_TEMP: shellPath(dir), ANDROID_HOME: shellPath(sdk), FAKE_EMULATOR_SOURCE: shellPath(join(dir, 'emulator-source')), @@ -178,7 +184,7 @@ function fixture(name) { }; const run = (script, extra = {}) => { const guarded = - 'export TEST_SLEEP="$(command -v sleep)" TEST_SED="$(command -v sed)"\n' + + '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], { @@ -198,6 +204,7 @@ function fixture(name) { .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'); @@ -221,6 +228,11 @@ try { 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], @@ -244,6 +256,7 @@ try { ]) { 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); @@ -325,7 +338,6 @@ try { mkdirSync(dirname(jar), { recursive: true }); writeFileSync(jar, 'fake'); const script = fileURLToPath(new URL('./sign-harmony-hap.py', import.meta.url)); - const python = process.platform === 'win32' ? 'python' : 'python3'; const source = ` import contextlib, io, json, os, runpy, sys from types import SimpleNamespace From a912752c80a15a0d724030d8f33b6463d7940ff5 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 22 Oct 2026 12:16:32 +0800 Subject: [PATCH 21/22] fix(ci): share AVD registry across SDK tools and emulator --- .github/workflows/ci.yml | 3 +++ openless-all/app/scripts/ci-workflow-behavior.test.mjs | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a36e98749..1a53e4cdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -370,6 +370,9 @@ jobs: 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" diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs index dc67aaad0..2040bb6e6 100644 --- a/openless-all/app/scripts/ci-workflow-behavior.test.mjs +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -76,6 +76,7 @@ if [ "\${1:-}" = '-version' ]; then 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 @@ -118,9 +119,11 @@ case "$name" in 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 @@ -177,6 +180,7 @@ function fixture(name) { 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', @@ -262,6 +266,8 @@ try { 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/); From d6d9cbd32c7e580fa7552bf62b958d97cf4fe2ec Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 22 Oct 2026 12:21:06 +0800 Subject: [PATCH 22/22] fix(ci): register pinned emulator before image dependencies --- .github/workflows/ci.yml | 53 ++++++++++--------- .../app/scripts/ci-workflow-behavior.test.mjs | 1 + 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a53e4cdf..2821c2b6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -309,6 +309,28 @@ jobs: 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 @@ -318,12 +340,14 @@ jobs: -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" \ - && sdkmanager 'system-images;android-35;google_apis;x86_64' \ - && validate_image; then + && validate_emulator "$work/emulator/emulator"; then rm -rf "$ANDROID_HOME/emulator" mv "$work/emulator" "$ANDROID_HOME/emulator" - break + 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 @@ -336,26 +360,7 @@ jobs: 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; } - # Raw emulator archives need local-package metadata for avdmanager. - 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 + 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') diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs index 2040bb6e6..f9c1b8959 100644 --- a/openless-all/app/scripts/ci-workflow-behavior.test.mjs +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -105,6 +105,7 @@ case "$name" in 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