diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ddeb3809 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,267 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + PYTHONDONTWRITEBYTECODE: '1' + +jobs: + ci: + name: CI required + if: always() + needs: [core, node, offline, android, app] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.13.15' + - name: Require every job to succeed + env: + CI_JOB_RESULTS: ${{ toJSON(needs) }} + run: python3 Tools/CI/check_jobs.py + + app: + name: Main App Release compile (unsigned) + needs: android + runs-on: macos-14 + timeout-minutes: 35 + env: + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.13.15' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '22.23.0' + - name: Prepare SHA-256 verified XcodeGen + run: | + mkdir -p '${{ runner.temp }}/okvideo-reports/app' + python3 Tools/CI/fetch_tool.py xcodegen --output '${{ runner.temp }}/okvideo-ci-tools' > '${{ runner.temp }}/okvideo-reports/app/tool-download.log' 2>&1 + - name: Receive tested compile-only bridge + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: android-compile-input + path: ${{ runner.temp }}/okvideo-android-compile-input + - name: Restore pinned App dependency inputs + id: app-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ runner.temp }}/okvideo-app-dependencies/CrashReporter/Source + ${{ runner.temp }}/okvideo-app-dependencies/Downloads + ${{ runner.temp }}/okvideo-app-dependencies/SourcePackages + key: app-deps-v1-${{ runner.os }}-${{ runner.arch }}-xcode16.2-${{ hashFiles('OKVideoMac/macOS/OKVideoMac/Packages/OKCrashDiagnostics/Package.swift', 'OKVideoMac/macOS/OKVideoMac/Scripts/prepare-crash-reporter.sh', 'OKVideoMac/macOS/OKVideoMac/Scripts/prepare-sparkle.py', 'ThirdParty/sparkle-lock.json') }} + - name: Compile and verify Release App structure without signing + run: >- + python3 Tools/CI/run_app.py + --work-dir '${{ runner.temp }}/okvideo-app-compile' + --dependencies '${{ runner.temp }}/okvideo-app-dependencies' + --output '${{ runner.temp }}/okvideo-reports/app' + --bridge-input '${{ runner.temp }}/okvideo-android-compile-input' + --xcodegen '${{ runner.temp }}/okvideo-ci-tools/xcodegen' + - name: Retain App compilation evidence only + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: app-compile-${{ github.run_attempt }} + path: ${{ runner.temp }}/okvideo-reports/app/ + if-no-files-found: error + retention-days: 7 + - name: Save successful main App dependency cache + if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.app-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ runner.temp }}/okvideo-app-dependencies/CrashReporter/Source + ${{ runner.temp }}/okvideo-app-dependencies/Downloads + ${{ runner.temp }}/okvideo-app-dependencies/SourcePackages + key: ${{ steps.app-cache.outputs.cache-primary-key }} + + android: + name: Android Release JVM and unsigned bridge + runs-on: ubuntu-24.04 + timeout-minutes: 25 + env: + GRADLE_USER_HOME: ${{ github.workspace }}/../okvideo-gradle + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.13.15' + - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: '17.0.19+10' + - name: Restore locked Gradle dependencies + id: gradle-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ env.GRADLE_USER_HOME }}/caches + ${{ env.GRADLE_USER_HOME }}/wrapper + key: gradle-v1-${{ runner.os }}-${{ runner.arch }}-java17.0.19-${{ hashFiles('OKVideoMac/Helpers/AndroidDexBridge/**/*.gradle', 'OKVideoMac/Helpers/AndroidDexBridge/**/*.lockfile', 'OKVideoMac/Helpers/AndroidDexBridge/gradle/wrapper/gradle-wrapper.properties') }} + - name: Build unsigned bridge and run Release JVM tests + run: >- + python3 Tools/CI/run_android.py + --work-dir '${{ runner.temp }}/okvideo-android' + --output '${{ runner.temp }}/okvideo-reports/android' + --artifact-dir '${{ runner.temp }}/okvideo-android-compile-input' + - name: Retain Android evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: android-tests-${{ github.run_attempt }} + path: ${{ runner.temp }}/okvideo-reports/android/ + if-no-files-found: error + retention-days: 7 + - name: Transfer compile-only unsigned APK to the App job + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: android-compile-input + path: ${{ runner.temp }}/okvideo-android-compile-input/ + if-no-files-found: error + retention-days: 1 + overwrite: true + - name: Save successful main Gradle cache + if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ env.GRADLE_USER_HOME }}/caches + ${{ env.GRADLE_USER_HOME }}/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + offline: + name: Offline audits and static checks + runs-on: macos-14 + timeout-minutes: 15 + env: + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.13.15' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '22.23.0' + - name: Prepare SHA-256 verified actionlint + run: | + mkdir -p '${{ runner.temp }}/okvideo-reports/offline' + python3 Tools/CI/fetch_tool.py actionlint --output '${{ runner.temp }}/okvideo-ci-tools' > '${{ runner.temp }}/okvideo-reports/offline/tool-download.log' 2>&1 + - name: Run all offline audits and static checks + run: >- + python3 Tools/CI/run_offline.py + --output '${{ runner.temp }}/okvideo-reports/offline' + --work-dir '${{ runner.temp }}/okvideo-offline-fixtures' + --actionlint '${{ runner.temp }}/okvideo-ci-tools/actionlint' + - name: Retain offline evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: offline-${{ github.run_attempt }} + path: ${{ runner.temp }}/okvideo-reports/offline/ + if-no-files-found: error + retention-days: 7 + + node: + name: Node + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.13.15' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '22.23.0' + - name: Verify report failure paths + run: python3 Tools/CI/run_python.py --suite ci --output '${{ runner.temp }}/okvideo-reports/node/validator' + - name: Run every Node test file + run: python3 Tools/CI/run_node.py --output '${{ runner.temp }}/okvideo-reports/node' + - name: Retain Node evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: node-${{ github.run_attempt }} + path: ${{ runner.temp }}/okvideo-reports/node/ + if-no-files-found: error + retention-days: 7 + + core: + name: Core (${{ matrix.configuration }}) + runs-on: macos-14 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + configuration: [debug, release] + env: + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.13.15' + - name: Verify selected toolchain and result-validator failure paths + run: | + test "$(uname -m)" = arm64 + xcodebuild -version + swift --version + python3 Tools/CI/run_python.py --suite ci --output '${{ runner.temp }}/okvideo-reports/core-${{ matrix.configuration }}/validator' + - name: Restore configuration-specific build cache + id: core-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/okvideo-core-${{ matrix.configuration }} + key: core-v1-${{ runner.os }}-${{ runner.arch }}-xcode16.2-${{ matrix.configuration }}-${{ hashFiles('OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/**') }} + - name: Build, discover and run complete suite + run: >- + python3 Tools/CI/run_core.py + --configuration '${{ matrix.configuration }}' + --scratch-path '${{ runner.temp }}/okvideo-core-${{ matrix.configuration }}' + --output '${{ runner.temp }}/okvideo-reports/core-${{ matrix.configuration }}' + --jobs 2 + - name: Retain test evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: core-${{ matrix.configuration }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/okvideo-reports/core-${{ matrix.configuration }}/ + if-no-files-found: error + retention-days: 7 + - name: Save successful main build cache + if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.core-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/okvideo-core-${{ matrix.configuration }} + key: ${{ steps.core-cache.outputs.cache-primary-key }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a18729c..84ef04f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,10 @@ ## [Unreleased] -No additional changes beyond 0.9.0. +- Add credential-free CI for Core/Persistence Debug and Release, all Node tests, offline audits/static checks, Android Release JVM tests and unsigned main App Release compilation; retain reports and require every job to succeed. +- Pin CI Actions and tool downloads, isolate build inputs, validate discovered/completed test results and allowed skips, and reject empty, incomplete or failed results. Verify the aggregate gate with an intentional failure in an isolated branch. +- Make crash-worker tests respect the selected Xcode, replace TMDB test timing sleeps with explicit coordination, publish the loopback fixture port atomically, and preserve Python class-fixture errors in reports. +- Document CI coverage, local reproduction and remaining limits. App version/build and existing v0.9.0 release assets remain unchanged. ## [0.9.0] - 2026-10-26 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6c22192..917edbfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,15 @@ PR 保持单一、清晰的范围。说明变更目的、验证方式以及已 - 影响许可、对应源码、SBOM 或 provenance 的变更必须同步更新相应记录; - 不要把未经验证的第三方 artifact 作为可发布二进制提交。 -仓库没有在本文件中承诺特定 CI 必过项;maintainer 会根据变更风险要求补充验证。 -发布包仍必须遵循 `Docs/SOURCE_RELEASE_PROCESS.md` 和仓库 release scripts 的 -fail-closed 检查。 +提交到 `main` 的 PR 和 `main` 推送会运行 [CI](.github/workflows/ci.yml)。合入前应确认 +`CI required` 及其依赖的 Core Debug/Release、Node、离线审计与静态检查、Android JVM +测试、主 App 无签名 Release 编译全部通过。失败、取消、跳过任务或缺失结果都不能 +视为通过。该汇总检查不等于已启用 GitHub 分支保护;本轮没有修改仓库保护规则。 + +运行方法、工具版本、报告和明确不覆盖的范围见 [CI 指南](Docs/CI.md)。Core 中既有的 +条件测试跳过项由测试名和原因白名单约束,不得通过扩大跳过范围掩盖失败。新增测试 +会自动发现;更改白名单需解释对应的环境要求,并保留相关的人工验收路径。 + +维护者仍会按变更风险要求补充实机、播放、兼容性或真实服务验证。CI 的未签名编译 +产物仅用于检查,不能安装或发布。发布包必须遵循 +[正式发布流程](Docs/SOURCE_RELEASE_PROCESS.md)和仓库 release scripts 的 fail-closed 检查。 diff --git a/Docs/CI.md b/Docs/CI.md new file mode 100644 index 00000000..6fc0b936 --- /dev/null +++ b/Docs/CI.md @@ -0,0 +1,149 @@ +# CI: coverage and local verification + +[Workflow](../.github/workflows/ci.yml) · [Runs](https://github.com/yaolin-dev/OKVideoMac/actions/workflows/ci.yml) · [Contributing](../CONTRIBUTING.md) + +CI runs on pull requests targeting `main`, pushes to `main`, and manual dispatch. +It uses synthetic fixtures and public build dependencies, without Developer ID, +notary credentials, service accounts, private providers or GitHub secrets. +Dependency downloads still require network access; “offline” describes the audit +fixtures, not installation of the CI toolchain. + +## Checks and scope + +| Check | What runs | Baseline after 0.9.0 | +| --- | --- | --- | +| Core (debug), Core (release) | Entire `OKVideoKit` Core/Persistence XCTest suite, separately built and discovered for each configuration | Each: 1,233 discovered, 1,204 passed, 29 declared skips | +| Node | All five `*.test.js` suites selected by `run_node.py`, using the Node test runner | 55 passed, no skips | +| Offline audits and static checks | SourceAudit synthetic tests, crash configuration tests, tiny Mach-O/dSYM symbol gates, CI validator tests, static checks | 51 audit tests (including 7 native launcher cases), 15 crash configuration tests, 7 symbol cases; no skips | +| Android Release JVM and unsigned bridge | Existing locked Gradle Release build and JVM tests in a fresh source copy; verify APK is unsigned | 45 JVM tests, no skips; compile-only APK | +| Main App Release compile (unsigned) | Existing project and dependency scripts, main App plus preview/relaunch helpers, arm64 Release | Three executables; validate version/build, empty service configuration and embedded APK hash | +| CI required | Aggregate the five job groups, including both Core matrix results | Only exact `success` for every group is accepted | + +Counts describe the checked baseline, not a target used to suppress new tests. +Discovery and generated reports are authoritative. Core skips must match the exact +test name and reason in [core-allowed-skips.json](../Tools/CI/core-allowed-skips.json). +They cover existing opt-in/live/resource/worker paths; they are not passed tests. +Empty suites, unexplained skips, missing completions, duplicates, test process +errors and contradictory totals fail the check. Other test suites allow no skips. +The 28 CI validator tests exercise malformed reports, missing work, failure, +cancellation, skipped jobs, and Python fixture errors. + +Static checks parse tracked Python, JavaScript, shell and JSON inputs, run actionlint, +require full commit SHA pins for Actions, reject workflow secret references and +scan the CI sources/workflow with the repository artifact scanner. This is syntax +and workflow validation; it does not claim SwiftLint, ShellCheck, Pyflakes, a full +repository secret-history scan or a security audit. + +`CI required` is a stable check name for review. Adding it does not enable a GitHub +branch-protection rule; repository protection settings were not changed. + +## Toolchain and isolation + +- macOS 14 arm64 runners select Xcode 16.2 / Swift 6.0.3. Linux jobs use Ubuntu 24.04. +- Python 3.13.15, Node 22.23.0 and Temurin JDK 17.0.19+10 are selected explicitly. +- actionlint 1.7.12 and XcodeGen 2.46.0 downloads must match the official archive + SHA-256 values in [tool-downloads.json](../Tools/CI/tool-downloads.json). +- Android keeps Gradle 8.9, AGP 8.7.3, SDK platform 35 and build-tools 34.0.0 with + the existing wrapper checksum and dependency locks. It does not load signing inputs. +- Android/App builds copy only tracked regular files to a new directory outside + the checkout. Ignored local outputs, keys and configuration are not copied. + Android uses a UTF-8 locale so non-ASCII checkout paths remain valid. +- The App job accepts only the same commit's successful Android test manifest and + matching APK SHA-256. It does not run `package-app.sh`, launch the App, install it, + replace a Desktop app or publish a Release. Native audit fixtures use ad-hoc + signing (`codesign -s -`), which requires no signing identity or certificate. + +Jobs and subprocesses have finite timeouts. A Core matrix failure does not cancel +its other configuration. Caches are separated by OS/architecture, toolchain and +relevant input hashes; only successful jobs on a `main` push save caches. Tests +still run after a cache hit. Android explicitly reruns tasks without build-cache +results. Toolchain/dependency upgrades must update the corresponding pins and +verification evidence together. + +## Local commands + +Run from the repository root with the matching toolchain. Use a new output directory +outside the checkout; reports and build products must not be committed. The scripts +record `HEAD`, so use a clean, committed checkout for evidence tied to a commit. + +```bash +export DEVELOPER_DIR=/Applications/Xcode_16.2.app/Contents/Developer +export PYTHONDONTWRITEBYTECODE=1 +ci_work_dir="$(mktemp -d /tmp/okvideo-ci.XXXXXX)" +python3 Tools/CI/run_python.py --suite ci --output "$ci_work_dir/validators" +python3 Tools/CI/run_core.py --configuration debug --jobs 2 \ + --scratch-path "$ci_work_dir/core-debug-build" --output "$ci_work_dir/core-debug" +python3 Tools/CI/run_core.py --configuration release --jobs 2 \ + --scratch-path "$ci_work_dir/core-release-build" --output "$ci_work_dir/core-release" +python3 Tools/CI/run_node.py --output "$ci_work_dir/node" +python3 Tools/CI/fetch_tool.py actionlint --output "$ci_work_dir/tools" +python3 Tools/CI/run_static.py --actionlint "$ci_work_dir/tools/actionlint" \ + --output "$ci_work_dir/static" +``` + +Run the complete offline/native audit in a dedicated test environment with no real +OKVideoMac instance running. Its process-isolation guard intentionally rejects an +active App; do not bypass the guard or stop someone else's playback to run it. +GitHub's fresh runner is the normal environment for this check. + +```bash +python3 Tools/CI/run_offline.py --actionlint "$ci_work_dir/tools/actionlint" \ + --work-dir "$ci_work_dir/offline-fixtures" --output "$ci_work_dir/offline" +``` + +For Android/App compilation, install the toolchain and Android SDK listed above, +set `JAVA_HOME` and `ANDROID_HOME` to those installations, and keep the complete +Node distribution (including its `LICENSE`) available on `PATH`: + +```bash +export GRADLE_USER_HOME="$ci_work_dir/gradle" +python3 Tools/CI/run_android.py --work-dir "$ci_work_dir/android-build" \ + --output "$ci_work_dir/android" --artifact-dir "$ci_work_dir/bridge-input" +python3 Tools/CI/fetch_tool.py xcodegen --output "$ci_work_dir/tools" +python3 Tools/CI/run_app.py --work-dir "$ci_work_dir/app-build" \ + --dependencies "$ci_work_dir/app-dependencies" --output "$ci_work_dir/app" \ + --bridge-input "$ci_work_dir/bridge-input" --xcodegen "$ci_work_dir/tools/xcodegen" +``` + +## Reports and failure verification + +Each job retains raw command logs, machine-readable summaries and available JUnit +results for seven days, including after a failure. Core records configuration, +toolchain, discovered identities and test-log SHA-256. A build/discovery failure may +have no test XML; the failed JSON summary and command log remain the evidence. +The unsigned APK and test manifest are an internal compile input with one-day +retention, not a release asset or installable deliverable. No App binary is uploaded. +If the APK has expired, rerun the whole workflow rather than only the App job. + +Inspect the first failed step and its retained report before retrying. A hosted +runner that never starts supplies no test evidence; queue/capacity cancellation +must not be counted as a pass. Use a new successful run to resolve it. Fix test or +build failures without weakening assertions or the skip policy. + +The initial [complete six-job run](https://github.com/yaolin-dev/OKVideoMac/actions/runs/40343489109) +passed. A separate [intentional failure run](https://github.com/yaolin-dev/OKVideoMac/actions/runs/40346059822) +then inserted one failing Core assertion: both configurations reported exactly one +failure, the other jobs passed, and `CI required` rejected the aggregate. The faulty +commit was isolated from the implementation branch. Follow the workflow page for +the current branch's latest result, rather than treating these historical runs as +proof for later changes. + +## Remaining limits and formal releases + +- This workflow does not run all macOS App XCTest/UI suites, AndroidRuntimeKit + installation tests, real Emulator/ADB/Bridge E2E, actual playback or private + provider/account flows. It does not establish compatibility on every supported + macOS version, including the historical macOS 12.5.1 crash path. +- Main App compilation leaves the existing optional libmpv/QuickJS native artifacts + unbuilt. Their runtime behavior and a complete distributable bundle require the + existing release build and playback verification. +- Existing AGP 8.7.3 lint metadata analysis emits diagnostics for Kotlin 2.2 metadata + in locked dependencies. Release assembly and JVM tests pass, and the Android + summary records these diagnostics. Full dependency lint coverage is **not** + established; resolving this requires a separately verified Android toolchain + upgrade, not suppressing the warnings or changing locks during CI setup. +- Developer ID, Hardened Runtime, Apple Accepted notarization, staple, Gatekeeper, + installation and public artifact/source integrity remain required by the + [formal release process](SOURCE_RELEASE_PROCESS.md). CI success is not release + approval. These CI/test/documentation changes leave App 0.9.0 / Build 155 and its + existing signed release assets unchanged. diff --git a/OKVideoMac/README.md b/OKVideoMac/README.md index ae2c8418..52af2f59 100644 --- a/OKVideoMac/README.md +++ b/OKVideoMac/README.md @@ -371,6 +371,8 @@ source index 和 binary-bound manifest 一致。 构建环境、依赖和故障排查见 [`macOS/OKVideoMac/Docs/BUILDING.md`](macOS/OKVideoMac/Docs/BUILDING.md)。 贡献规则见 [`CONTRIBUTING.md`](../CONTRIBUTING.md)。 +无需证书和真实账号的自动测试、静态检查、无签名 Release 编译及其限制见 +[CI 指南](../Docs/CI.md)。CI 检查产物不能作为安装包;正式发布继续使用下面的受控流程。 本地验证包: diff --git a/OKVideoMac/macOS/OKVideoMac/Docs/BUILDING.md b/OKVideoMac/macOS/OKVideoMac/Docs/BUILDING.md index f800392d..3f11c590 100644 --- a/OKVideoMac/macOS/OKVideoMac/Docs/BUILDING.md +++ b/OKVideoMac/macOS/OKVideoMac/Docs/BUILDING.md @@ -186,12 +186,17 @@ Homebrew ADB 或 SDK 不能代替 Managed Purity 门禁。 ## 工程和测试 +自动检查的精确工具版本、Debug/Release 区分、严格结果验证及本地复现命令见 +[CI 指南](../../../../Docs/CI.md)。CI 只执行无签名主 App Release 编译,不生成可分发 +安装包,也不替代本页的完整依赖构建、实机测试和正式发布流程。 + ```bash cd OKVideoMac/macOS/OKVideoMac export OKVIDEOMAC_BUILD_ROOT="${OKVIDEOMAC_BUILD_ROOT:-$PWD/Vendor/Build}" ./Scripts/bootstrap.sh swift test \ --package-path Packages/OKVideoKit \ + --configuration debug \ --scratch-path "$OKVIDEOMAC_BUILD_ROOT/SwiftPM" xcodebuild \ -project OKVideoMac.xcodeproj \ diff --git a/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoCoreTests/TMDBAvailabilityTests.swift b/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoCoreTests/TMDBAvailabilityTests.swift index e7e28082..abfeb320 100644 --- a/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoCoreTests/TMDBAvailabilityTests.swift +++ b/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoCoreTests/TMDBAvailabilityTests.swift @@ -56,20 +56,41 @@ final class TMDBAvailabilityTests: XCTestCase { } } func testProbeCoalescingPreservesUnexpiredSuccess() async throws { - let health = TMDBAvailability(), count = ProbeCounter() + let health = TMDBAvailability(), count = ProbeCounter(), gate = ProbeGate() + let started = expectation(description: "Probe operation started") + let checking = expectation(description: "Checking state published") + let completed = expectation(description: "Probe completion published") await health.finish(health.ticket(.metadata), outcome: .success) + let updates = await health.updates() + let observer = Task { + var sawChecking = false + for await state in updates { + if state.metadata.checking && !sawChecking { + sawChecking = true + checking.fulfill() + } else if sawChecking && !state.metadata.checking { + completed.fulfill() + return + } + } + } + defer { observer.cancel() } for _ in 0..<20 { await health.probe(.metadata, force: true) { await count.increment() - try? await Task.sleep(nanoseconds: 50_000_000) + started.fulfill() + await gate.wait() return .success } } + await fulfillment(of: [started, checking], timeout: 5) let during = await health.snapshot() XCTAssertTrue(during.metadata.checking); XCTAssertEqual(during.metadata.state, .available) - try await Task.sleep(nanoseconds: 80_000_000) + await gate.open() + await fulfillment(of: [completed], timeout: 5) let calls = await count.value, after = await health.snapshot() XCTAssertEqual(calls, 1); XCTAssertFalse(after.metadata.checking) + XCTAssertEqual(after.metadata.state, .available) } func testRateLimitCannotBeBypassedByRetryNetworkChangeOrInFlightSuccess() async throws { let clock = TMDBTestClock(), health = TMDBAvailability(now: { clock.now }), count = ProbeCounter() @@ -123,6 +144,20 @@ final class TMDBAvailabilityTests: XCTestCase { } } private actor ProbeCounter { var value = 0; func increment() { value += 1 } } +private actor ProbeGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } + func open() { + isOpen = true + let pending = waiters + waiters.removeAll() + for waiter in pending { waiter.resume() } + } +} private final class TMDBTestClock: @unchecked Sendable { private let lock = NSLock() private var value: TimeInterval = 1000 diff --git a/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoPersistenceTests/EPGCacheStoreTests.swift b/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoPersistenceTests/EPGCacheStoreTests.swift index ffe9179b..165b8a60 100644 --- a/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoPersistenceTests/EPGCacheStoreTests.swift +++ b/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoPersistenceTests/EPGCacheStoreTests.swift @@ -321,9 +321,8 @@ final class EPGCacheStoreTests: XCTestCase { let childDirectory = URL(fileURLWithPath: "/private/tmp/EPGCache-crash-" + UUID().uuidString) defer { try? FileManager.default.removeItem(at: childDirectory) } let process = Process() - let developer = ProcessInfo.processInfo.environment["DEVELOPER_DIR"] ?? "/Volumes/XcodeDev/Xcode.app/Contents/Developer" - process.executableURL = URL(fileURLWithPath: developer + "/usr/bin/xctest") - process.arguments = ["-XCTest", "OKVideoPersistenceTests.EPGCacheCrashWorkerTests/testWorker", Bundle(for: Self.self).bundleURL.path] + process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + process.arguments = ["xctest", "-XCTest", "OKVideoPersistenceTests.EPGCacheCrashWorkerTests/testWorker", Bundle(for: Self.self).bundleURL.path] var env = ProcessInfo.processInfo.environment env["OKVIDEO_EPG_CACHE_CRASH_DIRECTORY"] = childDirectory.path env["OKVIDEO_EPG_CACHE_CRASH_BOUNDARY"] = boundary diff --git a/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoPersistenceTests/EPGXMLTVImporterTests.swift b/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoPersistenceTests/EPGXMLTVImporterTests.swift index 1ed5ebc1..19226e7d 100644 --- a/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoPersistenceTests/EPGXMLTVImporterTests.swift +++ b/OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit/Tests/OKVideoPersistenceTests/EPGXMLTVImporterTests.swift @@ -298,9 +298,8 @@ final class EPGXMLTVImporterTests: XCTestCase { for boundary in ["programmeBatchCommitted", "parsedComplete", "validateBeforeCommit", "activateBeforeCommit", "activateAfterCommit"] { let old = try await run(Data(fixture().utf8)); store.close() let child = Process() - let developer = "/Volumes/XcodeDev/Xcode.app/Contents/Developer" - child.executableURL = URL(fileURLWithPath: developer + "/usr/bin/xctest") - child.arguments = ["-XCTest", "OKVideoPersistenceTests.EPGImportCrashWorkerTests/testWorker", Bundle(for: Self.self).bundleURL.path] + child.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") + child.arguments = ["xctest", "-XCTest", "OKVideoPersistenceTests.EPGImportCrashWorkerTests/testWorker", Bundle(for: Self.self).bundleURL.path] var env = ProcessInfo.processInfo.environment env["EPG9C3_CRASH_DB"] = directory.path; env["EPG9C3_CRASH_ROOT"] = root env["EPG9C3_CRASH_SOURCE"] = key.source.id.uuidString; env["EPG9C3_CRASH_AT"] = boundary diff --git a/README.md b/README.md index b109900f..73e11d50 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ English | [简体中文](README_zh-CN.md) selected TVBox/CatVod/CatPaw-style providers, and libmpv playback.** [![Latest release](https://img.shields.io/github/v/release/yaolin-dev/OKVideoMac?display_name=tag&sort=semver)](https://github.com/yaolin-dev/OKVideoMac/releases/latest) +[![CI](https://github.com/yaolin-dev/OKVideoMac/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/yaolin-dev/OKVideoMac/actions/workflows/ci.yml) ![macOS 12+](https://img.shields.io/badge/macOS-12%2B-000000?logo=apple&logoColor=white) ![Apple Silicon](https://img.shields.io/badge/Apple%20Silicon-arm64-000000?logo=apple&logoColor=white) [![GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) @@ -345,6 +346,12 @@ Runtime are not shipped as an Intel or Universal Binary stack. ## Development and architecture +Credential-free CI runs Core/Persistence tests in both Debug and Release, all Node tests, +offline audits and static checks, Android Release JVM tests, and an unsigned main App +Release compile. The `CI required` check succeeds only when every job succeeds. +See [CI coverage, local commands and limitations](Docs/CI.md). CI does not replace +playback/UI testing or the signed, notarized release process. + The repository separates core models/networking, SQLite persistence, macOS UI, native playback bridges, provider runtimes, Managed Runtime installation, and Android Emulator sessions. Installation and session lifecycle are deliberately diff --git a/README_zh-CN.md b/README_zh-CN.md index 81f6e0d8..184ed43d 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -8,6 +8,7 @@ 部分 TVBox/CatVod/CatPaw 风格 Provider,以及 libmpv 播放。** [![最新版本](https://img.shields.io/github/v/release/yaolin-dev/OKVideoMac?display_name=tag&sort=semver)](https://github.com/yaolin-dev/OKVideoMac/releases/latest) +[![CI](https://github.com/yaolin-dev/OKVideoMac/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/yaolin-dev/OKVideoMac/actions/workflows/ci.yml) ![macOS 12+](https://img.shields.io/badge/macOS-12%2B-000000?logo=apple&logoColor=white) ![Apple Silicon](https://img.shields.io/badge/Apple%20Silicon-arm64-000000?logo=apple&logoColor=white) [![GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](LICENSE) @@ -307,6 +308,11 @@ Intel 或 Universal Binary 组合发布。 ## 开发与架构 +无需证书和真实账号的 CI 已覆盖 Core/Persistence 的 Debug 与 Release 测试、 +全部 Node 测试、离线审计与静态检查、Android Release JVM 测试,以及主 App 的 +无签名 Release 编译。只有全部任务成功,`CI required` 汇总检查才会通过。 +范围、本地命令和限制见 [CI 指南](Docs/CI.md);CI 不替代播放/界面实测及签名、公证发布流程。 + 仓库将核心模型/网络、SQLite 持久化、macOS UI、原生播放桥、Provider Runtime、 Managed Runtime 安装与 Android Emulator Session 分开;安装和 Session 生命周期是 彼此独立的状态机。 diff --git a/Tools/CI/build_support.py b/Tools/CI/build_support.py new file mode 100644 index 00000000..4f3dc45c --- /dev/null +++ b/Tools/CI/build_support.py @@ -0,0 +1,26 @@ +"""Copy tracked inputs into fresh CI workspaces without local build products or keys.""" +from pathlib import Path +import shutil +import subprocess + +from run_core import ROOT + + +def copy_tracked(destination: Path, prefix: str = ''): + destination = destination.resolve() + if destination.exists() or destination == ROOT or ROOT in destination.parents: + raise RuntimeError('Build source destination must be new and outside the checkout') + files = subprocess.check_output(['git', 'ls-files', '-z', '--', prefix or '.'], cwd=ROOT).decode().split('\0') + files = [name for name in files if name] + if not files: + raise RuntimeError('No tracked build inputs') + destination.mkdir(parents=True) + for name in files: + source = ROOT / name + if source.is_symlink() or not source.is_file(): + raise RuntimeError(f'Build input is missing or not a regular tracked file: {name}') + relative = Path(name).relative_to(prefix) if prefix else Path(name) + target = destination / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + return destination diff --git a/Tools/CI/check_jobs.py b/Tools/CI/check_jobs.py new file mode 100644 index 00000000..481d4685 --- /dev/null +++ b/Tools/CI/check_jobs.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""A stable required check: all declared CI jobs must have actually succeeded.""" +import json +import os + +REQUIRED = {'core', 'node', 'offline', 'android', 'app'} + + +def validate(needs): + if not isinstance(needs, dict) or set(needs) != REQUIRED: + raise ValueError('Missing or unexpected CI job results') + results = {name: value.get('result') if isinstance(value, dict) else None for name, value in needs.items()} + if any(value != 'success' for value in results.values()): + raise ValueError(f'CI jobs did not all succeed: {results}') + return results + + +if __name__ == '__main__': + try: + results = validate(json.loads(os.environ['CI_JOB_RESULTS'])) + except (KeyError, ValueError, TypeError) as error: + raise SystemExit(str(error)) + print(json.dumps({'status': 'passed', 'jobs': results}, indent=2)) diff --git a/Tools/CI/core-allowed-skips.json b/Tools/CI/core-allowed-skips.json new file mode 100644 index 00000000..2c1795fb --- /dev/null +++ b/Tools/CI/core-allowed-skips.json @@ -0,0 +1,35 @@ +{ + "schema": 1, + "scope": "Optional external evidence and explicit resource/worker experiments; ordinary CI uses no credentials. New or changed skips fail until reviewed.", + "allowed_skips": { + "OKVideoCoreTests.DanmakuAutomationTests/testRecordedRealServicePayloadMatches34AndParsesComments": "Opt-in recorded service evidence", + "OKVideoPersistenceTests.EPGCacheCrashWorkerTests/testWorker": "Only launched by the independent-process recovery test", + "OKVideoPersistenceTests.EPGCacheQueryResourceTests/test10AGuideWindowQueryReleaseMatrix": "Explicit 10A independent Release query gate", + "OKVideoPersistenceTests.EPGCacheQueryResourceTests/testPathologicalDistributionsAreCorrectOrExplicitlyBudgeted": "Explicit 9C.2 resource gate", + "OKVideoPersistenceTests.EPGCacheQueryResourceTests/testTenThousandToTwoHundredThousandQueryMatrix": "Explicit 9C.2 resource gate", + "OKVideoPersistenceTests.EPGImportAcceptanceTests/testProductionPublicXMLTVFiniteQueriesMatchLegacyOracle": "Requires a frozen public XMLTV sample through the production repository", + "OKVideoPersistenceTests.EPGImportAcceptanceTests/testPublicXMLTVMatchesLegacyOracle": "Requires a frozen public XMLTV sample", + "OKVideoPersistenceTests.EPGImportAcceptanceTests/testReleaseRepeatedLifecycleAndChannelMetadataGate": "Requires independent Release lifecycle process", + "OKVideoPersistenceTests.EPGImportCrashWorkerTests/testWorker": "Independent process crash worker only", + "OKVideoPersistenceTests.EPGImportResourceTests/testReleaseNetworkImportResourceGate": "Explicit independent Release process resource gate", + "OKVideoPersistenceTests.EPGImportResourceTests/testReleaseProductionResourceGate": "Explicit independent 9C.4 Release production resource gate", + "OKVideoCoreTests.TMDBAvailabilityTests/testLiveBothLinkProbesUsingApplicationClient": "Opt-in live link probes", + "OKVideoCoreTests.TMDBCapturedTests/testDirectRealProviderReplayWithoutSavedBindings": "Private provider/API captures are required for replay acceptance", + "OKVideoCoreTests.TMDBCapturedTests/testRealLanxiangNormalAndColorAutomaticallyMatchInSeparateEmptyStores": "Opt-in captured provider acceptance", + "OKVideoCoreTests.TMDBCapturedTests/testRemainingRealDirectorAliasDoesNotInventChineseTranslation": "Private unedited provider and TMDB captures required", + "OKVideoCoreTests.TMDBCapturedTests/testRemainingRealUniqueTitleMatchesBeforeSourceLinesOnlyWhenRelaxed": "Private captures required", + "OKVideoCoreTests.TMDBClientTests/testLiveDirectThroughSystemNetworkConfiguration": "Opt-in live network check", + "OKVideoCoreTests.TMDBRepositoryTests/testCapturedFantaiDetailMatchesWithoutSavedBinding": "Opt-in private TVBox response replay", + "OKVideoCoreTests.XMLTVDownloadAdmissionTests/testAdmissionAndCoding": "Explicit B1 loopback harness only", + "OKVideoCoreTests.XMLTVDownloadAdmissionTests/testConversionCancellationWindows": "Explicit B1 loopback harness only", + "OKVideoCoreTests.XMLTVDownloadAdmissionTests/testConvertedDownloadMemory": "Explicit B1 loopback harness only", + "OKVideoCoreTests.XMLTVDownloadMemoryComparisonTests/testCommonLifecycleRepeatedSessionMemory": "Explicit B1-M loopback comparison only", + "OKVideoCoreTests.XMLTVDownloadSharedSessionTests/testConvertedSessionReuseMemory": "Explicit B1-N memory experiment only", + "OKVideoCoreTests.XMLTVDownloadSharedSessionTests/testSharedRouterIsolatesCancelledOperation": "Explicit B1-N loopback only", + "OKVideoCoreTests.XMLTVDownloadTaskFeasibilityTests/testDownloadTaskRepeatedSessionStability": "Explicit R3 loopback review only", + "OKVideoCoreTests.XMLTVDownloaderTests/testRealLoopbackNetworkFileParserChain": "Explicit 9B.3 loopback harness only", + "OKVideoCoreTests.XMLTVFileDownloadFeasibilityTests/testMemoryAttribution": "Explicit memory attribution only", + "OKVideoCoreTests.XMLTVFileDownloadFeasibilityTests/testRealURLSessionMemoryGate": "Explicit loopback feasibility harness only; not a production test invocation", + "OKVideoCoreTests.XMLTVFileDownloadFeasibilityTests/testRepeatedSessionStability": "Explicit budget review only" + } +} diff --git a/Tools/CI/fetch_tool.py b/Tools/CI/fetch_tool.py new file mode 100644 index 00000000..6e342419 --- /dev/null +++ b/Tools/CI/fetch_tool.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Download a pinned official CI tool and verify its archive before extracting one file.""" +import argparse +import hashlib +import io +import json +from pathlib import Path +import platform +import tarfile +import urllib.request +import zipfile + + +def fetch(name, output): + lock = json.loads((Path(__file__).parent / 'tool-downloads.json').read_text()) + entry = lock[name]['platforms'][platform.system() + '-' + platform.machine()] + request = urllib.request.Request(entry['url'], headers={'User-Agent': 'OKVideoMac-CI'}) + with urllib.request.urlopen(request, timeout=60) as response: + data = response.read(64 * 1024 * 1024 + 1) + if len(data) > 64 * 1024 * 1024 or hashlib.sha256(data).hexdigest() != entry['sha256']: + raise RuntimeError(f'{name} archive failed size/SHA-256 verification') + if entry.get('format') == 'zip': + with zipfile.ZipFile(io.BytesIO(data)) as archive: + member = archive.getinfo(entry['member']) + if member.is_dir() or (member.external_attr >> 16) & 0o170000 == 0o120000: + raise RuntimeError('Tool archive member is a directory or symlink') + binary = archive.read(member) + else: + with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive: + member = archive.getmember(entry['member']) + if not member.isfile(): + raise RuntimeError('Tool archive member is not a regular file') + binary = archive.extractfile(member).read() + output.mkdir(parents=True, exist_ok=True) + executable = output / name + executable.write_bytes(binary) + executable.chmod(0o755) + (output / (name + '-download.json')).write_text(json.dumps(dict(entry, version=lock[name]['version'], + executableSHA256=hashlib.sha256(binary).hexdigest()), indent=2) + '\n') + return executable + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('tool', choices=['actionlint', 'xcodegen']) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + print(fetch(args.tool, args.output)) diff --git a/Tools/CI/job_results_test.py b/Tools/CI/job_results_test.py new file mode 100644 index 00000000..17a49a19 --- /dev/null +++ b/Tools/CI/job_results_test.py @@ -0,0 +1,22 @@ +import unittest + +from check_jobs import REQUIRED, validate + + +class JobResultTests(unittest.TestCase): + def success(self): + return {name: {'result': 'success'} for name in REQUIRED} + + def test_all_jobs_must_succeed(self): + self.assertEqual(set(validate(self.success())), REQUIRED) + + def test_failure_cancel_skip_and_unknown_cannot_pass(self): + for status in ['failure', 'cancelled', 'skipped', 'timed_out', '', None]: + needs = self.success(); needs['core']['result'] = status + with self.subTest(status=status), self.assertRaises(ValueError): validate(needs) + + def test_empty_missing_or_extra_job_is_not_success(self): + missing = self.success(); del missing['app'] + extra = self.success(); extra['unexpected'] = {'result': 'success'} + for needs in [{}, missing, extra, [], None]: + with self.assertRaises(ValueError): validate(needs) diff --git a/Tools/CI/node_results_test.py b/Tools/CI/node_results_test.py new file mode 100644 index 00000000..fa812e47 --- /dev/null +++ b/Tools/CI/node_results_test.py @@ -0,0 +1,47 @@ +import unittest + +from test_results import InvalidResults, node_results + + +GOOD = ''' + +''' + + +class NodeResultTests(unittest.TestCase): + def test_complete_success(self): + self.assertEqual(node_results(GOOD, 'fixture.test.js', 0)['passed'], 1) + + def test_nonzero_exit_cannot_pass(self): + with self.assertRaises(InvalidResults): node_results(GOOD, 'fixture.test.js', 1) + + def test_empty_file_is_not_a_test(self): + with self.assertRaises(InvalidResults): + node_results(GOOD.replace('real test', '/tmp/fixture.test.js'), 'fixture.test.js', 0) + + def test_failure_skip_and_todo_are_rejected(self): + for fragment in ['', '', '']: + with self.assertRaises(InvalidResults): + node_results(GOOD.replace('', fragment + ''), 'fixture.test.js', 0) + with self.assertRaises(InvalidResults): + node_results(GOOD.replace('todo 0', 'todo 1'), 'fixture.test.js', 0) + + def test_missing_mismatched_and_duplicate_aggregates_fail(self): + for text in [GOOD.replace('', ''), GOOD.replace('tests 1', 'tests 2'), + GOOD.replace('', '')]: + with self.assertRaises(InvalidResults): node_results(text, 'fixture.test.js', 0) + + def test_empty_or_duplicate_cases_fail(self): + for text in [GOOD.replace('', ''), + GOOD.replace('', '')]: + with self.assertRaises(InvalidResults): node_results(text, 'fixture.test.js', 0) + + def test_subtest_parent_counts_but_describe_container_does_not(self): + nested = GOOD.replace('', + '') + parent = nested.replace('tests 1', 'tests 2').replace('pass 1', 'pass 2') + self.assertEqual(node_results(parent, 'fixture.test.js', 0)['total'], 2) + self.assertEqual(node_results(nested.replace('suites 0', 'suites 1'), 'fixture.test.js', 0)['total'], 1) + with self.assertRaises(InvalidResults): node_results(nested, 'fixture.test.js', 0) + with self.assertRaises(InvalidResults): + node_results(parent.replace('tests="1"', 'tests="2"'), 'fixture.test.js', 0) diff --git a/Tools/CI/python_results_test.py b/Tools/CI/python_results_test.py new file mode 100644 index 00000000..30295b6f --- /dev/null +++ b/Tools/CI/python_results_test.py @@ -0,0 +1,60 @@ +import io +import unittest + +from run_python import RecordedResult, identities, validate + + +class PythonResultTests(unittest.TestCase): + def result(self, behavior): + class Fixture(unittest.TestCase): + def runTest(self): + if behavior == 'skip': self.skipTest('not allowed in CI') + if behavior == 'fail': self.fail('injected failure') + if behavior == 'subtest': + with self.subTest(value=1): self.assertEqual(1, 2) + if behavior == 'subtest-skip': + with self.subTest(value=1): self.skipTest('subtest skip is not allowed') + suite = unittest.TestSuite([] if behavior == 'empty' else [Fixture()]) + discovered = list(identities(suite)) + result = unittest.TextTestRunner(stream=io.StringIO(), resultclass=RecordedResult).run(suite) + return result, discovered + + def test_success(self): + validate(*self.result('pass')) + + def test_empty_skip_failure_and_subtest_failure_rejected(self): + for behavior in ['empty', 'skip', 'fail', 'subtest', 'subtest-skip']: + with self.subTest(behavior=behavior), self.assertRaises(RuntimeError): + validate(*self.result(behavior)) + + def test_unexecuted_discovery_rejected(self): + result, discovered = self.result('pass') + with self.assertRaises(RuntimeError): validate(result, discovered + ['not.executed']) + + def fixture_result(self, phase, error): + class Fixture(unittest.TestCase): + def runTest(self): pass + def broken_fixture(cls): raise error + setattr(Fixture, phase, classmethod(broken_fixture)) + suite = unittest.TestSuite([Fixture()]) + discovered = list(identities(suite)) + result = unittest.TextTestRunner(stream=io.StringIO(), resultclass=RecordedResult).run(suite) + return result, discovered + + def test_class_fixture_errors_retain_original_diagnostic(self): + for phase in ['setUpClass', 'tearDownClass']: + with self.subTest(phase=phase): + result, discovered = self.fixture_result(phase, RuntimeError('fixture initialization failed')) + with self.assertRaises(RuntimeError): validate(result, discovered) + errors = [case for case in result.cases if case['status'] == 'error'] + self.assertEqual(len(errors), 1) + self.assertIn(phase, errors[0]['id']) + self.assertIn('fixture initialization failed', errors[0]['reason']) + self.assertEqual(result.testsRun, 0 if phase == 'setUpClass' else 1) + + def test_class_fixture_skips_are_recorded_and_rejected(self): + result, discovered = self.fixture_result('setUpClass', unittest.SkipTest('missing fixture')) + with self.assertRaises(RuntimeError): validate(result, discovered) + self.assertEqual(result.testsRun, 0) + self.assertEqual(result.cases[0]['status'], 'skipped') + self.assertEqual(result.cases[0]['reason'], 'missing fixture') diff --git a/Tools/CI/run_android.py b/Tools/CI/run_android.py new file mode 100644 index 00000000..9ae3de74 --- /dev/null +++ b/Tools/CI/run_android.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Build a compile-only unsigned bridge and run every Release JVM unit test.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +import zipfile + +from build_support import copy_tracked +from run_core import ROOT, command + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--work-dir', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--artifact-dir', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + report = dict(schema=1, suite='Android Release JVM and unsigned bridge', status='failed', compileOnly=True) + started = time.monotonic() + try: + # Java 17 uses the process locale for native paths. A C locale corrupts + # Chinese workspace paths when Gradle starts its single-use daemon. + os.environ['LC_ALL'] = 'en_US.UTF-8' if sys.platform == 'darwin' else 'C.UTF-8' + report['locale'] = os.environ['LC_ALL'] + signing = ['OKVIDEOMAC_ANDROID_BRIDGE_' + suffix for suffix in ['KEYSTORE', 'STORE_PASSWORD', 'KEY_ALIAS', 'KEY_PASSWORD']] + if any(os.environ.get(key) for key in signing): + raise RuntimeError('This CI path must not receive Android release signing inputs') + java = Path(os.environ['JAVA_HOME']) / 'bin/java' + version = subprocess.check_output([str(java), '-version'], text=True, stderr=subprocess.STDOUT) + if '17.0.19' not in version: + raise RuntimeError('Use the CI Java 17.0.19 toolchain') + report['javaVersion'] = version.strip() + report['sourceCommit'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + sdk = Path(os.environ['ANDROID_HOME']) + if not (sdk / 'platforms/android-35/android.jar').is_file() or not (sdk / 'build-tools/34.0.0/source.properties').is_file(): + raise RuntimeError('Android SDK platform 35 and AGP 8.7 build-tools 34.0.0 are required') + source = copy_tracked(args.work_dir / 'Source', 'OKVideoMac/Helpers/AndroidDexBridge') + argv = ['bash', str(source / 'gradlew'), '--project-dir', str(source), '--no-daemon', '--console=plain', + '--no-build-cache', '--rerun-tasks', ':app:assembleRelease', ':app:testReleaseUnitTest'] + report['command'] = argv + code = command(argv, args.output / 'gradle.log', 1200) + report['exitCode'] = code + if code: + raise RuntimeError(f'Gradle failed with exit {code}; see gradle.log') + report['lintMetadataDiagnostics'] = (args.output / 'gradle.log').read_text(errors='replace').count('Module was compiled with an incompatible version of Kotlin') + files = sorted((source / 'app/build/test-results/testReleaseUnitTest').glob('TEST-*.xml')) + if not files: + raise RuntimeError('No Release JVM test reports') + names = [] + for path in files: + root = ET.parse(path).getroot() + cases = root.findall('testcase') + if root.tag != 'testsuite' or not cases or int(root.get('tests', '-1')) != len(cases): + raise RuntimeError(f'Incomplete JVM test report: {path.name}') + if any(int(root.get(key, '0')) for key in ['failures', 'errors', 'skipped', 'disabled']): + raise RuntimeError(f'Failed or skipped JVM tests: {path.name}') + if any(list(root.iter(tag)) for tag in ['failure', 'error', 'skipped']): + raise RuntimeError(f'Unsuccessful JVM case: {path.name}') + names += [case.attrib['classname'] + '/' + case.attrib['name'] for case in cases] + shutil.copy2(path, args.output / path.name) + if len(names) != len(set(names)): + raise RuntimeError('Duplicate JVM test identities') + apk = source / 'app/build/outputs/apk/release/app-release-unsigned.apk' + with zipfile.ZipFile(apk) as archive: + if not {'AndroidManifest.xml', 'classes.dex'} <= set(archive.namelist()): + raise RuntimeError('Unsigned bridge APK is incomplete') + apksigner = sdk / 'build-tools/34.0.0/apksigner' + verification = subprocess.run([str(apksigner), 'verify', str(apk)], capture_output=True, text=True, timeout=30) + if verification.returncode == 0: + raise RuntimeError('CI bridge unexpectedly contains an Android signing certificate') + if 'DOES NOT VERIFY' not in verification.stderr + verification.stdout: + raise RuntimeError('Could not establish that APK is unsigned') + report.update(status='passed', tests=len(names), cases=names, skipped=0, + apkSHA256=hashlib.sha256(apk.read_bytes()).hexdigest()) + args.artifact_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(apk, args.artifact_dir / 'bridge-compile-only-unsigned.apk') + (args.artifact_dir / 'manifest.json').write_text(json.dumps({key:report[key] for key in + ['schema', 'status', 'compileOnly', 'sourceCommit', 'tests', 'skipped', 'apkSHA256']}, indent=2) + '\n') + except Exception as error: + report['status'] = 'failed' + report['error'] = str(error) + report['elapsedSeconds'] = round(time.monotonic() - started, 3) + (args.output / 'summary.json').write_text(json.dumps(report, indent=2) + '\n') + text = f"### Android Release JVM and unsigned bridge\n\nStatus: **{report['status']}**\n\n" + text += f"{report['tests']} JVM tests passed; no skips. Unsigned APK is only an input to the App compile check.\n" if report['status'] == 'passed' else report.get('error', 'Missing result') + '\n' + if report.get('lintMetadataDiagnostics'): + text += '\nAGP lint reported Kotlin dependency metadata incompatibility. Full dependency lint coverage is not established; see gradle.log.\n' + (args.output / 'summary.md').write_text(text) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as output: output.write(text) + print(text, flush=True) + return 0 if report['status'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/Tools/CI/run_app.py b/Tools/CI/run_app.py new file mode 100644 index 00000000..7ee98dbb --- /dev/null +++ b/Tools/CI/run_app.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Compile the main Release App and helpers in isolation; never package, install or publish.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import subprocess +import sys +import time + +from build_support import copy_tracked +from run_core import ROOT, command + + +def sha(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--work-dir', type=Path, required=True) + parser.add_argument('--dependencies', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--bridge-input', type=Path, required=True) + parser.add_argument('--xcodegen', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + args.dependencies.mkdir(parents=True, exist_ok=True) + report = dict(schema=1, suite='Main App Release compile', status='failed', configuration='Release', + compileOnly=True, codeSigningAllowed=False, stages=[]) + started = time.monotonic() + try: + commit = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + report['sourceCommit'] = commit + report['xcodeVersion'] = subprocess.check_output(['xcodebuild', '-version'], text=True).strip() + if not report['xcodeVersion'].startswith('Xcode 16.2\n'): + raise RuntimeError('Select Xcode 16.2 with DEVELOPER_DIR') + report['xcodegenVersion'] = subprocess.check_output([str(args.xcodegen), '--version'], text=True).strip() + if report['xcodegenVersion'].split()[-1] != '2.46.0': + raise RuntimeError('XcodeGen 2.46.0 is required') + node = Path(shutil.which('node')).resolve() + report['nodeVersion'] = subprocess.check_output([str(node), '--version'], text=True).strip() + if report['nodeVersion'] != 'v22.23.0' or not (node.parent.parent / 'LICENSE').is_file(): + raise RuntimeError('A complete Node 22.23.0 distribution including LICENSE is required') + bridge = args.bridge_input / 'bridge-compile-only-unsigned.apk' + manifest = json.loads((args.bridge_input / 'manifest.json').read_text()) + if (manifest.get('status') != 'passed' or manifest.get('compileOnly') is not True + or manifest.get('sourceCommit') != commit or manifest.get('skipped') != 0 + or manifest.get('tests', 0) <= 0 or sha(bridge) != manifest.get('apkSHA256')): + raise RuntimeError('Bridge must come from a successful test job for this commit and match its SHA-256') + source = copy_tracked(args.work_dir / 'Source') + project = source / 'OKVideoMac/macOS/OKVideoMac' + target_apk = source / 'OKVideoMac/Helpers/AndroidDexBridge/app/build/outputs/apk/release/app-release.apk' + target_apk.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(bridge, target_apk) + os.environ['OKVIDEOMAC_BUILD_ROOT'] = str(args.dependencies) + # These runtimes are dynamically loaded and explicitly optional in project.yml. + # Their playback and packaging gates remain part of the formal release process. + for name in ['libmpv', 'QuickJS']: + (args.dependencies / name).mkdir(exist_ok=True) + def run(name, argv, timeout=600): + item = dict(name=name, command=[str(x) for x in argv]) + report['stages'].append(item) + item['exitCode'] = command(item['command'], args.output / (name + '.log'), timeout) + if item['exitCode']: + raise RuntimeError(f'{name} failed with exit {item["exitCode"]}; see {name}.log') + run('prepare-crash-reporter', ['bash', project / 'Scripts/prepare-crash-reporter.sh']) + sparkle = args.dependencies / 'Sparkle' + run('prepare-sparkle', [sys.executable, project / 'Scripts/prepare-sparkle.py', '--output', sparkle]) + run('generate-project', [args.xcodegen, 'generate', '--spec', project / 'project.yml', '--project', project]) + derived = args.work_dir / 'DerivedData' + run('build-app', ['xcodebuild', '-project', project / 'OKVideoMac.xcodeproj', '-scheme', 'OKVideoMac', + '-configuration', 'Release', '-sdk', 'macosx', '-destination', 'generic/platform=macOS', '-jobs', '2', + '-derivedDataPath', derived, '-clonedSourcePackagesDirPath', args.dependencies / 'SourcePackages', + 'ARCHS=arm64', 'ONLY_ACTIVE_ARCH=YES', 'CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO', + 'CODE_SIGN_IDENTITY=', f'OKVIDEOMAC_BUILD_ROOT={args.dependencies}', f'OKVIDEOMAC_SPARKLE_ROOT={sparkle}', + f'OKVIDEOMAC_NODE_RUNTIME={node}', 'OKVIDEOMAC_CRASH_REPORT_DSN=', 'OKVIDEOMAC_UPDATE_FEED_URL=', + 'OKVIDEOMAC_UPDATE_PUBLIC_KEY=', 'OKVIDEOMAC_UPDATE_CHANNEL=unconfigured', 'build'], 1500) + if '** BUILD SUCCEEDED **' not in (args.output / 'build-app.log').read_text(errors='replace'): + raise RuntimeError('Missing Xcode build success result') + app = derived / 'Build/Products/Release/OKVideoMac.app' + info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) + spec = (project / 'project.yml').read_text() + for field, key in [('CFBundleShortVersionString', 'MARKETING_VERSION'), ('CFBundleVersion', 'CURRENT_PROJECT_VERSION')]: + expected = re.search(r'^\s*' + key + r':\s*(\S+)', spec, re.M).group(1).strip('"\'') + if str(info[field]) != expected: + raise RuntimeError(f'Compiled {field} does not match project.yml') + if info.get('OKCrashReportDSN') or info.get('SUFeedURL') or info.get('SUPublicEDKey'): + raise RuntimeError('Compile-only App unexpectedly has a live service/update configuration') + report['binaries'] = [] + for relative in ['Contents/MacOS/OKVideoMac', 'Contents/Helpers/OKVideoLivePreview', 'Contents/Helpers/OKVideoMacRelauncher']: + path = app / relative + architecture = subprocess.check_output(['lipo', '-archs', str(path)], text=True).strip() + if architecture != 'arm64' or not os.access(path, os.X_OK): + raise RuntimeError(f'Expected an executable arm64 target: {relative}') + report['binaries'].append(dict(path=relative, architecture=architecture, sha256=sha(path))) + if sha(app / 'Contents/Resources/AndroidDexBridge-release.apk') != manifest['apkSHA256']: + raise RuntimeError('Embedded Bridge does not match the tested compile input') + signing = subprocess.run(['codesign', '-dv', '--verbose=4', str(app)], capture_output=True, text=True) + inspection = signing.stdout + signing.stderr + (args.output / 'signing-inspection.log').write_text(inspection) + if 'Authority=' in inspection or (app / 'Contents/_CodeSignature/CodeResources').exists(): + raise RuntimeError('Compile-only App unexpectedly has a distribution bundle signature') + report.update(status='passed', version=info['CFBundleShortVersionString'], build=info['CFBundleVersion']) + except Exception as error: + report['error'] = str(error) + report['elapsedSeconds'] = round(time.monotonic() - started, 3) + (args.output / 'summary.json').write_text(json.dumps(report, indent=2) + '\n') + text = f"### Main App Release compile\n\nStatus: **{report['status']}**\n\n" + text += 'Main App, preview and relaunch helpers compiled for arm64. No signing credentials, packaging, notarization, installation or release upload.\n' if report['status'] == 'passed' else report.get('error', 'Missing result') + '\n' + text += '\nThis gate does not validate playback, UI interaction, signed APK installation or release eligibility.\n' + (args.output / 'summary.md').write_text(text) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as output: output.write(text) + print(text, flush=True) + return 0 if report['status'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/Tools/CI/run_core.py b/Tools/CI/run_core.py new file mode 100644 index 00000000..70b1bc17 --- /dev/null +++ b/Tools/CI/run_core.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Run the complete account-free Core/Persistence suite in an explicit configuration.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import signal +import subprocess +import time + +from test_results import discovered_tests, xctest_results, write_junit + +ROOT = Path(__file__).resolve().parents[2] +PACKAGE = ROOT / 'OKVideoMac/macOS/OKVideoMac/Packages/OKVideoKit' + + +def command(args, log: Path, timeout: int) -> int: + with log.open('w') as output: + process = subprocess.Popen(args, cwd=ROOT, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) + try: + return process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + return 124 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--configuration', choices=('debug', 'release'), required=True) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--scratch-path', type=Path, required=True) + parser.add_argument('--jobs', type=int, default=2) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + report = dict(schema=1, suite='Core/Persistence', configuration=args.configuration, status='failed', + platform=platform.platform(), architecture=platform.machine(), commands=[]) + started = time.monotonic() + try: + report['sourceCommit'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + report['swiftVersion'] = subprocess.check_output(['swift', '--version'], text=True, stderr=subprocess.STDOUT).strip() + report['xcodeVersion'] = subprocess.check_output(['xcodebuild', '-version'], text=True).strip() + common = ['swift', 'test', '--package-path', str(PACKAGE), '--scratch-path', str(args.scratch_path), + '--configuration', args.configuration, '--jobs', str(args.jobs), '--disable-sandbox'] + discover = common + ['list', '--enable-xctest', '--disable-swift-testing'] + report['commands'].append(discover) + print(f"Building/discovering Core/Persistence ({args.configuration})", flush=True) + code = command(discover, args.output / 'discovery.log', 1200) + if code: + raise RuntimeError(f"Build/discovery failed with exit {code}; see discovery.log") + tests = discovered_tests((args.output / 'discovery.log').read_text(errors='replace')) + (args.output / 'discovered-tests.json').write_text(json.dumps(sorted(tests), indent=2) + '\n') + run = common + ['--skip-build', '--no-parallel', '--enable-xctest', '--disable-swift-testing'] + report['commands'].append(run) + print(f"Running {len(tests)} discovered tests ({args.configuration})", flush=True) + code = command(run, args.output / 'tests.log', 600) + report['testExitCode'] = code + log = args.output / 'tests.log' + report['testLogSHA256'] = hashlib.sha256(log.read_bytes()).hexdigest() + policy = json.loads((Path(__file__).parent / 'core-allowed-skips.json').read_text()) + result = xctest_results(log.read_text(errors='replace'), tests, policy['allowed_skips'], code) + report.update(result) + write_junit(args.output / 'junit.xml', result, f"Core/Persistence ({args.configuration})") + report['status'] = 'passed' + except Exception as error: + report['error'] = str(error) + print(f"FAIL: {error}", flush=True) + finally: + report['elapsedSeconds'] = round(time.monotonic() - started, 3) + (args.output / 'summary.json').write_text(json.dumps(report, indent=2) + '\n') + text = f"### Core/Persistence — {args.configuration}\n\nStatus: **{report['status']}**\n\n" + if report['status'] == 'passed': + text += f"{report['passed']} passed / {report['skipped']} declared skips / {report['failed']} failed; {report['total']} discovered and completed.\n" + else: + text += f"{report.get('error', 'Missing results')}\n" + text += '\nSee the retained command logs, toolchain metadata and individual results.\n' + (args.output / 'summary.md').write_text(text) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as output: + output.write(text) + print(text, flush=True) + return 0 if report['status'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/Tools/CI/run_node.py b/Tools/CI/run_node.py new file mode 100644 index 00000000..a9d6fd2f --- /dev/null +++ b/Tools/CI/run_node.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Run every Node test file separately and require nonempty, complete JUnit results.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time + +from run_core import ROOT, command +from test_results import node_results + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + report = dict(schema=1, suite='Node', status='failed', files=[]) + started = time.monotonic() + try: + report['sourceCommit'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + report['nodeVersion'] = subprocess.check_output(['node', '--version'], text=True).strip() + if report['nodeVersion'] != 'v22.23.0': + raise RuntimeError('CI requires Node 22.23.0; use the pinned setup-node version') + files = sorted((ROOT / 'OKVideoMac/macOS/OKVideoMac/NodeTests').glob('*.test.js')) + if not files: + raise RuntimeError('No Node test files discovered') + for path in files: + log = args.output / (path.stem + '.xml') + argv = ['node', '--test', '--test-reporter=junit', str(path)] + item = dict(file=str(path.relative_to(ROOT)), command=argv, status='failed') + report['files'].append(item) + code = command(argv, log, 120) + item.update(exitCode=code, logSHA256=hashlib.sha256(log.read_bytes()).hexdigest()) + try: + item.update(node_results(log.read_text(), path.name, code), status='passed') + except Exception as error: + item['error'] = str(error) + print(f"{item['status']}: {path.name}", flush=True) + if any(item['status'] != 'passed' for item in report['files']): + raise RuntimeError('At least one Node file failed result validation; see individual logs') + report.update(status='passed', total=sum(item['total'] for item in report['files'])) + except Exception as error: + report['error'] = str(error) + finally: + report['elapsedSeconds'] = round(time.monotonic() - started, 3) + (args.output / 'summary.json').write_text(json.dumps(report, indent=2) + '\n') + text = f"### Node\n\nStatus: **{report['status']}**\n\n" + text += f"{report['total']} tests passed across {len(report['files'])} files; no skips.\n" if report['status'] == 'passed' else report.get('error', 'Missing result') + '\n' + (args.output / 'summary.md').write_text(text) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as output: + output.write(text) + print(text, flush=True) + return 0 if report['status'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/Tools/CI/run_offline.py b/Tools/CI/run_offline.py new file mode 100644 index 00000000..6c0f8228 --- /dev/null +++ b/Tools/CI/run_offline.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Run offline audit, crash/symbol gates and static checks with synthetic fixtures.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +import time + +from run_core import ROOT, command + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--work-dir', type=Path, required=True) + parser.add_argument('--actionlint', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + args.work_dir.mkdir(parents=True, exist_ok=True) + report = dict(schema=1, suite='Offline audits and static checks', status='failed', stages=[]) + started = time.monotonic() + try: + if sys.flags.optimize: + raise RuntimeError('Do not disable Python assertions') + report['sourceCommit'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip() + def run(name, argv, timeout=300): + item = dict(name=name, command=[str(x) for x in argv]) + report['stages'].append(item) + item['exitCode'] = command(item['command'], args.output / (name + '.log'), timeout) + if item['exitCode']: + raise RuntimeError(f'{name} failed with exit {item["exitCode"]}; see {name}.log') + launcher = args.work_dir / 'AcceptanceLauncher' + run('compile-fixture-launcher', ['xcrun', 'swiftc', '-O', '-parse-as-library', + 'Tools/SourceAudit/AcceptanceLauncher.swift', '-o', launcher]) + run('adhoc-fixture-launcher', ['/usr/bin/codesign', '--force', '--sign', '-', launcher]) + os.environ['OKVIDEO_ACCEPTANCE_TEST_LAUNCHER'] = str(launcher) + for suite in ['ci', 'audit', 'crash-configuration']: + run(suite, [sys.executable, 'Tools/CI/run_python.py', '--suite', suite, '--output', args.output / suite]) + result = json.loads((args.output / suite / 'summary.json').read_text()) + if result['status'] != 'passed' or result['total'] <= 0 or result['skipped']: + raise RuntimeError(f'{suite} machine report failed validation') + run('symbols', [sys.executable, 'Tools/CrashIntegration/test-symbol-archive.py']) + symbols = json.loads((args.output / 'symbols.log').read_text()) + if symbols.get('status') != 'PASS' or not symbols.get('cases') or len(symbols['cases']) != len(set(symbols['cases'])): + raise RuntimeError('Symbol test report was empty, duplicated or unsuccessful') + report['symbolCases'] = len(symbols['cases']) + run('static', [sys.executable, 'Tools/CI/run_static.py', '--actionlint', args.actionlint, '--output', args.output / 'static']) + report['status'] = 'passed' + except Exception as error: + report['error'] = str(error) + report['elapsedSeconds'] = round(time.monotonic() - started, 3) + (args.output / 'summary.json').write_text(json.dumps(report, indent=2) + '\n') + text = f"### Offline audits and static checks\n\nStatus: **{report['status']}**\n\n" + text += 'All discovered Python tests, native synthetic fixtures, symbol gates and static checks passed. No skips.\n' if report['status'] == 'passed' else report.get('error', 'Missing result') + '\n' + (args.output / 'summary.md').write_text(text) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as output: + output.write(text) + print(text, flush=True) + return 0 if report['status'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/Tools/CI/run_python.py b/Tools/CI/run_python.py new file mode 100644 index 00000000..facbf1e1 --- /dev/null +++ b/Tools/CI/run_python.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Run credential-free Python suites with nonempty discovery and no allowed skips.""" +import argparse +import importlib.util +import json +from pathlib import Path +import sys +import time +import unittest +import xml.etree.ElementTree as ET + +from run_core import ROOT + + +class RecordedResult(unittest.TextTestResult): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cases = [] + self.current = None + + def startTest(self, test): + super().startTest(test) + self.current = dict(id=test.id(), status='incomplete') + self.started = time.monotonic() + + def stopTest(self, test): + self.current['seconds'] = round(time.monotonic() - self.started, 6) + self.cases.append(self.current) + self.current = None + super().stopTest(test) + + def record_problem(self, test, status, reason): + # Class/module fixtures can report errors or skips without startTest. + if self.current is None: + self.cases.append(dict(id=test.id(), status=status, reason=reason, seconds=0)) + else: + self.current.update(status=status, reason=reason) + + def addSuccess(self, test): + if self.current['status'] == 'incomplete': + self.current['status'] = 'passed' + super().addSuccess(test) + + def addFailure(self, test, err): + self.record_problem(test, 'failed', self._exc_info_to_string(err, test)) + super().addFailure(test, err) + + def addError(self, test, err): + self.record_problem(test, 'error', self._exc_info_to_string(err, test)) + super().addError(test, err) + + def addSkip(self, test, reason): + self.record_problem(test, 'skipped', reason) + super().addSkip(test, reason) + + def addSubTest(self, test, subtest, err): + if err: + self.record_problem(test, 'failed', self._exc_info_to_string(err, test)) + super().addSubTest(test, subtest, err) + + +def identities(suite): + for test in suite: + if isinstance(test, unittest.TestSuite): + yield from identities(test) + else: + yield test.id() + + +def validate(result, discovered): + actual = [case['id'] for case in result.cases] + if (not discovered or len(discovered) != len(set(discovered)) or sorted(actual) != sorted(discovered) + or result.testsRun != len(discovered) or not result.wasSuccessful() + or result.skipped or result.expectedFailures or result.unexpectedSuccesses + or any(case['status'] != 'passed' for case in result.cases)): + raise RuntimeError('Python discovery/results incomplete, empty, failed or skipped; see tests.log') + + +def load_suite(name): + loader = unittest.TestLoader() + if name == 'crash-configuration': + path = ROOT / 'Tools/CrashIntegration/test-release-crash-configuration.py' + spec = importlib.util.spec_from_file_location('ci_crash_configuration', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return loader.loadTestsFromModule(module) + folder, pattern = ('Tools/SourceAudit', 'test_*.py') if name == 'audit' else ('Tools/CI', '*_test.py') + return loader.discover(str(ROOT / folder), pattern=pattern) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--suite', choices=['audit', 'crash-configuration', 'ci'], required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + report = dict(schema=1, suite=args.suite, status='failed', pythonVersion=sys.version) + try: + if sys.flags.optimize: + raise RuntimeError('Optimized Python would disable assertion gates') + suite = load_suite(args.suite) + discovered = list(identities(suite)) + report['discovered'] = discovered + with (args.output / 'tests.log').open('w') as log: + result = unittest.TextTestRunner(stream=log, verbosity=2, resultclass=RecordedResult).run(suite) + report.update(total=result.testsRun, passed=sum(case['status'] == 'passed' for case in result.cases), + failed=len(result.failures), errors=len(result.errors), skipped=len(result.skipped), cases=result.cases) + xml = ET.Element('testsuite', name=args.suite, tests=str(len(result.cases)), + failures=str(sum(case['status'] == 'failed' for case in result.cases)), + errors=str(sum(case['status'] not in {'passed', 'failed', 'skipped'} for case in result.cases)), + skipped=str(sum(case['status'] == 'skipped' for case in result.cases))) + for case in result.cases: + node = ET.SubElement(xml, 'testcase', name=case['id'], time=str(case['seconds'])) + if case['status'] != 'passed': + tag = {'skipped': 'skipped', 'failed': 'failure'}.get(case['status'], 'error') + ET.SubElement(node, tag).text = case.get('reason', case['status']) + ET.indent(xml) + ET.ElementTree(xml).write(args.output / 'junit.xml', encoding='utf-8', xml_declaration=True) + validate(result, discovered) + report['status'] = 'passed' + except Exception as error: + report['error'] = str(error) + (args.output / 'summary.json').write_text(json.dumps(report, indent=2) + '\n') + print(f"{args.suite}: {report['status']}; {report.get('total', 0)} tests; {report.get('error', '')}") + return 0 if report['status'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/Tools/CI/run_static.py b/Tools/CI/run_static.py new file mode 100644 index 00000000..1f44f152 --- /dev/null +++ b/Tools/CI/run_static.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Check tracked script/JSON syntax and fully pinned, credential-free CI workflows.""" +import argparse +import ast +import json +from pathlib import Path +import re +import subprocess + +from run_core import ROOT + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--actionlint', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + report = dict(schema=1, status='failed', files=[], commands=[]) + try: + paths = subprocess.check_output(['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], cwd=ROOT).decode().split('\0') + paths = sorted(set(p for p in paths if p)) + with (args.output / 'checks.log').open('w') as log: + def run(argv): + report['commands'].append([str(x) for x in argv]) + subprocess.run(argv, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=60) + for relative in paths: + path = ROOT / relative + suffix = path.suffix + if suffix not in ('.py', '.json', '.js', '.cjs', '.mjs', '.sh') and path.name != 'Package.resolved': + continue + if path.is_symlink(): + raise RuntimeError(f'Refusing a symlinked script/input: {relative}') + if suffix == '.py': + ast.parse(path.read_text(), filename=relative) + elif suffix == '.json' or path.name == 'Package.resolved': + json.loads(path.read_text()) + elif suffix in ('.js', '.cjs', '.mjs'): + run(['node', '--check', str(path)]) + else: + first = path.read_text().splitlines()[0] + match = re.fullmatch(r'#!(?:/usr/bin/env |/bin/)(bash|zsh|sh)', first) + if not match: + raise RuntimeError(f'Unrecognized shell interpreter: {relative}') + run(['/bin/' + match[1], '-n', str(path)]) + report['files'].append(relative) + workflows = sorted((ROOT / '.github/workflows').glob('*.y*ml')) + if not workflows or not report['files']: + raise RuntimeError('Static discovery returned no workflows or files') + for path in workflows: + text = path.read_text() + for action in re.findall(r'^\s*(?:-\s*)?uses:\s*(\S+)', text, re.M): + if not action.startswith('./') and not re.fullmatch(r'[\w./-]+@[0-9a-f]{40}', action): + raise RuntimeError(f'Action must use a full commit SHA: {path.name}: {action}') + if re.search(r'\bsecrets\s*[.\[]', text): + raise RuntimeError(f'Credential-free CI must not reference secrets: {path.name}') + version = subprocess.check_output([str(args.actionlint), '-version'], text=True).splitlines()[0] + lock = json.loads((ROOT / 'Tools/CI/tool-downloads.json').read_text()) + if version != lock['actionlint']['version']: + raise RuntimeError('actionlint version differs from the download lock') + run([str(args.actionlint), '-shellcheck=', '-pyflakes=', *map(str, workflows)]) + run(['python3', 'Tools/SourceAudit/scan_release_artifacts.py', 'Tools/CI', '.github/workflows', + '--json-output', str(args.output / 'ci-source-scan.json')]) + report.update(status='passed', actionlintVersion=version, workflows=[str(p.relative_to(ROOT)) for p in workflows]) + except Exception as error: + report['error'] = str(error) + (args.output / 'summary.json').write_text(json.dumps(report, indent=2) + '\n') + print(f"Static: {report['status']}; {len(report['files'])} files; {report.get('error', '')}") + return 0 if report['status'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/Tools/CI/test_results.py b/Tools/CI/test_results.py new file mode 100644 index 00000000..8f148b94 --- /dev/null +++ b/Tools/CI/test_results.py @@ -0,0 +1,111 @@ +"""Validate complete XCTest output; never infer success from an exit code alone.""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET + + +class InvalidResults(ValueError): + pass + + +DISCOVERED = re.compile(r"^[A-Za-z_][\w.]*/[A-Za-z_]\w*$") +STARTED = re.compile(r"^Test Case '-\[([^ ]+) ([^\]]+)\]' started\.$", re.M) +COMPLETED = re.compile( + r"^Test Case '-\[([^ ]+) ([^\]]+)\]' (passed|skipped|failed) \(([0-9.]+) seconds\)\.$", re.M +) +SKIP_REASON = re.compile(r"-\[([^ ]+) ([^\]]+)\] : Test skipped - (.*)") +TOTAL = re.compile( + r"Executed (\d+) tests?, with (?:(\d+) tests? skipped and )?(\d+) failures? \((\d+) unexpected\)" +) + + +def discovered_tests(text: str) -> set[str]: + names = [line.strip() for line in text.splitlines() if DISCOVERED.fullmatch(line.strip())] + if not names or len(names) != len(set(names)): + raise InvalidResults("Test discovery was empty or contained duplicate identities") + return set(names) + + +def xctest_results(text: str, discovered: set[str], allowed_skips: dict[str, str], exit_code: int) -> dict: + if exit_code != 0: + raise InvalidResults(f"Test process exited with {exit_code}") + if not discovered: + raise InvalidResults("No discovered tests") + stale = set(allowed_skips) - discovered + if stale: + raise InvalidResults(f"Skip policy references missing tests: {sorted(stale)}") + starts = [f"{group}/{name}" for group, name in STARTED.findall(text)] + reasons = {f"{group}/{name}": reason.strip() for group, name, reason in SKIP_REASON.findall(text)} + cases = [dict(id=f"{group}/{name}", classname=group, name=name, status=status, seconds=float(seconds)) + for group, name, status, seconds in COMPLETED.findall(text)] + identities = [c['id'] for c in cases] + if len(starts) != len(set(starts)) or len(identities) != len(set(identities)): + raise InvalidResults("Duplicate test starts/completions") + if set(starts) != discovered or set(identities) != discovered: + raise InvalidResults("Discovered, started and completed test identities differ") + for case in cases: + if case['status'] == 'skipped': + reason = reasons.get(case['id'], '') + if case['id'] not in allowed_skips or reason != allowed_skips[case['id']]: + raise InvalidResults(f"Unapproved skip or changed reason: {case['id']}") + case['reason'] = reason + counts = {key: sum(c['status'] == key for c in cases) for key in ('passed', 'skipped', 'failed')} + totals = TOTAL.findall(text) + if not totals: + raise InvalidResults("Missing XCTest aggregate summary") + total, skipped, failures, unexpected = totals[-1] + if (int(total), int(skipped or 0), int(failures), int(unexpected)) != (len(cases), counts['skipped'], counts['failed'], 0): + raise InvalidResults("XCTest aggregate disagrees with individual results") + if counts['failed'] or not counts['passed'] or "Test Suite 'All tests' passed at " not in text: + raise InvalidResults("The complete suite did not pass, or all tests were skipped") + return dict(total=len(cases), **counts, cases=cases) + + +def write_junit(path, result: dict, name: str) -> None: + suite = ET.Element('testsuite', name=name, tests=str(result['total']), failures=str(result['failed']), + errors='0', skipped=str(result['skipped'])) + for case in result['cases']: + node = ET.SubElement(suite, 'testcase', classname=case['classname'], name=case['name'], time=str(case['seconds'])) + if case['status'] == 'skipped': + ET.SubElement(node, 'skipped', message=case['reason']) + ET.indent(suite) + ET.ElementTree(suite).write(path, encoding='utf-8', xml_declaration=True) + + +def node_results(text: str, filename: str, exit_code: int) -> dict: + """Node 22 JUnit includes aggregate counters in XML comments.""" + if exit_code: + raise InvalidResults(f"Node test process exited with {exit_code}") + root = ET.fromstring(text) + if root.tag != 'testsuites': + raise InvalidResults('Missing Node JUnit root') + cases = list(root.iter('testcase')) + names = [case.get('name', '') for case in cases] + if not cases or any(not name or name.replace('\\', '/').split('/')[-1] == filename for name in names): + raise InvalidResults('No actual Node test cases; file-level execution is insufficient') + if len(names) != len(set(names)): + raise InvalidResults('Duplicate Node test names within one file') + if any(list(root.iter(tag)) for tag in ('failure', 'error', 'skipped')): + raise InvalidResults('Node reported a failure, error or skipped test') + counters = {} + for name in ('tests', 'suites', 'pass', 'fail', 'cancelled', 'skipped', 'todo'): + values = re.findall(r'', text) + if len(values) != 1: + raise InvalidResults(f'Missing or duplicate Node aggregate: {name}') + counters[name] = int(values[0]) + # test(..., t => t.test(...)) parents are testsuite elements too, but count + # as tests. Only describe() containers are excluded by the suites counter. + containers = list(root.iter('testsuite')) + if not 0 <= counters['suites'] <= len(containers): + raise InvalidResults('Node suite counter disagrees with JUnit containers') + for suite in containers: + if not len(suite) or any(int(suite.get(key, '0')) for key in ('failures', 'errors', 'skipped', 'disabled')): + raise InvalidResults('Empty or unsuccessful Node test suite') + if int(suite.get('tests', '-1')) != len(suite.findall('testcase')) + len(suite.findall('testsuite')): + raise InvalidResults('Node suite child count disagrees with JUnit cases') + total = len(cases) + len(containers) - counters['suites'] + if counters != dict(tests=total, suites=counters['suites'], **{'pass': total}, fail=0, cancelled=0, skipped=0, todo=0): + raise InvalidResults('Node aggregate disagrees with complete successful cases') + return dict(total=total, passed=total, skipped=0, failed=0, leafCases=len(cases), + suiteContainers=len(containers), describeSuites=counters['suites'], cases=names) diff --git a/Tools/CI/test_results_test.py b/Tools/CI/test_results_test.py new file mode 100644 index 00000000..fccce0c6 --- /dev/null +++ b/Tools/CI/test_results_test.py @@ -0,0 +1,74 @@ +"""Failure-path checks for CI result validation; no product code or credentials.""" +import unittest + +from test_results import InvalidResults, discovered_tests, xctest_results + + +def log(cases): + lines = [] + for name, status in cases: + lines.append(f"Test Case '-[Fixture.Tests {name}]' started.") + if status == 'skipped': + lines.append(f"fixture.swift:1: -[Fixture.Tests {name}] : Test skipped - explicit fixture") + lines.append(f"Test Case '-[Fixture.Tests {name}]' {status} (0.001 seconds).") + skips = sum(status == 'skipped' for _, status in cases) + failures = sum(status == 'failed' for _, status in cases) + lines += ["Test Suite 'All tests' passed at 2026-10-26 00:00:00.", + f"Executed {len(cases)} tests, with {skips} tests skipped and {failures} failures (0 unexpected)"] + return '\n'.join(lines) + + +class ResultValidationTests(unittest.TestCase): + def check(self, text=None, discovered=None, skips=None, code=0): + return xctest_results(text if text is not None else log([('testOne', 'passed')]), + {'Fixture.Tests/testOne'} if discovered is None else discovered, + {} if skips is None else skips, code) + + def test_complete_pass(self): + self.assertEqual(self.check()['passed'], 1) + + def test_discovery_ignores_build_noise(self): + self.assertEqual(discovered_tests('Building for production...\nFixture.Tests/testOne\n'), {'Fixture.Tests/testOne'}) + + def test_empty_and_duplicate_discovery_fail(self): + for value in ['', 'Fixture.Tests/testOne\nFixture.Tests/testOne']: + with self.assertRaises(InvalidResults): + discovered_tests(value) + + def test_process_failure_cannot_be_overridden_by_passed_output(self): + with self.assertRaises(InvalidResults): self.check(code=1) + + def test_test_failure_cannot_be_overridden_by_zero_exit(self): + with self.assertRaises(InvalidResults): self.check(log([('testOne', 'failed')])) + + def test_unrun_discovered_test_fails(self): + with self.assertRaises(InvalidResults): self.check(discovered={'Fixture.Tests/testOne', 'Fixture.Tests/testTwo'}) + + def test_duplicate_completion_fails(self): + with self.assertRaises(InvalidResults): self.check(log([('testOne', 'passed'), ('testOne', 'passed')])) + + def test_truncated_summary_fails(self): + with self.assertRaises(InvalidResults): self.check(log([('testOne', 'passed')]).split("Test Suite")[0]) + + def test_aggregate_mismatch_fails(self): + with self.assertRaises(InvalidResults): self.check(log([('testOne', 'passed')]).replace('Executed 1 tests', 'Executed 2 tests')) + + def test_new_skip_and_changed_reason_fail(self): + for policy in [{}, {'Fixture.Tests/testOne': 'different reason'}]: + with self.assertRaises(InvalidResults): self.check(log([('testOne', 'skipped')]), skips=policy) + + def test_all_skipped_fails(self): + with self.assertRaises(InvalidResults): self.check(log([('testOne', 'skipped')]), skips={'Fixture.Tests/testOne': 'explicit fixture'}) + + def test_stale_skip_entry_fails(self): + with self.assertRaises(InvalidResults): self.check(skips={'Fixture.Tests/testMissing': 'explicit fixture'}) + + def test_declared_skip_retains_reason(self): + result = self.check(log([('testOne', 'passed'), ('testTwo', 'skipped')]), + {'Fixture.Tests/testOne', 'Fixture.Tests/testTwo'}, {'Fixture.Tests/testTwo': 'explicit fixture'}) + self.assertEqual(result['skipped'], 1) + self.assertEqual(result['cases'][1]['reason'], 'explicit fixture') + + +if __name__ == '__main__': + unittest.main() diff --git a/Tools/CI/tool-downloads.json b/Tools/CI/tool-downloads.json new file mode 100644 index 00000000..f0a8305a --- /dev/null +++ b/Tools/CI/tool-downloads.json @@ -0,0 +1,28 @@ +{ + "actionlint": { + "version": "1.7.12", + "platforms": { + "Darwin-arm64": { + "url": "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_darwin_arm64.tar.gz", + "sha256": "aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f", + "member": "actionlint" + }, + "Linux-x86_64": { + "url": "https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz", + "sha256": "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8", + "member": "actionlint" + } + } + }, + "xcodegen": { + "version": "2.46.0", + "platforms": { + "Darwin-arm64": { + "url": "https://github.com/yonaskolb/XcodeGen/releases/download/2.46.0/xcodegen.zip", + "sha256": "4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806", + "member": "xcodegen/bin/xcodegen", + "format": "zip" + } + } + } +} diff --git a/Tools/SourceAudit/EPGImportProbe/server.py b/Tools/SourceAudit/EPGImportProbe/server.py index 80ec248e..804a3766 100644 --- a/Tools/SourceAudit/EPGImportProbe/server.py +++ b/Tools/SourceAudit/EPGImportProbe/server.py @@ -46,5 +46,8 @@ def log_message(self, *args): pass if __name__ == '__main__': p = argparse.ArgumentParser(); p.add_argument('root', type=Path); a = p.parse_args() server = ThreadingHTTPServer(('127.0.0.1', 0), Handler); server.root = a.root.resolve() - (server.root / 'port').write_text(str(server.server_port)) + # Existence is the parent's readiness signal: publish only a complete value. + pending_port = server.root / 'port.pending' + pending_port.write_text(str(server.server_port)) + pending_port.replace(server.root / 'port') server.serve_forever()