From b45711a7753fe78777e2fe6d2f09de75e8f4ec07 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 10 Nov 2026 20:46:41 +0800 Subject: [PATCH 1/7] ci: consolidate checks, fix maintenance and retire Linux automation --- .github/actionlint.yaml | 4 + .github/workflows/cache-maintenance.yml | 11 +- .github/workflows/check-ios.yml | 120 ------ .github/workflows/check-linux-egui.yml | 348 ------------------ .github/workflows/ci.yml | 264 +++++++++---- .github/workflows/release-linux-egui.yml | 36 -- .github/workflows/release-tauri.yml | 27 +- .github/workflows/sync-micyou.yml | 1 + RELEASING.md | 38 +- docs/ci-runner-balance.md | 74 ++++ docs/ci-trigger-and-cache-policy.md | 36 +- .../app/scripts/ci-artifact-reuse.test.mjs | 94 +++++ .../app/scripts/ci-cache-sharing.test.mjs | 20 +- openless-all/app/scripts/ci-cache-usage.sh | 53 ++- .../app/scripts/ci-cache-usage.test.mjs | 115 +++++- .../app/scripts/ci-changed-areas.test.mjs | 45 +-- .../app/scripts/ci-consolidation.test.mjs | 169 +++++++++ .../app/scripts/ci-performance.test.mjs | 9 +- .../app/scripts/ci-workflow-behavior.test.mjs | 67 +--- .../scripts/harmony-release-contract.test.mjs | 6 +- .../app/scripts/ios-ci-downloads.test.mjs | 107 ++++++ .../linux-egui-release-contract.test.mjs | 154 ++------ .../linux-egui-tauri-free-contract.test.mjs | 1 - openless-all/app/scripts/micyou-dsp.patch | 119 ++++-- openless-all/app/scripts/micyou-upstream.json | 6 +- .../release-publication-contract.test.mjs | 132 ++----- openless-all/app/scripts/sync-micyou.py | 9 +- .../scripts/test-windows-runtime-target.py | 9 +- .../app/scripts/test_ci_harness_cache.py | 74 ++++ openless-all/app/scripts/test_sync_micyou.py | 22 ++ .../workflow-concurrency-contract.test.mjs | 4 +- .../ios/scripts/bootstrap-local-speech.sh | 3 +- openless-all/ios/scripts/bootstrap.sh | 7 +- openless-all/ios/scripts/build-core.sh | 2 +- 34 files changed, 1138 insertions(+), 1048 deletions(-) create mode 100644 .github/actionlint.yaml delete mode 100644 .github/workflows/check-ios.yml delete mode 100644 .github/workflows/check-linux-egui.yml delete mode 100644 .github/workflows/release-linux-egui.yml create mode 100644 docs/ci-runner-balance.md create mode 100644 openless-all/app/scripts/ci-artifact-reuse.test.mjs create mode 100644 openless-all/app/scripts/ci-consolidation.test.mjs create mode 100644 openless-all/app/scripts/ios-ci-downloads.test.mjs create mode 100644 openless-all/app/scripts/test_ci_harness_cache.py diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 000000000..5479d7274 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,4 @@ +# Repository-specific runner label used by the iOS checks. +self-hosted-runner: + labels: + - xcode-27 diff --git a/.github/workflows/cache-maintenance.yml b/.github/workflows/cache-maintenance.yml index c26d72164..29d98ad9a 100644 --- a/.github/workflows/cache-maintenance.yml +++ b/.github/workflows/cache-maintenance.yml @@ -7,7 +7,8 @@ name: Cache maintenance # never be restored again: pull requests that are already closed or merged. # # It runs daily, when a pull request closes, and on demand. It retains the -# newest branch generations and never prunes tag scopes. +# newest branch generations first, then evicts least-recently-used branch caches +# if the baseline alone exceeds budget. Tag and open PR scopes stay protected. on: schedule: # Daily cleanup prevents superseded build generations evicting baseline caches. @@ -20,6 +21,7 @@ on: permissions: actions: write # required to delete caches contents: read + pull-requests: read # distinguish closed PR scopes from open/unreadable ones concurrency: group: cache-maintenance @@ -34,8 +36,11 @@ jobs: - uses: actions/checkout@v4 with: submodules: false + # A closed PR must not supply scripts executed with actions: write. + ref: ${{ github.event_name == 'pull_request' && github.event.repository.default_branch || github.sha }} + persist-credentials: false - - name: Report cache usage and prune dead pull request scopes + - name: Report cache usage and enforce the cache budget env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} @@ -46,4 +51,4 @@ jobs: # because `restore-keys` never needs more than the previous generation. run: >- bash scripts/ci-cache-usage.sh - --prune --keep-family 2 --repo "$REPO" --limit-gb "$LIMIT_GB" + --prune --keep-family 2 --prune-budget --repo "$REPO" --limit-gb "$LIMIT_GB" diff --git a/.github/workflows/check-ios.yml b/.github/workflows/check-ios.yml deleted file mode 100644 index 50e065ac9..000000000 --- a/.github/workflows/check-ios.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: iOS tests and device build - -on: - pull_request: - paths: - - 'openless-all/ios/**' - - 'openless-all/app/crates/openless-core/**' - - 'openless-all/app/crates/micyou-*/**' - - 'openless-all/app/contract/**' - - 'openless-all/app/assets/**' - - '.github/workflows/check-ios.yml' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ios-checks-${{ github.ref }} - cancel-in-progress: true - -env: - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 - -jobs: - changes: - # `test` builds and lints the shared Core for iOS, so it must run whenever the - # Rust graph moves. `device-build` (13 minutes on a 10x-cost macOS runner) - # only proves the Xcode/Rime project layer still builds — shared-crate edits - # cannot reach it, so it is gated on the project layer alone. - name: Changed areas - runs-on: ubuntu-latest - timeout-minutes: 10 - outputs: - # Dashes are not valid inside an output reference, so the area name and the - # output key differ here on purpose. - device: ${{ steps.detect.outputs.device }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Detect iOS project changes - id: detect - working-directory: openless-all/app - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - set -euo pipefail - verdict=$(bash scripts/ci-changed-areas.sh ios-device "$BASE_SHA") - # Fail open: only an explicit `false` skips the device build. - decision=$( [ "$verdict" = 'false' ] && echo false || echo true ) - echo "ios-device=$decision" - echo "device=$decision" >> "$GITHUB_OUTPUT" - - test: - runs-on: xcode-27 - timeout-minutes: 30 - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.99.0 - with: - components: clippy - - name: Record the unchanged shared source baseline - run: | - git diff --exit-code HEAD -- openless-all/app - git rev-parse HEAD:openless-all/app - - name: Prepare the isolated mobile Core - run: python3 openless-all/ios/scripts/prepare-core.py - - name: Test shared keyboard contracts - run: swift test --package-path openless-all/ios/Packages/OpenLessShared - - name: Test mobile Core and iOS Host - run: cargo test --manifest-path openless-all/ios/CoreHost/Cargo.toml --workspace --lib --locked - - name: Check mobile Core - run: cargo clippy --manifest-path openless-all/ios/CoreHost/Cargo.toml -p openless-core --all-targets --locked -- -D warnings - - name: Test interface resources, predictions and native audio - working-directory: openless-all/ios - run: | - python3 scripts/test-interface-resources.py - python3 scripts/build-predictions.py - bash scripts/test-predictions.sh - bash scripts/test-audio.sh - - name: Verify shared sources were not modified - run: git diff --exit-code -- openless-all/app - - device-build: - needs: [changes, test] - if: needs.changes.outputs.device != 'false' - runs-on: xcode-27 - timeout-minutes: 60 - defaults: - run: - working-directory: openless-all/ios - steps: - - uses: actions/checkout@v6 - - name: Prepare native build tools and dependencies - run: | - xcodebuild -version - command -v cmake >/dev/null || brew install cmake - bash scripts/bootstrap.sh - bash scripts/bootstrap-local-speech.sh - - name: Build Rime and test composition - run: | - bash scripts/build-rime.sh host - bash scripts/test-rime.sh - bash scripts/build-rime.sh device - - name: Build the mobile Core and refresh attribution - run: | - bash scripts/build-core.sh device - python3 scripts/collect-rust-licenses.py - - name: Build and validate the unsigned iPhone app - env: - OPENLESS_DERIVED_DATA: ${{ runner.temp }}/ios-device - run: | - python3 scripts/validate-project.py - bash scripts/build.sh device Release unsigned - python3 scripts/package-ipa.py \ - "$OPENLESS_DERIVED_DATA/Build/Products/Release-iphoneos/OpenLess.app" \ - "$RUNNER_TEMP/OpenLess-ci-unsigned.ipa" - - name: Verify shared sources were not modified - run: git diff --exit-code -- ../app diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml deleted file mode 100644 index cf66a1170..000000000 --- a/.github/workflows/check-linux-egui.yml +++ /dev/null @@ -1,348 +0,0 @@ -name: Linux Core, egui and deb/rpm checks - -# CI and independent manual Linux builds share this deb/rpm verification path. -# No standalone push trigger: a Linux PR must show one combined Linux check. -on: - workflow_call: - inputs: - # 'none' skips a change set that is only prose (see - # scripts/ci-changed-areas.sh): the callers that must always build the - # packages, including accepted tag builds, leave the default in place. - scope: - required: false - type: string - default: full - # The tag whose commit is being packaged. Set by the release entry point so - # the version assertion and the GitHub Release linkage cannot be skipped by - # dispatching the workflow on a branch instead. - release_tag: - required: false - type: string - default: '' - # Attach the verified packages to that Release. Linux used to be - # administrator-attached only; the packages now ship in the same Release as - # the desktop and Android assets. - publish: - required: false - type: boolean - default: false - checkout_ref: - required: false - type: string - default: '' - -# No `permissions:` here on purpose: a reusable workflow inherits its caller's -# grant, and the release caller needs `contents: write` for `gh release upload` -# while CI keeps `contents: read`. Asking for more than the caller grants is -# rejected before the run starts (`startup_failure`, zero jobs); asking for less -# silently breaks the upload. - -concurrency: - group: linux-egui-build-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - test-linux-egui: - # Run in parallel with packaging: the tests used - # to serialise ~8 minutes in front of the ~7 minute release build, making this - # workflow the critical path of the whole CI run. - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 - CARGO_TARGET_DIR: ${{ github.workspace }}/openless-all/app/target - steps: - - uses: actions/checkout@v6 - with: - submodules: false - ref: ${{ inputs.checkout_ref || github.ref }} - - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - if: inputs.scope != 'none' - - uses: actions/setup-node@v4 - with: - node-version: '22' - if: inputs.scope != 'none' - - - name: Install native build and test dependencies - timeout-minutes: 10 - if: inputs.scope != 'none' - # Same list as the packaging job: the fcitx5 plugin build and the - # `pwsh` contract scripts both live here now. - uses: ./.github/actions/setup-linux-dependencies - - - uses: swatinem/rust-cache@v2 - if: inputs.scope != 'none' - with: - # Test and release profiles are produced independently; each needs its own cache. - shared-key: test-linux-egui - workspaces: | - openless-all/app -> target - openless-all/app/src-tauri/backend-tests -> ../../target - cache-on-failure: true - save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - - - name: Build and test fcitx5 plugin - if: inputs.scope != 'none' - working-directory: openless-all/app/scripts/linux-fcitx5-plugin - run: | - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr - cmake --build build --parallel - ctest --test-dir build --output-on-failure - test -s build/libopenless.so - test -s build/openless.conf - - - name: Test Core, Linux host, and platform boundaries - timeout-minutes: 40 - if: inputs.scope != 'none' - working-directory: openless-all/app - run: | - set -euo pipefail - cargo test --locked -p openless-core - # Named re-run of the hotkey edge gate that used to be a standalone - # script spawning its own `cargo test`: the binary is already built by - # the line above, so this is free, and a rename now fails loudly here - # instead of silently dropping the gate. - cargo test --locked -p openless-core --lib \ - api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics -- --exact \ - | tee "$RUNNER_TEMP/openless-hotkey-edge.log" - grep -Fq 'test api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics ... ok' \ - "$RUNNER_TEMP/openless-hotkey-edge.log" - cargo test --locked -p openless-harmony-host - cargo clippy --locked -p openless-core --all-targets -- -D warnings - cargo test --locked -p openless-linux-egui --all-targets - cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings - node scripts/linux-egui-tauri-free-contract.test.mjs - node scripts/linux-egui-release-contract.test.mjs - node scripts/ci-changed-areas.test.mjs - node scripts/ci-cache-usage.test.mjs - node scripts/ci-cache-sharing.test.mjs - node scripts/harmony-release-contract.test.mjs - node scripts/tauri-linux-free-contract.test.mjs - pwsh -NoProfile -File ./scripts/check-core-deps.ps1 - pwsh -NoProfile -File ./scripts/check-core-deps.ps1 openless-linux-egui - pwsh -NoProfile -File ./scripts/check-core-secret-surface.ps1 - pwsh -NoProfile -File ./scripts/check-core-test-isolation.ps1 - pwsh -NoProfile -File ./scripts/check-core-runtime-seam.ps1 - pwsh -NoProfile -File ./scripts/check-linux-public-surface.ps1 - - - name: Test remote input TLS identity and Unix key permissions - timeout-minutes: 20 - 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 - # A cold dependency tree costs ~18 minutes; anything past this is a hang. - timeout-minutes: 90 - env: - RELEASE_TAG: ${{ inputs.release_tag || (github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') && github.ref_name || '') }} - # Debug info dominates both compile time and cache size for this host, and - # the gate below never reads it. The Linux job keeps its own cache so a - # red run still starts warm. - CARGO_PROFILE_DEV_DEBUG: 0 - CARGO_PROFILE_TEST_DEBUG: 0 - # The standalone TLS contract otherwise recompiles common dependencies in - # src-tauri/backend-tests/target, outside the host's restored cache. - CARGO_TARGET_DIR: ${{ github.workspace }}/openless-all/app/target - steps: - - uses: actions/checkout@v6 - with: - submodules: false - ref: ${{ inputs.checkout_ref || github.ref }} - - - name: Install native build and packaging dependencies - timeout-minutes: 10 - if: inputs.scope != 'none' - uses: ./.github/actions/setup-linux-dependencies - - - 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: Resolve package version and validate release tag - id: version - working-directory: openless-all/app - env: - VERSION_TAG: ${{ env.RELEASE_TAG }} - run: | - set -euo pipefail - VERSION=$(node -p "require('./package.json').version") - if [ -n "$VERSION_TAG" ]; then - TAG_VERSION=${VERSION_TAG#v} - TAG_VERSION=${TAG_VERSION%-tauri} - if [ "${VERSION%%+*}" != "$TAG_VERSION" ]; then - echo "::error::tag $VERSION_TAG does not match package.json version $VERSION" - exit 1 - fi - fi - # Linux packages use the same explicitly incremented -N revision as - # earlier Beta.2-78/-79 builds. Keep it separate from the Tauri tag. - REVISION=$(< linux-egui/package-revision) - [[ "$REVISION" =~ ^[1-9][0-9]*$ ]] - echo "version=${VERSION%%+*}-$REVISION" >> "$GITHUB_OUTPUT" - - - uses: swatinem/rust-cache@v2 - if: inputs.scope != 'none' - with: - # Keep the release cache shared with tag builds, separate from test profiles. - shared-key: build-linux-egui - workspaces: | - openless-all/app -> target - openless-all/app/src-tauri/backend-tests -> ../../target - # Failed beta checks can still warm the shared tree. PRs never write - # their own copy, even when tests fail or dependencies change. - 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: | - desktop-file-validate openless-all/app/linux-egui/packaging/openless.desktop - appstreamcli validate --no-net openless-all/app/linux-egui/packaging/top.openless.OpenLess.metainfo.xml - - - name: Build Linux egui host - timeout-minutes: 40 - if: inputs.scope != 'none' - working-directory: openless-all/app - run: cargo build --locked --release -p openless-linux-egui - - - name: Package deb and rpm without Tauri - timeout-minutes: 10 - if: inputs.scope != 'none' - working-directory: openless-all/app - env: - OPENLESS_LINUX_VERSION: ${{ steps.version.outputs.version }} - OPENLESS_LINUX_ARCH: x86_64 - run: | - # CI runners are disposable. Ignore old outputs restored by a cache. - rm -f target/linux-egui-packages/*.deb target/linux-egui-packages/*.rpm \ - target/linux-egui-packages/SHA256SUMS - bash scripts/package-linux-egui.sh - - - name: Generate checksums and verify distributable packages - if: inputs.scope != 'none' - working-directory: openless-all/app - run: | - set -euo pipefail - OUTPUT=target/linux-egui-packages - ( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS ) - # Release attachment name: the desktop and Android assets carry their own - # SHA256SUMS, so the Linux one is published under a distinct name. - cp "$OUTPUT/SHA256SUMS" "$OUTPUT/SHA256SUMS-linux" - bash scripts/verify-linux-egui-packages.sh - - - name: Stage direct download aliases - if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '') - shell: bash - run: | - set -euo pipefail - output=openless-all/app/target/linux-egui-packages - alias_dir="$RUNNER_TEMP/openless-linux-egui-direct-download" - rm -rf "$alias_dir" - mkdir -p "$alias_dir" - debs=("$output"/OpenLess-Linux-egui-*.deb) - rpms=("$output"/OpenLess-Linux-egui-*.rpm) - test "${#debs[@]}" -eq 1 - test "${#rpms[@]}" -eq 1 - cp "${debs[0]}" "$alias_dir/OpenLess_linux_x86_64.deb" - cp "${rpms[0]}" "$alias_dir/OpenLess_linux_x86_64.rpm" - - - name: Upload verified Linux packages as workflow artifacts - # Push builds keep the release path warm but do not publish artifacts; - # the tag release and pull request runs are the consumers. - if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '') - uses: actions/upload-artifact@v7 - with: - retention-days: 14 - name: openless-linux-egui-x86_64 - 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: - retention-days: 14 - 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 }} - LINUX_ACCEPTED_RELEASE_TAG: ${{ vars.LINUX_ACCEPTED_RELEASE_TAG }} - steps: - - name: Verify exact-tag Linux acceptance - run: | - set -euo pipefail - if [ -z "$RELEASE_TAG" ] || [ "$LINUX_ACCEPTED_RELEASE_TAG" != "$RELEASE_TAG" ]; then - echo "::error::Linux device acceptance must be recorded for this exact release tag before publication." - exit 1 - fi - - 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 - run: | - set -euo pipefail - 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 - fi - if [ "$attempt" = 40 ]; then - echo "::error::release $RELEASE_TAG was not created by the desktop workflow" - exit 1 - fi - sleep 15 - done - 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" \ - --clobber - echo "attached Linux assets to $RELEASE_TAG" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad1137756..1a425b1cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,8 @@ name: CI # 在合并前快速验证两件事: # 1. 全部前端/契约测试与 vite bundle 通过(捕获行为、合同及跨 locale 类型 drift) # 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux Core、 -# remote_tls、egui 和 deb/rpm 统一使用发版构建链检查(PR 不上传 Release)。 -# macOS/Windows/Android 不跑完整 Tauri bundle;Linux 则运行完整 deb/rpm 发版构建校验。 +# Linux egui 的专属 CI 与发行暂时停用,保留本地构建/打包脚本。 +# macOS/Windows/Android 不跑完整 Tauri bundle。 on: # Feature branches are covered by their pull request. Running both produced two full @@ -28,11 +28,16 @@ on: type: choice options: [macos-latest, macos-15-intel] default: macos-latest + windows_build_jobs: + description: Windows Cargo/CMake parallelism for controlled manual comparisons (default stays 2) + type: choice + options: ['2', '4'] + default: '2' # Supersede a PR or an equivalent manual request. Keep different manual # platform/runner selections independent, and preserve baseline push evidence. concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.platform || 'all' }}-${{ inputs.macos_runner || 'macos-latest' }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.platform || 'all' }}-${{ inputs.macos_runner || 'macos-latest' }}-${{ inputs.windows_build_jobs || '2' }} cancel-in-progress: ${{ github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' }} jobs: @@ -46,11 +51,11 @@ jobs: outputs: tauri: ${{ steps.detect.outputs.tauri }} msrv: ${{ steps.detect.outputs.msrv }} - linux: ${{ steps.detect.outputs.linux }} harmony: ${{ steps.detect.outputs.harmony }} frontend: ${{ steps.detect.outputs.frontend }} android: ${{ steps.detect.outputs.android }} capsule: ${{ steps.detect.outputs.capsule }} + ios: ${{ steps.detect.outputs.ios-device }} steps: - uses: actions/checkout@v4 with: @@ -64,7 +69,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail - for area in tauri msrv linux harmony frontend android capsule; do + for area in tauri msrv harmony frontend android capsule ios-device; do verdict=$(bash scripts/ci-changed-areas.sh "$area" "$BASE_SHA") # 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。 decision=$( [ "$verdict" = 'false' ] && echo false || echo true ) @@ -73,6 +78,140 @@ jobs: echo "$area=$decision" >> "$GITHUB_OUTPUT" done + # iOS is part of the same CI run and uses the Cargo dependency graph rather + # than a separate path-filtered workflow. Only explicit false skips it. + ios-test: + # Keep existing check-run names for branch protection compatibility. + name: test + needs: [changes] + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.ios != 'false' + runs-on: xcode-27 + timeout-minutes: 30 + env: + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@1.99.0 + with: + components: clippy + - name: Record the unchanged shared source baseline + run: | + git diff --exit-code HEAD -- openless-all/app + git rev-parse HEAD:openless-all/app + - name: Prepare the isolated mobile Core + run: python3 openless-all/ios/scripts/prepare-core.py + - name: Resolve Xcode cache identity + id: xcode + run: echo "version=$(xcodebuild -version | tr '\n' '-')" >> "$GITHUB_OUTPUT" + - name: Cache iOS host Rust dependencies + uses: swatinem/rust-cache@v2 + with: + shared-key: ios-host + key: ${{ steps.xcode.outputs.version }} + workspaces: 'openless-all/ios/CoreHost -> target' + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + - name: Test shared keyboard contracts + timeout-minutes: 5 + run: swift test --package-path openless-all/ios/Packages/OpenLessShared + - name: Test mobile Core and iOS Host + timeout-minutes: 12 + run: cargo test --manifest-path openless-all/ios/CoreHost/Cargo.toml --workspace --lib --locked --timings + - name: Check mobile Core + timeout-minutes: 5 + run: cargo clippy --manifest-path openless-all/ios/CoreHost/Cargo.toml -p openless-core --all-targets --locked -- -D warnings + - name: Test interface resources, predictions and native audio + timeout-minutes: 5 + working-directory: openless-all/ios + run: | + python3 scripts/test-interface-resources.py + python3 scripts/build-predictions.py + bash scripts/test-predictions.sh + bash scripts/test-audio.sh + - name: Verify shared sources were not modified + run: git diff --exit-code -- openless-all/app + - name: Upload host Cargo timings + if: ${{ !cancelled() }} + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: ios-host-cargo-timings + path: openless-all/ios/CoreHost/target/cargo-timings/ + if-no-files-found: ignore + retention-days: 7 + + ios-device-build: + name: device-build + # No outputs/artifacts are consumed from ios-test. Run independently on the + # same two VMs instead of adding ~8 minutes of tests before a ~15 minute build. + # Both checks still have to pass; this workflow does not publish the IPA. + needs: [changes] + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.ios != 'false' + runs-on: xcode-27 + timeout-minutes: 60 + env: + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + defaults: + run: + working-directory: openless-all/ios + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@1.99.0 + with: + targets: aarch64-apple-ios + - name: Prepare native build tools and dependencies + timeout-minutes: 10 + run: | + xcodebuild -version + command -v cmake >/dev/null || brew install cmake + bash scripts/bootstrap.sh + - name: Resolve Xcode cache identity + id: xcode + run: echo "version=$(xcodebuild -version | tr '\n' '-')" >> "$GITHUB_OUTPUT" + - name: Cache iOS device Rust dependencies + uses: swatinem/rust-cache@v2 + with: + shared-key: ios-device + key: ios16-${{ steps.xcode.outputs.version }} + workspaces: 'openless-all/ios/CoreHost -> ../.build/core-ios16' + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + - name: Prepare verified local speech dependencies + timeout-minutes: 10 + run: bash scripts/bootstrap-local-speech.sh + - name: Build Rime and test composition + timeout-minutes: 12 + run: | + bash scripts/build-rime.sh host + bash scripts/test-rime.sh + bash scripts/build-rime.sh device + - name: Build the mobile Core and refresh attribution + timeout-minutes: 10 + run: | + bash scripts/build-core.sh device + python3 scripts/collect-rust-licenses.py + - name: Build and validate the unsigned iPhone app + timeout-minutes: 12 + env: + OPENLESS_DERIVED_DATA: ${{ runner.temp }}/ios-device + run: | + python3 scripts/validate-project.py + bash scripts/build.sh device Release unsigned + python3 scripts/package-ipa.py \ + "$OPENLESS_DERIVED_DATA/Build/Products/Release-iphoneos/OpenLess.app" \ + "$RUNNER_TEMP/OpenLess-ci-unsigned.ipa" + - name: Verify shared sources were not modified + run: git diff --exit-code -- ../app + - name: Upload device Cargo timings + if: ${{ !cancelled() }} + timeout-minutes: 5 + uses: actions/upload-artifact@v4 + with: + name: ios-device-cargo-timings + path: openless-all/ios/.build/core-ios16/cargo-timings/ + if-no-files-found: ignore + retention-days: 7 + ui-motion-browsers: name: UI motion / ${{ matrix.browser }} strategy: @@ -593,69 +732,6 @@ jobs: "${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}" \ "$version_name" "$version_code" --allow-unsigned - # The Linux core/egui/deb+rpm checks live in a reusable workflow, which runs - # the same `cargo test -p openless-core` as upstream's `linux-core-contract` - # plus the Linux host, fcitx5 and package contracts. - linux-egui-package: - needs: [changes] - if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' - name: Linux Core, egui and deb/rpm checks - permissions: - contents: read - uses: ./.github/workflows/check-linux-egui.yml - with: - # 散文改动跳过整条 Linux 构建与打包链;输出缺失/未知时退回 full。 - scope: ${{ needs.changes.outputs.linux == 'false' && 'none' || 'full' }} - - windows-frontend: - # Split out of `cross-platform`: the job used to serialise 6 minutes of - # frontend work behind 15 minutes of Rust compiling on the same runner, and - # the Windows runner is the slowest of the three. Running them in parallel - # costs one extra setup but removes ~6 minutes from the critical path. - needs: [changes] - if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.tauri != 'false' - name: Windows frontend and contracts - runs-on: windows-latest - timeout-minutes: 40 - env: - CARGO_PROFILE_TEST_DEBUG: 0 - defaults: - run: - working-directory: openless-all/app - steps: - - uses: actions/checkout@v4 - with: - submodules: false - - uses: actions/setup-node@v4 - with: - node-version: "22" - - name: Cache npm from beta - uses: ./.github/actions/cache-npm - - name: Install frontend dependencies - timeout-minutes: 10 - 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 - timeout-minutes: 10 - run: npm run build - - name: Run the platform-sensitive contracts - # `process.platform` branches and the `windows-*` contracts only mean - # anything here; everything else runs on macOS (full suite) and Linux. - run: node scripts/frontend-test-runner.mjs --platform-only windows - native-capsule: # The capsule crates compile into the desktop shells. They get their own macOS + Windows # runners only because their `ui,host` feature set is not built anywhere else, and that @@ -716,10 +792,10 @@ jobs: # The shared Core and full Tauri test binary compile together; limit concurrency # so the arm64 runner is not OOM-killed # at peak memory. - CARGO_BUILD_JOBS: 2 + CARGO_BUILD_JOBS: ${{ matrix.label == 'Windows' && github.event_name == 'workflow_dispatch' && inputs.windows_build_jobs || '2' }} CARGO_PROFILE_DEV_DEBUG: 0 CARGO_PROFILE_TEST_DEBUG: 0 - CMAKE_BUILD_PARALLEL_LEVEL: 2 + CMAKE_BUILD_PARALLEL_LEVEL: ${{ matrix.label == 'Windows' && github.event_name == 'workflow_dispatch' && inputs.windows_build_jobs || '2' }} defaults: run: working-directory: openless-all/app @@ -742,6 +818,13 @@ jobs: # Pinned toolchains run in independent MSRV jobs with separate caches. - uses: dtolnay/rust-toolchain@stable + - name: Record runner resources and compiler limits + shell: bash + run: | + node -e 'const os = require("node:os"); console.log(JSON.stringify({platform: process.platform, arch: process.arch, cpu: os.cpus()[0]?.model, cpus: os.cpus().length, memoryGiB: +(os.totalmem()/2**30).toFixed(1), cargoJobs: process.env.CARGO_BUILD_JOBS, cmakeJobs: process.env.CMAKE_BUILD_PARALLEL_LEVEL}, null, 2))' + rustc --version + cargo --version + - name: Configure macOS check profile if: runner.os == 'macOS' run: echo "CARGO_PROFILE_DEV_DEBUG=0" >> "$GITHUB_ENV" @@ -781,6 +864,11 @@ jobs: timeout-minutes: 10 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 @@ -797,16 +885,23 @@ jobs: - name: Build the frontend for the Tauri context timeout-minutes: 10 # `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. + # Rust compile below needs the frontend built on Windows too. if: runner.os == 'Windows' run: npm run build + - name: Run the platform-sensitive contracts + # Run 42646377148: the separate Windows frontend VM took 95s, of which + # only 42s were contracts. npm ci/build and PowerShell parsing already + # happen here. Reuse this VM; stable + contracts remains below macOS's + # 920s critical path. Keep the full suite on macOS. + if: runner.os == 'Windows' + timeout-minutes: 10 + run: node scripts/frontend-test-runner.mjs --platform-only windows + - name: Build frontend and run frontend/contract tests timeout-minutes: 10 - # Windows runs the platform-sensitive subset in its own job: spawning 144 - # node processes there costs ~3x the macOS time, and the rest of the suite - # is already covered here. The frontend build it needs is the step above. + # Windows runs only its platform-sensitive subset above; spawning the + # full suite there is much slower and duplicates macOS coverage. if: runner.os == 'macOS' run: npm test @@ -829,7 +924,8 @@ jobs: if: runner.os == 'macOS' env: OPENLESS_CODEX_TEST_EXE: codex - run: cargo test --locked -p openless-core hardening_actually_narrows_the_writable_roots -- --ignored --nocapture --test-threads=1 + # Share dependency artifacts with the host, not a second workspace tree. + run: cargo test --locked -p openless-core --target-dir src-tauri/target hardening_actually_narrows_the_writable_roots -- --ignored --nocapture --test-threads=1 # 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 @@ -862,15 +958,21 @@ jobs: - name: Run isolated Windows hang diagnostics tests (off/on) timeout-minutes: 10 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/openless-all/app/src-tauri/target run: python scripts/test-windows-hang-diagnostics.py - name: Run owner-thread target ownership tests timeout-minutes: 10 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/openless-all/app/src-tauri/target run: python scripts/test-windows-runtime-target.py - name: Run real Windows runtime ownership lifecycle if: runner.os == 'Windows' timeout-minutes: 15 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/openless-all/app/src-tauri/target run: python scripts/test-windows-runtime-target.py --runtime - name: Run Windows symbol-pair format tests @@ -886,9 +988,11 @@ jobs: run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --target-dir src-tauri/target --timings - name: Run native recorder module tests (Windows) - # Exercise the production recorder module without hardware, Tauri, MLX, or ASR submodules. + # Keep the isolated source/test fixture, but reuse compiled dependencies. if: runner.os == 'Windows' timeout-minutes: 15 + env: + CARGO_TARGET_DIR: ${{ github.workspace }}/openless-all/app/src-tauri/target run: python scripts/test-recorder-module.py - name: Run Windows audio COM lifecycle tests @@ -896,7 +1000,7 @@ jobs: # require a microphone. Device-dependent capture/playback tests remain explicitly ignored. if: runner.os == 'Windows' timeout-minutes: 20 - run: cargo test --locked --manifest-path src-tauri/vendor/cpal/Cargo.toml --lib --test windows_lifecycle + run: cargo test --locked --manifest-path src-tauri/vendor/cpal/Cargo.toml --target-dir src-tauri/target --lib --test windows_lifecycle - name: Build the Windows TSF input method # The TSF DLL has its own vcxproj and is not part of the Tauri build, so @@ -1019,9 +1123,9 @@ jobs: runs-on: windows-latest timeout-minutes: 60 env: - CARGO_BUILD_JOBS: 2 + CARGO_BUILD_JOBS: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_build_jobs || '2' }} CARGO_PROFILE_TEST_DEBUG: 0 - CMAKE_BUILD_PARALLEL_LEVEL: 2 + CMAKE_BUILD_PARALLEL_LEVEL: ${{ github.event_name == 'workflow_dispatch' && inputs.windows_build_jobs || '2' }} defaults: run: working-directory: openless-all/app diff --git a/.github/workflows/release-linux-egui.yml b/.github/workflows/release-linux-egui.yml deleted file mode 100644 index 8e7dffeb2..000000000 --- a/.github/workflows/release-linux-egui.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Release Linux egui - -# Linux may accompany a desktop release only after independent device acceptance. -# Both automatic dispatch and the reusable publication job require the repository -# variable LINUX_ACCEPTED_RELEASE_TAG to match the immutable release tag exactly. -# Build-only dispatches remain available without authorizing publication. -on: - workflow_dispatch: - inputs: - tag: - description: Existing release tag to rebuild and attach (v-tauri) - required: true - type: string - publish: - description: Attach the verified packages to that Release - required: false - type: boolean - default: true - -permissions: - contents: write - -concurrency: - group: release-linux-egui-${{ inputs.tag }} - cancel-in-progress: false - -jobs: - build-linux-egui: - permissions: - contents: write - uses: ./.github/workflows/check-linux-egui.yml - with: - scope: full - release_tag: ${{ inputs.tag }} - publish: ${{ inputs.publish }} - checkout_ref: ${{ inputs.tag }} diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 4c57c86e9..7a2b8ba03 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -6,7 +6,7 @@ name: Release Tauri (cross-platform) # # Outputs: # macOS arm64/x64 .dmg + Windows x64 .msi/.exe, uploaded automatically as GitHub Release assets. -# Linux egui is built separately by release-linux-egui.yml; this workflow does not build Linux/Tauri. +# Linux egui CI/release is paused; local build/package scripts remain available. # # macOS distribution: # - With APPLE_CERTIFICATE / APPLE_CERTIFICATE_PASSWORD / APPLE_ID / @@ -858,13 +858,12 @@ jobs: # from the project CDN. The stage step copies installers only. files: release-files/* - # Harmony receives the immutable release tag. Linux publication additionally - # requires administrator acceptance for that exact tag, independent of desktop readiness. + # Harmony receives the immutable release tag. Linux automation is paused. dispatch-platform-releases: needs: [build, publish] - # Independent of the desktop result: the HAP and the Linux packages only need - # the tag, so a Windows-only or notarization failure must not cost them their - # Release entry. A cancelled run still dispatches nothing. + # Independent of the desktop result: the HAP only needs the tag, so a + # Windows-only or notarization failure must not cost it its Release entry. + # A cancelled run still dispatches nothing. if: >- !cancelled() && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref_name, '-tauri') @@ -884,9 +883,8 @@ jobs: # automatically - the upload never submits it for review, so that decision # stays human either way. HARMONY_CHANNEL: ${{ vars.HARMONY_AUTO_CHANNEL || 'none' }} - LINUX_ACCEPTED_RELEASE_TAG: ${{ vars.LINUX_ACCEPTED_RELEASE_TAG }} steps: - - name: Dispatch the HarmonyOS and Linux releases + - name: Dispatch the HarmonyOS release run: | set -euo pipefail case "$HARMONY_CHANNEL" in @@ -899,25 +897,16 @@ jobs: -f "channel=$HARMONY_CHANNEL" \ -f publish=true \ -f upload_to_appgallery=true - if [ "$LINUX_ACCEPTED_RELEASE_TAG" = "$RELEASE_TAG" ]; then - gh workflow run release-linux-egui.yml \ - --ref "$RELEASE_TAG" \ - -f "tag=$RELEASE_TAG" \ - -f publish=true - echo "Linux publication accepted for $RELEASE_TAG." >> "$GITHUB_STEP_SUMMARY" - else - echo "Linux omitted: device acceptance is not recorded for $RELEASE_TAG. Desktop and Android publication is unaffected." >> "$GITHUB_STEP_SUMMARY" - fi - name: Report the dispatched runs - # Both dispatches already returned a run id; this listing is the log line + # The dispatch already returned a run id; this listing is the log line # that makes the old silent miss visible, so a missing run must not fail # the tag build after every asset is already published. continue-on-error: true run: | set -euo pipefail sleep 10 - for workflow in release-harmony.yml release-linux-egui.yml; do + for workflow in release-harmony.yml; do echo "### $workflow" gh run list --workflow="$workflow" --limit 3 \ --json createdAt,status,conclusion,url \ diff --git a/.github/workflows/sync-micyou.yml b/.github/workflows/sync-micyou.yml index 46fec5f49..370672bec 100644 --- a/.github/workflows/sync-micyou.yml +++ b/.github/workflows/sync-micyou.yml @@ -33,6 +33,7 @@ jobs: id: sync env: REVISION: ${{ inputs.revision }} + GH_TOKEN: ${{ github.token }} run: | python3 openless-all/app/scripts/test_sync_micyou.py args=() diff --git a/RELEASING.md b/RELEASING.md index de35f716b..e13f93ca7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -45,26 +45,18 @@ See [the pinned NSIS bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli These tags build the macOS, Windows, and Android Tauri hosts in parallel. -Linux and HarmonyOS have dispatch-only entry points, -`.github/workflows/release-linux-egui.yml` and `.github/workflows/release-harmony.yml`. -Neither reacts directly to a tag push. After the desktop build, `release-tauri.yml` +HarmonyOS has a dispatch-only entry point, `.github/workflows/release-harmony.yml`. +It does not react directly to a tag push. After the desktop build, `release-tauri.yml` dispatches HarmonyOS on the same immutable release tag. -Linux is separate: an administrator must complete and record the L01-L12 device +Linux-specific CI and release automation is paused: the reusable Linux check and +release entry point have been removed, and desktop releases no longer dispatch Linux. +Linux source, local build/package/verification scripts, and device acceptance +requirements remain available. No Linux artifacts or aliases are automatically +built or attached. Before manually distributing packages, complete the L01-L12 acceptance in [docs/linux-egui-handoff/07-acceptance.md](docs/linux-egui-handoff/07-acceptance.md) -for the exact candidate, then set the repository variable -`LINUX_ACCEPTED_RELEASE_TAG` to that release tag. Automatic dispatch occurs only -when the variable exactly matches the current tag; missing or older acceptance -omits Linux and records the reason in the release workflow summary. Desktop and -Android delivery may proceed independently. The reusable Linux publication job -checks the same exact-tag gate, including manual dispatches, before attaching any -packages or aliases. A build-only dispatch with `publish=false` does not publish. - -Accepted Linux packages use the same Core/host/deb/rpm checks as CI and attach -automatically. There is no in-app updater manifest or AppImage. Never mark a tag -accepted merely because compilation or CI passed; the hardware acceptance remains -a separate requirement. To repair an accepted release, dispatch its workflow on -the same tag, with the acceptance variable still matching that tag. +for that exact candidate. Compilation alone is not device acceptance. Desktop and +Android delivery remain independent; Linux has no in-app updater or AppImage. HarmonyOS builds, signs and attaches `OpenLess_harmony_arm64.hap` plus the `OpenLess_harmony.hap` alias, and can upload the signed package (App Pack) to @@ -117,7 +109,7 @@ A local machine without store certificates can still produce a package: checks its contents without a device. For each new Linux package candidate, increment `openless-all/app/linux-egui/package-revision` from the last `-N` suffix (for example `2.0.0-Beta.2-79` → `2.0.0-Beta.2-80`). -Both local packaging and the shared CI/tag build use this explicit revision; never drop +Local packaging uses this explicit revision (Linux CI/tag builds are paused); never drop it or derive it from the Tauri tag, whose SemVer remains `2.0.0-Beta.2`. ## Direct download aliases @@ -128,13 +120,13 @@ The release workflows also publish stable, version-independent asset names for t - macOS Intel: `OpenLess_macos_x86_64.dmg` - Windows x64: `OpenLess_windows_x86_64-setup.exe` - Android: `OpenLess_android_.apk` (one each for `arm64-v8a`, `armeabi-v7a`, `x86`, and `x86_64`) -- Linux: `OpenLess_linux_x86_64.deb` and `OpenLess_linux_x86_64.rpm` +- Linux (manual distribution only while automation is paused): `OpenLess_linux_x86_64.deb` and `OpenLess_linux_x86_64.rpm` Stable website links use `https://github.com/Open-Less/openless/releases/latest/download/`. Beta links must use the release tag because `latest` excludes pre-releases: `https://github.com/Open-Less/openless/releases/download//`. -The Tauri and Android workflows attach their aliases automatically. Accepted Linux publications attach the two files from the `openless-linux-egui-direct-download` Actions artifact alongside the versioned packages, behind the same exact-tag acceptance gate. Do not replace the versioned assets; the aliases are an additional website-facing contract. +The Tauri and Android workflows attach their aliases automatically. Linux automation is paused; any manually accepted Linux distribution must provide its aliases alongside the versioned packages. Do not replace the versioned assets; the aliases are an additional website-facing contract. Under the [2026-09-06 2.0 requirements](docs/2.0-requirements.md), Windows and macOS must fully retain their respective Tauri 1.x features. The egui team owns Linux Host/UI work and Linux product acceptance. Linux application gaps do not block the Windows/macOS delivery, but shared Core defects do. Linux packages must not be attached before their own product acceptance. Existing Android builds do not expand this scope into a new full-support commitment. @@ -205,12 +197,12 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as or manual cask edit is needed. Do not publish via an asset workflow's `GITHUB_TOKEN`, which does not emit the downstream release event. -Before dispatching the Linux release build, additionally require all of the following: +Linux workflow dispatch is paused. Before manually distributing Linux packages (or restoring automation), require all of the following: 1. The egui team has completed the [Linux Host/UI gaps and acceptance](docs/linux-egui-handoff/07-acceptance.md). The existing `eframe::App` is a starting implementation; its presence and successful packaging alone do not establish product completeness. 2. Linux core/host tests, dependency gates, and secret-surface gates are green on Ubuntu. -3. The Linux workflow verifies ELF dependencies, deb/rpm contents, desktop metadata, fcitx5 plugin paths and package SHA-256 checksums. There is no AppImage, minisign key or Linux updater manifest. -4. The dispatch pins the tag as an input; the workflow checks that tag out and rejects a `package.json` version mismatch, so the packages provably come from the reviewed commit. +3. Run the local package verifier for ELF dependencies, deb/rpm contents, desktop metadata, fcitx5 plugin paths and package SHA-256 checksums. There is no AppImage, minisign key or Linux updater manifest. +4. Check out the reviewed immutable tag and verify the package version matches `package.json`, so the packages provably come from the reviewed commit. 5. The checksum file is produced and attached as `SHA256SUMS-linux` so it does not replace an existing desktop/Android checksum file. ## HarmonyOS signing material and AppGallery credentials diff --git a/docs/ci-runner-balance.md b/docs/ci-runner-balance.md new file mode 100644 index 000000000..ec586aed8 --- /dev/null +++ b/docs/ci-runner-balance.md @@ -0,0 +1,74 @@ +# CI runner 数量与耗时取舍 + +## 当前决定 + +- Linux egui 专属 CI 与自动发行暂停:删除两个 workflow 和桌面 tag 自动派发;源码、本地打包和包校验脚本保留。 +- Windows 平台敏感契约合入现有 stable job,复用已有 checkout、npm 安装、前端构建和 PowerShell 解析,不再单独启动 Windows 前端 VM。 +- iOS 测试与真机构建复用统一变更检测,但独立运行:两者不共享产物,不必串行。保留 `test` / `device-build` 检查名;两项都应作为合并门禁,不能仅要求其中一项。 +- 不增加大规格收费 runner、不增加浏览器 worker 或 Android ABI。浏览器双引擎、stable/MSRV、胶囊继续保留独立缓存/任务,避免强行合并造成关键路径重编译。 + +## 实际使用的机器 + +以下来自 GitHub job 日志,不是仅根据 `latest` 标签推测;镜像会随 GitHub 更新。 + +| 标签 | 日志中的镜像/系统 | 主要用途 | +|---|---|---| +| `ubuntu-latest` | Ubuntu 26.04(维护/同步任务日志) | 改动检测、Android、鸿蒙、维护与发行协调 | +| `ubuntu-24.04` | Ubuntu 24.04.5 | Chromium/WebKit 浏览器测试;Linux egui 原生任务现已移除 | +| `windows-latest` | Windows Server 2025 / `windows-2025-vs2026` | Windows stable、MSRV、胶囊与发行 | +| `macos-latest` | macOS 26.7.2 / `macos-26-arm64` | macOS stable、MSRV、胶囊、ARM 发行 | +| `macos-15-intel` | macOS 15.7.9 / `macos-15`,x86_64 目标 | Intel 桌面发行、手动 CI 回退 | +| `xcode-27` | macOS 27.0.1 / `xcode-27-arm64` | iOS 测试与真机构建 | + +抽查的 runner 日志标明 Hosted Compute Agent:Linux/Windows 在 Azure,ARM Mac/iOS 在 MacCloud。日志未记录完整 CPU/RAM 规格,本次不把标签当作硬件配置保证;也没有取得仓库自托管 runner 清单权限。 + +## 修改前的实测样本 + +时间是 job 的 started/completed 差值,不含 job 启动前排队;不能把它们相加当成整轮墙钟耗时。 + +| 样本 | job | 时间 | +|---|---|---| +| [CI 42646377148](https://github.com/Open-Less/openless/actions/runs/42646377148) | macOS stable | 920s,其中 Rust 后端 393s、Codex 沙箱契约 246s | +| 同上 | Windows stable / 前端 / MSRV | 511s / 95s / 197s;前端契约本身 42s | +| 同上 | WebKit / Chromium | 584s / 468s | +| 同上 | Android / 鸿蒙 | 614s / 508s | +| 同上(已停用) | Linux 测试 / 打包 | 602s / 420s,两台 VM 共约 17 VM-min | +| [iOS 42588075524](https://github.com/Open-Less/openless/actions/runs/42588075524) | 测试 / 真机构建 | 475s / 879s,原先串行 | +| [桌面发行 42609582726](https://github.com/Open-Less/openless/actions/runs/42609582726) | ARM Mac / Intel Mac / Windows | 386s / 1740s / 1747s | +| [Android 42609582763](https://github.com/Open-Less/openless/actions/runs/42609582763) | armv7 / aarch64 / x86_64 / i686 | 800s / 827s / 1075s / 1099s | +| [鸿蒙发行 42613396802](https://github.com/Open-Less/openless/actions/runs/42613396802) | HAP | 572s,其中工具链准备 130s、构建 412s | + +## 预期,而非已验证收益 + +- Windows 合并会给 stable 增加约 42s 契约工作和少量前置检查,但省掉一台约 95s 的 VM及重复安装/构建。该样本下 Windows 仍低于 macOS 920s 关键路径;缓存变冷时需重新测量。 +- iOS 两台 VM 总数不变,最大并发增加一台;不再等待 475s 测试完成后才启动 879s 构建,预计这部分墙钟从约 23 分钟变成约 15 分钟。若测试快速失败,设备构建可能已经消耗一部分计算,这是并行化代价。 +- 删除 Linux 专属 CI 每次全量少两台 Ubuntu VM,按样本省约 17 VM-min;因为其任务不是该轮关键路径,不能宣称整轮直接少 10 分钟。停止 Linux 发行也不再启动对应构建/发布 VM,但样本中的 tag 尚未派发 Linux,不能虚构这轮已经节省了多少。 +- 旧全量组合:主 CI 的 16 台执行 VM,加独立 iOS 的 3 台,共 19 台。新全量 CI 配置展开为 15 台:iOS 统一改动检测省 1、Windows 前端合并省 1、Linux 测试/打包移除省 2。该数字是每轮启动总数,不是峰值并发;PR 仍按改动范围跳过任务。 +- 桌面发行关键路径仍是 Intel Mac/Windows 的约 29 分钟编译。不能仅靠合并短协调 job、删除 Linux CI 或提高 timeout 改善,后续需要相同 profile/工具链的缓存命中证据再优化。 + +## 参考 PR #1412 的进一步优化 + +[PR #1412](https://github.com/Open-Less/openless/pull/1412) 的首轮把执行窗口从 21m14s 降至 14m28s,但未加权 runner 用量从 82m40s 增至 94m20s。第二轮产物复用减少了重复编译条目,整轮却因 Windows 波动变为 17m15s。因此延续其 profile/target 复用,不继续无条件扩张 VM。 + +已存在的浏览器分路、独立 MSRV、feature-on 诊断 `--profile test`、Windows backend 共用 target,以及 NSIS 二进制复用均保留。本轮新增: + +- macOS 沙箱契约和 Windows CPAL/COM 测试显式使用 `src-tauri/target`,不再分别在 workspace/vendor 下生成依赖树。 +- 诊断 off/on、所有权 helper、真实 Windows runtime、录音模块等临时源码 harness 通过步骤级 `CARGO_TARGET_DIR` 复用已恢复的主机 target。源码仍是隔离的临时工程,不改测试路径、features、锁文件或断言;仅复用 Cargo 能证明版本/features/profile 相同的产物,不保证每一个包都命中。 +- 所有权脚本尊重传入 target,并从同一位置寻找执行文件。未传变量的本地运行仍使用原先隔离目录。 +- Windows 手动运行新增 `windows_build_jobs=2/4`,默认 push/PR 和 macOS 仍为 2。concurrency group 包含该参数,使两组手动对照不会相互取消;没有额外新增 job 或 runner。该选项是同一 VM 内的 Cargo/CMake 并行度,不是 VM 台数。当前手动入口仍运行所选平台的既有 CI 范围,不应把两轮全量对照视为免费实验。 +- stable job 记录 CPU 型号/核心数、内存、Rust/Cargo 版本和编译上限,便于排除机器规格、缓存冷暖差异。仅在同一提交、相同 runner/缓存条件下对比后,再决定是否提高默认并行度;默认不冒 OOM 风险。 + +### 结合 PR #1417 的 iOS 补充 + +参考 [PR #1417](https://github.com/Open-Less/openless/pull/1417),将 host/device 独立 Rust 缓存、长步骤超时及 7 天 Cargo timings 合入现有 `ci.yml`,不恢复独立 iOS workflow。Host target 映射 `CoreHost/target`,device target 映射 `.build/core-ios16`;缓存族区分 host/device,包含 Xcode identity、Rust/lockfile 身份,device 再区分 iOS 16。仅 beta push/手动运行写入,PR 只读;生成的 Core 源码、模型和 CMake 二进制不缓存。新缓存体积及预热收益需线上测量,并受既有 8 GB 维护预算约束。 + +下载保留断点续传与 SHA-256 校验,同时设置连接、总时间和低速限制;Boost 先下载至临时文件,校验后才替换正式归档。验证覆盖首次成功、超时续传、持续超时退出、校验失败拒绝发布、已验证文件复用。PR #1417 的远端验证是该 PR 自身的证据,不替代本次迁入统一 CI 后的远端验收。 + +### 本地验证与待验证 + +- 修复了既有 `ci-performance.test.mjs` 的机器配置泄漏:清除 `CARGO_TARGET_DIR` 并不能覆盖 `~/.cargo/config.toml` 的 `build.target-dir`,先前第一轮构建被重定向到全局目录,第二轮却显式使用 fixture 目录,导致 `fresh` 断言失败。现在所有离线 fixture 命令都显式使用同一临时 target;复用、非测试生产构建及故意损坏的 feature 编译失败用例全部通过。 +- `npm test` 完整通过:生产前端构建及 169 个 JS/TS 测试文件(包含新增的复用/合并和 iOS 下载契约)。 +- 4 个 Python harness 路由/模式用例通过;真实 Rust 所有权 helper 的 7 项单测及 2 项编译失败边界在指定 target 下通过。 +- Actionlint、相关格式与 `git diff --check` 通过。完整 Windows runtime/录音/COM 原生执行和 macOS 沙箱需要对应平台的 Actions 验证。 + +上述结果为本地验证,尚未完成修改后的完整远端流水线验收;本轮不新增 VM,不承诺未经测量的整轮提速百分比。 diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index 8b4e0f3df..6f07724ab 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -1,6 +1,6 @@ # CI 触发范围与缓存配额 -状态:canonical;更新:2026-11-08。本文说明 CI 为什么在什么情况下跑哪些 job、缓存在 10 GB 预算内怎么分配,以及发版前怎么把耗时压下来。 +状态:canonical。本文说明 CI 的触发、缓存与历史性能证据。最新 runner/耗时取舍见 [CI runner 平衡记录](ci-runner-balance.md):Linux 专属 CI/发行暂停,Windows 前端复用 stable VM,iOS 两项独立验证并行。后文实测历史不代表这些新改动已在线验收。 ## 1. 触发与门控 @@ -12,7 +12,7 @@ | tag `v*-tauri` | 发布 Tauri 桌面与 Android;Linux 包不随标签自动发布 | | `workflow_dispatch` | 全量;`platform=macos` 只跑 macOS(仅 macOS 验证入口) | -同一 PR 连续推送会取消旧运行(`concurrency.cancel-in-progress`)。CI 的相同 ref、platform、macos_runner 手动请求也会取消旧运行,不同参数独立运行。桌面手动构建按 ref、platform、诊断开关区分;Android 按 ref、ABI、profile、签名模式区分。标签发布(包括手动选择标签)和修复发布不会被新运行中途取消。 +同一 PR 连续推送会取消旧运行(`concurrency.cancel-in-progress`)。CI 的相同 ref、platform、macos_runner、windows_build_jobs 手动请求也会取消旧运行,不同参数独立运行。桌面手动构建按 ref、platform、诊断开关区分;Android 按 ref、ABI、profile、签名模式区分。标签发布(包括手动选择标签)和修复发布不会被新运行中途取消。 ### 改动范围判定 @@ -25,15 +25,15 @@ Rust 部分不靠手写路径表,而是走 Cargo 自己的包图:`cargo meta | `tauri` | macOS / Windows 桌面检查 | 改动能到达 `openless`(含 vendor 路径依赖)或桌面专属的 `openless-native-capsule` | | `android` | Android cargo check | 改动能到达 `openless` 的 Android 闭包,或影响 Android 项目生成/构建的 npm manifest;纯 bundle-only 改动不单独触发 | | `msrv` | macOS / Windows Rust 1.99 MSRV | 有任何 `.rs` / `Cargo.toml` / `Cargo.lock` / `rust-toolchain*` / `.github/**` | -| `linux` | Linux Core/egui 测试与 deb/rpm 打包链 | 改动能到达 `openless-linux-egui` 的闭包。Linux 宿主是原生 egui,不读前端 `dist/` | +| `linux` | 自动化已暂停;仅保留本地区域判定能力 | CI 不再计算或消费此输出,Linux 源码与本地打包脚本保留 | | `harmony` | 鸿蒙 ArkTS 契约、Rust host、HAP | 改动能到达 `openless-harmony-host` 的闭包,或动了它要打包的前端 bundle | | `frontend` | Chromium/WebKit UI 动效 | 只动浏览器会渲染的面:`src/**`、`e2e/**`、样式与 vite/playwright 配置;`app/assets/**` / `app/public/**` 按 native consumer 处理 | | `capsule` | 胶囊 crates(macOS + Windows 双 runner) | 拥有改动文件的包是 `openless-capsule` / `openless-native-capsule`。它们的 `ui,host` feature 组合不在别处构建,所以保留独立 job,但常见 PR 不再为它付出两台 runner | -| `ios-device` | iOS `device-build`(Xcode/Rime 与真实 device Core 编译) | 改动能到达 iOS Core 闭包,或动了 `openless-all/ios/**`;共享 Rust 代码也要保留 device target 证据 | +| `ios-device` | CI 内的 `ios-test` / `ios-device-build`(共享契约、Xcode/Rime 与真实 device Core 编译) | 改动能到达 iOS Core 闭包,或动了 `openless-all/ios/**`;共享 Rust 代码也要保留 device target 证据 | 契约测试 `scripts/ci-changed-areas.test.mjs` 用真实 git diff 驱动这些判定,双向断言:够不到的必须跳过,其余的必须运行。改动前它抓到的真实故障(`linux` 输出漏写导致 Linux job 被误跳过)就是这条规则存在的理由。 -`linux-egui-package` 通过可复用工作流的 `scope` 输入接收结果(`full` / `none`,**默认 `full`**):纯散文改动跳过整个 Linux 构建与打包链。独立 Linux 手动构建沿用完整检查,只上传 Actions 产物;管理员在产品验收后,针对已接受的现有 tag 构建并单独附加包,见 [发布规范](../RELEASING.md)。 +Linux 专属自动化已暂停:删除 `check-linux-egui.yml`、`release-linux-egui.yml` 和 CI 的 `linux-egui-package` 调用,桌面发布不再派发 Linux。保留 Linux 源码及本地构建/打包/校验脚本;Ubuntu 上的浏览器、Android、鸿蒙等任务不受影响。恢复 Linux 自动发行前仍须完成产品验收,见 [发布规范](../RELEASING.md)。 `ui-motion` 使用生产前端构建运行 `npm run test:ui-motion`,在 Chromium 与 WebKit 上验证快速往返切页、弹窗中断、手机抽屉退出以及减少动态效果下的模型入口规则。失败时保留浏览器 trace 和截图;浏览器 mock 验证不替代原生权限与设备交互。 @@ -43,7 +43,7 @@ Rust 部分不靠手写路径表,而是走 Cargo 自己的包图:`cargo meta 该数字由依赖实际要求决定:`tauri 2.12` 要 1.90、`aes 0.9.3`/`uuid 1.28` 要 1.89,代码库选择跟随当前 stable(1.99)以保持单一数字,不再为旧工具链回退依赖。 - 工具链不同 ⇒ cargo fingerprint 全失效、产物与缓存不能共用。历史上 stable 与 MSRV 挤在同一个 job 里共用缓存,串行 29 分 59 秒(见 [`macos-build-performance.md`](macos-build-performance.md));拆成并行独立 job、缓存分开后是 stable 8m36s / MSRV 3m39s。 - 平台专属依赖图不同,所以 macOS 与 Windows 各自验证:macOS 是 `keyring/apple-native`;Windows 是 `keyring/windows-native`、TSF IME。Windows MSRV 也在独立 job 中运行,避免 stable → MSRV 串行重编译和污染 stable 缓存。`Windows checks` 汇总两路结果,任一路失败、取消或意外跳过都会失败。 -- Linux 宿主 `linux-egui` 与桌面侧同用 `rust-version = "1.99"`(egui 0.37 本身只要求 1.95),Linux 侧按 stable 验证。 +- Linux 宿主 `linux-egui` 与桌面侧同用 `rust-version = "1.99"`(egui 0.37 本身只要求 1.95);专属 CI 暂停期间不再提供 Linux 编译证据。 ## 3. 缓存配额分配(10 GB / 仓库) @@ -70,7 +70,7 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job |---|---|---|---| | Windows 依赖树 | ~1.6 GB | beta 的 CI / 手动构建 / 周期构建 | 所有 PR、发版 | | macOS(stable 0.87 + MSRV 0.72) | ~1.6 GB | 同上 | 同上 | -| Linux(egui 0.75 + core) | ~1.7 GB | beta `push` / 手动运行 | 同上 | +| Linux(历史缓存) | 不再新增 | — | 专属 CI/发行已暂停;历史缓存按维护策略回收 | | Android(Rust 0.85 + Rime 0.40 + gradle 0.31) | ~1.6 GB | beta 的 CI / 手动构建 / 周期构建 | PR、发版 | | Harmony Rust host | 0.28 GB | beta `push` / 手动运行 | PR、发版 | | Playwright 浏览器 | 0.37 GB | beta `push` / 手动运行 | PR | @@ -85,7 +85,7 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job - `playwright` 与 `mobile-rime` 使用 restore/save 拆分;`swatinem/rust-cache` 用 `save-if` 达到同样效果。Playwright 按引擎与版本分键,每个 runner 只下载、恢复自己的浏览器;新键首次使用需要冷下载。 - npm 复合 action 内允许 beta 门控的 `actions/cache@v4`,其他 ref 只用 `cache/restore`;周期构建也可以维护 beta 下载缓存。 - 历史标签修复入口是兼容例外:标签可能早于 npm 复合 action,继续使用原有 `setup-node` npm 缓存;常规 tag 发布使用只读复合 action。 -- Linux 测试与打包各自使用独立缓存,PR 只读、beta 写入;dev/test 关闭 debug info。Windows MSRV 新增独立缓存族,实际体积需在 beta 首次写入后由维护作业重新核算,不能把上表历史余量视为保证。 +- Linux 专属测试与打包已暂停,不再写入其缓存。Windows MSRV 使用独立缓存族,实际体积需由维护作业重新核算,不能把上表历史余量视为保证。 - key 结构为 `v0-rust---<环境哈希>-`:环境哈希含工具链与 `CARGO_PROFILE_*`,所以 stable / MSRV / release 天然分开,不要用跨平台 `shared-key` 混合。 - 相同 `shared-key` 只是必要条件:恢复仍要求工具链/环境哈希和缓存路径版本兼容。CI 的 dev/test 缓存不能保证让 release 热启动,发布配置依赖 beta 的周期构建预热;不能把共同前缀当作已命中的证据。 - 原生胶囊(`native-capsule` job)原本是一个 `pull_request`-only workflow:缓存写在 `refs/pull//merge`,这个作用域此后再不会被恢复,而 beta 上没有 job 产出同 key 的树,所以 PR 永远冷编译。现在它在 `ci.yml` 里,用 `shared-key: native-capsule` + beta 门控的 `save-if`,并且由 `capsule` 区域门控——常见 PR 完全不会为它拉起 runner。 @@ -94,10 +94,18 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job ## 4. 维护 -- 报告与清理:`bash openless-all/app/scripts/ci-cache-usage.sh [--prune] [--limit-gb 8] [--repo OWNER/NAME]`。`--prune` 只删已关闭/已合并 PR 的 `refs/pull/*` 作用域(先查 PR 状态,状态读不到就保留),永不触碰分支与 tag 作用域;用量超过预算时退出码为 1。 -- 自动维护:`.github/workflows/cache-maintenance.yml` 每周一 03:17 UTC 运行并支持手动触发,预算 8 GB。 +- 报告与清理:`bash openless-all/app/scripts/ci-cache-usage.sh --prune --keep-family 2 --prune-budget --limit-gb 8 --repo OWNER/NAME`。先清理关闭/合并 PR 的作用域(状态不可读则保留),再保留每个分支缓存族最新两代;仍超预算时,`--prune-budget` 按最后访问时间淘汰分支缓存,直到用量不超过预算。即使每个族只剩当前基线,也可能因总量过大而被淘汰,之后对应 job 需要冷构建;淘汰计划由维护脚本显式报告,而不是等触及 GitHub 配额后被动驱逐。tag、开放或状态不可读的 PR 不参与预算淘汰;它们独占超额时仍报错。`--dry-run` 只报告计划,不删除。 +- 自动维护:`.github/workflows/cache-maintenance.yml` 每天 03:17 UTC、`main/beta` PR 关闭时运行,也支持手动触发,预算 8 GB。PR 关闭事件只执行默认分支的脚本,不执行 PR 提交中的脚本;token 需要 `actions: write` 和 `pull-requests: read`。 - 契约测试 `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`。 +- 合并/关闭 PR 后可手动运行上述维护脚本;删除 API 必须同时传入 `key` 与 `ref`,仅传 `ref` 会返回 422。 + +### CI 入口合并与上游同步 + +- iOS 检查从独立的 `check-ios.yml` 迁入 `ci.yml`,统一运行、取消和 Cargo 图区域检测,保留原有 `test` / `device-build` 检查名称(兼容按名称设置的分支保护)及全部共享契约、Core 测试/Clippy、原生音频、预测和真机打包检查;`platform=macos` 的手动运行不启动 iOS。默认分支 push 也提供 iOS 基线证据,PR 仅在 `ios-device` 区域可达时运行。 +- Linux 专属 CI/发行按要求暂停,两个 YAML 及桌面自动派发逻辑已删除,本地构建链保留。 +- 缓存维护与 MicYou 同步仍是独立 workflow:前者需要删除缓存权限,后者会生成 draft PR;两者不是 PR 质量门禁,不把写权限带进 CI。 +- MicYou 固定版本更新至 `692ecbb9cee1ac45da590cadb59b645f23c1ea65`。上游新增的 `crate::aec` 可用性检查和测试依赖 MicYou 设备宿主;OpenLess 的 DSP-only 适配补丁明确移除这些宿主逻辑,保留自身参考音频捕获边界。此次刷新后导入的 Rust/模型/许可证字节不变,只更新补丁和溯源校验。未来真实补丁冲突仍必须失败并人工审查,不能静默跳过。 +- MicYou API 查询使用只读 `GH_TOKEN` 避免共享出口匿名限流;源码下载不携带 token。 ## 5. 实测对照 @@ -120,12 +128,12 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job **发版前预热必须在默认分支 `beta` 上执行**:其他分支的缓存不能被未来的 tag 恢复。桌面每周 schedule 在 beta 维护 release 缓存;手动预热也选择 beta。Android 周期构建默认只维护 aarch64;需要全部 release ABI 时在 beta 手动选择 `abis=all`。Tag 和其他分支只读 Rust/Gradle/npm 缓存,避免一次性副本挤掉可复用的基线。缓存命中仍需编译应用本身,不承诺固定发版时长。 -Linux 发版不受影响:`release-linux-egui.yml` 复用 CI 的同一个 `build-linux-egui` job(同名同 key),tag 运行能恢复基线分支的缓存。 +Linux 发行自动化已暂停,不再有独立构建或自动附件上传;上述历史数据不代表当前仍运行 Linux job。 ## 7. 已知取舍 -- 改动范围门控只跳过“够不到”的平台;`push` 到 `main`/`beta` 始终全量,所以合并后仍有完整证据。 -- `build-linux-egui` 在 `push` 时仍会构建 release 二进制(保持基线缓存里有 release 产物、并持续验证打包链),但不再上传 Action artifact;artifact 只在 PR 与 tag 运行产生。 +- 改动范围门控只跳过“够不到”的平台;`push` 到 `main`/`beta` 对仍启用的平台始终全量。Linux 专属自动化暂停后,不再承诺其原生验证证据。 +- 移除 Linux 专属 CI 后,不再自动验证 Core/egui 的 Linux 原生依赖、fcitx5、remote TLS 与 deb/rpm 打包链;本地契约测试仍保留,但不能替代这些原生运行证据。 - 所有 job 都设了 `timeout-minutes`,避免卡死的运行长时间占用并发与时间。 ## 桌面整理后的依赖与维护 diff --git a/openless-all/app/scripts/ci-artifact-reuse.test.mjs b/openless-all/app/scripts/ci-artifact-reuse.test.mjs new file mode 100644 index 000000000..69952386d --- /dev/null +++ b/openless-all/app/scripts/ci-artifact-reuse.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import vm from 'node:vm'; +import yaml from 'js-yaml'; + +const ci = yaml.load( + readFileSync(new URL('../../../.github/workflows/ci.yml', import.meta.url), 'utf8'), +); +const stable = ci.jobs['cross-platform']; +const step = (name) => { + const value = stable.steps.find((candidate) => candidate.name === name); + assert.ok(value, `retain ${name}`); + return value; +}; +for (const name of [ + 'Run isolated Windows hang diagnostics tests (off/on)', + 'Run owner-thread target ownership tests', + 'Run real Windows runtime ownership lifecycle', + 'Run native recorder module tests (Windows)', +]) { + assert.equal( + step(name).env.CARGO_TARGET_DIR, + '${{ github.workspace }}/openless-all/app/src-tauri/target', + ); +} +assert.match( + step('Run Codex writable-roots sandbox contract').run, + /--target-dir src-tauri\/target /, +); +assert.match(step('Run Windows audio COM lifecycle tests').run, /--target-dir src-tauri\/target /); +assert.match( + step('Run Rust-only backend unit tests (Windows)').run, + /--target-dir src-tauri\/target /, +); +assert.match( + step('Compile opt-in Windows hang diagnostics host').run, + /--profile test.*--lib --bins/, +); +assert.equal(step('Run real Windows runtime ownership lifecycle').if, "runner.os == 'Windows'"); +assert.equal(step('Run native recorder module tests (Windows)').if, "runner.os == 'Windows'"); +assert.match(step('Record runner resources and compiler limits').run, /os\.totalmem\(\)/); +const input = ci.on.workflow_dispatch.inputs.windows_build_jobs; +assert.equal(input.type, 'choice'); +assert.equal(input.default, '2'); +assert.deepEqual(input.options, ['2', '4']); +for (const key of ['CARGO_BUILD_JOBS', 'CMAKE_BUILD_PARALLEL_LEVEL']) { + assert.equal( + stable.env[key], + "${{ matrix.label == 'Windows' && github.event_name == 'workflow_dispatch' && inputs.windows_build_jobs || '2' }}", + ); + assert.equal( + ci.jobs['windows-msrv'].env[key], + "${{ github.event_name == 'workflow_dispatch' && inputs.windows_build_jobs || '2' }}", + ); + assert.equal(ci.jobs['macos-msrv'].env[key], 2, 'do not raise the memory-sensitive Mac limit'); +} +// Exercise the actual expressions: push/PR cannot accidentally opt into 4, +// and selecting 4 on a manual all-platform run must not increase Mac pressure. +for (const [event, label, selected, expected] of [ + ['push', 'Windows', '4', '2'], + ['pull_request', 'Windows', '4', '2'], + ['workflow_dispatch', 'Windows', '2', '2'], + ['workflow_dispatch', 'Windows', '4', '4'], + ['workflow_dispatch', 'macOS', '4', '2'], + ['workflow_dispatch', 'Windows', '', '2'], +]) { + const expression = stable.env.CARGO_BUILD_JOBS.slice(3, -2).trim(); + assert.equal( + vm.runInNewContext(expression, { + github: { event_name: event }, + matrix: { label }, + inputs: { windows_build_jobs: selected }, + }), + expected, + `${event}/${label}/${selected}`, + ); +} +assert.ok( + ci.concurrency.group.includes("inputs.windows_build_jobs || '2'"), + 'A/B requests must not cancel each other', +); +const python = process.platform === 'win32' ? 'python' : 'python3'; +const tests = spawnSync( + python, + [fileURLToPath(new URL('./test_ci_harness_cache.py', import.meta.url))], + { encoding: 'utf8' }, +); +assert.ifError(tests.error); +assert.equal(tests.status, 0, tests.stdout + tests.stderr); +console.log( + 'CI target reuse, preserved native harness modes and controlled parallelism contracts passed', +); diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs index 1e21611db..6aa352717 100644 --- a/openless-all/app/scripts/ci-cache-sharing.test.mjs +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -7,8 +7,7 @@ // only enters the key when `shared-key` is absent, so two different jobs share a // cache exactly when they declare the same `shared-key`. // -// Runs without `npm ci` (the Linux contract job has no node_modules), so the -// workflows are read as text. +// Runs without `npm ci`, so the workflows are read as text. import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; @@ -20,8 +19,6 @@ const read = (name) => readFileSync(join(root, '.github/workflows', name), 'utf8 const ci = read('ci.yml'); 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) => @@ -40,8 +37,6 @@ 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( @@ -85,15 +80,6 @@ assert.ok(saveIfs.length >= 3, 'every shared cache in ci.yml must be beta-only f assert.doesNotMatch(androidApk, /^\s*key:\s/m); assert.ok(sharedKeys(androidApk).some((key) => key.startsWith('android-'))); -// 5. Linux already shares one job between CI and release: the release entry point -// must reuse the same reusable workflow rather than a second build path. -assert.match(linuxRelease, /uses: \.\/\.github\/workflows\/check-linux-egui\.yml/); -assert.match(linux, /cargo build --locked --release -p openless-linux-egui/); - -// 6. Release entry points pin the tag, so the artifacts cannot come from a branch. -assert.match(linuxRelease, /release_tag: \$\{\{ inputs\.tag \}\}/); -assert.match(linuxRelease, /checkout_ref: \$\{\{ inputs\.tag \}\}/); - // The capsule crates used to run in a pull-request-only workflow that wrote a // dependency tree under `refs/pull//merge` (never restored again) while beta // produced nothing to restore. They now live in `ci.yml` behind their own area @@ -115,10 +101,6 @@ assert.match(linuxRelease, /checkout_ref: \$\{\{ inputs\.tag \}\}/); ); } -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); diff --git a/openless-all/app/scripts/ci-cache-usage.sh b/openless-all/app/scripts/ci-cache-usage.sh index ce49269ba..2bb0e2c6e 100755 --- a/openless-all/app/scripts/ci-cache-usage.sh +++ b/openless-all/app/scripts/ci-cache-usage.sh @@ -4,7 +4,10 @@ # delete the entries that can never be restored again or that are superseded # by a newer sibling. # -# scripts/ci-cache-usage.sh [--prune] [--keep-family N] [--dry-run] [--limit-gb N] [--repo OWNER/NAME] +# scripts/ci-cache-usage.sh [--prune] [--keep-family N] [--prune-budget] [--dry-run] [--limit-gb N] [--repo OWNER/NAME] +# --prune-budget additionally evicts least-recently-used branch caches until +# inside budget. This is opt-in because even a current baseline may be evicted; +# tag scopes and open/unreadable PR scopes remain protected. # # Why this exists: GitHub keeps 10 GB of caches per repository and evicts the # least recently used entries. The platform dependency trees are large (the @@ -33,11 +36,13 @@ limit_gb=${LIMIT_GB:-8} prune=0 keep_family=0 dry_run=0 +prune_budget=0 while [ $# -gt 0 ]; do case "$1" in --prune) prune=1 ;; --dry-run) dry_run=1 ;; + --prune-budget) prune_budget=1 ;; --keep-family) keep_family=${2:?--keep-family needs a number} shift @@ -51,7 +56,7 @@ while [ $# -gt 0 ]; do shift ;; *) - echo "usage: $0 [--prune] [--keep-family N] [--dry-run] [--limit-gb N] [--repo OWNER/NAME]" >&2 + echo "usage: $0 [--prune] [--keep-family N] [--prune-budget] [--dry-run] [--limit-gb N] [--repo OWNER/NAME]" >&2 exit 2 ;; esac @@ -65,6 +70,15 @@ case "$keep_family" in ;; esac +if ! awk -v limit="$limit_gb" 'BEGIN { exit (limit ~ /^[0-9]+([.][0-9]+)?$/ && limit + 0 > 0) ? 0 : 1 }'; then + echo "--limit-gb needs a positive number" >&2 + exit 2 +fi +if [ "$prune_budget" = 1 ] && [ "$prune" != 1 ]; then + echo "--prune-budget requires --prune" >&2 + exit 2 +fi + command -v gh > /dev/null 2>&1 || { echo "gh is required" >&2 exit 1 @@ -81,13 +95,15 @@ urlencode() { } caches=$(mktemp) -trap 'rm -f "$caches"' EXIT +deleted=$(mktemp) +trap 'rm -f "$caches" "$deleted"' EXIT gh api --paginate "repos/$repo/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.ref, .key, .size_in_bytes, .created_at] | @tsv' > "$caches" count=$(wc -l < "$caches" | tr -d ' ') -total_gb=$(awk -F'\t' '{ sum += $3 } END { printf "%.2f", sum / 1e9 }' "$caches") +total_bytes=$(awk -F'\t' '{ sum += $3 } END { printf "%.0f", sum }' "$caches") +total_gb=$(awk -v size="$total_bytes" 'BEGIN { printf "%.2f", size / 1e9 }') echo "$repo: $count cache(s), ${total_gb} GB used, budget ${limit_gb} GB" echo "largest scopes:" @@ -99,6 +115,7 @@ delete_cache() { # GitHub changed this endpoint: DELETE requires `key`, and `ref` only # narrows it. Without both, the call answers HTTP 422. local key=$1 ref=$2 + printf '%s\t%s\n' "$ref" "$key" >> "$deleted" if [ "$dry_run" = 1 ]; then echo " would prune $ref $key" return 0 @@ -161,13 +178,35 @@ if [ "$prune" = 1 ]; then echo "pruned $pruned cache(s)" fi +if [ "$prune_budget" = 1 ]; then + # Refresh after dead-scope/family pruning so no cache is counted or deleted + # twice. Prefer access time over creation time: an old but hot baseline is + # more valuable than a recently created cache nobody restores. + gh api --paginate "repos/$repo/actions/caches?per_page=100" \ + --jq '.actions_caches[] | [.ref, .key, .size_in_bytes, (.last_accessed_at // .created_at)] | @tsv' \ + | awk -F'\t' 'FILENAME == ARGV[1] { removed[$1 "\t" $2] = 1; next } !removed[$1 "\t" $2]' "$deleted" - > "$caches" + remaining=$(awk -F'\t' '{ sum += $3 } END { printf "%.0f", sum }' "$caches") + while IFS=$(printf '\t') read -r ref key size accessed; do + if awk -v used="$remaining" -v limit="$limit_gb" 'BEGIN { exit (used <= limit * 1e9) ? 0 : 1 }'; then + break + fi + echo " prune $ref $key (budget LRU, last accessed $accessed)" + delete_cache "$key" "$ref" + remaining=$((remaining - size)) + done < <(awk -F'\t' '$1 ~ /^refs\/heads\//' "$caches" | sort -t "$(printf '\t')" -k4,4 -k1,1 -k2,2) + if [ "$dry_run" = 1 ]; then + echo "projected budget-prune usage: $(awk -v size="$remaining" 'BEGIN { printf "%.2f", size / 1e9 }') GB" + fi +fi + if [ "$prune" = 1 ] && [ "$dry_run" = 0 ]; then - total_gb=$(gh api --paginate "repos/$repo/actions/caches?per_page=100" \ - --jq '.actions_caches[].size_in_bytes' | awk '{ sum += $1 } END { printf "%.2f", sum / 1e9 }') + total_bytes=$(gh api --paginate "repos/$repo/actions/caches?per_page=100" \ + --jq '.actions_caches[].size_in_bytes' | awk '{ sum += $1 } END { printf "%.0f", sum }') + total_gb=$(awk -v size="$total_bytes" 'BEGIN { printf "%.2f", size / 1e9 }') echo "post-prune usage: ${total_gb} GB" fi -if awk -v used="$total_gb" -v limit="$limit_gb" 'BEGIN { exit (used + 0 > limit + 0) ? 0 : 1 }'; then +if awk -v used="$total_bytes" -v limit="$limit_gb" 'BEGIN { exit (used > limit * 1e9) ? 0 : 1 }'; then echo "::error::cache usage ${total_gb} GB exceeds the ${limit_gb} GB budget" exit 1 fi diff --git a/openless-all/app/scripts/ci-cache-usage.test.mjs b/openless-all/app/scripts/ci-cache-usage.test.mjs index 7222ee2c7..b241fef72 100644 --- a/openless-all/app/scripts/ci-cache-usage.test.mjs +++ b/openless-all/app/scripts/ci-cache-usage.test.mjs @@ -104,6 +104,17 @@ args="$*" case "$args" in *-X\\ DELETE*) printf '%s\\n' "$args" >>"$DELETE_LOG" + if [ "\${STATEFUL_CACHE:-0}" = 1 ]; then + ${pythonCommand} - "$CACHES_JSON" "$args" <<'PY' +import json, sys, urllib.parse +path, args = sys.argv[1:] +query = urllib.parse.parse_qs(args.split('?', 1)[1]) +data = json.load(open(path)) +data['actions_caches'] = [e for e in data['actions_caches'] if not (e['key'] == query['key'][0] and e['ref'] == query['ref'][0])] +with open(path, 'w') as output: + json.dump(data, output) +PY + fi ;; *actions/caches?per_page=100*) ${pythonCommand} - "$CACHES_JSON" "$args" <<'PY' @@ -113,7 +124,8 @@ for entry in data["actions_caches"]: if "--jq .actions_caches[].size_in_bytes" in " ".join(sys.argv[2:]): print(entry['size_in_bytes']) continue - print(f"{entry['ref']}\\t{entry['key']}\\t{entry['size_in_bytes']}\\t{entry['created_at']}") + timestamp = entry.get('last_accessed_at', entry['created_at']) if 'last_accessed_at' in sys.argv[2] else entry['created_at'] + print(f"{entry['ref']}\\t{entry['key']}\\t{entry['size_in_bytes']}\\t{timestamp}") PY ;; *repos/*/pulls/*) @@ -242,6 +254,105 @@ try { false, ); + // Budget pruning uses access time, not creation time, and never evicts + // open PRs/tags even if they are older than every branch cache. + const budgetFixtures = [ + { + ref: 'refs/heads/beta', + key: 'hot-baseline', + size_in_bytes: 2e9, + created_at: '2026-01-01', + last_accessed_at: '2026-11-10', + }, + { + ref: 'refs/heads/beta', + key: 'cold-baseline', + size_in_bytes: 2e9, + created_at: '2026-11-01', + last_accessed_at: '2026-11-02', + }, + { ref: 'refs/tags/v1', key: 'protected-tag', size_in_bytes: 2e9, created_at: '2025-01-01' }, + { + ref: 'refs/pull/101/merge', + key: 'protected-open-pr', + size_in_bytes: 2e9, + created_at: '2025-01-01', + }, + { + ref: 'refs/pull/999/merge', + key: 'protected-unreadable-pr', + size_in_bytes: 1e9, + created_at: '2025-01-01', + }, + ]; + const budgetPath = join(scratch, 'budget.json'); + const budgetEnv = { CACHES_JSON: budgetPath, STATEFUL_CACHE: '1' }; + const resetBudget = () => { + writeFileSync(budgetPath, JSON.stringify({ actions_caches: budgetFixtures })); + rmSync(deleteLog, { force: true }); + }; + resetBudget(); + const budget = run(['--prune', '--prune-budget', '--limit-gb', '7'], budgetEnv); + check('budget pruning succeeds after real deletions', budget.code, 0); + const budgetDeleted = readFileSync(deleteLog, 'utf8').trim().split('\n'); + check('budget pruning deletes just one cold branch cache', budgetDeleted.length, 1); + check( + 'budget pruning chooses LRU rather than oldest-created', + budgetDeleted[0].includes('key=cold-baseline'), + true, + ); + check('budget pruning rechecks usage', /post-prune usage: 7\.0+ GB/.test(budget.stdout), true); + + resetBudget(); + const dryBudget = run(['--prune', '--prune-budget', '--dry-run', '--limit-gb', '7'], budgetEnv); + check('dry run remains over actual budget', dryBudget.code, 1); + check('dry run makes no deletes', existsSync(deleteLog), false); + check( + 'dry run reports projected usage', + /projected budget-prune usage: 7\.00 GB/.test(dryBudget.stdout), + true, + ); + + resetBudget(); + writeFileSync( + budgetPath, + JSON.stringify({ + actions_caches: [ + ...budgetFixtures, + { + ref: 'refs/pull/102/merge', + key: 'closed-pr', + size_in_bytes: 2e9, + created_at: '2025-01-01', + }, + ], + }), + ); + const combinedDryRun = run( + ['--prune', '--prune-budget', '--dry-run', '--limit-gb', '7'], + budgetEnv, + ); + check( + 'dry budget accounts for planned dead-scope deletions', + /projected budget-prune usage: 7\.00 GB/.test(combinedDryRun.stdout), + true, + ); + check( + 'dry budget does not over-prune the hot baseline', + /would prune.*hot-baseline/.test(combinedDryRun.stdout), + false, + ); + check('combined dry run still deletes nothing', existsSync(deleteLog), false); + + resetBudget(); + const impossible = run(['--prune', '--prune-budget', '--limit-gb', '4'], budgetEnv); + check('protected scopes exceeding budget still fail visibly', impossible.code, 1); + check( + 'protected scopes are never budget-pruned', + readFileSync(deleteLog, 'utf8').trim().split('\n').length, + 2, + ); + // Inside the budget: exit 0. const healthy = run(['--repo', 'example/openless', '--limit-gb', '10']); check('inside budget exits 0', healthy.code, 0); @@ -252,6 +363,8 @@ try { check('unknown flag exits 2', badFlag.code, 2); const badKeep = run(['--prune', '--keep-family', 'two']); check('non-numeric --keep-family exits 2', badKeep.code, 2); + check('budget pruning requires prune', run(['--prune-budget']).code, 2); + check('invalid budget exits 2', run(['--limit-gb', 'NaN']).code, 2); } finally { rmSync(scratch, { recursive: true, force: true }); } diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index f6e57e09c..ceee81982 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -253,15 +253,7 @@ const workflow = readFileSync( resolve(dirname(fileURLToPath(import.meta.url)), '../../..', '.github/workflows/ci.yml'), 'utf8', ); -const reusable = readFileSync( - resolve( - dirname(fileURLToPath(import.meta.url)), - '../../..', - '.github/workflows/check-linux-egui.yml', - ), - 'utf8', -); -const areas = ['tauri', 'msrv', 'linux', 'harmony', 'frontend', 'android', 'capsule']; +const areas = ['tauri', 'msrv', 'harmony', 'frontend', 'android', 'capsule']; check( 'workflow publishes every area', areas.every((area) => workflow.includes(area + ': ${{ steps.detect.outputs.' + area + ' }}')), @@ -269,7 +261,7 @@ check( ); check( 'workflow computes every area in the detect step', - /for area in tauri msrv linux harmony frontend android capsule; do/.test(workflow), + /for area in tauri msrv harmony frontend android capsule ios-device; do/.test(workflow), true, ); check( @@ -330,30 +322,16 @@ check( workflow.includes("needs.changes.outputs.harmony != 'false'"), true, ); -check( - 'linux scope defaults to full', - workflow.includes("needs.changes.outputs.linux == 'false' && 'none' || 'full'"), - true, -); -check( - 'reusable workflow defaults to a full build', - /scope:[\s\S]{0,80}default: full/.test(reusable), - true, -); -check( - 'reusable workflow gates every build step', - (reusable.match(/if: inputs\.scope != 'none'/g) ?? []).length >= 10, - true, -); +check('Linux CI is paused', workflow.includes('\n linux-egui-package:'), false); -// 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. +// Balance VM count and latency: the small Windows-only subset reuses the +// existing stable runner; Linux tests and packaging remain parallel. check( - 'windows frontend runs in its own job', - / windows-frontend:[\s\S]*?runs-on: windows-latest/.test(workflow), + 'windows contracts reuse the stable job', + / cross-platform:[\s\S]*?name: Run the platform-sensitive contracts\n[\s\S]*?if: runner\.os == 'Windows'\n\s+timeout-minutes: 10\n\s+run: node scripts\/frontend-test-runner\.mjs --platform-only windows/.test(workflow), true, ); +check('no duplicate Windows frontend VM', workflow.includes('\n windows-frontend:'), false); check( 'windows only runs the platform-sensitive contracts', workflow.includes('--platform-only windows'), @@ -367,7 +345,7 @@ check( // `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. +// job on Windows, which also runs the Windows-specific contract subset. check( 'windows builds the frontend before compiling the Tauri lib', /if: runner\.os == 'Windows'\n\s+run: npm run build/.test(workflow), @@ -389,8 +367,8 @@ for (const [label, changed] of [ ), ], [ - 'missing separate frontend job', - workflow.replace('\n windows-frontend:', '\n removed-frontend:'), + 'missing Windows contracts in the stable job', + workflow.replace('node scripts/frontend-test-runner.mjs --platform-only windows', 'echo missing-windows-contracts'), ], ]) { check(label + ': mutation applied', changed !== workflow, true); @@ -401,7 +379,6 @@ for (const [label, changed] of [ wiring, { workflow: changed, - reusable, vm, check: undefined, console: { log() {}, error() {} }, diff --git a/openless-all/app/scripts/ci-consolidation.test.mjs b/openless-all/app/scripts/ci-consolidation.test.mjs new file mode 100644 index 000000000..9779ac63d --- /dev/null +++ b/openless-all/app/scripts/ci-consolidation.test.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { load } from 'js-yaml'; + +const path = (name) => new URL(`../../../.github/workflows/${name}`, import.meta.url); +const workflow = (name) => load(readFileSync(path(name), 'utf8')); +const ci = workflow('ci.yml'); +assert.equal(existsSync(path('check-ios.yml')), false, 'iOS must not run twice'); +assert.equal(ci.jobs.changes.outputs.ios, '${{ steps.detect.outputs.ios-device }}'); +assert.match( + ci.jobs.changes.steps.find((s) => s.id === 'detect').run, + /for area in .* ios-device; do/, +); +for (const id of ['ios-test', 'ios-device-build']) { + const job = ci.jobs[id]; + assert.ok(job.needs.includes('changes')); + assert.equal(job['runs-on'], 'xcode-27'); + assert.equal(job.env.CARGO_PROFILE_DEV_DEBUG, 0); + assert.equal(job.env.CARGO_PROFILE_TEST_DEBUG, 0); + assert.equal( + job.if, + "(github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.ios != 'false'", + ); + assert.ok(job.steps.some((s) => s.run?.includes('git diff --exit-code'))); +} +assert.equal(ci.jobs['ios-test'].name, 'test', 'preserve the existing required check name'); +assert.equal(ci.jobs['ios-device-build'].name, 'device-build'); +const test = ci.jobs['ios-test']; +const testCommands = test.steps.map((s) => s.run ?? '').join('\n'); +for (const command of [ + 'prepare-core.py', + 'swift test', + 'cargo test', + 'cargo clippy', + 'test-interface-resources.py', + 'build-predictions.py', + 'test-predictions.sh', + 'test-audio.sh', +]) { + assert.ok(testCommands.includes(command), `retain iOS check: ${command}`); +} +const device = ci.jobs['ios-device-build']; +assert.deepEqual( + device.needs, + ['changes'], + 'independent iOS checks should reuse the same two VMs in parallel', +); +const deviceCommands = device.steps.map((s) => s.run ?? '').join('\n'); +for (const [job, family, workspace, timingPath] of [ + [ + test, + 'ios-host', + 'openless-all/ios/CoreHost -> target', + 'openless-all/ios/CoreHost/target/cargo-timings/', + ], + [ + device, + 'ios-device', + 'openless-all/ios/CoreHost -> ../.build/core-ios16', + 'openless-all/ios/.build/core-ios16/cargo-timings/', + ], +]) { + const cache = job.steps.find((s) => s.uses === 'swatinem/rust-cache@v2'); + assert.equal(cache.with['shared-key'], family); + assert.equal(cache.with.workspaces, workspace); + assert.ok(cache.with.key.includes('steps.xcode.outputs.version')); + assert.match( + cache.with['save-if'], + /github\.ref == 'refs\/heads\/beta'.*github\.event_name == 'push'.*workflow_dispatch/, + ); + const timing = job.steps.find((s) => s.with?.name === `${family}-cargo-timings`); + assert.equal(timing.with.path, timingPath); + assert.equal(timing.with['retention-days'], 7); + assert.equal(timing.if, '${{ !cancelled() }}'); +} +assert.ok( + device.steps.find((s) => s.uses === 'swatinem/rust-cache@v2').with.key.startsWith('ios16-'), +); +for (const name of [ + 'Test shared keyboard contracts', + 'Test mobile Core and iOS Host', + 'Check mobile Core', + 'Test interface resources, predictions and native audio', +]) { + assert.ok(test.steps.find((s) => s.name === name)['timeout-minutes'] > 0); +} +for (const name of [ + 'Prepare native build tools and dependencies', + 'Prepare verified local speech dependencies', + 'Build Rime and test composition', + 'Build the mobile Core and refresh attribution', + 'Build and validate the unsigned iPhone app', +]) { + assert.ok(device.steps.find((s) => s.name === name)['timeout-minutes'] > 0); +} +assert.ok(testCommands.includes('--locked --timings')); +for (const command of [ + 'bootstrap.sh', + 'bootstrap-local-speech.sh', + 'build-rime.sh host', + 'test-rime.sh', + 'build-rime.sh device', + 'build-core.sh device', + 'collect-rust-licenses.py', + 'validate-project.py', + 'build.sh device Release unsigned', + 'package-ipa.py', +]) { + assert.ok(deviceCommands.includes(command), `retain device check: ${command}`); +} +assert.equal(ci.jobs['linux-egui-package'], undefined, 'Linux CI is paused'); +for (const name of ['check-linux-egui.yml', 'release-linux-egui.yml']) { + assert.equal( + existsSync(path(name)), + false, + `${name} must stay removed while Linux automation is paused`, + ); +} +assert.equal(ci.jobs.changes.outputs.linux, undefined); +assert.equal( + ci.jobs['windows-frontend'], + undefined, + 'do not launch a duplicate Windows frontend VM', +); +const stable = ci.jobs['cross-platform']; +const windowsContracts = stable.steps.find( + (s) => s.name === 'Run the platform-sensitive contracts', +); +assert.equal(windowsContracts.if, "runner.os == 'Windows'"); +assert.equal(windowsContracts.run, 'node scripts/frontend-test-runner.mjs --platform-only windows'); +const frontend = stable.steps.find((s) => s.name === 'Build the frontend for the Tauri context'); +assert.equal(frontend.if, "runner.os == 'Windows'"); +assert.ok(stable.steps.indexOf(frontend) < stable.steps.indexOf(windowsContracts)); +assert.ok( + stable.steps.indexOf(windowsContracts) < + stable.steps.findIndex((s) => s.name === 'Compile Rust backend unit tests (Windows)'), + 'fail fast on contract errors', +); +assert.equal( + stable.steps.find((s) => s.name === 'Check Windows prerequisites').if, + 'matrix.preflight', +); +assert.equal(stable.steps.filter((s) => s.name === 'Check PowerShell scripts').length, 1); +assert.equal( + stable.steps.find((s) => s.name === 'Build frontend and run frontend/contract tests').run, + 'npm test', +); +assert.ok( + ci.jobs['windows-checks'].needs.includes('cross-platform'), + 'the Windows gate must include frontend failures', +); +const cache = workflow('cache-maintenance.yml'); +assert.equal(cache.permissions['pull-requests'], 'read'); +assert.match( + cache.jobs.report.steps[0].with.ref, + /github\.event_name == 'pull_request'.*github\.event\.repository\.default_branch/, +); +assert.equal(cache.jobs.report.steps[0].with['persist-credentials'], false); +assert.match( + cache.jobs.report.steps.find((s) => s.run).run, + /--prune --keep-family 2 --prune-budget/, +); +const sync = workflow('sync-micyou.yml'); +assert.equal(sync.jobs.candidate.permissions?.contents ?? sync.permissions.contents, 'read'); +assert.equal( + sync.jobs.candidate.steps.find((s) => s.id === 'sync').env.GH_TOKEN, + '${{ github.token }}', +); +console.log('CI consolidation and maintenance contracts passed'); diff --git a/openless-all/app/scripts/ci-performance.test.mjs b/openless-all/app/scripts/ci-performance.test.mjs index 35d7672db..042d40e47 100644 --- a/openless-all/app/scripts/ci-performance.test.mjs +++ b/openless-all/app/scripts/ci-performance.test.mjs @@ -137,8 +137,13 @@ ci-shared = { path = "../shared" } join(cargoScratch, 'backend/src/lib.rs'), '#[test] fn shared_contract() { assert_eq!(ci_shared::value(), 42); }', ); - const cargoEnv = { ...process.env, CARGO_PROFILE_TEST_DEBUG: '0' }; - delete cargoEnv.CARGO_TARGET_DIR; + // Removing the env variable is insufficient: ~/.cargo/config.toml may still + // redirect the first build elsewhere. Pin every fixture command explicitly. + const cargoEnv = { + ...process.env, + CARGO_PROFILE_TEST_DEBUG: '0', + CARGO_TARGET_DIR: join(cargoScratch, 'host/target'), + }; const cargo = (args) => spawnSync('cargo', [...args, '--offline'], { cwd: cargoScratch, diff --git a/openless-all/app/scripts/ci-workflow-behavior.test.mjs b/openless-all/app/scripts/ci-workflow-behavior.test.mjs index 80e0c8493..5e7c6c0c7 100644 --- a/openless-all/app/scripts/ci-workflow-behavior.test.mjs +++ b/openless-all/app/scripts/ci-workflow-behavior.test.mjs @@ -19,7 +19,6 @@ const workflow = (name) => load(readFileSync(new URL(`../../../.github/workflows/${name}`, import.meta.url), 'utf8')); const python = process.platform === 'win32' ? 'python' : 'python3'; const ci = workflow('ci.yml'); -const linux = workflow('check-linux-egui.yml'); const harmony = workflow('release-harmony.yml'); const androidSteps = ci.jobs['android-check'].steps; const freeEmulatorSpace = androidSteps.find( @@ -55,19 +54,7 @@ const install = androidSteps.find( 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, freeEmulatorSpace.run]) { +for (const script of [install, boot, freeEmulatorSpace.run]) { const result = spawnSync('bash', ['-n'], { input: script, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr); } @@ -324,56 +311,6 @@ try { /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'); @@ -440,5 +377,5 @@ print(json.dumps([command[command.index('-keystorePwd') + 1], command[command.in 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', + 'ci-workflow-behavior: install retries/cache hits, emulator cleanup 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 e245b6178..30ca73d50 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -169,11 +169,7 @@ assert.match( 'the downstream workflow definition must be loaded from the same immutable release tag', ); assert.match(tauriRelease, /gh workflow run release-harmony\.yml[\s\S]{0,400}-f publish=true/); -assert.match( - tauriRelease, - /gh workflow run release-linux-egui\.yml[\s\S]{0,80}--ref "\$RELEASE_TAG"[\s\S]{0,180}-f "tag=\$RELEASE_TAG"/, - 'the Linux workflow definition must use the same immutable release tag', -); +assert.doesNotMatch(tauriRelease, /release-linux-egui|LINUX_ACCEPTED_RELEASE_TAG/, 'Linux automation is paused'); assert.match( tauriRelease, /github\.event_name == 'push'[\s\S]{0,120}refs\/tags\/v/, diff --git a/openless-all/app/scripts/ios-ci-downloads.test.mjs b/openless-all/app/scripts/ios-ci-downloads.test.mjs new file mode 100644 index 000000000..3f45b11ab --- /dev/null +++ b/openless-all/app/scripts/ios-ci-downloads.test.mjs @@ -0,0 +1,107 @@ +// Adapted from PR #1417; exercise the production resumable downloader offline. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const source = readFileSync( + new URL('../../ios/scripts/bootstrap-local-speech.sh', import.meta.url), + 'utf8', +); +const fetchFunction = source.match(/^fetch\(\) \{[\s\S]*?^\}/m)?.[0]; +assert.ok(fetchFunction); +const scratch = mkdtempSync(join(tmpdir(), 'openless-ios-download-')); +try { + const bin = join(scratch, 'bin'); + mkdirSync(bin); + const executable = (name, content) => { + const path = join(bin, name); + writeFileSync(path, '#!/usr/bin/env bash\nset -eu\n' + content); + chmodSync(path, 0o755); + }; + executable('sleep', 'exit 0\n'); + executable( + 'curl', + ` +max=0; speed=0; window=0; resume=''; output='' +while [ "$#" -gt 0 ]; do + case "$1" in + --max-time) max="$2"; shift ;; + --speed-limit) speed="$2"; shift ;; + --speed-time) window="$2"; shift ;; + --continue-at) resume="$2"; shift ;; + -o) output="$2"; shift ;; + esac + shift +done +[ "$max" -gt 0 ] && [ "$speed" -gt 0 ] && [ "$window" -gt 0 ] && [ "$resume" = '-' ] || exit 99 +count=0; [ ! -f "$TRACE" ] || count=$(cat "$TRACE") +count=$((count + 1)); echo "$count" > "$TRACE" +case "$MODE" in + timeout) printf partial > "$output"; exit 28 ;; + resume) + if [ "$count" = 1 ]; then printf partial > "$output"; exit 28; fi + [ "$(cat "$output")" = partial ] || exit 99 + ;; + corrupt) printf corrupt > "$output"; exit 0 ;; +esac +printf verified > "$output" +`, + ); + const sha = createHash('sha256').update('verified').digest('hex'); + const run = (mode, output) => { + const result = spawnSync( + 'bash', + ['-e', '-c', fetchFunction + '\nfetch https://example.invalid/model "$SHA" "$OUTPUT"'], + { + env: { + ...process.env, + PATH: bin + ':' + process.env.PATH, + MODE: mode, + TRACE: join(scratch, mode + '-calls'), + SHA: sha, + OUTPUT: output, + }, + encoding: 'utf8', + timeout: 5000, + }, + ); + assert.ifError(result.error); + return result; + }; + for (const [mode, expectedCalls] of [ + ['success', 1], + ['resume', 2], + ]) { + const output = join(scratch, mode); + const result = run(mode, output); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(readFileSync(output, 'utf8'), 'verified'); + assert.ok(!existsSync(output + '.download')); + assert.equal(Number(readFileSync(join(scratch, mode + '-calls'), 'utf8')), expectedCalls); + assert.equal(run(mode, output).status, 0); + assert.equal(Number(readFileSync(join(scratch, mode + '-calls'), 'utf8')), expectedCalls); + } + for (const [mode, expectedCalls] of [ + ['timeout', 6], + ['corrupt', 1], + ]) { + const output = join(scratch, mode); + assert.notEqual(run(mode, output).status, 0); + assert.ok(!existsSync(output), 'failed downloads must not be promoted to verified files'); + assert.equal(Number(readFileSync(join(scratch, mode + '-calls'), 'utf8')), expectedCalls); + } +} finally { + rmSync(scratch, { recursive: true, force: true }); +} +console.log('ios-ci-downloads.test.mjs 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 70be4e3a6..ef4d22735 100644 --- a/openless-all/app/scripts/linux-egui-release-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -1,3 +1,4 @@ +// Linux automation is paused, but the local packaging/source contracts remain. import assert from 'node:assert/strict'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; @@ -6,84 +7,34 @@ import { fileURLToPath } from 'node:url'; const root = fileURLToPath(new URL('../../..', import.meta.url)); const load = (path) => readFile(join(root, path), 'utf8'); -const [release, ci, check, pack, verify, gate, manifest, tauri, revisionFile, buildScript, host] = +const [ci, tauri, pack, verify, gate, manifest, revisionFile, buildScript, host] = await Promise.all([ - load('.github/workflows/release-linux-egui.yml'), load('.github/workflows/ci.yml'), - load('.github/workflows/check-linux-egui.yml'), + load('.github/workflows/release-tauri.yml'), load('openless-all/app/scripts/package-linux-egui.sh'), load('openless-all/app/scripts/verify-linux-egui-packages.sh'), load('openless-all/app/scripts/check-core-deps.ps1'), load('openless-all/app/linux-egui/Cargo.toml'), - load('.github/workflows/release-tauri.yml'), load('openless-all/app/linux-egui/package-revision'), load('openless-all/app/linux-egui/build.rs'), load('openless-all/app/linux-egui/src/main.rs'), ]); - -// CI must execute the exact test/build/package/checksum path used for tags. -assert.match(ci, /linux-egui-package:[\s\S]*?uses: \.\/\.github\/workflows\/check-linux-egui\.yml/); -assert.match(ci, /linux-egui-package:[\s\S]*?permissions:\s*contents: read/); -// The release entry point needs write access to attach assets; the CI caller is -// read-only and must not be able to publish. -assert.match(release, /permissions:\n contents: write/); -assert.deepEqual( - [...ci.matchAll(/^ (linux-[\w-]+):/gm)].map((match) => match[1]), - ['linux-egui-package'], - 'PR CI must expose one Linux job, including Core and remote TLS checks', -); -assert.match(release, /uses: \.\/\.github\/workflows\/check-linux-egui\.yml/); -assert.match( - check, - /^on:\n workflow_call:/m, - 'the reusable check must not run as a duplicate standalone workflow', -); -assert.match(check, /contents: write/); -assert.match(check, /runs-on: ubuntu-24\.04/); -assert.match(check, /cargo test --locked -p openless-core/); -for (const crate of ['openless-core', 'openless-linux-egui']) { - assert.ok(check.includes(`cargo clippy --locked -p ${crate} --all-targets -- -D warnings`)); +for (const name of ['check-linux-egui.yml', 'release-linux-egui.yml']) { + assert.equal( + existsSync(join(root, '.github/workflows', name)), + false, + `${name}: Linux automation is paused`, + ); } -assert.match( - check, - /cargo test --locked --manifest-path src-tauri\/backend-tests\/Cargo\.toml --test remote_tls/, -); -assert.match(check, /cargo test --locked -p openless-linux-egui --all-targets/); -// `cargo test --all-targets` already compiles every target, so a following -// `cargo check --all-targets` was a second full compile for no extra coverage. -assert.doesNotMatch(check, /cargo check --locked -p openless-linux-egui/); -assert.match(check, /check-core-deps\.ps1 openless-linux-egui/); -assert.match(check, /ctest --test-dir build --output-on-failure/); -assert.match(check, /cargo build --locked --release -p openless-linux-egui/); -assert.match(check, /bash scripts\/package-linux-egui\.sh/); -assert.match(check, /sha256sum \.\/\*\.deb \.\/\*\.rpm > SHA256SUMS/); -assert.match(check, /bash scripts\/verify-linux-egui-packages\.sh/); -assert.match(check, /OpenLess_linux_x86_64\.deb/); -assert.match(check, /OpenLess_linux_x86_64\.rpm/); -assert.match(check, /openless-linux-egui-direct-download/); +assert.doesNotMatch(ci, /linux-egui-package:|check-linux-egui\.yml/); +assert.doesNotMatch(tauri, /release-linux-egui\.yml|LINUX_ACCEPTED_RELEASE_TAG|build-linux-egui:/); +assert.doesNotMatch(ci, /libgtk-3|webkit2gtk|libwebkit/); assert.match(gate, /cargo tree --locked/); assert.match(gate, /tauri\|wry\|webkit2gtk/); -assert.doesNotMatch(ci + check, /libgtk-3|webkit2gtk|libwebkit/); -// Linux artifacts require independent device acceptance and an exact-tag publication gate. -// Shared Tauri tags and PRs must never publish unaccepted Linux packages. -assert.match(release, /^on:\n workflow_dispatch:/m); -assert.doesNotMatch(release, /^ (push|pull_request):/m); -// Accepted Linux packages can accompany the same release; manual and automatic -// publication share the exact-tag acceptance gate before any upload. -assert.match(check, /LINUX_ACCEPTED_RELEASE_TAG/); -assert.match(tauri, /LINUX_ACCEPTED_RELEASE_TAG/); -assert.match(check, /gh release upload/); -assert.match(check, /inputs\.publish/); -assert.match(check, /inputs\.release_tag != ''/); -assert.match(check, /SHA256SUMS-linux/); -assert.match(check, /github\.ref_type == 'tag'/); -assert.match(check, /does not match package\.json version/); const revision = revisionFile.trim(); -assert.match(revision, /^[1-9][0-9]*$/, 'Linux package revision must be a positive integer'); -assert.ok(Number(revision) > 79, 'new Linux packages must upgrade the last -79 candidate'); -assert.match(check, /REVISION=\$\(< linux-egui\/package-revision\)/); -assert.match(check, /version=\$\{VERSION%%\+\*\}-\$REVISION/); +assert.match(revision, /^[1-9][0-9]*$/, 'positive Linux package revision'); +assert.ok(Number(revision) > 79, 'local packages must upgrade the last -79 candidate'); assert.match(buildScript, /rerun-if-changed=package-revision/); assert.match(buildScript, /rerun-if-env-changed=OPENLESS_LINUX_VERSION/); assert.match(buildScript, /OPENLESS_APP_VERSION=\{expected\}/); @@ -92,35 +43,12 @@ assert.match(verify, /DEB_VERSION=\$\(dpkg-deb --field/); assert.match(verify, /OpenLess \$DEB_VERSION/); assert.match(pack, /linux-egui\/package-revision/); assert.match(pack, /if \[ "\$VERSION" != "\$EXPECTED_VERSION" \]/); -assert.ok(!tauri.includes('build-linux-egui:'), 'Linux does not build Tauri'); -assert.match(release, /release_tag: \$\{\{ inputs\.tag \}\}/); -assert.match(release, /checkout_ref: \$\{\{ inputs\.tag \}\}/); - -// Automatic dispatch is enabled only by administrator acceptance of the exact tag. -assert.match( - tauri, - /^ dispatch-platform-releases:$/m, - 'release-tauri.yml must hand the tag to this workflow', -); -assert.match( - tauri, - /gh workflow run release-linux-egui\.yml[\s\S]{0,200}-f "tag=\$RELEASE_TAG"[\s\S]{0,80}-f publish=true/, - 'the Linux hand-off must attach the packages for the same tag', -); - -// Keep deb/rpm only, including dynamically-loaded desktop libraries and the -// fcitx5 addon. No AppImage, minisign, embedded downloader or Qwen ASR runtime. -// -// Rendering is Vulkan-only: the GL/EGL paths were removed (`refactor: remove -// Linux egui OpenGL rendering`), so the packages depend on the Vulkan loader -// and nothing dlopens libEGL / libwayland-egl any more. If libegl1 or -// libwayland-egl1 comes back, a GL renderer was reintroduced. assert.match(pack, /dpkg-deb --build/); assert.match(pack, /rpmbuild --define/); assert.match(pack, /x86_64-linux-gnu\/fcitx5\/libopenless\.so/); assert.match(pack, /\/usr\/lib64\/fcitx5\/libopenless\.so/); -assert.doesNotMatch(pack, /appimagetool|AppImage|qwen-asr|qwen_asr/i); -assert.doesNotMatch(release + check, /appimagetool|APPIMAGE_|MINISIGN|latest-linux-egui/i); +assert.match(pack, /require_file "\$PLUGIN_ROOT\/libopenless\.so"/); +assert.doesNotMatch(pack, /appimagetool|AppImage|qwen-asr|qwen_asr|src-tauri/i); assert.match(verify, /test "\$\{#debs\[@\]\}" -eq 1/); assert.match(verify, /test "\$\{#rpms\[@\]\}" -eq 1/); assert.match(verify, /test "\$\{#appimages\[@\]\}" -eq 0/); @@ -134,23 +62,17 @@ const debDeps = pack.match(/^Depends: ([^\n]+)/m)?.[1].split(/,\s*/) ?? []; for (const dep of ['fcitx5', 'libpipewire-0.3-0', 'libvulkan1', 'liblzma5', 'libwayland-client0']) { 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`, - ); +for (const dep of ['libegl1', 'libgl1', 'libwayland-egl1']) { + assert.ok(!debDeps.includes(dep), `deb must not require ${dep}`); } 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`, - ); +for (const dep of ['libglvnd-egl', 'libwayland-egl.so.1()(64bit)']) { + assert.ok(!rpmDeps.includes(dep), `rpm must not require ${dep}`); } -assert.match(pack, /^Recommends: mesa-vulkan-drivers$/m, 'packages must recommend a Vulkan driver'); +assert.match(pack, /^Recommends: mesa-vulkan-drivers$/m); for (const [file, size] of [ ['32x32.png', '32x32'], ['64x64.png', '64x64'], @@ -165,36 +87,4 @@ for (const [file, size] of [ assert.deepEqual(copy, shared, `${file} must match the shared icon`); assert.ok(pack.includes(`${size}:${file}`)); } -assert.doesNotMatch(pack + release, /src-tauri/); -assert.doesNotMatch( - check, - /cargo (?:build|check|test) --locked --manifest-path src-tauri\/Cargo\.toml/, -); - -// The package installs the fcitx5 plugin, so the packaging job must build it -// even though `ctest` lives in the parallel test job: dropping it made -// `package-linux-egui.sh` fail on an empty `test -s` with no output at all. -{ - const buildJob = check.match(/ build-linux-egui:[\s\S]*$/)?.[0] ?? ''; - assert.match( - buildJob, - /cmake --build build --parallel/, - 'build-linux-egui must compile the fcitx5 plugin that the package installs', - ); - assert.match( - pack, - /require_file "\$PLUGIN_ROOT\/libopenless\.so"/, - 'package-linux-egui.sh must report a missing plugin artifact instead of a bare test -s', - ); -} - -assert.match( - check, - /cargo test --locked -p openless-core --lib \\\n\s+api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics -- --exact/, -); -assert.match( - check, - /grep -Fq 'test api::tests::shared_hotkey_edges_own_hold_auto_and_combo_abort_semantics \.\.\. ok'/, -); -assert.match(check, /needs: \[test-linux-egui, build-linux-egui\]/); -console.log('linux-egui-release-contract.test.mjs passed'); +console.log('linux-egui-release-contract: automation paused, local packaging contracts passed'); diff --git a/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs b/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs index 93c830069..876bdc570 100644 --- a/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs @@ -6,7 +6,6 @@ const appRoot = new URL('..', import.meta.url); const roots = [ new URL('../linux-egui', import.meta.url), new URL('../scripts/package-linux-egui.sh', import.meta.url), - new URL('../../../.github/workflows/release-linux-egui.yml', import.meta.url), ]; const sourceExtensions = new Set(['.rs', '.toml', '.sh', '.yml', '.yaml']); diff --git a/openless-all/app/scripts/micyou-dsp.patch b/openless-all/app/scripts/micyou-dsp.patch index fdf3d5d0a..8b3f3c464 100644 --- a/openless-all/app/scripts/micyou-dsp.patch +++ b/openless-all/app/scripts/micyou-dsp.patch @@ -66,19 +66,47 @@ processing_chain: vec![ "AEC".to_string(), "NoiseReduction".to_string(), -@@ -117,11 +121,6 @@ +@@ -116,38 +120,7 @@ + if !matches!(self.ns_type.as_str(), "PureVox" | "RNNoise" | "Speexdsp") { self.ns_type = Self::default().ns_type; } - } +- self.enforce_aec_availability(); +- } +- +- /// Clears `aec_enabled` when this platform cannot capture a far-end +- /// reference, so a configuration that was force-enabled by hand (or carried +- /// over from another machine) is downgraded instead of processing without +- /// echo cancellation while the UI claims it is running. +- /// +- /// Returns `true` when the stored value had to be changed. +- pub fn enforce_aec_availability(&mut self) -> bool { +- self.apply_aec_availability(crate::aec::aec_reference_availability()) +- } +- +- fn apply_aec_availability(&mut self, availability: crate::aec::AecAvailability) -> bool { +- if !self.aec_enabled || availability.available { +- return false; +- } +- log::warn!( +- "[AEC] Disabled: no far-end reference capture is available ({})", +- availability +- .reason +- .map(|reason| reason.as_str()) +- .unwrap_or("unknown") +- ); +- self.aec_enabled = false; +- true +- } -} - -/// Default output playback buffer headroom in milliseconds. -fn default_output_buffer_ms() -> u32 { - 300 ++ } } // ─── Shared STFT window helpers ─────────────────────────────────────────── -@@ -874,76 +873,13 @@ +@@ -900,76 +873,13 @@ } } @@ -157,7 +185,7 @@ // RNNoise states - separate per channel to avoid RNN state cross-contamination #[cfg(feature = "dsp")] denoiser_left: Box>, -@@ -983,9 +919,6 @@ +@@ -1009,9 +919,6 @@ speex_ns: SpeexStyleNS, // Equalizer equalizer: EqualizerEffect, @@ -167,7 +195,7 @@ // Dereverb state dereverb_buffer_left: Vec, dereverb_buffer_right: Vec, -@@ -996,6 +929,9 @@ +@@ -1022,6 +929,9 @@ agc_smoothed_gain: f32, // VAD fade state (0.0 = muted, 1.0 = full) vad_fade: f32, @@ -177,7 +205,7 @@ // Spectrum snapshots raw_spectrum: Vec, processed_spectrum: Vec, -@@ -1038,10 +974,59 @@ +@@ -1064,10 +974,59 @@ const RNNOISE_FRAME_SIZE: usize = 480; @@ -237,7 +265,7 @@ #[cfg(feature = "dsp")] denoiser_left: DenoiseState::new(), #[cfg(feature = "dsp")] -@@ -1069,13 +1054,15 @@ +@@ -1095,13 +1054,15 @@ #[cfg(feature = "dsp")] speex_ns: SpeexStyleNS::new(), equalizer: EqualizerEffect::new(), @@ -254,7 +282,7 @@ raw_spectrum: vec![0.0; 64], processed_spectrum: vec![0.0; 64], accum_buffer: Vec::new(), -@@ -1101,6 +1088,7 @@ +@@ -1127,6 +1088,7 @@ } let raw_rms = compute_rms(data); @@ -262,7 +290,7 @@ self.compute_spectrum(data, true); // Frame accumulation: align to 480*channels samples before processing, -@@ -1176,6 +1164,14 @@ +@@ -1202,6 +1164,14 @@ settings.agc_target, attack_rate, decay_rate, @@ -277,7 +305,7 @@ ); } } -@@ -1186,7 +1182,12 @@ +@@ -1212,7 +1182,12 @@ // Legacy synthetic plugin stage (may be absent): runs // every registered plugin at this chain position. if let Some(hook) = &mut self.external_hook { @@ -291,7 +319,7 @@ } } node if node.starts_with(PLUGIN_NODE_PREFIX) => { -@@ -1200,8 +1201,8 @@ +@@ -1226,8 +1201,8 @@ } } @@ -302,7 +330,7 @@ // Soft clip — avoids harsh hard-clipping artifacts (crackling/pops) for sample in self.output_buffer.iter_mut() { -@@ -1368,14 +1369,45 @@ +@@ -1394,14 +1369,45 @@ channels: usize, settings: &AudioDspSettings, ) { @@ -352,7 +380,7 @@ } } -@@ -1397,18 +1429,19 @@ +@@ -1423,18 +1429,19 @@ } // Process each channel with its own denoiser (no RNN state cross-contamination) @@ -374,7 +402,7 @@ data.clear(); for i in 0..frames { -@@ -1416,7 +1449,7 @@ +@@ -1442,7 +1449,7 @@ data.push(right[i]); } } else { @@ -383,7 +411,7 @@ data, &mut self.ns_buffer_left, &mut self.denoiser_left, -@@ -1431,12 +1464,13 @@ +@@ -1457,12 +1464,13 @@ ns_buffer: &mut Vec, denoiser: &mut DenoiseState<'static>, intensity: f32, @@ -398,7 +426,7 @@ while ns_buffer.len() >= RNNOISE_FRAME_SIZE { let frame: Vec = ns_buffer.drain(..RNNOISE_FRAME_SIZE).collect(); -@@ -1444,7 +1478,8 @@ +@@ -1470,7 +1478,8 @@ let input_frame: Vec = frame.iter().map(|s| s * 32767.0).collect(); let mut output_frame = vec![0.0f32; RNNOISE_FRAME_SIZE]; @@ -408,7 +436,7 @@ for i in 0..RNNOISE_FRAME_SIZE { let clean = output_frame[i] / 32767.0; -@@ -1463,6 +1498,7 @@ +@@ -1489,6 +1498,7 @@ } data.copy_from_slice(&output); @@ -416,7 +444,7 @@ } #[cfg(feature = "noise-suppression")] -@@ -1618,7 +1654,14 @@ +@@ -1644,7 +1654,14 @@ // ── AGC ───────────────────────────────────────────────────────────────── @@ -432,7 +460,7 @@ let target_linear = target / 32767.0; // Noise gate threshold: below this level, don't apply AGC gain // (prevents amplifying hiss/noise floor during speech pauses) -@@ -1633,7 +1676,7 @@ +@@ -1659,7 +1676,7 @@ } if self.agc_envelope > gate_threshold { let desired_gain = target_linear / self.agc_envelope; @@ -441,7 +469,7 @@ // Smooth gain transition to avoid pops (exponential moving average) let smooth_factor = 0.005_f32; self.agc_smoothed_gain += smooth_factor * (clamped_gain - self.agc_smoothed_gain); -@@ -1671,59 +1714,34 @@ +@@ -1697,59 +1714,34 @@ // ── Spectrum ───────────────────────────────────────────────────────────── @@ -524,7 +552,54 @@ } } -@@ -2096,11 +2114,13 @@ +@@ -2026,46 +2018,6 @@ + "AGC should have amplified the signal" + ); + } +- +- #[test] +- fn aec_is_downgraded_when_no_reference_is_available() { +- let mut settings = AudioDspSettings { +- aec_enabled: true, +- ..Default::default() +- }; +- +- assert!(settings.apply_aec_availability(crate::aec::AecAvailability::unsupported())); +- assert!(!settings.aec_enabled); +- // Idempotent: a second pass has nothing left to downgrade. +- assert!(!settings.apply_aec_availability(crate::aec::AecAvailability::unsupported())); +- } +- +- #[test] +- fn aec_is_kept_when_a_reference_is_available() { +- let mut settings = AudioDspSettings { +- aec_enabled: true, +- ..Default::default() +- }; +- +- assert!(!settings.apply_aec_availability(crate::aec::AecAvailability::ready())); +- assert!(settings.aec_enabled); +- } +- +- #[test] +- fn downgrade_leaves_other_stages_untouched() { +- let mut settings = AudioDspSettings { +- aec_enabled: true, +- ns_enabled: true, +- gain: 6.0, +- ..Default::default() +- }; +- +- settings.apply_aec_availability(crate::aec::AecAvailability::unsupported()); +- +- assert!(!settings.aec_enabled); +- assert!(settings.ns_enabled); +- assert_eq!(settings.gain, 6.0); +- } + } + + // ─── Native model property tests ────────────────────────────────────────── +@@ -2162,11 +2114,13 @@ #[test] fn dsp_processor_chain_smoke() { // Full chain: AEC → PureVox NS through the public DspProcessor API. @@ -543,7 +618,7 @@ let settings = Arc::new(RwLock::new(settings)); let mut dsp = DspProcessor::new(settings, None); -@@ -2127,3 +2147,42 @@ +@@ -2193,3 +2147,42 @@ ); } } diff --git a/openless-all/app/scripts/micyou-upstream.json b/openless-all/app/scripts/micyou-upstream.json index b170cdfa9..334634049 100644 --- a/openless-all/app/scripts/micyou-upstream.json +++ b/openless-all/app/scripts/micyou-upstream.json @@ -1,10 +1,10 @@ { "repository": "MicYou-Dev/MicYou", - "revision": "d821da032e2b72330ced09678dd127acd2714bef", - "patchSha256": "5fdacc4530a0a09f8c179bbb956f52b08636c88eeaa1f7226c1ac9cd6a990c72", + "revision": "692ecbb9cee1ac45da590cadb59b645f23c1ea65", + "patchSha256": "0fc70937f31790947f8e260b5209689b80c18a1187533e38f2d8951342f164db", "files": { "crates/micyou-dsp/src/lib.rs": { - "upstreamSha256": "9e3cb90b48c843554d75704188d4490d197f0134f57cf2fbc2f4a2aad1753d24", + "upstreamSha256": "5595c3078f9e66072711384c9d1376ec27503bafba27d26939f11e608dd37992", "localSha256": "6a366a2e5831f8fb9d68086f57a696f150855e094073e1fd6a8410bf10f2651e" }, "crates/micyou-infer/src/lib.rs": { diff --git a/openless-all/app/scripts/release-publication-contract.test.mjs b/openless-all/app/scripts/release-publication-contract.test.mjs index bdeb5f50a..d789b52af 100644 --- a/openless-all/app/scripts/release-publication-contract.test.mjs +++ b/openless-all/app/scripts/release-publication-contract.test.mjs @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; 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')); @@ -12,30 +11,15 @@ for (const name of ['release-tauri.yml', 'android-apk.yml']) { const steps = Object.values(workflow(name).jobs).flatMap((job) => job.steps ?? []); const publishers = steps.filter((step) => step.uses?.startsWith('softprops/action-gh-release@')); assert.equal(publishers.length, 1, `${name} must have one asset publisher`); - assert.equal( - publishers[0].with.draft, - true, - `${name}: no channel may publish a partially built release`, - ); + assert.equal(publishers[0].with.draft, true, `${name}: never publish a partial release`); assert.match(publishers[0].with.prerelease, /OPENLESS_RELEASE_CHANNEL == 'beta'/); - // Updater manifests belong to the CDN, not to the release assets: current clients read - // `/updates/{stable,beta}/` (see src-tauri/src/update_source.rs), and cloning them into the - // release produced fourteen near-identical files per tag. - assert.doesNotMatch( - publishers[0].with.files, - /latest-[^\s]*\.json/, - `${name}: updater manifests must not be attached to the release`, - ); + assert.doesNotMatch(publishers[0].with.files, /latest-[^\s]*\.json/); const manifestUploads = steps.filter( (step) => step.uses?.startsWith('actions/upload-artifact@') && /latest-[^\s]*\.json/.test(step.with?.path ?? ''), ); - assert.equal( - manifestUploads.length, - 1, - `${name}: exactly one artifact must carry the updater manifests for the CDN sync`, - ); + assert.equal(manifestUploads.length, 1, `${name}: updater manifests belong to the CDN artifact`); } const sync = workflow('sync-homebrew-cask.yml'); assert.deepEqual(sync.on.release.types, ['published']); @@ -57,117 +41,55 @@ for (const name of [ 'Update Casks/openless.rb', 'Commit & push cask bump', ]) { - const step = steps.find((candidate) => candidate.name === name); assert.equal( - step?.if, + steps.find((step) => step.name === name)?.if, "steps.publication.outputs.ready == 'true'", - `${name}: a draft must not change the public cask`, + `${name}: drafts 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/); -const acceptance = publish.steps.find((step) => step.name === 'Verify exact-tag Linux acceptance'); -assert.ok(acceptance); -assert.match(publish.env.LINUX_ACCEPTED_RELEASE_TAG, /vars\.LINUX_ACCEPTED_RELEASE_TAG/); -for (const [tag, accepted, expected] of [ - ['v2.0.2-Beta.5-tauri', '', 1], - ['v2.0.2-Beta.5-tauri', 'v2.0.2-Beta.4-tauri', 1], - ['', '', 1], - ['v2.0.2-Beta.5-tauri', 'v2.0.2-Beta.5-tauri', 0], -]) { - const result = spawnSync('bash', ['-c', acceptance.run], { - env: { ...process.env, RELEASE_TAG: tag, LINUX_ACCEPTED_RELEASE_TAG: accepted }, - }); - assert.equal(result.status, expected, `publication acceptance: ${tag}/${accepted}`); -} const handoff = workflow('release-tauri.yml').jobs['dispatch-platform-releases']; -assert.match(handoff.env.LINUX_ACCEPTED_RELEASE_TAG, /vars\.LINUX_ACCEPTED_RELEASE_TAG/); +assert.equal(handoff.env.LINUX_ACCEPTED_RELEASE_TAG, undefined); const dispatch = handoff.steps.find((step) => step.run?.includes('gh workflow run release-harmony.yml'), ); assert.ok(dispatch); -const directory = mkdtempSync(join(tmpdir(), 'openless-release-acceptance-')); +assert.doesNotMatch(JSON.stringify(handoff), /release-linux-egui|LINUX_ACCEPTED_RELEASE_TAG/); +const directory = mkdtempSync(join(tmpdir(), 'openless-release-handoff-')); try { const calls = join(directory, 'calls'); - const summary = join(directory, 'summary'); writeFileSync(join(directory, 'gh'), '#!/bin/sh\nprintf "%s\\n" "$*" >> "$CALLS"\n', { mode: 0o755, }); - for (const accepted of ['', 'v2.0.2-Beta.4-tauri', 'v2.0.2-Beta.5-tauri']) { + for (const [channel, expected] of [ + ['none', 0], + ['release', 0], + ['test', 0], + ['invalid', 1], + ]) { writeFileSync(calls, ''); - writeFileSync(summary, ''); const result = spawnSync('bash', ['-c', dispatch.run], { env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, CALLS: calls, RELEASE_TAG: 'v2.0.2-Beta.5-tauri', - LINUX_ACCEPTED_RELEASE_TAG: accepted, - HARMONY_CHANNEL: 'none', - GITHUB_STEP_SUMMARY: summary, + HARMONY_CHANNEL: channel, }, }); - assert.equal(result.status, 0); + assert.equal(result.status, expected, channel); const logged = readFileSync(calls, 'utf8'); - assert.match(logged, /release-harmony\.yml.*channel=none/); - assert.equal(logged.includes('release-linux-egui.yml'), accepted === 'v2.0.2-Beta.5-tauri'); - assert.match( - readFileSync(summary, 'utf8'), - accepted - ? accepted.endsWith('Beta.5-tauri') - ? /Linux publication accepted/ - : /Linux omitted/ - : /Linux omitted/, - ); + if (expected === 0) { + assert.match( + logged, + new RegExp( + `release-harmony\\.yml.*--ref v2\\.0\\.2-Beta\\.5-tauri.*tag=v2\\.0\\.2-Beta\\.5-tauri.*channel=${channel}`, + ), + ); + assert.equal(logged.trim().split('\n').length, 1, 'dispatch only Harmony'); + } else { + assert.equal(logged, '', 'bad channel must not dispatch'); + } } } finally { rmSync(directory, { recursive: true, force: true }); diff --git a/openless-all/app/scripts/sync-micyou.py b/openless-all/app/scripts/sync-micyou.py index 2a3ad9146..4f956f1ae 100644 --- a/openless-all/app/scripts/sync-micyou.py +++ b/openless-all/app/scripts/sync-micyou.py @@ -4,6 +4,7 @@ import difflib import hashlib import json +import os from pathlib import Path, PurePosixPath import re import subprocess @@ -49,7 +50,13 @@ def revision(value): def download(url): - request = urllib.request.Request(url, headers={"User-Agent": "OpenLess-MicYou-Sync"}) + headers = {"User-Agent": "OpenLess-MicYou-Sync"} + # Authenticate API lookups to avoid the shared runner IP's anonymous limit. + # Never send the token to raw.githubusercontent.com or another download host. + token = os.environ.get("GH_TOKEN") + if token and url.startswith("https://api.github.com/"): + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(request, timeout=60) as response: data = response.read(16 * 1024 * 1024 + 1) if len(data) > 16 * 1024 * 1024: diff --git a/openless-all/app/scripts/test-windows-runtime-target.py b/openless-all/app/scripts/test-windows-runtime-target.py index 8d36064df..d14bce33f 100644 --- a/openless-all/app/scripts/test-windows-runtime-target.py +++ b/openless-all/app/scripts/test-windows-runtime-target.py @@ -11,6 +11,9 @@ app = Path(__file__).resolve().parents[1] vendor = app / 'src-tauri/vendor/tauri-runtime-wry' helper = vendor / 'src/owner_thread.rs' +# CI reuses the already-restored host target. Local runs retain the isolated +# default; never hardcode an executable path separate from Cargo's target. +target = Path(os.environ.get('CARGO_TARGET_DIR') or app / 'src-tauri/target/ownership-harness').resolve() parser = argparse.ArgumentParser() parser.add_argument('--runtime', action='store_true') args = parser.parse_args() @@ -18,7 +21,7 @@ def cargo(root, *arguments, capture=False): env = os.environ.copy() - env['CARGO_TARGET_DIR'] = str(app / 'src-tauri/target/ownership-harness') + env['CARGO_TARGET_DIR'] = str(target) return subprocess.run( ['cargo', *arguments, '--manifest-path', str(root / 'Cargo.toml')], check=True, text=True, encoding='utf-8', capture_output=capture, env=env, @@ -51,7 +54,7 @@ def cargo(root, *arguments, capture=False): result = subprocess.run( ['cargo', 'check', '--manifest-path', str(root / 'Cargo.toml'), '--bin', name], text=True, encoding='utf-8', capture_output=True, - env={**os.environ, 'CARGO_TARGET_DIR': str(app / 'src-tauri/target/ownership-harness')}, + env={**os.environ, 'CARGO_TARGET_DIR': str(target)}, ) if result.returncode == 0 or diagnostic not in result.stderr: raise RuntimeError(name + ' did not fail for the expected ownership reason:\n' + result.stderr) @@ -92,7 +95,7 @@ def cargo(root, *arguments, capture=False): patched = [p for p in isolated['packages'] if p['name'] == 'tauri-runtime-wry'] assert len(patched) == 1 and Path(patched[0]['manifest_path']).resolve() == (vendor / 'Cargo.toml').resolve() cargo(root, 'build', '--locked') - exe = app / 'src-tauri/target/ownership-harness/debug/openless-runtime-ownership-tests.exe' + exe = target / 'debug/openless-runtime-ownership-tests.exe' for mode in ['unstarted', 'iterations', 'return', 'run']: result = subprocess.run([str(exe), mode], timeout=120) expected_code = 23 if mode == 'run' else 0 diff --git a/openless-all/app/scripts/test_ci_harness_cache.py b/openless-all/app/scripts/test_ci_harness_cache.py new file mode 100644 index 000000000..c45185492 --- /dev/null +++ b/openless-all/app/scripts/test_ci_harness_cache.py @@ -0,0 +1,74 @@ +"""Check temporary source harnesses honor the CI target without running native code.""" +import os +from pathlib import Path +import runpy +import subprocess +import tempfile +import unittest +from unittest import mock + +SCRIPTS = Path(__file__).resolve().parent + + +class HarnessCacheTests(unittest.TestCase): + def run_harness(self, script, args=(), target=None): + calls = [] + + def cargo(command, **kwargs): + env = kwargs.get("env", os.environ) + calls.append((command, env.get("CARGO_TARGET_DIR"))) + if "owner_must_not_move" in command: + return subprocess.CompletedProcess(command, 1, "", "E0277") + if "borrow_must_not_escape" in command: + return subprocess.CompletedProcess(command, 1, "", "lifetime may not live long enough") + return subprocess.CompletedProcess(command, 0, "", "") + + with mock.patch.dict(os.environ), mock.patch("sys.argv", [script, *args]): + if target is None: + os.environ.pop("CARGO_TARGET_DIR", None) + else: + os.environ["CARGO_TARGET_DIR"] = str(target) + with mock.patch("subprocess.run", side_effect=cargo): + namespace = runpy.run_path(str(SCRIPTS / script), run_name="__main__") + return calls, namespace + + def test_owner_harness_honors_shared_target_for_tests_and_compile_fail_checks(self): + with tempfile.TemporaryDirectory() as directory: + target = Path(directory).resolve() / "host-target" + calls, namespace = self.run_harness("test-windows-runtime-target.py", target=target) + self.assertEqual(namespace["target"], target) + self.assertEqual(len(calls), 3) + self.assertTrue(all(value == str(target) for _, value in calls)) + self.assertIn("test", calls[0][0]) + self.assertTrue(all("check" in command for command, _ in calls[1:])) + + def test_owner_local_default_is_still_isolated(self): + calls, namespace = self.run_harness("test-windows-runtime-target.py") + expected = (SCRIPTS.parent / "src-tauri/target/ownership-harness").resolve() + self.assertEqual(namespace["target"], expected) + self.assertTrue(all(value == str(expected) for _, value in calls)) + + def test_diagnostics_reuses_target_without_dropping_off_on_or_facade_coverage(self): + with tempfile.TemporaryDirectory() as directory: + calls, _ = self.run_harness("test-windows-hang-diagnostics.py", target=directory) + self.assertEqual(len(calls), 4) + self.assertTrue(all(value == directory for _, value in calls)) + self.assertEqual(sum("--features" in command for command, _ in calls), 2) + self.assertEqual(sum("test" in command for command, _ in calls), 2) + self.assertEqual(sum("run" in command for command, _ in calls), 2) + + def test_recorder_preserves_target_and_test_or_cross_check_mode(self): + for args in [(), ("--check", "--target", "x86_64-pc-windows-msvc")]: + with self.subTest(args=args), tempfile.TemporaryDirectory() as directory: + calls, _ = self.run_harness("test-recorder-module.py", args, directory) + self.assertEqual(len(calls), 1) + command, target = calls[0] + self.assertEqual(target, directory) + self.assertEqual(command[1], "check" if args else "test") + if args: + self.assertIn("--tests", command) + self.assertIn("x86_64-pc-windows-msvc", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/openless-all/app/scripts/test_sync_micyou.py b/openless-all/app/scripts/test_sync_micyou.py index 78a621fd9..95f963921 100644 --- a/openless-all/app/scripts/test_sync_micyou.py +++ b/openless-all/app/scripts/test_sync_micyou.py @@ -88,6 +88,28 @@ def test_local_changes_and_pinned_corruption_are_not_overwritten(self): with self.assertRaisesRegex(ValueError, "integrity mismatch"): sync.sync(self.root, self.manifest, "a"*40, True) + def test_api_authentication_never_leaks_to_download_hosts(self): + for url, authenticated in [ + ("https://api.github.com/repos/MicYou-Dev/MicYou/commits/master", True), + ("https://raw.githubusercontent.com/MicYou-Dev/MicYou/sha/LICENSE", False), + ("https://api.github.com.evil.example/file", False), + ]: + with self.subTest(url=url), mock.patch.dict(os.environ, {"GH_TOKEN": "test-token"}): + with mock.patch.object(sync.urllib.request, "urlopen") as request: + request.return_value.__enter__.return_value.read.return_value = b"source" + self.assertEqual(sync.download(url), b"source") + headers = dict(request.call_args.args[0].header_items()) + self.assertEqual(headers.get("Authorization"), "Bearer test-token" if authenticated else None) + + def test_reviewed_adapter_stays_dsp_only(self): + # Upstream AEC availability depends on MicYou's device host. OpenLess + # supplies its own far-end capture; importing that host would not build. + source = (sync.APP_ROOT / self.path).read_text() + self.assertNotIn("crate::aec::", source) + patch = (sync.APP_ROOT / sync.PATCH).read_text() + self.assertIn("- self.enforce_aec_availability();", patch) + sync.verify_local(sync.APP_ROOT, sync.load_manifest(sync.APP_ROOT)) + def test_patch_cannot_escape_source_allowlist(self): with self.assertRaisesRegex(ValueError, "only the three"): sync.apply_patch(self.sources, b"--- a/../../escape\n+++ b/../../escape\n@@ -1 +1 @@\n-old\n+new\n") diff --git a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs index ea96ac047..f7c965ab3 100644 --- a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs +++ b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs @@ -46,9 +46,9 @@ assert.ok( assert.ok( workflows.ci.includes( - "group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.platform || 'all' }}-${{ inputs.macos_runner || 'macos-latest' }}", + "group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}-${{ inputs.platform || 'all' }}-${{ inputs.macos_runner || 'macos-latest' }}-${{ inputs.windows_build_jobs || '2' }}", ), - 'CI must group equivalent requests without mixing event, platform or runner selections', + 'CI must group equivalent requests without mixing event, platform, runner or compiler parallelism selections', ); assert.ok( workflows.ci.includes( diff --git a/openless-all/ios/scripts/bootstrap-local-speech.sh b/openless-all/ios/scripts/bootstrap-local-speech.sh index 8c9cf4229..6f98c671a 100755 --- a/openless-all/ios/scripts/bootstrap-local-speech.sh +++ b/openless-all/ios/scripts/bootstrap-local-speech.sh @@ -12,7 +12,8 @@ fetch() { # truncate a partially downloaded file back to its initial offset. local attempt for attempt in 1 2 3 4 5 6; do - if curl --http1.1 -fL --continue-at - --connect-timeout 20 "$url" -o "$output.download"; then break; fi + if curl --http1.1 -fL --continue-at - --connect-timeout 20 \ + --max-time 180 --speed-limit 1024 --speed-time 30 "$url" -o "$output.download"; then break; fi [ "$attempt" != 6 ] || return 1 sleep 2 done diff --git a/openless-all/ios/scripts/bootstrap.sh b/openless-all/ios/scripts/bootstrap.sh index ee27bfd81..73cf090cb 100755 --- a/openless-all/ios/scripts/bootstrap.sh +++ b/openless-all/ios/scripts/bootstrap.sh @@ -16,7 +16,12 @@ grep -q '^MIT License' "$ROOT/.vendor/KeyboardKit/LICENSE" cp "$ROOT/.vendor/KeyboardKit/LICENSE" "$ROOT/Resources/Licenses/KeyboardKit-LICENSE.txt" checkout https://github.com/rime/rime-pinyin-simp.git "$ROOT/.vendor/rime-pinyin-simp" 0c6861ef7420ee780270ca6d993d18d4101049d0 BOOST="$ROOT/.vendor/boost_1_85_0.tar.bz2" -[ -f "$BOOST" ] || curl -LfsS --retry 2 https://archives.boost.io/release/1.85.0/source/boost_1_85_0.tar.bz2 -o "$BOOST" +if [ ! -f "$BOOST" ]; then + curl -LfsS --retry 2 --connect-timeout 20 --max-time 300 \ + --speed-limit 1024 --speed-time 30 https://archives.boost.io/release/1.85.0/source/boost_1_85_0.tar.bz2 -o "$BOOST.download" + echo "7009fe1faa1697476bdc7027703a2badb84e849b7b0baad5086b087b971f8617 $BOOST.download" | shasum -a 256 -c - + mv "$BOOST.download" "$BOOST" +fi echo "7009fe1faa1697476bdc7027703a2badb84e849b7b0baad5086b087b971f8617 $BOOST" | shasum -a 256 -c - [ -d "$ROOT/.vendor/boost_1_85_0/boost" ] || tar -xjf "$BOOST" -C "$ROOT/.vendor" boost_1_85_0/boost boost_1_85_0/LICENSE_1_0.txt swift build --package-path "$ROOT/.build-tools/XcodeGen" -c release diff --git a/openless-all/ios/scripts/build-core.sh b/openless-all/ios/scripts/build-core.sh index 3b793c0b5..5cca70f98 100755 --- a/openless-all/ios/scripts/build-core.sh +++ b/openless-all/ios/scripts/build-core.sh @@ -17,7 +17,7 @@ for spec in "device:aarch64-apple-ios:iphoneos" "simulator:aarch64-apple-ios-sim [ "$PLATFORM" = all ] || [ "$PLATFORM" = "$platform" ] || continue mkdir -p "$ROOT/Frameworks/$platform" IPHONEOS_DEPLOYMENT_TARGET=16.0 SDKROOT="$(xcrun --sdk "$sdk" --show-sdk-path)" \ - rustup run 1.99.0 cargo build --manifest-path "$ROOT/CoreHost/Cargo.toml" --locked --release --target "$target" + rustup run 1.99.0 cargo build --manifest-path "$ROOT/CoreHost/Cargo.toml" --locked --release --target "$target" --timings cp "$CARGO_TARGET_DIR/$target/release/libopenless_ios_host.a" "$ROOT/Frameworks/$platform/libOpenLessCore.a" python3 "$ROOT/scripts/validate-deployment.py" "$ROOT/Frameworks/$platform/libOpenLessCore.a" done From 4256d52e6ebbd325cb5548272b15464fef67d473 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 10 Nov 2026 21:16:19 +0800 Subject: [PATCH 2/7] release: stop generating duplicate download aliases in future runs --- .github/workflows/android-apk.yml | 24 +++-------- .github/workflows/release-harmony.yml | 1 - .github/workflows/release-tauri.yml | 23 +---------- RELEASING.md | 22 ++++------ .../android-apk-workflow-contract.test.mjs | 7 ++-- .../scripts/harmony-release-contract.test.mjs | 13 ++++-- .../app/scripts/package-harmony-hap.sh | 6 +-- .../release-publication-contract.test.mjs | 41 ++++++++++++++++++- 8 files changed, 69 insertions(+), 68 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 06dba14d8..0c22ae786 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -512,19 +512,6 @@ jobs: "$out_dir/OpenLess_${version}_x86.apk" \ "$out_dir/OpenLess_${version}_x86_64.apk" - - name: Stage direct download aliases - if: needs.plan.outputs.is_tag_release == 'true' - working-directory: openless-all/app - env: - OPENLESS_DOWNLOAD_SOURCE_DIR: ${{ steps.stage.outputs.out_dir }} - OPENLESS_DOWNLOAD_OUTPUT_DIR: ${{ steps.stage.outputs.out_dir }} - run: | - set -euo pipefail - for arch in aarch64 armv7 i686 x86_64; do - OPENLESS_DOWNLOAD_TARGET=android OPENLESS_DOWNLOAD_ARCH="$arch" \ - node scripts/stage-release-download-aliases.mjs - done - - name: Write Android updater manifests working-directory: openless-all/app env: @@ -550,17 +537,18 @@ jobs: path: | ${{ steps.stage.outputs.out_dir }}/latest-android-*.json - - name: Append release files (signatures + aliases) + - name: Append release files (originals + signatures) id: release_assets shell: bash run: | set -euo pipefail - out_dir="${{ steps.stage.outputs.out_dir }}" { echo "${{ steps.stage.outputs.release_files }}" - for f in "$out_dir"/*.apk.sig "$out_dir"/OpenLess_android_*.apk; do - [ -e "$f" ] && echo "$f" - done + while IFS= read -r f; do + if [ -n "$f" ] && [ -f "$f.sig" ]; then + echo "$f.sig" + fi + done <<< "${{ steps.stage.outputs.release_files }}" } > "$RUNNER_TEMP/android-release-files.txt" echo "files<> "$GITHUB_OUTPUT" cat "$RUNNER_TEMP/android-release-files.txt" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index d9101ff6f..c1c44cb0e 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -311,7 +311,6 @@ jobs: output=target/harmony-packages gh release upload "$RELEASE_TAG" \ "$output"/OpenLess_harmony_arm64.hap \ - "$output"/OpenLess_harmony.hap \ "$output"/SHA256SUMS \ --clobber echo "attached HarmonyOS assets to $RELEASE_TAG" diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 7a2b8ba03..9e9b17223 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -637,27 +637,6 @@ jobs: fi done - - name: Stage direct download alias - if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') - working-directory: openless-all/app - env: - OPENLESS_DOWNLOAD_TARGET: ${{ matrix.updater-target }} - OPENLESS_DOWNLOAD_ARCH: ${{ matrix.updater-arch }} - OPENLESS_DOWNLOAD_SOURCE_DIR: ${{ matrix.updater-target == 'darwin' && 'src-tauri/target/release/bundle/dmg' || 'src-tauri/target/release/bundle/nsis' }} - OPENLESS_DOWNLOAD_OUTPUT_DIR: src-tauri/target/release/downloads - run: node scripts/stage-release-download-aliases.mjs - - - name: Upload direct download aliases - if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') - timeout-minutes: 10 - uses: actions/upload-artifact@v4 - with: - retention-days: 14 - name: openless-download-alias-${{ matrix.updater-target }}-${{ matrix.updater-arch }} - compression-level: 0 - path: openless-all/app/src-tauri/target/release/downloads/* - if-no-files-found: error - - name: Upload macOS artifacts if: startsWith(matrix.platform, 'macos') uses: actions/upload-artifact@v4 @@ -822,7 +801,7 @@ jobs: while IFS= read -r -d '' file; do base=$(basename "$file") case "$base" in - *.json|*.pdb|openless.exe) + OpenLess_macos_*|OpenLess_windows_*|*.json|*.pdb|openless.exe) ;; *.dmg|*.dmg.sig|*.msi|*.msi.sig|*.app.tar.gz|*.app.tar.gz.sig|*setup.exe|*setup.exe.sig) cp "$file" "$dest/$base" diff --git a/RELEASING.md b/RELEASING.md index e13f93ca7..ec4cbd9e8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -58,8 +58,8 @@ acceptance in [docs/linux-egui-handoff/07-acceptance.md](docs/linux-egui-handoff for that exact candidate. Compilation alone is not device acceptance. Desktop and Android delivery remain independent; Linux has no in-app updater or AppImage. -HarmonyOS builds, signs and attaches `OpenLess_harmony_arm64.hap` plus the -`OpenLess_harmony.hap` alias, and can upload the signed package (App Pack) to +HarmonyOS builds, signs and attaches the single `OpenLess_harmony_arm64.hap`, +and can upload the signed package (App Pack) to HUAWEI AppGallery Connect. The automatic dispatch uses `channel: none`, the repository path: the HAP is attached to the GitHub Release unsigned and nothing reaches the store. Set the repository variable `HARMONY_AUTO_CHANNEL` to @@ -86,7 +86,7 @@ redistribution, which is also why it is fetched at job time rather than vendored | Channel | Artifact | Signing | Where it goes | | --- | --- | --- | --- | -| Repository / GitHub Release | `OpenLess_harmony_arm64.hap` (+ `OpenLess_harmony.hap`) | **unsigned** | Release assets, `SHA256SUMS` | +| Repository / GitHub Release | `OpenLess_harmony_arm64.hap` | **unsigned** | Release assets, `SHA256SUMS` | | AppGallery 正式版 | `OpenLess_harmony_arm64-signed.hap` | `release.*` material | AppGallery only, never a Release asset | | 测试渠道 | `OpenLess_harmony_arm64-signed.hap` | `test.*` material | AppGallery test channel | @@ -112,21 +112,13 @@ from the last `-N` suffix (for example `2.0.0-Beta.2-79` → `2.0.0-Beta.2-80`). Local packaging uses this explicit revision (Linux CI/tag builds are paused); never drop it or derive it from the Tauri tag, whose SemVer remains `2.0.0-Beta.2`. -## Direct download aliases +## Download assets: no duplicate aliases in new releases -The release workflows also publish stable, version-independent asset names for the official website: +Future release runs publish only the original versioned macOS DMGs, Windows installers and Android APKs (with their original signatures). HarmonyOS publishes only `OpenLess_harmony_arm64.hap` and its checksum. They no longer generate or attach the duplicate names `OpenLess_macos_*`, `OpenLess_windows_*`, `OpenLess_android_*` or `OpenLess_harmony.hap`. macOS `.app.tar.gz` updater archives and their signatures are distinct products and remain published. Linux automation remains paused. -- macOS Apple Silicon: `OpenLess_macos_aarch64.dmg` -- macOS Intel: `OpenLess_macos_x86_64.dmg` -- Windows x64: `OpenLess_windows_x86_64-setup.exe` -- Android: `OpenLess_android_.apk` (one each for `arm64-v8a`, `armeabi-v7a`, `x86`, and `x86_64`) -- Linux (manual distribution only while automation is paused): `OpenLess_linux_x86_64.deb` and `OpenLess_linux_x86_64.rpm` +Existing releases and their download aliases are not migrated or deleted. The alias helper remains available for legacy/manual use, but no automatic release workflow invokes it. Partial desktop reruns also filter out old alias artifacts instead of publishing them again; they do not delete assets already attached to an existing release. -Stable website links use `https://github.com/Open-Less/openless/releases/latest/download/`. -Beta links must use the release tag because `latest` excludes pre-releases: -`https://github.com/Open-Less/openless/releases/download//`. - -The Tauri and Android workflows attach their aliases automatically. Linux automation is paused; any manually accepted Linux distribution must provide its aliases alongside the versioned packages. Do not replace the versioned assets; the aliases are an additional website-facing contract. +Website/CDN download resolution must use the actual asset names from the selected release API (or an external redirect to those assets), rather than assume the old fixed alias exists in a new release. This change does not implement a website redirect. Stable selection may use the latest stable release; beta selection must explicitly select its prerelease tag because GitHub `latest` excludes prereleases. Historical fixed-name links remain valid for historical releases. Updater manifest generation and its CDN artifact handoff are unchanged. Under the [2026-09-06 2.0 requirements](docs/2.0-requirements.md), Windows and macOS must fully retain their respective Tauri 1.x features. The egui team owns Linux Host/UI work and Linux product acceptance. Linux application gaps do not block the Windows/macOS delivery, but shared Core defects do. Linux packages must not be attached before their own product acceptance. Existing Android builds do not expand this scope into a new full-support commitment. diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs index 90312c3cd..ef46f4b98 100644 --- a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -155,8 +155,8 @@ assert.match(workflow, /CARGO_PROFILE_RELEASE_LTO/); assert.match(workflow, /first ABI may compile twice|android-studio-script/); assert.match(workflow, /publish-android-release/); assert.match(workflow, /download-artifact/); -assert.match(workflow, /stage-release-download-aliases\.mjs/); -assert.match(workflow, /OpenLess_android_\*\.apk/); +assert.doesNotMatch(workflow, /stage-release-download-aliases\.mjs/); +assert.doesNotMatch(workflow, /OpenLess_android_\*\.apk/); assert.match(workflow, /Rust cache/); // Exercise the upload list so overlapping globs cannot upload the same asset twice. @@ -197,7 +197,8 @@ try { const assets = readFileSync(join(releaseTmp, 'android-release-files.txt'), 'utf8') .trim() .split(/\r?\n/); - assert.deepEqual(assets.sort(), names.map((name) => `${directory}/${name}`).sort()); + assert.deepEqual(assets.sort(), originals.flatMap((path) => [path, `${path}.sig`]).sort()); + assert.ok(assets.every((path) => !path.includes('/OpenLess_android_'))); const manifestUpload = parsed.jobs['publish-android-release'].steps.find((step) => step.uses?.startsWith('actions/upload-artifact@'), ); diff --git a/openless-all/app/scripts/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs index 30ca73d50..b5b33e85b 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -120,11 +120,12 @@ assert.match(packaging, /sign-harmony-hap\.py/); assert.match(packaging, /--prefix "\$SIGN_PREFIX"/); assert.match( packaging, - /sha256sum \.\/OpenLess_harmony_arm64\.hap \.\/OpenLess_harmony\.hap > SHA256SUMS/, - 'Release checksums must cover only the unsigned HAP pair uploaded to GitHub', + /sha256sum \.\/OpenLess_harmony_arm64\.hap > SHA256SUMS/, + 'Release checksums must cover the single unsigned HAP uploaded to GitHub', ); assert.match(packaging, /OpenLess_harmony_arm64\.hap/); -assert.match(packaging, /OpenLess_harmony\.hap/); +assert.doesNotMatch(packaging, /OpenLess_harmony\.hap/); +assert.doesNotMatch(workflow, /OpenLess_harmony\.hap/); assert.match(signing, /HARMONY_KEYSTORE_PASSWORD/); assert.match(signing, /\[redacted\]/); @@ -169,7 +170,11 @@ assert.match( 'the downstream workflow definition must be loaded from the same immutable release tag', ); assert.match(tauriRelease, /gh workflow run release-harmony\.yml[\s\S]{0,400}-f publish=true/); -assert.doesNotMatch(tauriRelease, /release-linux-egui|LINUX_ACCEPTED_RELEASE_TAG/, 'Linux automation is paused'); +assert.doesNotMatch( + tauriRelease, + /release-linux-egui|LINUX_ACCEPTED_RELEASE_TAG/, + 'Linux automation is paused', +); assert.match( tauriRelease, /github\.event_name == 'push'[\s\S]{0,120}refs\/tags\/v/, diff --git a/openless-all/app/scripts/package-harmony-hap.sh b/openless-all/app/scripts/package-harmony-hap.sh index 00dcc259e..7e8a45a99 100755 --- a/openless-all/app/scripts/package-harmony-hap.sh +++ b/openless-all/app/scripts/package-harmony-hap.sh @@ -12,7 +12,6 @@ # # Output: target/harmony-packages/ # OpenLess_harmony_arm64.hap versioned artifact -# OpenLess_harmony.hap stable alias used by the website links # SHA256SUMS set -euo pipefail @@ -156,7 +155,6 @@ mkdir -p "$OUTPUT" # The repository and the GitHub Release distribute the unsigned package, so a # 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. @@ -167,7 +165,7 @@ if [ -n "$SIGNED" ]; then # of the Release. cp "$SIGNED" "$OUTPUT/OpenLess_harmony_arm64-signed.hap" fi -# SHA256SUMS belongs to the GitHub Release, which contains only the unsigned HAP pair. -(cd "$OUTPUT" && sha256sum ./OpenLess_harmony_arm64.hap ./OpenLess_harmony.hap > SHA256SUMS) +# SHA256SUMS belongs to the GitHub Release, which contains only the unsigned HAP. +(cd "$OUTPUT" && sha256sum ./OpenLess_harmony_arm64.hap > SHA256SUMS) echo "HAP packages ready in $OUTPUT" ls -l "$OUTPUT" diff --git a/openless-all/app/scripts/release-publication-contract.test.mjs b/openless-all/app/scripts/release-publication-contract.test.mjs index d789b52af..b03c9849b 100644 --- a/openless-all/app/scripts/release-publication-contract.test.mjs +++ b/openless-all/app/scripts/release-publication-contract.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { readFileSync, mkdtempSync, mkdirSync, readdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -21,6 +21,45 @@ for (const name of ['release-tauri.yml', 'android-apk.yml']) { ); assert.equal(manifestUploads.length, 1, `${name}: updater manifests belong to the CDN artifact`); } +const desktop = workflow('release-tauri.yml'); +assert.doesNotMatch( + JSON.stringify(desktop), + /stage-release-download-aliases|openless-download-alias/, +); +const staging = mkdtempSync(join(tmpdir(), 'openless-release-assets-')); +try { + const source = join(staging, 'openless-release-artifacts', 'desktop'); + mkdirSync(source, { recursive: true }); + const originals = [ + 'OpenLess_2.0.2-Beta.7_x64-setup.exe', + 'OpenLess_2.0.2-Beta.7_x64-setup.exe.sig', + 'OpenLess_2.0.2-Beta.7_aarch64.dmg', + 'OpenLess_aarch64.app.tar.gz', + 'OpenLess_aarch64.app.tar.gz.sig', + ]; + // Old artifacts can survive a partial rerun: do not publish their aliases again. + const excluded = [ + 'OpenLess_windows_x86_64-setup.exe', + 'OpenLess_windows_x86_64-setup.exe.sig', + 'OpenLess_macos_aarch64.dmg', + 'latest-darwin-aarch64.json', + 'openless.exe', + 'debug.pdb', + ]; + for (const name of [...originals, ...excluded]) writeFileSync(join(source, name), 'fixture'); + const stage = Object.values(desktop.jobs) + .flatMap((job) => job.steps ?? []) + .find((step) => step.name === 'Stage release files'); + assert.ok(stage); + const result = spawnSync('bash', ['-c', stage.run], { + env: { ...process.env, RUNNER_TEMP: staging, GITHUB_WORKSPACE: staging }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.deepEqual(readdirSync(join(staging, 'release-files')).sort(), originals.sort()); +} finally { + rmSync(staging, { recursive: true, force: true }); +} const sync = workflow('sync-homebrew-cask.yml'); assert.deepEqual(sync.on.release.types, ['published']); assert.ok(sync.on.workflow_dispatch.inputs.tag.required); From 4e7402a8feb39437ae65a0aee1f674ae71916ea9 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 10 Nov 2026 22:10:01 +0800 Subject: [PATCH 3/7] =?UTF-8?q?[pi]=20=E6=98=AF=E7=9A=84=EF=BC=8C**?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E6=9B=B4=E6=96=B0=E7=9A=84=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=EF=BC=8C=E4=BD=86=E7=89=88=E6=9C=AC=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E3=80=81=E4=B8=8B=E8=BD=BD=E6=BA=90=E5=92=8C=E7=AD=BE?= =?UTF-8?q?=E5=90=8D=E9=AA=8C=E8=AF=81=E7=AD=96=E7=95=A5=E5=BA=94=E8=AF=A5?= =?UTF-8?q?=E7=BB=9F=E4=B8=80**=E3=80=82=E4=B8=8D=E8=83=BD=E8=AE=A9?= =?UTF-8?q?=E5=90=84=E5=B9=B3=E5=8F=B0=E5=90=84=E8=87=AA=E5=86=B3=E5=AE=9A?= =?UTF-8?q?=E2=80=9C=E4=BB=8E...?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android-apk.yml | 14 +++- .github/workflows/release-tauri.yml | 18 +++++ .../app/scripts/merge-release-signatures.mjs | 65 +++++++++++++++++ .../app/src-tauri/src/update_source.rs | 16 +++- .../src-tauri/src/update_source/android.rs | 19 +++-- .../src/update_source/artifact_fallback.rs | 73 +++++++++++++++++++ .../src/update_source/signature_index.rs | 30 ++++++++ 7 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 openless-all/app/scripts/merge-release-signatures.mjs create mode 100644 openless-all/app/src-tauri/src/update_source/artifact_fallback.rs create mode 100644 openless-all/app/src-tauri/src/update_source/signature_index.rs diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 0c22ae786..798d79de3 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -425,6 +425,9 @@ jobs: publish-android-release: timeout-minutes: 30 name: Publish Android release assets + concurrency: + group: openless-release-assets-${{ github.ref_name }} + cancel-in-progress: false if: needs.plan.outputs.is_tag_release == 'true' needs: [plan, build-android-apk] permissions: @@ -537,7 +540,14 @@ jobs: path: | ${{ steps.stage.outputs.out_dir }}/latest-android-*.json - - name: Append release files (originals + signatures) + - name: Consolidate release signatures + timeout-minutes: 5 + working-directory: openless-all/app + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/merge-release-signatures.mjs "${{ steps.stage.outputs.out_dir }}" "$GITHUB_REF_NAME" "$GITHUB_REPOSITORY" + + - name: Append release files (originals + signature index) id: release_assets shell: bash run: | @@ -549,6 +559,8 @@ jobs: echo "$f.sig" fi done <<< "${{ steps.stage.outputs.release_files }}" + index="${{ steps.stage.outputs.out_dir }}/updater-signatures.json" + if [ -f "$index" ]; then echo "$index"; fi } > "$RUNNER_TEMP/android-release-files.txt" echo "files<> "$GITHUB_OUTPUT" cat "$RUNNER_TEMP/android-release-files.txt" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 9e9b17223..9a8b5206b 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -730,6 +730,10 @@ jobs: # are not copied into the release. publish: name: Publish Tauri release + # Same lock as Android: merge/upload one shared signature index without races. + concurrency: + group: openless-release-assets-${{ inputs.release_tag || github.ref_name }} + cancel-in-progress: false needs: build if: >- !cancelled() && @@ -816,6 +820,20 @@ jobs: echo "Staged $copied release file(s):" find "$dest" -type f -print + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + path: release-tooling + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Consolidate release signatures + timeout-minutes: 5 + env: + GH_TOKEN: ${{ github.token }} + run: node release-tooling/openless-all/app/scripts/merge-release-signatures.mjs release-files "$OPENLESS_RELEASE_TAG" "$GITHUB_REPOSITORY" + - name: Create / update release timeout-minutes: 20 uses: softprops/action-gh-release@v2 diff --git a/openless-all/app/scripts/merge-release-signatures.mjs b/openless-all/app/scripts/merge-release-signatures.mjs new file mode 100644 index 000000000..e4d65dad0 --- /dev/null +++ b/openless-all/app/scripts/merge-release-signatures.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// Called only inside the shared release-assets job concurrency lock. +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +export const INDEX_NAME = 'updater-signatures.json'; +export function signatureFromIndex(index, tag, name) { + if (index.schema !== 1 || index.release_tag !== tag || !Object.hasOwn(index.artifacts ?? {}, name)) throw new Error('Invalid signature index or missing artifact'); + const value = index.artifacts[name]; + if (typeof value.signature !== 'string' || !value.signature.trim()) throw new Error('Missing signature'); + return value.signature.trim(); +} +export function mergeSignatures(directory, tag, previous) { + if (previous && (previous.schema !== 1 || previous.release_tag !== tag || !previous.artifacts || Array.isArray(previous.artifacts))) throw new Error('Signature index schema/tag mismatch'); + const artifacts = Object.assign(Object.create(null), previous?.artifacts ?? {}); + for (const name of Object.keys(artifacts)) { + if (basename(name) !== name || name.includes('\\') || name === '__proto__') throw new Error('Invalid artifact name'); + signatureFromIndex(previous, tag, name); + } + const signatures = readdirSync(directory).filter((name) => name.endsWith('.sig')).sort(); + if (!signatures.length) throw new Error('No local signatures to consolidate'); + for (const name of signatures) { + const artifact = name.slice(0, -4); + if (!existsSync(join(directory, artifact))) throw new Error(`Orphan signature: ${name}`); + const signature = readFileSync(join(directory, name), 'utf8').trim(); + if (!signature) throw new Error(`Empty signature: ${name}`); + const bytes = readFileSync(join(directory, artifact)); + artifacts[artifact] = { signature, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }; + } + return { schema: 1, release_tag: tag, artifacts: Object.fromEntries(Object.entries(artifacts).sort(([a], [b]) => a.localeCompare(b))) }; +} +function gh(args) { + return spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 }); +} +function main() { + const [directoryArg, tag, repository] = process.argv.slice(2); + if (!directoryArg || !/^v[^/]+-tauri$/.test(tag ?? '') || !/^[\w.-]+\/[\w.-]+$/.test(repository ?? '')) throw new Error('Usage: merge-release-signatures.mjs '); + const directory = resolve(directoryArg); + const result = gh(['api', `repos/${repository}/releases/tags/${tag}`]); + let previous; + if (result.status === 0) { + const release = JSON.parse(result.stdout); + if (!release.draft) throw new Error('Refusing to mutate signatures on a published release'); + const asset = release.assets.find((a) => a.name === INDEX_NAME); + if (!asset && release.assets.some((a) => a.name.endsWith('.sig'))) { + console.log('Legacy draft: retain its original detached signature format'); + return; + } + if (asset) { + const download = gh(['api', `repos/${repository}/releases/assets/${asset.id}`, '-H', 'Accept: application/octet-stream']); + if (download.status !== 0) throw new Error(`Cannot read existing signature index: ${download.stderr}`); + previous = JSON.parse(download.stdout); + } + } else if (!result.stderr.includes('HTTP 404')) { + throw new Error(`Cannot inspect release: ${result.stderr}`); + } + const index = mergeSignatures(directory, tag, previous); + writeFileSync(join(directory, INDEX_NAME), JSON.stringify(index, null, 2) + '\n'); + for (const name of readdirSync(directory).filter((name) => name.endsWith('.sig'))) unlinkSync(join(directory, name)); + console.log(`Consolidated ${Object.keys(index.artifacts).length} signatures in ${INDEX_NAME}`); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/openless-all/app/src-tauri/src/update_source.rs b/openless-all/app/src-tauri/src/update_source.rs index ac60abdfc..05bb34b3a 100644 --- a/openless-all/app/src-tauri/src/update_source.rs +++ b/openless-all/app/src-tauri/src/update_source.rs @@ -3,6 +3,7 @@ //! Tauri still verifies the signed artifact and version before installation. use crate::types::UpdateChannel; +mod signature_index; /// CDN that serves both the update manifests and the mirrored release artifacts. pub(crate) const CDN_BASE: &str = "https://dl.aeoform.com"; @@ -216,9 +217,11 @@ async fn github_api_manifest(channel: UpdateChannel) -> Result Result Result Option<(St })?; let (signature, _) = paths .iter() - .find(|(_, candidate)| *candidate == format!("{name}.sig"))?; + .find(|(_, candidate)| *candidate == signature_index::NAME) + .or_else(|| paths.iter().find(|(_, candidate)| *candidate == format!("{name}.sig")))?; Some(( format!("https://github.com{artifact}"), format!("https://github.com{signature}"), diff --git a/openless-all/app/src-tauri/src/update_source/android.rs b/openless-all/app/src-tauri/src/update_source/android.rs index 852dac394..f7c71f9e9 100644 --- a/openless-all/app/src-tauri/src/update_source/android.rs +++ b/openless-all/app/src-tauri/src/update_source/android.rs @@ -13,7 +13,7 @@ const METADATA_REQUEST_BUDGET: Duration = Duration::from_secs(8); const PROBE_BUDGET: Duration = Duration::from_secs(4); const METADATA_LIMIT: usize = 2 * 1024 * 1024; const API_METADATA_LIMIT: usize = 8 * 1024 * 1024; -const SIGNATURE_LIMIT: usize = 16 * 1024; +const SIGNATURE_LIMIT: usize = super::signature_index::LIMIT; fn github_url(url: &Url) -> bool { url.host_str() == Some("github.com") @@ -280,7 +280,7 @@ fn latest_android_release<'a>( .find(|release| api_artifact(release, arch).is_some()) } -fn decode_asset_name(encoded: &str) -> Option { +pub(super) fn decode_asset_name(encoded: &str) -> Option { let mut bytes = Vec::with_capacity(encoded.len()); let mut chars = encoded.as_bytes().iter().copied(); while let Some(ch) = chars.next() { @@ -310,7 +310,8 @@ fn api_artifact<'a>( let name = artifact["name"].as_str()?; let signature = assets .iter() - .find(|a| a["name"].as_str() == Some(&format!("{name}.sig")))?; + .find(|a| a["name"].as_str() == Some(super::signature_index::NAME)) + .or_else(|| assets.iter().find(|a| a["name"].as_str() == Some(&format!("{name}.sig"))))?; let artifact_url = artifact["browser_download_url"].as_str()?; let signature_url = signature["browser_download_url"].as_str()?; let expected = format!("/Open-Less/openless/releases/download/{tag}/"); @@ -382,9 +383,11 @@ async fn api_manifest( *candidate_seen .lock() .unwrap_or_else(|error| error.into_inner()) = Some(full_version.clone()); - let signature = text(sig_url, SIGNATURE_LIMIT) + let body = text(sig_url, SIGNATURE_LIMIT) .await .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; + let signature = super::signature_index::extract(&body, tag, name, sig_url.ends_with(super::signature_index::NAME)) + .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; let version = android_signature_version(signature.trim(), name, &version.to_string(), arch) .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; Ok( @@ -408,7 +411,8 @@ fn web_artifact(page: &str, tag: &str, arch: &str) -> Option<(String, String, St .filter_map(|(p, n)| Some((artifact_version(n, arch)?, p, n))) .max_by(|a, b| a.0.cmp(&b.0)) .map(|(_, p, n)| (p, n))?; - let signature = paths.iter().find(|(_, n)| *n == format!("{name}.sig"))?.0; + let signature = paths.iter().find(|(_, n)| *n == super::signature_index::NAME) + .or_else(|| paths.iter().find(|(_, n)| *n == format!("{name}.sig")))?.0; Some(( name.clone(), format!("https://github.com{path}"), @@ -449,9 +453,12 @@ where *candidate_seen .lock() .unwrap_or_else(|error| error.into_inner()) = Some(full_version.clone()); - let signature = fetch(sig_url, SIGNATURE_LIMIT).await.map_err(|error| { + let indexed = sig_url.ends_with(super::signature_index::NAME); + let body = fetch(sig_url, SIGNATURE_LIMIT).await.map_err(|error| { candidate_error(&full_version, &format!("public signature: {error}")) })?; + let signature = super::signature_index::extract(&body, tag, &name, indexed) + .map_err(|error| candidate_error(&full_version, &error))?; let version = android_signature_version(signature.trim(), &name, &version.to_string(), arch) .map_err(|error| candidate_error(&full_version, &error))?; diff --git a/openless-all/app/src-tauri/src/update_source/artifact_fallback.rs b/openless-all/app/src-tauri/src/update_source/artifact_fallback.rs new file mode 100644 index 000000000..342507a01 --- /dev/null +++ b/openless-all/app/src-tauri/src/update_source/artifact_fallback.rs @@ -0,0 +1,73 @@ +//! Adapted from PR #1399: prefer the Aliyun binary, retain the same signed bytes on GitHub. +use std::time::Duration; +const BUDGET: Duration = Duration::from_secs(8); + +fn decode(value: &str) -> Option { super::android::decode_asset_name(value) } +fn segment(value: &str) -> bool { !value.is_empty() && !value.contains("..") && !value.contains(['/', '\\', '\0']) } +fn encode(value: &str) -> String { + let mut output = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) { output.push(byte as char); } + else { output.push_str(&format!("%{byte:02X}")); } + } + output +} +fn parts(raw: &str, github: bool) -> Option<(String, String)> { + let url = url::Url::parse(raw).ok()?; + if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() + || url.port().is_some() || url.query().is_some() || url.fragment().is_some() { return None; } + let prefix = if github { + if url.host_str()? != "github.com" { return None; } + "/Open-Less/openless/releases/download/" + } else { + if url.host_str()? != "dl.aeoform.com" { return None; } + "/releases/" + }; + let mut path = url.path().strip_prefix(prefix)?.split('/'); + let tag = decode(path.next()?)?; let asset = decode(path.next()?)?; + if path.next().is_some() || !segment(&tag) || !segment(&asset) { return None; } + Some((tag, asset)) +} +pub(crate) fn github_fallback_for_cdn_artifact(raw: &str) -> Option { + let (tag, asset) = parts(raw, false)?; + Some(format!("https://github.com/Open-Less/openless/releases/download/{}/{}", encode(&tag), encode(&asset))) +} +fn aliyun_for_github(raw: &str) -> Option { + let (tag, asset) = parts(raw, true)?; + Some(format!("https://dl.aeoform.com/releases/{}/{}", encode(&tag), encode(&asset))) +} +/// A failed/forbidden HEAD is confirmed with a bounded range request. Redirects +/// use the updater's existing origin allowlist; neither probe buffers the payload. +async fn available(raw: &str) -> bool { + let Ok(url) = url::Url::parse(raw) else { return false; }; + let client = crate::net::anonymous_no_redirect_http(); + let status = match client.head(url.clone()).timeout(BUDGET).send().await { + Ok(response) => response.status(), + Err(_) => return false, + }; + if status.is_success() { return true; } + if !status.is_redirection() && status != reqwest::StatusCode::FORBIDDEN && status != reqwest::StatusCode::METHOD_NOT_ALLOWED { return false; } + tokio::time::timeout(BUDGET, super::android::probe_android_download(raw)).await.ok().flatten().is_some() +} +pub(crate) async fn resolve_artifact_download_url(raw: &str) -> String { + let cdn = if parts(raw, false).is_some() { raw.to_string() } + else if let Some(cdn) = aliyun_for_github(raw) { cdn } + else { return raw.to_string(); }; + if available(&cdn).await { cdn } + else { github_fallback_for_cdn_artifact(&cdn).unwrap_or_else(|| raw.to_string()) } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn exact_asset_mapping_and_rejection() { + let cdn = "https://dl.aeoform.com/releases/v2-Beta.1-tauri/OpenLess_2%2Bbuild_x64-setup.exe"; + let github = github_fallback_for_cdn_artifact(cdn).unwrap(); + assert_eq!(aliyun_for_github(&github).unwrap(), cdn); + assert_eq!(github_fallback_for_cdn_artifact(&cdn.replace("%2B", "+")).unwrap(), github); + for invalid in ["http://dl.aeoform.com/releases/v2/a", "https://dl.aeoform.com.attacker.invalid/releases/v2/a", "https://u@dl.aeoform.com/releases/v2/a", "https://dl.aeoform.com/releases/v2/a%2Fb", "https://dl.aeoform.com/releases/v2/a?x=1", "https://dl.aeoform.com/releases/v2/a/b", "https://dl.aeoform.com/releases/v2/%2e%2e", "https://dl.aeoform.com/updates/beta/latest.json"] { + assert!(github_fallback_for_cdn_artifact(invalid).is_none(), "{invalid}"); + } + } +} diff --git a/openless-all/app/src-tauri/src/update_source/signature_index.rs b/openless-all/app/src-tauri/src/update_source/signature_index.rs new file mode 100644 index 000000000..8789a5359 --- /dev/null +++ b/openless-all/app/src-tauri/src/update_source/signature_index.rs @@ -0,0 +1,30 @@ +//! Release metadata only. The embedded minisign signature must still verify the payload. +pub(super) const NAME: &str = "updater-signatures.json"; +pub(super) const LIMIT: usize = 128 * 1024; + +pub(super) fn extract(body: &str, tag: &str, artifact: &str, indexed: bool) -> Result { + if body.len() > LIMIT { return Err("signature metadata exceeds size limit".into()); } + if !indexed { return Ok(body.trim().to_string()); } + let index: serde_json::Value = serde_json::from_str(body).map_err(|_| "invalid signature index")?; + if index["schema"].as_u64() != Some(1) || index["release_tag"].as_str() != Some(tag) { + return Err("signature index schema/tag mismatch".into()); + } + index["artifacts"][artifact]["signature"].as_str().map(str::trim) + .filter(|value| !value.is_empty()).map(str::to_owned) + .ok_or_else(|| "artifact missing from signature index".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn indexed_and_legacy_signatures() { + let body = r#"{"schema":1,"release_tag":"v2-tauri","artifacts":{"app.exe":{"signature":" signed "}}}"#; + assert_eq!(extract(body, "v2-tauri", "app.exe", true).unwrap(), "signed"); + assert!(extract(body, "v3-tauri", "app.exe", true).is_err()); + assert!(extract(body, "v2-tauri", "other.exe", true).is_err()); + assert!(extract("{}", "v2-tauri", "app.exe", true).is_err()); + assert!(extract(&"x".repeat(LIMIT + 1), "v2-tauri", "app.exe", true).is_err()); + assert_eq!(extract(" old-signature\n", "v1", "app", false).unwrap(), "old-signature"); + } +} From 78db1ae326e61ff4c10c03f4bf39ed47692a3138 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 10 Nov 2026 22:46:39 +0800 Subject: [PATCH 4/7] release: migrate OSS mirroring and prefer Aliyun with signed GitHub fallback --- .github/workflows/android-apk.yml | 14 +- .github/workflows/mirror-release.yml | 44 +++++ RELEASING.md | 17 +- docs/ci-runner-balance.md | 2 +- docs/release-distribution.md | 75 ++++++++ .../app/scripts/merge-release-signatures.mjs | 58 ++++-- openless-all/app/scripts/mirror-release.py | 170 +++++++++++++++++ .../app/scripts/release-distribution.test.mjs | 177 ++++++++++++++++++ .../app/scripts/test_release_mirror.py | 82 ++++++++ .../src-tauri/src/android/update_pipeline.rs | 39 ++++ .../app/src-tauri/src/commands/settings.rs | 50 ++++- openless-all/app/src-tauri/src/lib.rs | 2 + .../app/src-tauri/src/update_source.rs | 33 +++- .../src-tauri/src/update_source/android.rs | 42 ++++- .../src/update_source/artifact_fallback.rs | 115 +++++++++--- .../src/update_source/signature_index.rs | 35 +++- .../app/src/components/AutoUpdate.tsx | 28 ++- openless-all/app/src/lib/ipc/index.ts | 1 + openless-all/app/src/lib/ipc/updater.ts | 6 + openless-all/app/src/lib/updateDownload.ts | 17 ++ 20 files changed, 937 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/mirror-release.yml create mode 100644 docs/release-distribution.md create mode 100644 openless-all/app/scripts/mirror-release.py create mode 100644 openless-all/app/scripts/release-distribution.test.mjs create mode 100644 openless-all/app/scripts/test_release_mirror.py create mode 100644 openless-all/app/src/lib/updateDownload.ts diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 798d79de3..2d77d78d6 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -554,13 +554,15 @@ jobs: set -euo pipefail { echo "${{ steps.stage.outputs.release_files }}" - while IFS= read -r f; do - if [ -n "$f" ] && [ -f "$f.sig" ]; then - echo "$f.sig" - fi - done <<< "${{ steps.stage.outputs.release_files }}" index="${{ steps.stage.outputs.out_dir }}/updater-signatures.json" - if [ -f "$index" ]; then echo "$index"; fi + if [ -f "$index" ]; then + echo "$index" + else + # Targeted repairs of historical drafts keep their original format. + while IFS= read -r f; do + if [ -n "$f" ] && [ -f "$f.sig" ]; then echo "$f.sig"; fi + done <<< "${{ steps.stage.outputs.release_files }}" + fi } > "$RUNNER_TEMP/android-release-files.txt" echo "files<> "$GITHUB_OUTPUT" cat "$RUNNER_TEMP/android-release-files.txt" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/mirror-release.yml b/.github/workflows/mirror-release.yml new file mode 100644 index 000000000..e06aa84be --- /dev/null +++ b/.github/workflows/mirror-release.yml @@ -0,0 +1,44 @@ +name: Mirror release assets to OSS + +on: + release: + types: [published, edited] + schedule: + # Repair missed events without the website's old 15-minute full downloads. + - cron: '17 */6 * * *' + workflow_dispatch: + repository_dispatch: + types: [mirror-release] + +permissions: + contents: read + +concurrency: + group: openless-oss-release-mirror + cancel-in-progress: false + +jobs: + mirror: + if: github.repository == 'Open-Less/openless' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + # Published tags are data, never executable code in this credentialed job. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Install distribution tools + timeout-minutes: 10 + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends rclone minisign + - name: Mirror verified packages then update channels + timeout-minutes: 45 + env: + GH_TOKEN: ${{ github.token }} + OSS_BUCKET: ${{ secrets.OSS_BUCKET }} + OSS_AK: ${{ secrets.OSS_ACCESS_KEY_ID }} + OSS_SK: ${{ secrets.OSS_ACCESS_KEY_SECRET }} + OSS_ENDPOINT: ${{ vars.OSS_ENDPOINT || 'oss-cn-zhangjiakou.aliyuncs.com' }} + run: python3 openless-all/app/scripts/mirror-release.py diff --git a/RELEASING.md b/RELEASING.md index ec4cbd9e8..5fc45e24c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -100,7 +100,7 @@ Dispatch the workflow with `channel: none` (default), `channel: release` or `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 +the single unsigned HAP and its checksum, 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: @@ -114,7 +114,7 @@ it or derive it from the Tauri tag, whose SemVer remains `2.0.0-Beta.2`. ## Download assets: no duplicate aliases in new releases -Future release runs publish only the original versioned macOS DMGs, Windows installers and Android APKs (with their original signatures). HarmonyOS publishes only `OpenLess_harmony_arm64.hap` and its checksum. They no longer generate or attach the duplicate names `OpenLess_macos_*`, `OpenLess_windows_*`, `OpenLess_android_*` or `OpenLess_harmony.hap`. macOS `.app.tar.gz` updater archives and their signatures are distinct products and remain published. Linux automation remains paused. +Future release runs publish only the original versioned macOS DMGs, Windows installers and Android APKs. Updater signatures are consolidated into one `updater-signatures.json` per release, not separate `.sig` attachments. Temporary `.sig` files remain in build artifacts to generate the index and CDN manifests. HarmonyOS publishes only `OpenLess_harmony_arm64.hap` and its checksum. They no longer generate or attach the duplicate names `OpenLess_macos_*`, `OpenLess_windows_*`, `OpenLess_android_*` or `OpenLess_harmony.hap`. macOS `.app.tar.gz` updater archives remain published, with signatures in the index. Linux automation remains paused. See [release distribution](docs/release-distribution.md) for format, compatibility limits and cutover. Existing releases and their download aliases are not migrated or deleted. The alias helper remains available for legacy/manual use, but no automatic release workflow invokes it. Partial desktop reruns also filter out old alias artifacts instead of publishing them again; they do not delete assets already attached to an existing release. @@ -158,8 +158,10 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as The workflows also write the channel updater manifests (`latest-{target}-{arch}.json` and `latest-{target}-{arch}-beta.json`) but deliberately keep them **out** of the release assets — - current clients read the CDN, not the release. Download them from the run and publish them to - the CDN as part of publishing the release: + current clients can read the CDN and independently discover signed GitHub releases. + The main repository's `mirror-release.yml` now mirrors published binaries, verifies their + Minisign signatures, and generates/uploads rolling manifests. Downloadable run artifacts + remain available for diagnosis or manual recovery: | platform | workflow artifact | | --- | --- | @@ -176,8 +178,11 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as ``` Their `url` fields must point at the CDN artifacts (`.../releases/{tag}/{asset}`). - Manifest upload is the step that makes a release reachable by auto-update: a published - release whose manifests were not synced is downloadable by hand but invisible to the updater. + The mirror must upload binaries successfully before advancing rolling manifests. New clients + can fall back to GitHub release discovery/download if Aliyun is unavailable; older CDN-only + clients still require rolling manifests. Configure `OSS_BUCKET`, `OSS_ACCESS_KEY_ID` and + `OSS_ACCESS_KEY_SECRET` in the main repository before cutting over the website workflow. + The website migration must only be merged after a successful main-repository mirror run. Clients built before the CDN switch read the old `releases/latest/download/latest-*.json` assets, which new releases no longer carry, so they need one manual download. 7. Publishing Stable with the administrator's CLI/UI credential automatically diff --git a/docs/ci-runner-balance.md b/docs/ci-runner-balance.md index ec586aed8..5072ac450 100644 --- a/docs/ci-runner-balance.md +++ b/docs/ci-runner-balance.md @@ -67,7 +67,7 @@ ### 本地验证与待验证 - 修复了既有 `ci-performance.test.mjs` 的机器配置泄漏:清除 `CARGO_TARGET_DIR` 并不能覆盖 `~/.cargo/config.toml` 的 `build.target-dir`,先前第一轮构建被重定向到全局目录,第二轮却显式使用 fixture 目录,导致 `fresh` 断言失败。现在所有离线 fixture 命令都显式使用同一临时 target;复用、非测试生产构建及故意损坏的 feature 编译失败用例全部通过。 -- `npm test` 完整通过:生产前端构建及 169 个 JS/TS 测试文件(包含新增的复用/合并和 iOS 下载契约)。 +- `npm test` 完整通过:生产前端构建及 170 个 JS/TS 测试文件(包含新增的复用/合并、iOS 下载、签名汇总与双源下载回退契约)。 - 4 个 Python harness 路由/模式用例通过;真实 Rust 所有权 helper 的 7 项单测及 2 项编译失败边界在指定 target 下通过。 - Actionlint、相关格式与 `git diff --check` 通过。完整 Windows runtime/录音/COM 原生执行和 macOS 沙箱需要对应平台的 Actions 验证。 diff --git a/docs/release-distribution.md b/docs/release-distribution.md new file mode 100644 index 000000000..1cde96db4 --- /dev/null +++ b/docs/release-distribution.md @@ -0,0 +1,75 @@ +# Release 签名索引、OSS 同步与双源更新 + +本次在 PR #1426 中补充单一签名 JSON、从 `aeoform/openless-workspace` 迁移 OSS 同步,并适配 PR #1399 的阿里云优先/GitHub 二进制回退。只改变配置生效后的发行;不删除或迁移已有 GitHub Release 附件。 + +## 单一签名 JSON + +新 Release 只附加 `updater-signatures.json`,格式: + +```json +{ + "schema": 1, + "release_tag": "v2.0.2-Beta.7-tauri", + "artifacts": { + "OpenLess_2.0.2-Beta.7+build.example_x64-setup.exe": { + "signature": "原始 Tauri Base64 Minisign 签名", + "size": 123, + "sha256": "安装包 SHA-256" + } + } +} +``` + +- 构建过程仍由 Tauri/`tauri signer sign` 生成临时独立 `.sig`,CDN 清单继续内嵌原签名,不变更密钥、签名算法或验签标准。 +- 桌面与 Android 的发布 job 共用 `openless-release-assets-` 并发锁;每个发布者读入草稿上已有索引,合并自己平台,再上传同一个 JSON,不能覆盖丢失另一个平台的记录。不新增聚合 VM。 +- 合并成功后只删除本机 staging 中的独立 `.sig`,不删除远端历史附件。历史草稿若已有 `.sig` 而无索引,维修时保持旧格式;已发布的 Release 拒绝修改签名索引。 +- 新客户端优先解析同一 Release 的索引,检查 schema、标签和精确文件名;历史 Release 仍读取独立 `.sig`。最终必须用内置公钥验证包本身与 trusted comment,不能把索引里的 SHA-256 当成来源认证。 +- **旧客户端兼容限制**:已安装、只认识独立 `.sig` 的客户端仍可通过 CDN 内嵌签名更新,但无法在 CDN 完全不可用时独立发现只附加索引的新 Release。这不能靠修改未来发行文件修复;需先在 CDN 可用时升级到支持索引的新客户端。历史 Release 原有 GitHub 路径不变。 + +## 阿里云优先,GitHub 托底 + +保留当前独立 GitHub 版本发现、CDN 清单备份、精确版本绑定和 Android 防降级检查,不倒退到只能依赖 CDN 发现版本的旧架构。 + +- 即使新版本先由 GitHub 发现,也先尝试同标签、同文件名的阿里云对象。 +- CDN HEAD 不成功时,403/405/重定向使用有超时、来源限制的 Range 探测;缺失、超时、网络失败则选择 GitHub。 +- 映射仅允许官方 HTTPS 地址,拒绝用户信息、查询参数、错误端口、额外路径和编码后的路径穿越;`+` 与 `%2B` 对应同一原包。 +- 桌面在存入 Tauri resource 前选择源。若阿里云在探测成功后中断或验签失败,前端只重试下载:后端克隆原 resource 的相同版本和签名,改成 GitHub URL。安装失败不触发换源安装重试。 +- Android 对相同候选依次尝试阿里云和 GitHub,每次完整传输后均验签;超时、流中断、长度不匹配或签名失败均不能把坏包交给安装器。 +- 阿里云传输总预算 120 秒,GitHub 600 秒;Android 既有整体下载与分块停滞预算仍保留。两源都失败则明确报错,不声称更新成功。 + +## OSS 同步迁入主仓库 + +实现:`.github/workflows/mirror-release.yml` 和 `openless-all/app/scripts/mirror-release.py`。 + +触发:Release published/edited、手动/repository dispatch,以及每 6 小时的补偿调度。必须合并到默认分支才能启用事件和调度。只执行默认分支的工具,不用带 OSS 密钥的 job 执行 Release 标签内代码。 + +1. 读取已发布的最新 Stable 和 Beta;不处理草稿。网站旧任务每 15 分钟下载 3+3 个标签的方式不再沿用。 +2. 镜像它们的实际附件,校验下载长度与 GitHub 提供的 SHA-256。 +3. 用索引(新格式)或独立 `.sig`(历史格式)获取签名,用内置公钥运行 Minisign 验证所有七个更新目标,并检查签名中的完整版本/文件名。 +4. 两个 macOS、Windows NSIS 和四个 Android ABI 必须齐全;缺包、缺签名、校验失败时不推进该渠道。 +5. 先把包上传到 `releases//`,全部成功后才替换 `updates//` 的滚动 JSON。包保持 immutable 缓存头,清单缓存 60 秒。每个对象的 PUT 是原子的,不声称七个平台的清单能事务性同时切换。 +6. 使用 `rclone copy` 而非 `sync`/`purge`,不删除历史 OSS 对象,也不修改 GitHub 附件。无需重写或单独上传每个平台的 `.sig` 才能让新版 CDN 客户端更新。 + +### 必须人工完成的切换前置条件 + +主仓库目前尚未配置以下三个仓库 Secrets: + +- `OSS_BUCKET` +- `OSS_ACCESS_KEY_ID` +- `OSS_ACCESS_KEY_SECRET` + +可选仓库变量 `OSS_ENDPOINT`,默认 `oss-cn-zhangjiakou.aliyuncs.com`。Bucket 继续私有,不将凭据放入代码、日志或普通变量。GitHub API 无法读回网站仓库已有 Secret 的值,必须由凭据持有者重新配置。 + +切换顺序: + +1. 在主仓库配置三个 Secrets,合并本 PR。 +2. 手动运行主仓库 `Mirror release assets to OSS`,验证七个平台清单、对应对象与签名;无需新发版即可读取现有发布版。 +3. 再合并网站仓库的迁移 PR,删除旧镜像 workflow 和重复生成器;保留网站部署与 release.json 快照。 + +网站仓库的迁移分支不会自动禁用线上旧任务,避免凭据未就绪时切断现有更新。 + +## 验证边界 + +本地离线测试覆盖两发布者任意顺序合并、重跑不丢记录、旧格式维修不迁移、已发布拒绝改写、API 错误不覆盖、索引标签/缺记录错误、七目标生成、历史签名、缺包/摘要/验签失败不推进、下载中途错误后一次 GitHub 重试。Rust 直接包含生产模块的独立 harness 测试覆盖更新发现、签名解析、源合并与 APK 验签。 + +真实 OSS 上传、桌面系统安装、Android 设备安装及真实网络故障注入仍需远端/设备验收;未在本地调用 OSS 写入或触发产品发行。 diff --git a/openless-all/app/scripts/merge-release-signatures.mjs b/openless-all/app/scripts/merge-release-signatures.mjs index e4d65dad0..71b1b574b 100644 --- a/openless-all/app/scripts/merge-release-signatures.mjs +++ b/openless-all/app/scripts/merge-release-signatures.mjs @@ -8,19 +8,35 @@ import { spawnSync } from 'node:child_process'; export const INDEX_NAME = 'updater-signatures.json'; export function signatureFromIndex(index, tag, name) { - if (index.schema !== 1 || index.release_tag !== tag || !Object.hasOwn(index.artifacts ?? {}, name)) throw new Error('Invalid signature index or missing artifact'); + if ( + index.schema !== 1 || + index.release_tag !== tag || + !Object.hasOwn(index.artifacts ?? {}, name) + ) + throw new Error('Invalid signature index or missing artifact'); const value = index.artifacts[name]; - if (typeof value.signature !== 'string' || !value.signature.trim()) throw new Error('Missing signature'); + if (typeof value.signature !== 'string' || !value.signature.trim()) + throw new Error('Missing signature'); return value.signature.trim(); } export function mergeSignatures(directory, tag, previous) { - if (previous && (previous.schema !== 1 || previous.release_tag !== tag || !previous.artifacts || Array.isArray(previous.artifacts))) throw new Error('Signature index schema/tag mismatch'); + if ( + previous && + (previous.schema !== 1 || + previous.release_tag !== tag || + !previous.artifacts || + Array.isArray(previous.artifacts)) + ) + throw new Error('Signature index schema/tag mismatch'); const artifacts = Object.assign(Object.create(null), previous?.artifacts ?? {}); for (const name of Object.keys(artifacts)) { - if (basename(name) !== name || name.includes('\\') || name === '__proto__') throw new Error('Invalid artifact name'); + if (basename(name) !== name || name.includes('\\') || name === '__proto__') + throw new Error('Invalid artifact name'); signatureFromIndex(previous, tag, name); } - const signatures = readdirSync(directory).filter((name) => name.endsWith('.sig')).sort(); + const signatures = readdirSync(directory) + .filter((name) => name.endsWith('.sig')) + .sort(); if (!signatures.length) throw new Error('No local signatures to consolidate'); for (const name of signatures) { const artifact = name.slice(0, -4); @@ -28,16 +44,29 @@ export function mergeSignatures(directory, tag, previous) { const signature = readFileSync(join(directory, name), 'utf8').trim(); if (!signature) throw new Error(`Empty signature: ${name}`); const bytes = readFileSync(join(directory, artifact)); - artifacts[artifact] = { signature, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }; + artifacts[artifact] = { + signature, + size: bytes.length, + sha256: createHash('sha256').update(bytes).digest('hex'), + }; } - return { schema: 1, release_tag: tag, artifacts: Object.fromEntries(Object.entries(artifacts).sort(([a], [b]) => a.localeCompare(b))) }; + return { + schema: 1, + release_tag: tag, + artifacts: Object.fromEntries(Object.entries(artifacts).sort(([a], [b]) => a.localeCompare(b))), + }; } function gh(args) { return spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 }); } function main() { const [directoryArg, tag, repository] = process.argv.slice(2); - if (!directoryArg || !/^v[^/]+-tauri$/.test(tag ?? '') || !/^[\w.-]+\/[\w.-]+$/.test(repository ?? '')) throw new Error('Usage: merge-release-signatures.mjs '); + if ( + !directoryArg || + !/^v[^/]+-tauri$/.test(tag ?? '') || + !/^[\w.-]+\/[\w.-]+$/.test(repository ?? '') + ) + throw new Error('Usage: merge-release-signatures.mjs '); const directory = resolve(directoryArg); const result = gh(['api', `repos/${repository}/releases/tags/${tag}`]); let previous; @@ -50,8 +79,14 @@ function main() { return; } if (asset) { - const download = gh(['api', `repos/${repository}/releases/assets/${asset.id}`, '-H', 'Accept: application/octet-stream']); - if (download.status !== 0) throw new Error(`Cannot read existing signature index: ${download.stderr}`); + const download = gh([ + 'api', + `repos/${repository}/releases/assets/${asset.id}`, + '-H', + 'Accept: application/octet-stream', + ]); + if (download.status !== 0) + throw new Error(`Cannot read existing signature index: ${download.stderr}`); previous = JSON.parse(download.stdout); } } else if (!result.stderr.includes('HTTP 404')) { @@ -59,7 +94,8 @@ function main() { } const index = mergeSignatures(directory, tag, previous); writeFileSync(join(directory, INDEX_NAME), JSON.stringify(index, null, 2) + '\n'); - for (const name of readdirSync(directory).filter((name) => name.endsWith('.sig'))) unlinkSync(join(directory, name)); + for (const name of readdirSync(directory).filter((name) => name.endsWith('.sig'))) + unlinkSync(join(directory, name)); console.log(`Consolidated ${Object.keys(index.artifacts).length} signatures in ${INDEX_NAME}`); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/openless-all/app/scripts/mirror-release.py b/openless-all/app/scripts/mirror-release.py new file mode 100644 index 000000000..c99957988 --- /dev/null +++ b/openless-all/app/scripts/mirror-release.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Mirror published releases, then publish rolling manifests after binary upload. +Migrated from aeoform/openless-workspace/tools and mirror-release.yml. +No remote pruning: existing release objects and historical GitHub assets are untouched. +""" +from __future__ import annotations +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import urllib.parse +import urllib.request + +REPO = 'Open-Less/openless' +BASE = 'https://dl.aeoform.com' +INDEX = 'updater-signatures.json' +TARGETS = { + 'darwin-aarch64': r'^OpenLess_aarch64\.app\.tar\.gz$', + 'darwin-x86_64': r'^OpenLess_x86_64\.app\.tar\.gz$', + 'windows-x86_64': r'^OpenLess_\d.*_x64-setup\.exe$', + 'android-aarch64': r'^OpenLess_\d.*_arm64-v8a\.apk$', + 'android-armv7': r'^OpenLess_\d.*_armeabi-v7a\.apk$', + 'android-i686': r'^OpenLess_\d.*_x86\.apk$', + 'android-x86_64': r'^OpenLess_\d.*_x86_64\.apk$', +} + +def tag_version(tag): + match = re.fullmatch(r'v(\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?)-tauri', tag) + if not match: + raise ValueError('Invalid release tag') + return match[1] + +def signature_for(directory, tag, name): + index = directory / INDEX + if index.exists(): + value = json.loads(index.read_text()) + if value.get('schema') != 1 or value.get('release_tag') != tag: + raise ValueError('Signature index schema/tag mismatch') + entry = value['artifacts'][name] + data = (directory / name).read_bytes() + if entry['size'] != len(data) or entry['sha256'] != hashlib.sha256(data).hexdigest(): + raise ValueError('Index payload digest mismatch') + return entry['signature'].strip() + return (directory / (name + '.sig')).read_text().strip() + +def signed_version(signature, name, tag, legacy_version=None): + text = base64.b64decode(signature, validate=True).decode() + comment = next(line[len('trusted comment: '):] for line in text.splitlines() if line.startswith('trusted comment: ')) + fields = dict(part.split(':', 1) for part in comment.split('\t') if ':' in part) + version = fields.get('version') + if name.endswith('.apk'): + if fields.get('file') != name: + raise ValueError('APK signed filename mismatch') + match = re.fullmatch(r'OpenLess_(.+)_(?:arm64-v8a|armeabi-v7a|x86_64|x86)\.apk', name) + version = match[1] if match else None + if not version and legacy_version is not None: + # Historical Tauri signatures have no version field. Prefer their signed + # filename; old mac archives used the unversioned OpenLess.app.tar.gz. + match = re.fullmatch(r'OpenLess_(.+)_(?:x64-setup\.exe|arm64-v8a\.apk|armeabi-v7a\.apk|x86_64\.apk|x86\.apk)', fields.get('file', '')) + version = match[1] if match else legacy_version if name.endswith('.app.tar.gz') else None + if not version or version.split('+', 1)[0] != tag_version(tag): + raise ValueError('Signed version does not match release tag') + return version + +def generate_manifests(directory, tag, channel, published_at, output, verify): + expected = tag_version(tag) + if channel not in ('stable', 'beta') or (channel == 'beta') != ('-' in expected): + raise ValueError('Release channel/tag mismatch') + names = [p.name for p in directory.iterdir() if p.is_file()] + documents = {} + legacy_version = None + if not (directory / INDEX).exists(): + versions = set() + for name in names: + match = re.fullmatch(r'OpenLess_(\d[^_]+)_(?:x64-setup\.exe|arm64-v8a\.apk|armeabi-v7a\.apk|x86_64\.apk|x86\.apk)', name) + if match and match[1].split('+', 1)[0] == expected: + versions.add(match[1]) + if len(versions) != 1: + raise ValueError('Historical release has inconsistent full versions') + legacy_version = versions.pop() + for target, pattern in TARGETS.items(): + matches = [name for name in names if re.fullmatch(pattern, name)] + if len(matches) != 1: + raise ValueError(f'{tag}: expected one payload for {target}, found {matches}') + name = matches[0] + signature = signature_for(directory, tag, name) + verify(directory / name, signature) # includes the trusted comment's global signature + version = signed_version(signature, name, tag, legacy_version) + documents[target] = dict(version=version, pub_date=published_at, + url=f'{BASE}/releases/{urllib.parse.quote(tag, safe="")}/{urllib.parse.quote(name, safe="")}', signature=signature) + # Build every document before writing anything; never advance a partial channel. + output.mkdir(parents=True, exist_ok=True) + for target, document in documents.items(): + stem = f'latest-{target}' + ('-beta' if channel == 'beta' else '') + for suffix in ('', '-mirror'): + (output / f'{stem}{suffix}.json').write_text(json.dumps(document, indent=2) + '\n') + +def main(): + if not all(os.environ.get(key) for key in ('OSS_BUCKET', 'OSS_AK', 'OSS_SK')): + raise ValueError('Configure OSS_BUCKET, OSS_ACCESS_KEY_ID and OSS_ACCESS_KEY_SECRET in the main repository') + releases = json.loads(subprocess.check_output(['gh', 'api', f'repos/{REPO}/releases?per_page=100'])) + releases = [r for r in releases if not r['draft'] and re.fullmatch(r'v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?-tauri', r['tag_name'])] + selected = {} + for channel, prerelease in [('stable', False), ('beta', True)]: + candidates = [r for r in releases if r['prerelease'] == prerelease] + if candidates: + selected[channel] = max(candidates, key=lambda r: r['published_at']) + if not selected: + raise ValueError('No published desktop releases') + os.environ.update(RCLONE_CONFIG_OSS_TYPE='s3', RCLONE_CONFIG_OSS_PROVIDER='Alibaba', + RCLONE_CONFIG_OSS_ACCESS_KEY_ID=os.environ['OSS_AK'], RCLONE_CONFIG_OSS_SECRET_ACCESS_KEY=os.environ['OSS_SK'], + RCLONE_CONFIG_OSS_ENDPOINT=os.environ.get('OSS_ENDPOINT', 'oss-cn-zhangjiakou.aliyuncs.com')) + root = Path(os.environ.get('RUNNER_TEMP', tempfile.gettempdir())) / 'openless-release-mirror' + root.mkdir(parents=True, exist_ok=True) + pubkey = json.loads((Path(__file__).parent / '../src-tauri/tauri.conf.json').read_text())['plugins']['updater']['pubkey'] + public = root / 'updater.pub' + public.write_bytes(base64.b64decode(pubkey, validate=True)) + def verify(payload, signature): + sig = root / 'verify.minisig' + sig.write_bytes(base64.b64decode(signature, validate=True)) + subprocess.run(['minisign', '-Vm', str(payload), '-p', str(public), '-x', str(sig), '-q'], check=True) + def sync_channel(channel, release): + tag = release['tag_name'] + directory = root / 'stage' / tag + directory.mkdir(parents=True, exist_ok=True) + for asset in release['assets']: + name = asset['name'] + if Path(name).name != name or '\\' in name or name in ('.', '..'): + raise ValueError('Unsafe release asset name') + if not (re.search(r'\.(dmg|exe|msi|deb|rpm|apk|hap|sig|tar\.gz)$', name) or name in (INDEX, 'SHA256SUMS')): + continue + url = asset['browser_download_url'] + expected = f'https://github.com/{REPO}/releases/download/{tag}/' + if not url.startswith(expected) or urllib.parse.unquote(url[len(expected):]) != name: + raise ValueError('Unexpected release download URL') + path = directory / name + subprocess.run(['curl', '-fL', '--retry', '3', '--connect-timeout', '20', '--max-time', '600', '-o', str(path) + '.download', url], check=True) + downloaded = Path(str(path) + '.download') + if downloaded.stat().st_size != asset['size']: + raise ValueError('Incomplete release download') + if (asset.get('digest') or '').startswith('sha256:') and hashlib.sha256(downloaded.read_bytes()).hexdigest() != asset['digest'][7:]: + raise ValueError('Release asset SHA-256 mismatch') + downloaded.replace(path) + channels = root / 'channels' / channel + generate_manifests(directory, tag, channel, release['published_at'], channels, verify) + destination = f'oss:{os.environ["OSS_BUCKET"]}' + common = ['--checksum', '--transfers', '4', '--retries', '5', '--retries-sleep', '10s'] + # Copy binaries first; if any transfer fails, never advance the rolling JSON. + subprocess.run(['rclone', 'copy', str(directory), f'{destination}/releases/{tag}', *common, + '--header-upload', 'Cache-Control: public, max-age=31536000, immutable'], check=True) + subprocess.run(['rclone', 'copy', str(channels), f'{destination}/updates/{channel}', *common, + '--header-upload', 'Cache-Control: public, max-age=60'], check=True) + print(f'Mirrored {channel}: {tag}', flush=True) + failures = [] + for channel, release in selected.items(): + try: + sync_channel(channel, release) + except Exception as error: + failures.append(channel) + print(f'{channel} mirror failed: {error}', file=sys.stderr, flush=True) + if failures: + raise RuntimeError(f'Mirror incomplete for: {", ".join(failures)}') + +if __name__ == '__main__': + main() diff --git a/openless-all/app/scripts/release-distribution.test.mjs b/openless-all/app/scripts/release-distribution.test.mjs new file mode 100644 index 000000000..4185965c8 --- /dev/null +++ b/openless-all/app/scripts/release-distribution.test.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import { + mkdtempSync, + readFileSync, + writeFileSync, + rmSync, + mkdirSync, + chmodSync, + existsSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import ts from 'typescript'; +import yaml from 'js-yaml'; +import { mergeSignatures, signatureFromIndex } from './merge-release-signatures.mjs'; +const root = mkdtempSync(join(tmpdir(), 'openless-distribution-')); +try { + const tag = 'v2.0.2-Beta.7-tauri'; + const desktop = join(root, 'desktop'); + const android = join(root, 'android'); + mkdirSync(desktop); + mkdirSync(android); + const put = (directory, name, bytes, signature) => { + writeFileSync(join(directory, name), bytes); + writeFileSync(join(directory, name + '.sig'), signature); + }; + put(desktop, 'OpenLess_2.0.2-Beta.7_x64-setup.exe', 'desktop', 'desktop-signature'); + put(android, 'OpenLess_2.0.2-Beta.7_arm64-v8a.apk', 'android', 'android-signature'); + const first = mergeSignatures(desktop, tag); + const both = mergeSignatures(android, tag, first); + assert.equal(Object.keys(both.artifacts).length, 2); + assert.equal( + signatureFromIndex(both, tag, 'OpenLess_2.0.2-Beta.7_x64-setup.exe'), + 'desktop-signature', + ); + assert.equal(both.artifacts['OpenLess_2.0.2-Beta.7_arm64-v8a.apk'].size, 7); + assert.deepEqual( + mergeSignatures(desktop, tag, both), + both, + 'publisher retry preserves the other platform', + ); + assert.deepEqual( + mergeSignatures(desktop, tag, mergeSignatures(android, tag)), + both, + 'either publisher can finish first', + ); + assert.throws(() => mergeSignatures(android, 'v3-tauri', both), /schema\/tag/); + assert.throws(() => signatureFromIndex(both, tag, 'missing.exe')); + put(android, 'orphan.apk', '', ''); + assert.throws(() => mergeSignatures(android, tag), /Empty signature/); + + const bin = join(root, 'bin'); + mkdirSync(bin); + writeFileSync( + join(bin, 'gh'), + '#!/bin/sh\nif [ -n "$MOCK_FAIL" ]; then echo "HTTP $MOCK_FAIL" >&2; exit 1; fi\nprintf "%s" "$MOCK_RELEASE"\n', + ); + chmodSync(join(bin, 'gh'), 0o755); + const runMerge = (release, failure = '') => + spawnSync( + process.execPath, + [ + new URL('./merge-release-signatures.mjs', import.meta.url).pathname, + desktop, + tag, + 'Open-Less/openless', + ], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: bin + ':' + process.env.PATH, + MOCK_RELEASE: JSON.stringify(release), + MOCK_FAIL: failure, + }, + }, + ); + assert.notEqual(runMerge({ draft: false, assets: [] }).status, 0); + assert.ok(existsSync(join(desktop, 'OpenLess_2.0.2-Beta.7_x64-setup.exe.sig'))); + assert.equal(runMerge({ draft: true, assets: [{ name: 'historical.exe.sig' }] }).status, 0); + assert.ok(!existsSync(join(desktop, 'updater-signatures.json'))); + assert.notEqual(runMerge({}, '503').status, 0, 'API failures must not discard other platforms'); + assert.equal(runMerge({ draft: true, assets: [] }).status, 0); + assert.ok(existsSync(join(desktop, 'updater-signatures.json'))); + assert.ok(!existsSync(join(desktop, 'OpenLess_2.0.2-Beta.7_x64-setup.exe.sig'))); + + const tauri = yaml.load( + readFileSync(new URL('../../../.github/workflows/release-tauri.yml', import.meta.url), 'utf8'), + ); + const apk = yaml.load( + readFileSync(new URL('../../../.github/workflows/android-apk.yml', import.meta.url), 'utf8'), + ); + assert.match(tauri.jobs.publish.concurrency.group, /openless-release-assets-/); + assert.match(apk.jobs['publish-android-release'].concurrency.group, /openless-release-assets-/); + for (const job of [tauri.jobs.publish, apk.jobs['publish-android-release']]) { + assert.equal(job.concurrency['cancel-in-progress'], false); + const merge = job.steps.find((step) => step.name === 'Consolidate release signatures'); + const publish = job.steps.find((step) => step.uses?.startsWith('softprops/action-gh-release@')); + assert.ok(job.steps.indexOf(merge) < job.steps.indexOf(publish)); + } + const mirror = yaml.load( + readFileSync(new URL('../../../.github/workflows/mirror-release.yml', import.meta.url), 'utf8'), + ); + assert.equal(mirror.permissions.contents, 'read'); + assert.ok(mirror.on.release.types.includes('published')); + assert.equal( + mirror.jobs.mirror.steps[0].with.ref, + '${{ github.event.repository.default_branch }}', + ); + const python = spawnSync( + 'python3', + [new URL('./test_release_mirror.py', import.meta.url).pathname], + { encoding: 'utf8' }, + ); + assert.equal(python.status, 0, python.stdout + python.stderr); + + const source = readFileSync(new URL('../src/lib/updateDownload.ts', import.meta.url), 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const { downloadWithGithubFallback } = await import( + 'data:text/javascript;base64,' + Buffer.from(compiled).toString('base64') + ); + const cdn = { source: 'cdn', version: '2.0.2-Beta.7', signature: 'same-signature' }; + const github = { ...cdn, source: 'github' }; + const calls = []; + const fallback = async (original) => { + assert.equal(original, cdn); + return github; + }; + const failedCdn = async (candidate) => { + calls.push(candidate); + if (candidate.source === 'cdn') throw new Error('connection reset halfway'); + }; + assert.equal(await downloadWithGithubFallback(cdn, failedCdn, () => true, fallback), github); + assert.deepEqual(calls, [cdn, github]); + let retries = 0; + assert.equal( + await downloadWithGithubFallback( + cdn, + async () => {}, + () => true, + async () => { + retries++; + return github; + }, + ), + cdn, + ); + assert.equal(retries, 0); + await assert.rejects( + downloadWithGithubFallback( + github, + async () => { + throw new Error('github down'); + }, + () => false, + fallback, + ), + /github down/, + ); + await assert.rejects( + downloadWithGithubFallback( + cdn, + async () => { + throw new Error('both down'); + }, + () => true, + fallback, + ), + /both down/, + ); +} finally { + rmSync(root, { recursive: true, force: true }); +} +console.log('release distribution and mid-download fallback contracts passed'); diff --git a/openless-all/app/scripts/test_release_mirror.py b/openless-all/app/scripts/test_release_mirror.py new file mode 100644 index 000000000..4a6f29b3e --- /dev/null +++ b/openless-all/app/scripts/test_release_mirror.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import base64 +import hashlib +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location('release_mirror', Path(__file__).with_name('mirror-release.py')) +mirror = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mirror) + +class MirrorTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) / 'release' + self.directory.mkdir() + self.output = Path(self.temp.name) / 'channel' + self.tag = 'v2.0.2-Beta.7-tauri' + self.version = '2.0.2-Beta.7+build.fixture' + self.names = ['OpenLess_aarch64.app.tar.gz', 'OpenLess_x86_64.app.tar.gz', + f'OpenLess_{self.version}_x64-setup.exe', + *[f'OpenLess_{self.version}_{abi}.apk' for abi in ('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64')]] + self.index = dict(schema=1, release_tag=self.tag, artifacts={}) + for name in self.names: + data = b'fixture binary' + (self.directory / name).write_bytes(data) + comment = f'timestamp:1\tfile:{name}' + ('' if name.endswith('.apk') else f'\tversion:{self.version}') + signature = base64.b64encode(f'untrusted comment: fixture\nplaceholder\ntrusted comment: {comment}\nplaceholder\n'.encode()).decode() + self.index['artifacts'][name] = dict(signature=signature, size=len(data), sha256=hashlib.sha256(data).hexdigest()) + def generate(self, verify=lambda *_: None): + mirror.generate_manifests(self.directory, self.tag, 'beta', '2026-11-10T00:00:00Z', self.output, verify) + def save_index(self): + (self.directory / mirror.INDEX).write_text(json.dumps(self.index)) + def test_index_generates_all_platforms_without_detached_files(self): + self.save_index() + verified = [] + self.generate(lambda payload, signature: verified.append(payload.name)) + self.assertEqual(len(verified), 7) + self.assertEqual(len(list(self.output.glob('*.json'))), 14) + for path in self.output.glob('*.json'): + document = json.loads(path.read_text()) + self.assertEqual(document['version'], self.version) + self.assertIn('%2Bbuild.fixture', document['url']) if 'android' in path.name or 'windows' in path.name else None + self.assertTrue(document['signature']) + def test_legacy_signatures_remain_supported(self): + for name, entry in self.index['artifacts'].items(): + (self.directory / (name + '.sig')).write_text(entry['signature']) + self.generate() + self.assertEqual(len(list(self.output.glob('*.json'))), 14) + def test_historical_tauri_without_version_comment(self): + for name, entry in self.index['artifacts'].items(): + decoded = base64.b64decode(entry['signature']).decode().replace(f'\tversion:{self.version}', '') + (self.directory / (name + '.sig')).write_text(base64.b64encode(decoded.encode()).decode()) + self.generate() + document = json.loads((self.output / 'latest-darwin-aarch64-beta.json').read_text()) + self.assertEqual(document['version'], self.version) + def test_index_digest_mismatch_never_advances_channel(self): + self.save_index() + (self.directory / self.names[0]).write_bytes(b'corrupt') + with self.assertRaises(ValueError): self.generate() + self.assertFalse(self.output.exists()) + def test_partial_release_never_advances_channel(self): + self.save_index() + (self.directory / self.names[-1]).unlink() + with self.assertRaises(ValueError): self.generate() + self.assertFalse(self.output.exists()) + def test_signature_verification_failure_never_advances_channel(self): + self.save_index() + def reject(*_): raise ValueError('minisign verification failed') + with self.assertRaises(ValueError): self.generate(reject) + self.assertFalse(self.output.exists()) + def test_index_cannot_cross_tags_or_channels(self): + self.index['release_tag'] = 'v9.0.0-tauri' + self.save_index() + with self.assertRaises(ValueError): self.generate() + with self.assertRaises(ValueError): + mirror.generate_manifests(self.directory, self.tag, 'stable', '', self.output, lambda *_: None) + +if __name__ == '__main__': unittest.main() diff --git a/openless-all/app/src-tauri/src/android/update_pipeline.rs b/openless-all/app/src-tauri/src/android/update_pipeline.rs index b27cda6ed..2437eeb25 100644 --- a/openless-all/app/src-tauri/src/android/update_pipeline.rs +++ b/openless-all/app/src-tauri/src/android/update_pipeline.rs @@ -199,6 +199,45 @@ pub(crate) async fn check( async fn transfer)>( candidate: &UpdateCandidate, progress: &P, +) -> Result, String> { + let chosen = update_source::resolve_artifact_download_url(&candidate.url).await; + let fallback = update_source::github_fallback_for_cdn_artifact(&chosen); + let mut errors = Vec::new(); + for url in std::iter::once(chosen).chain(fallback) { + let seconds = if url.starts_with("https://dl.aeoform.com/") { + 120 + } else { + 600 + }; + let mut attempt = candidate.clone(); + attempt.url = url; + progress(0, attempt.size); + let result = tokio::time::timeout( + Duration::from_secs(seconds), + transfer_once(&attempt, progress), + ) + .await + .map_err(|_| "APK download timed out".to_string()) + .and_then(|value| value) + .and_then(|bytes| { + updater_logic::verify_updater_signature( + &bytes, + &attempt.signature, + updater_logic::UPDATER_PUBKEY_B64, + )?; + Ok(bytes) + }); + match result { + Ok(bytes) => return Ok(bytes), + Err(error) => errors.push(error), + } + } + Err(errors.join("; ")) +} + +async fn transfer_once)>( + candidate: &UpdateCandidate, + progress: &P, ) -> Result, String> { use futures_util::StreamExt; let url = url::Url::parse(&candidate.url).map_err(|_| "invalid APK source")?; diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 936f1ba26..b4b150f2d 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -825,9 +825,22 @@ pub async fn app_check_update_with_channel( (Ok(update), Err(_)) => update, (Err(mirror), Err(github)) => return Err(format!("Aliyun: {mirror}; GitHub: {github}")), }; - let Some(update) = update else { + let Some(mut update) = update else { return Ok(None); }; + // PR #1399's source policy, without giving up independent GitHub discovery. + // Preserve the same version/signature even if the rolling CDN manifest lags. + let chosen = + crate::update_source::resolve_artifact_download_url(update.download_url.as_str()).await; + update.download_url = url::Url::parse(&chosen).map_err(|e| e.to_string())?; + update.raw_json["url"] = serde_json::Value::String(chosen); + update.timeout = Some(std::time::Duration::from_secs( + if update.download_url.host_str() == Some("dl.aeoform.com") { + 120 + } else { + 600 + }, + )); // Passing through the date field would require the time crate; the frontend // AutoUpdate.tsx never uses date, so set it to None here and avoid pulling a new dep // into src-tauri/Cargo.toml. @@ -842,6 +855,41 @@ pub async fn app_check_update_with_channel( Ok(Some(metadata)) } +/// Retry only the exact stored signed artifact, never re-discover a different version. +#[tauri::command] +#[cfg(not(mobile))] +pub async fn app_update_github_fallback( + webview: tauri::Webview, + rid: tauri::ResourceId, +) -> Result { + let resource = webview + .resources_table() + .get::(rid) + .map_err(|e| e.to_string())?; + let mut update = (*resource).clone(); + let fallback = + crate::update_source::github_fallback_for_cdn_artifact(update.download_url.as_str()) + .ok_or("No GitHub fallback for this update source")?; + update.download_url = url::Url::parse(&fallback).map_err(|e| e.to_string())?; + update.raw_json["url"] = serde_json::Value::String(fallback); + update.timeout = Some(std::time::Duration::from_secs(600)); + Ok(AppUpdateMetadata { + current_version: update.current_version.clone(), + version: update.version.clone(), + date: None, + body: update.body.clone(), + raw_json: update.raw_json.clone(), + rid: webview.resources_table().add(update), + }) +} + +#[tauri::command] +#[cfg(mobile)] +pub async fn app_update_github_fallback(rid: u32) -> Result { + let _ = rid; + Err("Desktop-only update retry".into()) +} + /// The Beta manifest endpoint, with `{{target}}` / `{{arch}}` left in place for /// `tauri-plugin-updater` to substitute at check time. /// diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index c0c84b527..04496bf0b 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -286,6 +286,7 @@ macro_rules! app_invoke_handler_desktop { commands::set_update_channel, commands::fetch_latest_beta_release, commands::app_check_update_with_channel, + commands::app_update_github_fallback, commands::check_network, commands::take_splash_playback, commands::get_desktop_onboarding_state, @@ -725,6 +726,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::set_update_channel, $crate::commands::fetch_latest_beta_release, $crate::commands::app_check_update_with_channel, + $crate::commands::app_update_github_fallback, $crate::commands::app_download_and_install_android_update, $crate::restart_app, $crate::reset_accessibility_permission_and_restart_app, diff --git a/openless-all/app/src-tauri/src/update_source.rs b/openless-all/app/src-tauri/src/update_source.rs index 05bb34b3a..74abc8318 100644 --- a/openless-all/app/src-tauri/src/update_source.rs +++ b/openless-all/app/src-tauri/src/update_source.rs @@ -3,7 +3,13 @@ //! Tauri still verifies the signed artifact and version before installation. use crate::types::UpdateChannel; +#[path = "update_source/artifact_fallback.rs"] +mod artifact_fallback; +#[path = "update_source/signature_index.rs"] mod signature_index; +pub(crate) use artifact_fallback::{ + github_fallback_for_cdn_artifact, resolve_artifact_download_url, +}; /// CDN that serves both the update manifests and the mirrored release artifacts. pub(crate) const CDN_BASE: &str = "https://dl.aeoform.com"; @@ -218,7 +224,11 @@ async fn github_api_manifest(channel: UpdateChannel) -> Result Result Option<(St let (signature, _) = paths .iter() .find(|(_, candidate)| *candidate == signature_index::NAME) - .or_else(|| paths.iter().find(|(_, candidate)| *candidate == format!("{name}.sig")))?; + .or_else(|| { + paths + .iter() + .find(|(_, candidate)| *candidate == format!("{name}.sig")) + })?; Some(( format!("https://github.com{artifact}"), format!("https://github.com{signature}"), @@ -489,6 +506,13 @@ mod public_release_tests { .0 .ends_with("_x64-setup.exe")); assert!(github_page_assets(page, tag, "macos", "x86_64").is_none()); + let indexed_page = page.replace("OpenLess_aarch64.app.tar.gz.sig", signature_index::NAME); + for (os, arch) in [("macos", "aarch64"), ("windows", "x86_64")] { + assert!(github_page_assets(&indexed_page, tag, os, arch) + .unwrap() + .1 + .ends_with(signature_index::NAME)); + } assert!(github_page_assets(page, "v2.0.2-Beta.3-tauri", "macos", "aarch64").is_none()); assert!(github_page_assets( &page.replace(".tar.gz.sig", ".tar.gz.unsigned"), @@ -500,7 +524,8 @@ mod public_release_tests { } } -#[cfg(any(target_os = "android", test))] +#[path = "update_source/android.rs"] +#[cfg_attr(not(any(target_os = "android", test)), allow(dead_code))] mod android; #[cfg(any(target_os = "android", test))] #[allow(unused_imports)] diff --git a/openless-all/app/src-tauri/src/update_source/android.rs b/openless-all/app/src-tauri/src/update_source/android.rs index f7c71f9e9..0b27d078b 100644 --- a/openless-all/app/src-tauri/src/update_source/android.rs +++ b/openless-all/app/src-tauri/src/update_source/android.rs @@ -311,7 +311,11 @@ fn api_artifact<'a>( let signature = assets .iter() .find(|a| a["name"].as_str() == Some(super::signature_index::NAME)) - .or_else(|| assets.iter().find(|a| a["name"].as_str() == Some(&format!("{name}.sig"))))?; + .or_else(|| { + assets + .iter() + .find(|a| a["name"].as_str() == Some(&format!("{name}.sig"))) + })?; let artifact_url = artifact["browser_download_url"].as_str()?; let signature_url = signature["browser_download_url"].as_str()?; let expected = format!("/Open-Less/openless/releases/download/{tag}/"); @@ -386,8 +390,13 @@ async fn api_manifest( let body = text(sig_url, SIGNATURE_LIMIT) .await .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; - let signature = super::signature_index::extract(&body, tag, name, sig_url.ends_with(super::signature_index::NAME)) - .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; + let signature = super::signature_index::extract( + &body, + tag, + name, + sig_url.ends_with(super::signature_index::NAME), + ) + .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; let version = android_signature_version(signature.trim(), name, &version.to_string(), arch) .map_err(|error| ApiDiscoveryError::Candidate(candidate_error(&full_version, &error)))?; Ok( @@ -411,8 +420,11 @@ fn web_artifact(page: &str, tag: &str, arch: &str) -> Option<(String, String, St .filter_map(|(p, n)| Some((artifact_version(n, arch)?, p, n))) .max_by(|a, b| a.0.cmp(&b.0)) .map(|(_, p, n)| (p, n))?; - let signature = paths.iter().find(|(_, n)| *n == super::signature_index::NAME) - .or_else(|| paths.iter().find(|(_, n)| *n == format!("{name}.sig")))?.0; + let signature = paths + .iter() + .find(|(_, n)| *n == super::signature_index::NAME) + .or_else(|| paths.iter().find(|(_, n)| *n == format!("{name}.sig")))? + .0; Some(( name.clone(), format!("https://github.com{path}"), @@ -586,6 +598,26 @@ mod tests { let page=format!(""); assert_eq!(web_artifact(&page, tag, arch).unwrap().0, name); assert!(web_artifact(&page.replace(".apk.sig", ".apk.unsigned"), tag, arch).is_none()); + let mut indexed = release.clone(); + indexed["assets"][1] = json!({"name":super::super::signature_index::NAME, + "browser_download_url":format!("https://github.com/Open-Less/openless/releases/download/{tag}/updater-signatures.json")}); + assert!(api_artifact(&indexed, arch) + .unwrap() + .2 + .ends_with("updater-signatures.json")); + let index_page = page.replace(&format!("{name}.sig"), "updater-signatures.json"); + assert!(web_artifact(&index_page, tag, arch) + .unwrap() + .2 + .ends_with("updater-signatures.json")); + let index = json!({"schema":1,"release_tag":tag,"artifacts":{name.clone():{"signature":signature(&name)}}}); + let extracted = + super::super::signature_index::extract(&index.to_string(), tag, &name, true) + .unwrap(); + assert_eq!( + android_signature_version(&extracted, &name, "2.0.2-Beta.6", arch).unwrap(), + "2.0.2-Beta.6+build.20261109-test" + ); } assert!(artifact_version("OpenLess_2.0.2-Beta.6_x86_64.apk", "i686").is_none()); assert!(artifact_version("OpenLess_android_aarch64.apk", "aarch64").is_none()); diff --git a/openless-all/app/src-tauri/src/update_source/artifact_fallback.rs b/openless-all/app/src-tauri/src/update_source/artifact_fallback.rs index 342507a01..03aebf56d 100644 --- a/openless-all/app/src-tauri/src/update_source/artifact_fallback.rs +++ b/openless-all/app/src-tauri/src/update_source/artifact_fallback.rs @@ -2,59 +2,108 @@ use std::time::Duration; const BUDGET: Duration = Duration::from_secs(8); -fn decode(value: &str) -> Option { super::android::decode_asset_name(value) } -fn segment(value: &str) -> bool { !value.is_empty() && !value.contains("..") && !value.contains(['/', '\\', '\0']) } +fn decode(value: &str) -> Option { + super::android::decode_asset_name(value) +} +fn segment(value: &str) -> bool { + !value.is_empty() && !value.contains("..") && !value.contains(['/', '\\', '\0']) +} fn encode(value: &str) -> String { let mut output = String::new(); for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) { output.push(byte as char); } - else { output.push_str(&format!("%{byte:02X}")); } + if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) { + output.push(byte as char); + } else { + output.push_str(&format!("%{byte:02X}")); + } } output } fn parts(raw: &str, github: bool) -> Option<(String, String)> { let url = url::Url::parse(raw).ok()?; - if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() - || url.port().is_some() || url.query().is_some() || url.fragment().is_some() { return None; } + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.port().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } let prefix = if github { - if url.host_str()? != "github.com" { return None; } + if url.host_str()? != "github.com" { + return None; + } "/Open-Less/openless/releases/download/" } else { - if url.host_str()? != "dl.aeoform.com" { return None; } + if url.host_str()? != "dl.aeoform.com" { + return None; + } "/releases/" }; let mut path = url.path().strip_prefix(prefix)?.split('/'); - let tag = decode(path.next()?)?; let asset = decode(path.next()?)?; - if path.next().is_some() || !segment(&tag) || !segment(&asset) { return None; } + let tag = decode(path.next()?)?; + let asset = decode(path.next()?)?; + if path.next().is_some() || !segment(&tag) || !segment(&asset) { + return None; + } Some((tag, asset)) } pub(crate) fn github_fallback_for_cdn_artifact(raw: &str) -> Option { let (tag, asset) = parts(raw, false)?; - Some(format!("https://github.com/Open-Less/openless/releases/download/{}/{}", encode(&tag), encode(&asset))) + Some(format!( + "https://github.com/Open-Less/openless/releases/download/{}/{}", + encode(&tag), + encode(&asset) + )) } fn aliyun_for_github(raw: &str) -> Option { let (tag, asset) = parts(raw, true)?; - Some(format!("https://dl.aeoform.com/releases/{}/{}", encode(&tag), encode(&asset))) + Some(format!( + "https://dl.aeoform.com/releases/{}/{}", + encode(&tag), + encode(&asset) + )) } /// A failed/forbidden HEAD is confirmed with a bounded range request. Redirects /// use the updater's existing origin allowlist; neither probe buffers the payload. async fn available(raw: &str) -> bool { - let Ok(url) = url::Url::parse(raw) else { return false; }; + let Ok(url) = url::Url::parse(raw) else { + return false; + }; let client = crate::net::anonymous_no_redirect_http(); let status = match client.head(url.clone()).timeout(BUDGET).send().await { Ok(response) => response.status(), Err(_) => return false, }; - if status.is_success() { return true; } - if !status.is_redirection() && status != reqwest::StatusCode::FORBIDDEN && status != reqwest::StatusCode::METHOD_NOT_ALLOWED { return false; } - tokio::time::timeout(BUDGET, super::android::probe_android_download(raw)).await.ok().flatten().is_some() + if status.is_success() { + return true; + } + if !status.is_redirection() + && status != reqwest::StatusCode::FORBIDDEN + && status != reqwest::StatusCode::METHOD_NOT_ALLOWED + { + return false; + } + tokio::time::timeout(BUDGET, super::android::probe_android_download(raw)) + .await + .ok() + .flatten() + .is_some() } pub(crate) async fn resolve_artifact_download_url(raw: &str) -> String { - let cdn = if parts(raw, false).is_some() { raw.to_string() } - else if let Some(cdn) = aliyun_for_github(raw) { cdn } - else { return raw.to_string(); }; - if available(&cdn).await { cdn } - else { github_fallback_for_cdn_artifact(&cdn).unwrap_or_else(|| raw.to_string()) } + let cdn = if parts(raw, false).is_some() { + raw.to_string() + } else if let Some(cdn) = aliyun_for_github(raw) { + cdn + } else { + return raw.to_string(); + }; + if available(&cdn).await { + cdn + } else { + github_fallback_for_cdn_artifact(&cdn).unwrap_or_else(|| raw.to_string()) + } } #[cfg(test)] @@ -62,12 +111,28 @@ mod tests { use super::*; #[test] fn exact_asset_mapping_and_rejection() { - let cdn = "https://dl.aeoform.com/releases/v2-Beta.1-tauri/OpenLess_2%2Bbuild_x64-setup.exe"; + let cdn = + "https://dl.aeoform.com/releases/v2-Beta.1-tauri/OpenLess_2%2Bbuild_x64-setup.exe"; let github = github_fallback_for_cdn_artifact(cdn).unwrap(); assert_eq!(aliyun_for_github(&github).unwrap(), cdn); - assert_eq!(github_fallback_for_cdn_artifact(&cdn.replace("%2B", "+")).unwrap(), github); - for invalid in ["http://dl.aeoform.com/releases/v2/a", "https://dl.aeoform.com.attacker.invalid/releases/v2/a", "https://u@dl.aeoform.com/releases/v2/a", "https://dl.aeoform.com/releases/v2/a%2Fb", "https://dl.aeoform.com/releases/v2/a?x=1", "https://dl.aeoform.com/releases/v2/a/b", "https://dl.aeoform.com/releases/v2/%2e%2e", "https://dl.aeoform.com/updates/beta/latest.json"] { - assert!(github_fallback_for_cdn_artifact(invalid).is_none(), "{invalid}"); + assert_eq!( + github_fallback_for_cdn_artifact(&cdn.replace("%2B", "+")).unwrap(), + github + ); + for invalid in [ + "http://dl.aeoform.com/releases/v2/a", + "https://dl.aeoform.com.attacker.invalid/releases/v2/a", + "https://u@dl.aeoform.com/releases/v2/a", + "https://dl.aeoform.com/releases/v2/a%2Fb", + "https://dl.aeoform.com/releases/v2/a?x=1", + "https://dl.aeoform.com/releases/v2/a/b", + "https://dl.aeoform.com/releases/v2/%2e%2e", + "https://dl.aeoform.com/updates/beta/latest.json", + ] { + assert!( + github_fallback_for_cdn_artifact(invalid).is_none(), + "{invalid}" + ); } } } diff --git a/openless-all/app/src-tauri/src/update_source/signature_index.rs b/openless-all/app/src-tauri/src/update_source/signature_index.rs index 8789a5359..4449cdcfc 100644 --- a/openless-all/app/src-tauri/src/update_source/signature_index.rs +++ b/openless-all/app/src-tauri/src/update_source/signature_index.rs @@ -2,15 +2,28 @@ pub(super) const NAME: &str = "updater-signatures.json"; pub(super) const LIMIT: usize = 128 * 1024; -pub(super) fn extract(body: &str, tag: &str, artifact: &str, indexed: bool) -> Result { - if body.len() > LIMIT { return Err("signature metadata exceeds size limit".into()); } - if !indexed { return Ok(body.trim().to_string()); } - let index: serde_json::Value = serde_json::from_str(body).map_err(|_| "invalid signature index")?; +pub(super) fn extract( + body: &str, + tag: &str, + artifact: &str, + indexed: bool, +) -> Result { + if body.len() > LIMIT { + return Err("signature metadata exceeds size limit".into()); + } + if !indexed { + return Ok(body.trim().to_string()); + } + let index: serde_json::Value = + serde_json::from_str(body).map_err(|_| "invalid signature index")?; if index["schema"].as_u64() != Some(1) || index["release_tag"].as_str() != Some(tag) { return Err("signature index schema/tag mismatch".into()); } - index["artifacts"][artifact]["signature"].as_str().map(str::trim) - .filter(|value| !value.is_empty()).map(str::to_owned) + index["artifacts"][artifact]["signature"] + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) .ok_or_else(|| "artifact missing from signature index".into()) } @@ -20,11 +33,17 @@ mod tests { #[test] fn indexed_and_legacy_signatures() { let body = r#"{"schema":1,"release_tag":"v2-tauri","artifacts":{"app.exe":{"signature":" signed "}}}"#; - assert_eq!(extract(body, "v2-tauri", "app.exe", true).unwrap(), "signed"); + assert_eq!( + extract(body, "v2-tauri", "app.exe", true).unwrap(), + "signed" + ); assert!(extract(body, "v3-tauri", "app.exe", true).is_err()); assert!(extract(body, "v2-tauri", "other.exe", true).is_err()); assert!(extract("{}", "v2-tauri", "app.exe", true).is_err()); assert!(extract(&"x".repeat(LIMIT + 1), "v2-tauri", "app.exe", true).is_err()); - assert_eq!(extract(" old-signature\n", "v1", "app", false).unwrap(), "old-signature"); + assert_eq!( + extract(" old-signature\n", "v1", "app", false).unwrap(), + "old-signature" + ); } } diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index dd2b9f3c3..fe5edacbf 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next'; import { appCheckUpdateWithChannel, appDownloadAndInstallAndroidUpdate, + appUpdateGithubFallback, isAndroid, isTauri, logClientError, @@ -26,6 +27,7 @@ import { type UpdateChannel, } from '../lib/ipc'; import { isStableChannelSwitch } from '../lib/appVersion'; +import { downloadWithGithubFallback } from '../lib/updateDownload'; import { Btn } from '../pages/_atoms'; const UPDATE_CHECK_TIMEOUT_MS = 15_000; @@ -240,13 +242,13 @@ export function useAutoUpdate(): UseAutoUpdate { return; } - const update = updateRef.current; + let update = updateRef.current; if (!update) return; resetProgress(); setStatus('downloading'); try { await persistStableChannelSwitch(); - await update.download((event: DownloadEvent) => { + const onDownloadEvent = (event: DownloadEvent) => { if (event.event === 'Started') { resetProgress(); setContentLength(event.data.contentLength ?? null); @@ -255,7 +257,27 @@ export function useAutoUpdate(): UseAutoUpdate { } else if (event.event === 'Finished') { setStatus('installing'); } - }); + }; + update = await downloadWithGithubFallback( + update, + (candidate) => candidate.download(onDownloadEvent), + (candidate) => + String(candidate.rawJson?.url ?? '').startsWith('https://dl.aeoform.com/releases/'), + async (previous) => { + // The backend clones the stored signed version; it does not discover a new release. + const metadata = await appUpdateGithubFallback(previous.rid); + const retry = new Update({ + ...metadata, + date: metadata.date ?? undefined, + body: metadata.body ?? undefined, + }); + updateRef.current = retry; + await previous.close().catch(() => undefined); + resetProgress(); + setStatus('downloading'); + return retry; + }, + ); setStatus('installing'); await update.install(); await closeUpdate(); diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index e998b56e0..61c3e9d1a 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -215,6 +215,7 @@ export { fetchLatestBetaRelease, appCheckUpdateWithChannel, appDownloadAndInstallAndroidUpdate, + appUpdateGithubFallback, } from './updater'; // remote-server diff --git a/openless-all/app/src/lib/ipc/updater.ts b/openless-all/app/src/lib/ipc/updater.ts index ce1285117..6e827333a 100644 --- a/openless-all/app/src/lib/ipc/updater.ts +++ b/openless-all/app/src/lib/ipc/updater.ts @@ -59,6 +59,12 @@ export function appCheckUpdateWithChannel( }); } +export function appUpdateGithubFallback(rid: number): Promise { + return invokeOrMock('app_update_github_fallback', { rid }, () => { + throw new Error('Desktop update fallback requires Tauri'); + }); +} + export function appDownloadAndInstallAndroidUpdate(args: { url: string; signature: string; diff --git a/openless-all/app/src/lib/updateDownload.ts b/openless-all/app/src/lib/updateDownload.ts new file mode 100644 index 000000000..a06f5fe4e --- /dev/null +++ b/openless-all/app/src/lib/updateDownload.ts @@ -0,0 +1,17 @@ +// Retry only a download, never an installation and never a fresh version check. +export async function downloadWithGithubFallback( + initial: T, + download: (update: T) => Promise, + canFallback: (update: T) => boolean, + fallback: (update: T) => Promise, +): Promise { + try { + await download(initial); + return initial; + } catch (error) { + if (!canFallback(initial)) throw error; + const retry = await fallback(initial); + await download(retry); + return retry; + } +} From f39f66732ce52e39b867b9f98b2ccc930d4216df Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 10 Nov 2026 23:03:09 +0800 Subject: [PATCH 5/7] ci: refresh Harmony IPC audit and check freshness in frontend tests --- docs/release-distribution.md | 4 ++-- openless-all/app/harmony/ipc-audit.json | 11 +++++++++-- openless-all/app/scripts/audit-harmony-ipc.mjs | 10 +++++++++- .../app/scripts/release-distribution.test.mjs | 6 ++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/docs/release-distribution.md b/docs/release-distribution.md index 1cde96db4..35244d493 100644 --- a/docs/release-distribution.md +++ b/docs/release-distribution.md @@ -52,13 +52,13 @@ ### 必须人工完成的切换前置条件 -主仓库目前尚未配置以下三个仓库 Secrets: +主仓库必须配置以下三个仓库 Secrets(本次已从历史会话恢复配置;值不写入仓库): - `OSS_BUCKET` - `OSS_ACCESS_KEY_ID` - `OSS_ACCESS_KEY_SECRET` -可选仓库变量 `OSS_ENDPOINT`,默认 `oss-cn-zhangjiakou.aliyuncs.com`。Bucket 继续私有,不将凭据放入代码、日志或普通变量。GitHub API 无法读回网站仓库已有 Secret 的值,必须由凭据持有者重新配置。 +可选仓库变量 `OSS_ENDPOINT`,默认 `oss-cn-zhangjiakou.aliyuncs.com`。Bucket 继续私有,不将凭据放入代码、日志或普通变量。GitHub API 无法读回网站仓库已有 Secret 的值;本次恢复使用的是用户先前提供的原始配置,已通过标准输入写入主仓库 Secrets,未回显或落盘密钥。配置存在不等同于 OSS 同步验收通过。 切换顺序: diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index cf764060e..d14081ba2 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 187, + "total": 188, "wired": 103, - "missing": 84, + "missing": 85, "commands": [ { "name": "accept_pending_correction", @@ -46,6 +46,13 @@ ], "implementation": "unimplemented" }, + { + "name": "app_update_github_fallback", + "calledFrom": [ + "src/lib/ipc/updater.ts" + ], + "implementation": "unimplemented" + }, { "name": "apply_quick_note_repolish", "calledFrom": [ diff --git a/openless-all/app/scripts/audit-harmony-ipc.mjs b/openless-all/app/scripts/audit-harmony-ipc.mjs index 9edee7139..7da8e7b62 100644 --- a/openless-all/app/scripts/audit-harmony-ipc.mjs +++ b/openless-all/app/scripts/audit-harmony-ipc.mjs @@ -40,7 +40,15 @@ const report = { missing: commands.filter((x) => x.implementation === 'unimplemented').length, commands, }; -fs.writeFileSync(path.join(root, 'harmony/ipc-audit.json'), JSON.stringify(report, null, 2) + '\n'); +const reportPath = path.join(root, 'harmony/ipc-audit.json'); +const serialized = JSON.stringify(report, null, 2) + '\n'; +if (process.argv.includes('--check')) { + if (fs.readFileSync(reportPath, 'utf8') !== serialized) { + throw new Error('Harmony IPC audit is stale; run node scripts/audit-harmony-ipc.mjs'); + } +} else { + fs.writeFileSync(reportPath, serialized); +} console.log( `Harmony IPC: ${report.wired}/${report.total} wired; ${report.missing} unimplemented. Report: harmony/ipc-audit.json`, ); diff --git a/openless-all/app/scripts/release-distribution.test.mjs b/openless-all/app/scripts/release-distribution.test.mjs index 4185965c8..8a220a935 100644 --- a/openless-all/app/scripts/release-distribution.test.mjs +++ b/openless-all/app/scripts/release-distribution.test.mjs @@ -14,6 +14,12 @@ import { spawnSync } from 'node:child_process'; import ts from 'typescript'; import yaml from 'js-yaml'; import { mergeSignatures, signatureFromIndex } from './merge-release-signatures.mjs'; +const audit = spawnSync( + process.execPath, + [new URL('./audit-harmony-ipc.mjs', import.meta.url).pathname, '--check'], + { encoding: 'utf8' }, +); +assert.equal(audit.status, 0, audit.stdout + audit.stderr); const root = mkdtempSync(join(tmpdir(), 'openless-distribution-')); try { const tag = 'v2.0.2-Beta.7-tauri'; From 913cde9f40954543a0727a6ad0b6865d79d1eca9 Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 10 Nov 2026 23:37:39 +0800 Subject: [PATCH 6/7] release: build desktop Android and Harmony concurrently in one workflow --- .github/workflows/android-apk.yml | 27 ++-- .github/workflows/release-harmony.yml | 58 +++++--- .github/workflows/release-tauri.yml | 114 ++++++++-------- RELEASING.md | 15 ++- docs/release-distribution.md | 21 +++ .../android-apk-workflow-contract.test.mjs | 12 +- .../scripts/harmony-release-contract.test.mjs | 52 ++++---- openless-all/app/scripts/prepare-release.mjs | 51 +++++++ .../release-publication-contract.test.mjs | 125 +++++++++++++++--- .../workflow-concurrency-contract.test.mjs | 7 +- 10 files changed, 339 insertions(+), 143 deletions(-) create mode 100644 openless-all/app/scripts/prepare-release.mjs diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 2d77d78d6..27cc9d580 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -1,7 +1,7 @@ name: Android APK (debug) # Triggers: -# - push v*-tauri tag → signed release APK + minisign + updater manifest → GitHub Release +# - workflow_call from release-tauri.yml on v*-tauri tags → signed APKs + signatures → shared Release # - workflow_dispatch → signed release APK when ANDROID_KEYSTORE_* secrets exist # (overlay install + user data preserved); otherwise unsigned debug APK (annotated, non-blocking) # - workflow_dispatch + signed_debug=true — debug APK signed with the release @@ -15,12 +15,19 @@ name: Android APK (debug) # - Tauri still runs plugin-init cargo + android-studio-script per ABI (first ABI twice). on: - push: - # Same reasoning as release-tauri: an APK per branch push cost 13-18 minutes - # and duplicated the Gradle + Rust trees that `android-check` already keeps - # warm. Tags, a weekly smoke build and explicit dispatch cover it. - tags: - - 'v*-tauri' + # Tag releases have exactly one entry point: release-tauri.yml. Keeping a + # second tag trigger here would compile/sign all four ABIs twice. + workflow_call: + inputs: + abis: + type: string + default: aarch64 + fast_profile: + type: boolean + default: false + signed_debug: + type: boolean + default: false schedule: # Monday 05:23 UTC: weekly APK smoke build (debug-grade, not published). - cron: '23 5 * * 1' @@ -76,7 +83,7 @@ jobs: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - INPUT_SIGNED_DEBUG: ${{ github.event.inputs.signed_debug }} + INPUT_SIGNED_DEBUG: ${{ inputs.signed_debug }} run: | set -euo pipefail is_tag=false @@ -129,8 +136,8 @@ jobs: id: abis shell: bash env: - INPUT_ABIS: ${{ github.event.inputs.abis }} - INPUT_FAST_PROFILE: ${{ github.event.inputs.fast_profile }} + INPUT_ABIS: ${{ inputs.abis }} + INPUT_FAST_PROFILE: ${{ inputs.fast_profile }} run: | set -euo pipefail if [ "${{ steps.mode.outputs.is_tag_release }}" = "true" ]; then diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml index c1c44cb0e..a6d49a89a 100644 --- a/.github/workflows/release-harmony.yml +++ b/.github/workflows/release-harmony.yml @@ -14,12 +14,28 @@ name: Release HarmonyOS # # Dispatch it after the tag exists; it rebuilds that tag, signs it with material # from Secrets, attaches the HAP to the Release and uploads it to AppGallery. -# `release-tauri.yml` dispatches it automatically once the desktop/Android assets -# for a `v*-tauri` tag are published (see `dispatch-platform-releases`), so a tag -# push ships the HAP instead of depending on somebody remembering this page. The -# manual dispatch stays the repair path for a published tag, and the channel it -# runs with is `vars.HARMONY_AUTO_CHANNEL` (default `none`) on the automatic one. +# `release-tauri.yml` calls this workflow in parallel with desktop and Android +# after a short shared-draft preflight, using the same immutable tag commit. +# Manual dispatch remains the repair path; automatic channel is +# `vars.HARMONY_AUTO_CHANNEL` (default `none`). on: + workflow_call: + inputs: + tag: + required: true + type: string + channel: + type: string + default: none + publish: + type: boolean + default: true + upload_to_appgallery: + type: boolean + default: true + runner_labels: + type: string + default: '["ubuntu-latest"]' workflow_dispatch: inputs: tag: @@ -104,9 +120,20 @@ jobs: APPGALLERY_CLIENT_SECRET: ${{ secrets.APPGALLERY_CLIENT_SECRET }} APPGALLERY_APP_ID: ${{ secrets.APPGALLERY_APP_ID }} steps: + - name: Validate release channel + env: + HARMONY_CHANNEL: ${{ inputs.channel }} + run: | + set -euo pipefail + case "$HARMONY_CHANNEL" in + none | release | test) ;; + *) echo "::error::HARMONY_AUTO_CHANNEL must be none, release or test"; exit 1 ;; + esac - uses: actions/checkout@v4 with: - ref: ${{ inputs.tag }} + # Tag calls build the exact triggering commit, even if a tag is moved + # while queued. Manual repair still explicitly chooses its input tag. + ref: ${{ github.event_name == 'push' && github.sha || inputs.tag }} submodules: false - uses: actions/setup-java@v4 @@ -296,18 +323,13 @@ jobs: working-directory: openless-all/app run: | set -euo pipefail - # The desktop workflow creates the Release for this tag; wait for it - # rather than racing its creation. - for attempt in $(seq 1 40); do - if gh release view "$RELEASE_TAG" --json isDraft > /dev/null 2>&1; then - break - fi - if [ "$attempt" = 40 ]; then - echo "::error::release $RELEASE_TAG was not created by the desktop workflow" - exit 1 - fi - sleep 15 - done + # Automatic builds already have a shared draft: never wait for desktop. + # Direct manual dispatch still supports explicit published-tag repairs. + draft=$(gh release view "$RELEASE_TAG" --json isDraft --jq .isDraft) + if [ "${{ github.event_name }}" = push ] && [ "$draft" != true ]; then + echo "::error::Release was published while building; refusing automatic mutation" + exit 1 + fi output=target/harmony-packages gh release upload "$RELEASE_TAG" \ "$output"/OpenLess_harmony_arm64.hap \ diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 9a8b5206b..b0cc32fbe 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -1,4 +1,4 @@ -name: Release Tauri (cross-platform) +name: Release (all platforms) # meta: ensure Actions indexes this workflow on forks (no behavior change). # Triggers: # - push a v*.*.*-tauri tag (kept distinct from the legacy Swift vX.Y.Z tags, no conflict) @@ -55,7 +55,31 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/') && inputs.release_tag == '' }} jobs: + prepare-release: + name: Prepare shared draft + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref_name, '-tauri') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Prepare the shared release without modifying published versions + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: node openless-all/app/scripts/prepare-release.mjs "$RELEASE_TAG" "$GITHUB_REPOSITORY" + build: + needs: prepare-release + # Branch/manual desktop builds still run when the tag-only preflight is skipped. + if: ${{ !cancelled() && (needs.prepare-release.result == 'success' || needs.prepare-release.result == 'skipped') }} # Read-only. Tag and repair publishes run in `publish`, which is the only # job that needs contents: write. The tag/repair guard stays on that job. permissions: @@ -785,11 +809,19 @@ jobs: EOF echo "OPENLESS_RELEASE_BODY_PATH=$RUNNER_TEMP/release-body.md" >> "$GITHUB_ENV" - - name: Download built release artifacts + # Called workflows share this run's artifacts. Never download the APK/HAP + # trees (or store-signed Harmony packages) into the desktop publisher. + - name: Download built macOS release artifacts + timeout-minutes: 20 + uses: actions/download-artifact@v4 + with: + pattern: openless-macos-* + path: ${{ runner.temp }}/openless-release-artifacts + - name: Download built Windows release artifacts timeout-minutes: 20 uses: actions/download-artifact@v4 with: - pattern: openless-* + pattern: openless-windows-* path: ${{ runner.temp }}/openless-release-artifacts - name: Stage release files @@ -855,57 +887,27 @@ jobs: # from the project CDN. The stage step copies installers only. files: release-files/* - # Harmony receives the immutable release tag. Linux automation is paused. - dispatch-platform-releases: - needs: [build, publish] - # Independent of the desktop result: the HAP only needs the tag, so a - # Windows-only or notarization failure must not cost it its Release entry. - # A cancelled run still dispatches nothing. - if: >- - !cancelled() && github.event_name == 'push' && - startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref_name, '-tauri') - runs-on: ubuntu-latest - timeout-minutes: 10 + # Same-run reusable workflows resolve from the caller's immutable commit. + # Only the shared draft preflight precedes them; no platform waits for another. + android: + name: Android release + needs: prepare-release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref_name, '-tauri') permissions: - actions: write - contents: read - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - RELEASE_TAG: ${{ github.ref_name }} - # Repository distribution path by default: the HAP is attached to the - # GitHub Release unsigned and nothing reaches AppGallery. Set the - # `HARMONY_AUTO_CHANNEL` repository variable to `release` (production) or - # `test` (test channel) to upload the signed package as the AGC candidate - # automatically - the upload never submits it for review, so that decision - # stays human either way. - HARMONY_CHANNEL: ${{ vars.HARMONY_AUTO_CHANNEL || 'none' }} - steps: - - name: Dispatch the HarmonyOS release - run: | - set -euo pipefail - case "$HARMONY_CHANNEL" in - none | release | test) ;; - *) echo "::error::HARMONY_AUTO_CHANNEL must be none, release or test (got '$HARMONY_CHANNEL')"; exit 1 ;; - esac - gh workflow run release-harmony.yml \ - --ref "$RELEASE_TAG" \ - -f "tag=$RELEASE_TAG" \ - -f "channel=$HARMONY_CHANNEL" \ - -f publish=true \ - -f upload_to_appgallery=true - - - name: Report the dispatched runs - # The dispatch already returned a run id; this listing is the log line - # that makes the old silent miss visible, so a missing run must not fail - # the tag build after every asset is already published. - continue-on-error: true - run: | - set -euo pipefail - sleep 10 - for workflow in release-harmony.yml; do - echo "### $workflow" - gh run list --workflow="$workflow" --limit 3 \ - --json createdAt,status,conclusion,url \ - --jq '.[] | "\(.createdAt) \(.status) \(.conclusion // "-") \(.url)"' - done + contents: write + uses: ./.github/workflows/android-apk.yml + secrets: inherit + + harmony: + name: HarmonyOS release + needs: prepare-release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref_name, '-tauri') + permissions: + contents: write + uses: ./.github/workflows/release-harmony.yml + with: + tag: ${{ github.ref_name }} + channel: ${{ vars.HARMONY_AUTO_CHANNEL || 'none' }} + publish: true + upload_to_appgallery: true + secrets: inherit diff --git a/RELEASING.md b/RELEASING.md index 5fc45e24c..290fca2c3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -43,11 +43,16 @@ component, which is a 16-bit field. The full version still appears in the app an updater manifest; NSIS uses its supported numeric fallback for file metadata. See [the pinned NSIS bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.10.1/crates/tauri-bundler/src/bundle/windows/nsis/mod.rs#L149). -These tags build the macOS, Windows, and Android Tauri hosts in parallel. +These tags have one entry point, `.github/workflows/release-tauri.yml` (`Release (all platforms)`). +A short preflight creates or validates the shared **draft**; macOS/Windows, all four +Android ABIs, and HarmonyOS then build in parallel in the same Actions run. +Android and HarmonyOS are reusable workflows resolved from the caller's immutable +commit, not delayed dispatches after desktop publication. Android no longer has +its own tag-push trigger, so the APKs are not built twice. -HarmonyOS has a dispatch-only entry point, `.github/workflows/release-harmony.yml`. -It does not react directly to a tag push. After the desktop build, `release-tauri.yml` -dispatches HarmonyOS on the same immutable release tag. +Standalone Android weekly/debug/manual builds and HarmonyOS manual repairs remain +available. Desktop branch/manual builds and Windows draft repairs keep their existing +scope. See [release distribution](docs/release-distribution.md) for the dependency graph. Linux-specific CI and release automation is paused: the reusable Linux check and release entry point have been removed, and desktop releases no longer dispatch Linux. @@ -60,7 +65,7 @@ Android delivery remain independent; Linux has no in-app updater or AppImage. HarmonyOS builds, signs and attaches the single `OpenLess_harmony_arm64.hap`, and can upload the signed package (App Pack) to -HUAWEI AppGallery Connect. The automatic dispatch uses `channel: none`, the +HUAWEI AppGallery Connect. The automatic reusable call uses `channel: none`, the repository path: the HAP is attached to the GitHub Release unsigned and nothing reaches the store. Set the repository variable `HARMONY_AUTO_CHANNEL` to `release` (production) or `test` (test channel) to have the signed package diff --git a/docs/release-distribution.md b/docs/release-distribution.md index 35244d493..1178629e3 100644 --- a/docs/release-distribution.md +++ b/docs/release-distribution.md @@ -2,6 +2,27 @@ 本次在 PR #1426 中补充单一签名 JSON、从 `aeoform/openless-workspace` 迁移 OSS 同步,并适配 PR #1399 的阿里云优先/GitHub 二进制回退。只改变配置生效后的发行;不删除或迁移已有 GitHub Release 附件。 +## 单一 Release 入口,平台并行 + +`v*-tauri` 标签只触发 `.github/workflows/release-tauri.yml`,显示为 `Release (all platforms)`: + +```text +tag → prepare-release(仅创建/验证共享草稿,不构建) + ├─ desktop matrix(macOS ARM / Intel / Windows)→ desktop publish ┐ + ├─ Android plan → 四 ABI matrix → Android publish ├─ 同一草稿 + └─ HarmonyOS build → HAP 上传/可选商店候选包 ┘ +``` + +- 三条构建线只依赖同一个轻量预检,不相互依赖;移除原先桌面发布后才 dispatch Harmony 的等待及 Harmony 等待桌面创建 Release 的轮询。 +- Android/Harmony 使用同一 run 的本地 `workflow_call`,定义来自标签的不可变提交。Android 删除独立 tag push,避免同一标签重复构建四个 ABI;独立周检、debug/手动入口保留。 +- 预检遇到已有已发布 Release、错误 tag/渠道、API 错误时停止,不重开或修改历史版本;已有草稿只读取,不覆盖正文和附件。 +- 桌面下载器只匹配 `openless-macos-*`、`openless-windows-*`,避免合并 run 后重复下载数百 MB 的 APK/HAP,且不会混入商店签名包。 +- 桌面和 Android 的发布 job 仍共享签名索引互斥锁,防止读改写覆盖;锁覆盖发布段,不阻塞构建。Harmony 不写该索引,不需等待它们。编译并行度仍默认 2,不额外拆分构建矩阵。 +- 一个平台失败不取消其他平台的构建;整个 Release run 会报失败,产物保留在草稿中。仍由管理员确认后发布,OSS 清单在发布之后同步,不能为了并行而先指向不存在的包。 +- 桌面分支/周检和 Windows 草稿维修在 tag-only 预检跳过时仍可运行,不会顺带启动 Android/Harmony 发布。Harmony 手动维修入口及渠道校验保留。 + +这里消除的是可确认的依赖等待,不承诺尚未实际发版测量的提速百分比。 + ## 单一签名 JSON 新 Release 只附加 `updater-signatures.json`,格式: diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs index ef46f4b98..ba1f5bbcf 100644 --- a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -117,12 +117,12 @@ try { // --- workflow contract (#1103) --- const workflow = readFileSync(workflowPath, 'utf8'); const parsed = load(workflow); -// Tags build the release APK; a weekly schedule keeps the Gradle/Rust cache -// warm now that a per-branch push would cost 13-18 minutes and duplicate what -// `android-check` already covers. -assert.deepEqual(Object.keys(parsed.on).sort(), ['push', 'schedule', 'workflow_dispatch']); -assert.equal(parsed.on.push.branches, undefined); -assert.deepEqual(parsed.on.push.tags, ['v*-tauri']); +// The unified tag workflow calls Android exactly once; the independent weekly +// smoke build and manual/debug entry points remain available. +assert.deepEqual(Object.keys(parsed.on).sort(), ['schedule', 'workflow_call', 'workflow_dispatch']); +assert.equal(parsed.on.push, undefined, 'do not build every ABI twice for a tag'); +assert.equal(parsed.on.workflow_call.inputs.abis.default, 'aarch64'); +assert.equal(parsed.on.workflow_call.inputs.fast_profile.type, 'boolean'); assert.equal(parsed.on.schedule.length, 1); assert.match(parsed.on.schedule[0].cron, /^\d+ \d+ \* \* [0-6]$/); assert.equal(typeof parsed.concurrency.group, 'string'); diff --git a/openless-all/app/scripts/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs index b5b33e85b..75bc2c175 100644 --- a/openless-all/app/scripts/harmony-release-contract.test.mjs +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -22,14 +22,17 @@ const tauriRelease = readFileSync( 'utf8', ); -// Dispatch-only, with the tag as a required input: the HAP can never come from a -// branch or ride along with an unrelated push. -assert.match(workflow, /^on:\n workflow_dispatch:/m); +// Reusable and manual entry points require a tag; no independent push trigger +// may create a second HAP build outside the unified release. +assert.match(workflow, /^ workflow_dispatch:/m); assert.doesNotMatch(workflow, /^ (push|pull_request):/m); assert.match(workflow, /inputs:[\s\S]*?tag:[\s\S]*?required: true/); -// The tag is what gets built. -assert.match(workflow, /ref: \$\{\{ inputs\.tag \}\}/); +// Automatic calls pin the triggering commit; manual repairs choose the input tag. +assert.match( + workflow, + /ref: \$\{\{ github\.event_name == 'push' && github\.sha \|\| inputs\.tag \}\}/, +); assert.match(workflow, /TAG_VERSION=\$\{TAG_VERSION%-tauri\}/); assert.match(workflow, /does not match package\.json version/); @@ -151,25 +154,17 @@ assert.match(workflow, /node scripts\/upload-appgallery\.mjs \\/); assert.match(workflow, /gh release upload/); assert.match(workflow, /OpenLess_harmony_arm64\.hap/); -// This entry point is dispatch-only, so the Tauri tag build has to hand the tag -// over explicitly. Without that hand-off the workflow sits at zero runs and every -// Release ships without a HAP - which is exactly what happened to 2.0.x. -assert.match( - tauriRelease, - /^ dispatch-platform-releases:$/m, - 'release-tauri.yml must hand the tag to this workflow', -); -assert.match( - tauriRelease, - /gh workflow run release-harmony\.yml[\s\S]{0,300}-f "tag=\$RELEASE_TAG"/, - 'the hand-off must dispatch with the tag as an input', -); -assert.match( - tauriRelease, - /gh workflow run release-harmony\.yml[\s\S]{0,80}--ref "\$RELEASE_TAG"[\s\S]{0,180}-f "tag=\$RELEASE_TAG"/, - 'the downstream workflow definition must be loaded from the same immutable release tag', -); -assert.match(tauriRelease, /gh workflow run release-harmony\.yml[\s\S]{0,400}-f publish=true/); +// Reusable workflows stay in the same run and resolve at the caller's immutable +// commit; Harmony must not wait for desktop build/publication before starting. +assert.match(workflow, /^ workflow_call:$/m); +const harmonyCall = tauriRelease.slice(tauriRelease.indexOf('\n harmony:')); +assert.match(harmonyCall, /uses: \.\/\.github\/workflows\/release-harmony\.yml/); +assert.match(harmonyCall, /needs: prepare-release/); +assert.doesNotMatch(harmonyCall, /needs:.*(?:build|publish|android)/); +assert.match(harmonyCall, /tag: \$\{\{ github\.ref_name \}\}/); +assert.match(harmonyCall, /publish: true/); +assert.match(harmonyCall, /secrets: inherit/); +assert.doesNotMatch(tauriRelease, /gh workflow run|dispatch-platform-releases/); assert.doesNotMatch( tauriRelease, /release-linux-egui|LINUX_ACCEPTED_RELEASE_TAG/, @@ -180,13 +175,12 @@ assert.match( /github\.event_name == 'push'[\s\S]{0,120}refs\/tags\/v/, 'the hand-off must be tag-push only, so a branch or schedule run dispatches nothing', ); -assert.match(tauriRelease, /actions: write/, 'GITHUB_TOKEN needs actions: write to dispatch'); +assert.doesNotMatch(tauriRelease, /actions: write/, 'same-run calls need no dispatch write token'); assert.match( - tauriRelease, - /HARMONY_CHANNEL: \$\{\{ vars\.HARMONY_AUTO_CHANNEL \|\| 'none' \}\}/, - 'the automatic hand-off must default to the unsigned repository path', + harmonyCall, + /channel: \$\{\{ vars\.HARMONY_AUTO_CHANNEL \|\| 'none' \}\}/, + 'the automatic call must default to the unsigned repository path', ); -assert.match(tauriRelease, /channel=\$HARMONY_CHANNEL/); assert.match(releasing, /HARMONY_AUTO_CHANNEL/); // `env:` at job level outranks `$GITHUB_ENV`, so a placeholder key here would diff --git a/openless-all/app/scripts/prepare-release.mjs b/openless-all/app/scripts/prepare-release.mjs new file mode 100644 index 000000000..d0f8f2e7c --- /dev/null +++ b/openless-all/app/scripts/prepare-release.mjs @@ -0,0 +1,51 @@ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const runGh = (args) => + execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + +// One short preflight creates the shared draft before any platform starts. +// Existing published releases are read-only; platform retries must not reopen one. +export function prepareRelease(tag, repository, gh = runGh) { + if (!/^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?-tauri$/.test(tag)) { + throw new Error('Expected an immutable v-tauri release tag'); + } + if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error('Invalid repository'); + const prerelease = tag.endsWith('-beta-tauri') || tag.includes('-Beta.'); + let release; + try { + release = JSON.parse( + gh(['api', `repos/${repository}/releases/tags/${encodeURIComponent(tag)}`]), + ); + } catch (error) { + if (!/HTTP 404/.test(String(error.stderr ?? ''))) throw error; + gh([ + 'release', + 'create', + tag, + '--repo', + repository, + '--verify-tag', + '--draft', + '--title', + `OpenLess ${tag}`, + '--notes', + '', + ...(prerelease ? ['--prerelease'] : []), + ]); + return 'created'; + } + if (release.tag_name !== tag) throw new Error('Release tag mismatch'); + if (release.draft !== true) throw new Error('Refusing to modify an already published release'); + if (release.prerelease !== prerelease) throw new Error('Release channel mismatch'); + return 'existing draft'; +} + +if (resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url)) { + const result = prepareRelease(process.argv[2], process.argv[3]); + console.log(`Shared release: ${result}`); +} diff --git a/openless-all/app/scripts/release-publication-contract.test.mjs b/openless-all/app/scripts/release-publication-contract.test.mjs index b03c9849b..da5aafa85 100644 --- a/openless-all/app/scripts/release-publication-contract.test.mjs +++ b/openless-all/app/scripts/release-publication-contract.test.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; import yaml from 'js-yaml'; +import { prepareRelease } from './prepare-release.mjs'; const workflow = (name) => yaml.load(readFileSync(new URL(`../../../.github/workflows/${name}`, import.meta.url), 'utf8')); @@ -87,13 +88,111 @@ for (const name of [ ); } -const handoff = workflow('release-tauri.yml').jobs['dispatch-platform-releases']; -assert.equal(handoff.env.LINUX_ACCEPTED_RELEASE_TAG, undefined); -const dispatch = handoff.steps.find((step) => - step.run?.includes('gh workflow run release-harmony.yml'), +const unified = workflow('release-tauri.yml'); +assert.deepEqual(unified.on.push.tags, ['v*-tauri']); +assert.ok(unified.jobs['prepare-release']); +assert.match(unified.jobs['prepare-release'].if, /github.event_name == 'push'/); +assert.equal(unified.jobs['prepare-release'].permissions.contents, 'write'); +assert.ok( + unified.jobs['prepare-release'].steps.some((step) => + step.run?.includes('scripts/prepare-release.mjs'), + ), +); +for (const name of ['build', 'android', 'harmony']) { + assert.equal( + unified.jobs[name].needs, + 'prepare-release', + `${name} starts after the same short preflight`, + ); +} +assert.match( + unified.jobs.build.if, + /result == 'skipped'/, + 'manual/scheduled desktop builds still work', +); +for (const [name, file] of [ + ['android', 'android-apk.yml'], + ['harmony', 'release-harmony.yml'], +]) { + const job = unified.jobs[name]; + assert.equal(job.uses, `./.github/workflows/${file}`); + assert.equal(job.secrets, 'inherit'); + assert.match(job.if, /github.event_name == 'push'/); + assert.match(job.if, /refs\/tags\/v/); + assert.equal(job.permissions.contents, 'write'); + assert.ok(workflow(file).on.workflow_call); +} +assert.equal(unified.jobs['dispatch-platform-releases'], undefined); +const desktopDownloads = unified.jobs.publish.steps.filter((step) => + step.uses?.startsWith('actions/download-artifact@'), +); +assert.deepEqual(desktopDownloads.map((step) => step.with.pattern).sort(), [ + 'openless-macos-*', + 'openless-windows-*', +]); +const channelValidation = workflow('release-harmony.yml').jobs['build-harmony-hap'].steps.find( + (step) => step.name === 'Validate release channel', +); +assert.ok(channelValidation); +assert.doesNotMatch(JSON.stringify(unified), /release-linux-egui|LINUX_ACCEPTED_RELEASE_TAG/); +const tag = 'v2.0.2-Beta.7-tauri'; +const repository = 'Open-Less/openless'; +const apiCalls = []; +const missing = (args) => { + apiCalls.push(args); + if (args[0] === 'api') throw Object.assign(new Error('not found'), { stderr: 'HTTP 404' }); + return ''; +}; +assert.equal(prepareRelease(tag, repository, missing), 'created'); +assert.equal(apiCalls.length, 2); +for (const flag of ['--verify-tag', '--draft', '--prerelease']) + assert.ok(apiCalls[1].includes(flag)); +const stableCalls = []; +prepareRelease('v2.0.2-tauri', repository, (args) => { + stableCalls.push(args); + if (args[0] === 'api') throw Object.assign(new Error('not found'), { stderr: 'HTTP 404' }); + return ''; +}); +assert.ok(!stableCalls[1].includes('--prerelease')); +let reads = 0; +const existing = { tag_name: tag, draft: true, prerelease: true }; +assert.equal( + prepareRelease(tag, repository, () => { + reads++; + return JSON.stringify(existing); + }), + 'existing draft', +); +assert.equal(reads, 1, 'retry reads the draft without changing its body or assets'); +assert.throws( + () => prepareRelease(tag, repository, () => JSON.stringify({ ...existing, draft: false })), + /already published/, +); +assert.throws( + () => + prepareRelease(tag, repository, () => + JSON.stringify({ ...existing, tag_name: 'v2.0.1-tauri' }), + ), + /tag mismatch/, +); +assert.throws( + () => prepareRelease(tag, repository, () => JSON.stringify({ ...existing, prerelease: false })), + /channel mismatch/, +); +let unavailableCalls = 0; +assert.throws( + () => + prepareRelease(tag, repository, () => { + unavailableCalls++; + throw Object.assign(new Error('API unavailable'), { stderr: 'HTTP 503' }); + }), + /API unavailable/, +); +assert.equal(unavailableCalls, 1, 'API errors cannot trigger release creation'); +assert.throws( + () => prepareRelease('main', repository, () => assert.fail('invalid tags cannot call GitHub')), + /immutable/, ); -assert.ok(dispatch); -assert.doesNotMatch(JSON.stringify(handoff), /release-linux-egui|LINUX_ACCEPTED_RELEASE_TAG/); const directory = mkdtempSync(join(tmpdir(), 'openless-release-handoff-')); try { const calls = join(directory, 'calls'); @@ -107,7 +206,7 @@ try { ['invalid', 1], ]) { writeFileSync(calls, ''); - const result = spawnSync('bash', ['-c', dispatch.run], { + const result = spawnSync('bash', ['-c', channelValidation.run], { env: { ...process.env, PATH: `${directory}:${process.env.PATH}`, @@ -118,17 +217,7 @@ try { }); assert.equal(result.status, expected, channel); const logged = readFileSync(calls, 'utf8'); - if (expected === 0) { - assert.match( - logged, - new RegExp( - `release-harmony\\.yml.*--ref v2\\.0\\.2-Beta\\.5-tauri.*tag=v2\\.0\\.2-Beta\\.5-tauri.*channel=${channel}`, - ), - ); - assert.equal(logged.trim().split('\n').length, 1, 'dispatch only Harmony'); - } else { - assert.equal(logged, '', 'bad channel must not dispatch'); - } + assert.equal(logged, '', 'channel validation must not dispatch another workflow'); } } finally { rmSync(directory, { recursive: true, force: true }); diff --git a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs index f7c965ab3..ed33f183a 100644 --- a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs +++ b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs @@ -78,7 +78,12 @@ for (const [name, source] of [ `${name} workflow must not rebuild on every branch push`, ); assert.match(source, /schedule:/, `${name} workflow needs a warm-up schedule`); - assert.match(source, /tags:[\s\S]{0,40}v\*-tauri/, `${name} workflow must still ship tags`); + if (name === 'Tauri release') { + assert.match(source, /tags:[\s\S]{0,40}v\*-tauri/, 'the unified workflow owns tag releases'); + } else { + assert.match(source, /workflow_call:/, 'Android is called inside the unified release'); + assert.doesNotMatch(source, /^ push:/m, 'avoid duplicate tag builds'); + } } console.log('workflow-concurrency-contract.test.mjs passed'); From bbe9a76b784dcde318ebbca7fa6d72168478b3ef Mon Sep 17 00:00:00 2001 From: aeoform <2790848120@qq.com> Date: Tue, 10 Nov 2026 23:45:29 +0800 Subject: [PATCH 7/7] release: resolve drafts via gh release view and fix asset API lookup --- docs/release-distribution.md | 3 +- .../app/scripts/merge-release-signatures.mjs | 31 +++++---- openless-all/app/scripts/prepare-release.mjs | 21 ++++-- .../app/scripts/release-distribution.test.mjs | 65 +++++++++++++++++-- .../release-publication-contract.test.mjs | 21 +++--- 5 files changed, 108 insertions(+), 33 deletions(-) diff --git a/docs/release-distribution.md b/docs/release-distribution.md index 1178629e3..c3786bf69 100644 --- a/docs/release-distribution.md +++ b/docs/release-distribution.md @@ -16,6 +16,7 @@ tag → prepare-release(仅创建/验证共享草稿,不构建) - 三条构建线只依赖同一个轻量预检,不相互依赖;移除原先桌面发布后才 dispatch Harmony 的等待及 Harmony 等待桌面创建 Release 的轮询。 - Android/Harmony 使用同一 run 的本地 `workflow_call`,定义来自标签的不可变提交。Android 删除独立 tag push,避免同一标签重复构建四个 ABI;独立周检、debug/手动入口保留。 - 预检遇到已有已发布 Release、错误 tag/渠道、API 错误时停止,不重开或修改历史版本;已有草稿只读取,不覆盖正文和附件。 +- 草稿必须用 `gh release view ` 解析:REST 的 `GET /releases/tags/` 对真实草稿返回 `404`,旧实现会误判为“尚无 Release”而重复创建,签名汇总也会误以为没有旧索引而覆盖另一平台记录。查询失败只接受 `release not found`/`HTTP 404`;其他错误直接失败,不创建。索引附件按 `assets[].apiUrl` 下载,不用 `id`(那是 GraphQL node ID,不是 REST 数字 ID)。 - 桌面下载器只匹配 `openless-macos-*`、`openless-windows-*`,避免合并 run 后重复下载数百 MB 的 APK/HAP,且不会混入商店签名包。 - 桌面和 Android 的发布 job 仍共享签名索引互斥锁,防止读改写覆盖;锁覆盖发布段,不阻塞构建。Harmony 不写该索引,不需等待它们。编译并行度仍默认 2,不额外拆分构建矩阵。 - 一个平台失败不取消其他平台的构建;整个 Release run 会报失败,产物保留在草稿中。仍由管理员确认后发布,OSS 清单在发布之后同步,不能为了并行而先指向不存在的包。 @@ -43,7 +44,7 @@ tag → prepare-release(仅创建/验证共享草稿,不构建) - 构建过程仍由 Tauri/`tauri signer sign` 生成临时独立 `.sig`,CDN 清单继续内嵌原签名,不变更密钥、签名算法或验签标准。 - 桌面与 Android 的发布 job 共用 `openless-release-assets-` 并发锁;每个发布者读入草稿上已有索引,合并自己平台,再上传同一个 JSON,不能覆盖丢失另一个平台的记录。不新增聚合 VM。 -- 合并成功后只删除本机 staging 中的独立 `.sig`,不删除远端历史附件。历史草稿若已有 `.sig` 而无索引,维修时保持旧格式;已发布的 Release 拒绝修改签名索引。 +- 合并成功后只删除本机 staging 中的独立 `.sig`,不删除远端历史附件。历史草稿若已有 `.sig` 而无索引,维修时保持旧格式;已发布的 Release 拒绝修改签名索引。草稿查询走 `gh release view`(REST tag 端点对草稿返 404),索引附件按其 `apiUrl` 下载。 - 新客户端优先解析同一 Release 的索引,检查 schema、标签和精确文件名;历史 Release 仍读取独立 `.sig`。最终必须用内置公钥验证包本身与 trusted comment,不能把索引里的 SHA-256 当成来源认证。 - **旧客户端兼容限制**:已安装、只认识独立 `.sig` 的客户端仍可通过 CDN 内嵌签名更新,但无法在 CDN 完全不可用时独立发现只附加索引的新 Release。这不能靠修改未来发行文件修复;需先在 CDN 可用时升级到支持索引的新客户端。历史 Release 原有 GitHub 路径不变。 diff --git a/openless-all/app/scripts/merge-release-signatures.mjs b/openless-all/app/scripts/merge-release-signatures.mjs index 71b1b574b..e53888ba9 100644 --- a/openless-all/app/scripts/merge-release-signatures.mjs +++ b/openless-all/app/scripts/merge-release-signatures.mjs @@ -5,6 +5,7 @@ import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from import { basename, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; +import { isMissingRelease, readRelease } from './prepare-release.mjs'; export const INDEX_NAME = 'updater-signatures.json'; export function signatureFromIndex(index, tag, name) { @@ -59,6 +60,13 @@ export function mergeSignatures(directory, tag, previous) { function gh(args) { return spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 }); } +// Same failure shape as a throwing `execFileSync` call, so the shared missing- +// release check works for both injection styles. +function ghOrThrow(args) { + const result = gh(args); + if (result.status === 0) return result.stdout; + throw Object.assign(new Error('gh failed'), { stderr: result.stderr }); +} function main() { const [directoryArg, tag, repository] = process.argv.slice(2); if ( @@ -68,29 +76,28 @@ function main() { ) throw new Error('Usage: merge-release-signatures.mjs '); const directory = resolve(directoryArg); - const result = gh(['api', `repos/${repository}/releases/tags/${tag}`]); let previous; - if (result.status === 0) { - const release = JSON.parse(result.stdout); - if (!release.draft) throw new Error('Refusing to mutate signatures on a published release'); + let release; + try { + release = readRelease(tag, repository, ghOrThrow); + } catch (error) { + if (!isMissingRelease(error.stderr)) throw new Error(`Cannot inspect release: ${error.stderr}`); + release = undefined; + } + if (release) { + if (release.tagName !== tag) throw new Error('Release tag mismatch'); + if (!release.isDraft) throw new Error('Refusing to mutate signatures on a published release'); const asset = release.assets.find((a) => a.name === INDEX_NAME); if (!asset && release.assets.some((a) => a.name.endsWith('.sig'))) { console.log('Legacy draft: retain its original detached signature format'); return; } if (asset) { - const download = gh([ - 'api', - `repos/${repository}/releases/assets/${asset.id}`, - '-H', - 'Accept: application/octet-stream', - ]); + const download = gh(['api', asset.apiUrl, '-H', 'Accept: application/octet-stream']); if (download.status !== 0) throw new Error(`Cannot read existing signature index: ${download.stderr}`); previous = JSON.parse(download.stdout); } - } else if (!result.stderr.includes('HTTP 404')) { - throw new Error(`Cannot inspect release: ${result.stderr}`); } const index = mergeSignatures(directory, tag, previous); writeFileSync(join(directory, INDEX_NAME), JSON.stringify(index, null, 2) + '\n'); diff --git a/openless-all/app/scripts/prepare-release.mjs b/openless-all/app/scripts/prepare-release.mjs index d0f8f2e7c..b280c6149 100644 --- a/openless-all/app/scripts/prepare-release.mjs +++ b/openless-all/app/scripts/prepare-release.mjs @@ -8,6 +8,15 @@ const runGh = (args) => stdio: ['ignore', 'pipe', 'pipe'], }); +// Draft releases are invisible to `GET /releases/tags/` (GitHub answers +// 404 for a real draft), so resolve by tag through `gh release view`, which also +// sees drafts. Any other failure is an outage, not permission to create one. +export const RELEASE_FIELDS = 'apiUrl,isDraft,isPrerelease,tagName,assets'; +export const isMissingRelease = (stderr) => /release not found|HTTP 404/.test(String(stderr ?? '')); +export function readRelease(tag, repository, gh = runGh) { + return JSON.parse(gh(['release', 'view', tag, '--repo', repository, '--json', RELEASE_FIELDS])); +} + // One short preflight creates the shared draft before any platform starts. // Existing published releases are read-only; platform retries must not reopen one. export function prepareRelease(tag, repository, gh = runGh) { @@ -18,11 +27,9 @@ export function prepareRelease(tag, repository, gh = runGh) { const prerelease = tag.endsWith('-beta-tauri') || tag.includes('-Beta.'); let release; try { - release = JSON.parse( - gh(['api', `repos/${repository}/releases/tags/${encodeURIComponent(tag)}`]), - ); + release = readRelease(tag, repository, gh); } catch (error) { - if (!/HTTP 404/.test(String(error.stderr ?? ''))) throw error; + if (!isMissingRelease(error.stderr)) throw error; gh([ 'release', 'create', @@ -39,9 +46,9 @@ export function prepareRelease(tag, repository, gh = runGh) { ]); return 'created'; } - if (release.tag_name !== tag) throw new Error('Release tag mismatch'); - if (release.draft !== true) throw new Error('Refusing to modify an already published release'); - if (release.prerelease !== prerelease) throw new Error('Release channel mismatch'); + if (release.tagName !== tag) throw new Error('Release tag mismatch'); + if (release.isDraft !== true) throw new Error('Refusing to modify an already published release'); + if (release.isPrerelease !== prerelease) throw new Error('Release channel mismatch'); return 'existing draft'; } diff --git a/openless-all/app/scripts/release-distribution.test.mjs b/openless-all/app/scripts/release-distribution.test.mjs index 8a220a935..757a83f3c 100644 --- a/openless-all/app/scripts/release-distribution.test.mjs +++ b/openless-all/app/scripts/release-distribution.test.mjs @@ -58,12 +58,20 @@ try { const bin = join(root, 'bin'); mkdirSync(bin); + // `gh release view` resolves drafts; the mock only has to answer that call and + // the asset download (which must use the numeric API URL, not the node id). writeFileSync( join(bin, 'gh'), - '#!/bin/sh\nif [ -n "$MOCK_FAIL" ]; then echo "HTTP $MOCK_FAIL" >&2; exit 1; fi\nprintf "%s" "$MOCK_RELEASE"\n', + '#!/bin/sh\nif [ -n "$MOCK_FAIL" ]; then echo "HTTP $MOCK_FAIL" >&2; exit 1; fi\n' + + 'if [ "$MOCK_MISSING" = 1 ]; then echo "release not found" >&2; exit 1; fi\n' + + 'case "$1 $2" in\n' + + ' "release view") printf "%s" "$MOCK_RELEASE" ;;\n' + + ' "api https://api.github.com/repos/Open-Less/openless/releases/assets/7") printf "%s" "$MOCK_INDEX" ;;\n' + + ' *) echo "unexpected gh invocation: $*" >&2; exit 1 ;;\n' + + 'esac\n', ); chmodSync(join(bin, 'gh'), 0o755); - const runMerge = (release, failure = '') => + const runMerge = (release, failure = '', extra = {}) => spawnSync( process.execPath, [ @@ -79,15 +87,62 @@ try { PATH: bin + ':' + process.env.PATH, MOCK_RELEASE: JSON.stringify(release), MOCK_FAIL: failure, + ...extra, }, }, ); - assert.notEqual(runMerge({ draft: false, assets: [] }).status, 0); + assert.notEqual(runMerge({ tagName: tag, isDraft: false, assets: [] }).status, 0); assert.ok(existsSync(join(desktop, 'OpenLess_2.0.2-Beta.7_x64-setup.exe.sig'))); - assert.equal(runMerge({ draft: true, assets: [{ name: 'historical.exe.sig' }] }).status, 0); + assert.equal( + runMerge({ tagName: tag, isDraft: true, assets: [{ name: 'historical.exe.sig' }] }).status, + 0, + ); + assert.notEqual( + runMerge({ tagName: 'v2.0.2-Beta.6-tauri', isDraft: true, assets: [] }).status, + 0, + 'a draft for another tag must not be mutated', + ); assert.ok(!existsSync(join(desktop, 'updater-signatures.json'))); assert.notEqual(runMerge({}, '503').status, 0, 'API failures must not discard other platforms'); - assert.equal(runMerge({ draft: true, assets: [] }).status, 0); + // A real draft found through `release view` keeps the other platform's entry. + const existingIndex = JSON.stringify({ + schema: 1, + release_tag: tag, + artifacts: { + 'OpenLess_2.0.2-Beta.7_arm64-v8a.apk': { + signature: 'android-signature', + size: 7, + sha256: '', + }, + }, + }); + assert.equal( + runMerge( + { + tagName: tag, + isDraft: true, + assets: [ + { + name: 'updater-signatures.json', + id: 'RA_kwDOSNoZCc4rVTlL', + apiUrl: 'https://api.github.com/repos/Open-Less/openless/releases/assets/7', + }, + ], + }, + '', + { MOCK_INDEX: existingIndex }, + ).status, + 0, + ); + const merged = JSON.parse(readFileSync(join(desktop, 'updater-signatures.json'), 'utf8')); + assert.deepEqual(Object.keys(merged.artifacts), [ + 'OpenLess_2.0.2-Beta.7_arm64-v8a.apk', + 'OpenLess_2.0.2-Beta.7_x64-setup.exe', + ]); + rmSync(join(desktop, 'OpenLess_2.0.2-Beta.7_x64-setup.exe.sig'), { force: true }); + rmSync(join(desktop, 'updater-signatures.json'), { force: true }); + put(desktop, 'OpenLess_2.0.2-Beta.7_x64-setup.exe', 'desktop', 'desktop-signature'); + assert.equal(runMerge({ tagName: tag, isDraft: true, assets: [] }).status, 0); assert.ok(existsSync(join(desktop, 'updater-signatures.json'))); assert.ok(!existsSync(join(desktop, 'OpenLess_2.0.2-Beta.7_x64-setup.exe.sig'))); diff --git a/openless-all/app/scripts/release-publication-contract.test.mjs b/openless-all/app/scripts/release-publication-contract.test.mjs index da5aafa85..5ac99c7a6 100644 --- a/openless-all/app/scripts/release-publication-contract.test.mjs +++ b/openless-all/app/scripts/release-publication-contract.test.mjs @@ -138,24 +138,31 @@ assert.doesNotMatch(JSON.stringify(unified), /release-linux-egui|LINUX_ACCEPTED_ const tag = 'v2.0.2-Beta.7-tauri'; const repository = 'Open-Less/openless'; const apiCalls = []; +// Draft lookup must go through `gh release view`: the REST tag endpoint answers +// 404 for a real draft, which previously looked like "no release yet". const missing = (args) => { apiCalls.push(args); - if (args[0] === 'api') throw Object.assign(new Error('not found'), { stderr: 'HTTP 404' }); + assert.notEqual(args[0], 'api', 'never probe drafts through /releases/tags'); + if (args[1] === 'view') + throw Object.assign(new Error('not found'), { stderr: 'release not found' }); return ''; }; assert.equal(prepareRelease(tag, repository, missing), 'created'); assert.equal(apiCalls.length, 2); +assert.equal(apiCalls[0][1], 'view'); +assert.equal(apiCalls[1][1], 'create'); for (const flag of ['--verify-tag', '--draft', '--prerelease']) assert.ok(apiCalls[1].includes(flag)); const stableCalls = []; prepareRelease('v2.0.2-tauri', repository, (args) => { stableCalls.push(args); - if (args[0] === 'api') throw Object.assign(new Error('not found'), { stderr: 'HTTP 404' }); + if (args[1] === 'view') + throw Object.assign(new Error('not found'), { stderr: 'release not found' }); return ''; }); assert.ok(!stableCalls[1].includes('--prerelease')); let reads = 0; -const existing = { tag_name: tag, draft: true, prerelease: true }; +const existing = { tagName: tag, isDraft: true, isPrerelease: true }; assert.equal( prepareRelease(tag, repository, () => { reads++; @@ -165,18 +172,16 @@ assert.equal( ); assert.equal(reads, 1, 'retry reads the draft without changing its body or assets'); assert.throws( - () => prepareRelease(tag, repository, () => JSON.stringify({ ...existing, draft: false })), + () => prepareRelease(tag, repository, () => JSON.stringify({ ...existing, isDraft: false })), /already published/, ); assert.throws( () => - prepareRelease(tag, repository, () => - JSON.stringify({ ...existing, tag_name: 'v2.0.1-tauri' }), - ), + prepareRelease(tag, repository, () => JSON.stringify({ ...existing, tagName: 'v2.0.1-tauri' })), /tag mismatch/, ); assert.throws( - () => prepareRelease(tag, repository, () => JSON.stringify({ ...existing, prerelease: false })), + () => prepareRelease(tag, repository, () => JSON.stringify({ ...existing, isPrerelease: false })), /channel mismatch/, ); let unavailableCalls = 0;