diff --git a/.github/actions/native-rust-cache/action.yml b/.github/actions/native-rust-cache/action.yml new file mode 100644 index 0000000000..15f036e65d --- /dev/null +++ b/.github/actions/native-rust-cache/action.yml @@ -0,0 +1,39 @@ +name: Restore the Rust crates of the native build +description: >- + `native:build` compiles the physics crate and the ui-overlay crate. Rust took 112 s of 173 s in the + starter-linux x64 build (job 124904693949). Only a push saves, so the pull-request and queue boards + spend no cache budget. Cargo fingerprints content, so an older restore rebuilds only what moved. +runs: + using: composite + steps: + # A `-sys` crate does not rerun when a system library changes, so the image and the two libraries + # it links to name the prefix, and no restore crosses them. + - id: identity + shell: bash + run: | + libs="" + if command -v dpkg-query >/dev/null; then + libs=$( (dpkg-query -W libwebkit2gtk-4.1-dev libgtk-3-dev 2>/dev/null || true) | sha256sum | cut -c1-12) + fi + rust=$( (rustc --version 2>/dev/null || echo none) | tr -c 'a-zA-Z0-9.\n' '-') + echo "prefix=cargo-v1-${{ runner.os }}-${{ runner.arch }}-${ImageVersion:-self}-$rust-$libs-" >> "$GITHUB_OUTPUT" + - if: github.event_name == 'push' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git/db + packages/runtime-native/.runtime/physics-target + packages/runtime-native/native/ui-overlay/target + key: ${{ steps.identity.outputs.prefix }}${{ hashFiles('packages/runtime-native/native/**/Cargo.lock', 'packages/runtime-native/native/**/Cargo.toml', 'packages/runtime-native/native/**/*.rs', 'packages/runtime-native/scripts/build-native-physics.mjs', 'packages/runtime-native/scripts/native-build.mjs') }} + restore-keys: ${{ steps.identity.outputs.prefix }} + - if: github.event_name != 'push' + uses: actions/cache/restore@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git/db + packages/runtime-native/.runtime/physics-target + packages/runtime-native/native/ui-overlay/target + key: ${{ steps.identity.outputs.prefix }}${{ hashFiles('packages/runtime-native/native/**/Cargo.lock', 'packages/runtime-native/native/**/Cargo.toml', 'packages/runtime-native/native/**/*.rs', 'packages/runtime-native/scripts/build-native-physics.mjs', 'packages/runtime-native/scripts/native-build.mjs') }} + restore-keys: ${{ steps.identity.outputs.prefix }} diff --git a/.github/actions/playwright-chromium/action.yml b/.github/actions/playwright-chromium/action.yml index 9c39e4f027..905e40943a 100644 --- a/.github/actions/playwright-chromium/action.yml +++ b/.github/actions/playwright-chromium/action.yml @@ -23,6 +23,22 @@ runs: for source in /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources; do [ ! -f "$source" ] || sudo sed -i 's|http://azure.archive.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g; s|https://azure.archive.ubuntu.com/ubuntu|https://archive.ubuntu.com/ubuntu|g' "$source" done + # apt checks each .deb against its index, so a stale file is only unused. A push saves. + - name: Record the apt package cache identity + id: debs + shell: bash + run: | + echo "key=playwright-debs-v1-${{ runner.os }}-${{ runner.arch }}-${ImageVersion:-self}-${{ steps.version.outputs.value }}" >> "$GITHUB_OUTPUT" + mkdir -p ~/playwright-debs + - name: Restore the Chromium system packages + id: debs-restore + uses: actions/cache/restore@v4 + with: + path: ~/playwright-debs + key: ${{ steps.debs.outputs.key }} + - name: Offer the restored packages to apt + shell: bash + run: sudo find ~/playwright-debs -name '*.deb' -exec cp -t /var/cache/apt/archives/ {} + # The browser binary caches; its shared libraries are apt packages that do not, so # `install-deps` still runs. On a cache hit that is the only work left. # It takes 11-114 s; a mirror that trickles held it 29 minutes (run 41249368283), so each @@ -34,3 +50,14 @@ runs: pnpm exec playwright install chromium fi sh scripts/retry-download.sh sudo timeout --kill-after=15 240 env "PATH=$PATH" node_modules/.bin/playwright install-deps chromium + - name: Keep the packages apt fetched + if: github.event_name == 'push' && steps.debs-restore.outputs.cache-hit != 'true' + shell: bash + run: find /var/cache/apt/archives -maxdepth 1 -name '*.deb' -exec cp -t ~/playwright-debs/ {} + + - name: Save the Chromium system packages + if: github.event_name == 'push' && steps.debs-restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ~/playwright-debs + key: ${{ steps.debs.outputs.key }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2097e235a..0453c4b834 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -921,6 +921,9 @@ jobs: pnpm --dir "$target" install pnpm --dir "$target" run check - name: Run the template's GPU-free scenarios + # Owner cut (2026-11-01): the board plays the starter through golden-path and keeps its + # pristine check above; its scenarios, 21 engine guards and parity pose run on the nightly. + if: matrix.template != 'starter' || github.event_name == 'schedule' run: | set -euo pipefail target="$RUNNER_TEMP/threenative-${{ matrix.template }}" @@ -930,7 +933,9 @@ jobs: extras="$GITHUB_WORKSPACE/packages/create-threenative/template-playtests/${{ matrix.template }}" if [ -d "$extras" ]; then cp -r "$extras/." "$target/playtests/"; fi # The PRD-748 engine-cost row runs in `template-engine-cost`, beside this leg. - scenario_list=$(node "$GITHUB_WORKSPACE/scripts/non-visual-scenarios.mjs" "$target") + board=() + if [ "${{ github.event_name }}" != schedule ]; then board=(--board "${{ matrix.template }}"); fi + scenario_list=$(node "$GITHUB_WORKSPACE/scripts/non-visual-scenarios.mjs" "$target" "${board[@]}") mapfile -t scenarios <<< "$scenario_list" test "${#scenarios[@]}" -gt 0 echo "${{ matrix.template }}: ${#scenarios[@]} nonvisual scenarios" @@ -948,13 +953,14 @@ jobs: # 4x4 tile means (scripts/engine-parity.ts). Both arms share this runner's adapter, so a # software rasteriser still catches a wrong exposure, colour, fog or a missing object. - name: Compare the two engines on one pose + if: matrix.template != 'starter' || github.event_name == 'schedule' run: >- pnpm exec tsx scripts/engine-parity.ts --project "$RUNNER_TEMP/threenative-${{ matrix.template }}" --template ${{ matrix.template }} --out artifacts/engine-parity/${{ matrix.template }} - name: Retain the engine parity frames - if: always() + if: always() && (matrix.template != 'starter' || github.event_name == 'schedule') uses: actions/upload-artifact@v4 with: name: engine-parity-${{ matrix.template }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -970,14 +976,14 @@ jobs: pnpm --dir "$target" exec threenative build --target web test -s "$target/dist/index.html" - # PRD-748's engine-cost contract row, every PR, per kit. It draws all 1260 ticks one by one for - # its four counter windows, and on hosted SwiftShader that alone took 14m51s (run 41170629638, - # adventure): inside a kit's 30-minute `template-nonvisual` leg it cancelled adventure and - # collectathon. It runs here in parallel, with the same scenario, bounds and ticks. A kit that - # opts out prints its reason and passes; `ci-required` gates this job like any other. + # PRD-748's engine-cost contract row, nightly only (owner, 2026-11-01), per kit. It draws all 1260 + # ticks one by one for its four counter windows, and on hosted SwiftShader that alone took 14m51s + # (run 41170629638, adventure): inside a kit's 30-minute `template-nonvisual` leg it cancelled + # adventure and collectathon. It runs here in parallel, with the same scenario, bounds and ticks. + # A kit that opts out prints its reason and passes; `ci-required` gates the nightly's run. template-engine-cost: needs: [scope, build-artifacts] - if: ${{ (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'template') }} + if: ${{ github.event_name == 'schedule' && (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'template') }} strategy: fail-fast: false matrix: ${{ fromJSON(needs.scope.outputs.plan).templateMatrix }} @@ -1091,6 +1097,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - uses: ./.github/actions/workspace-dist + if: github.event_name == 'schedule' with: # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is # ordered behind it above, so the upload always exists and the cache key never misses. @@ -1098,13 +1105,19 @@ jobs: save-bundles: "false" pack-archives: "false" - run: pnpm tsx scripts/count-loc.ts --check - - run: pnpm --filter abyss-vanilla build - - run: pnpm --filter abyss-framework build - - run: pnpm exec vitest run scripts/__tests__/count-loc.spec.ts scripts/__tests__/score-blind.spec.ts + # Owner cut (2026-11-01): a board keeps `count-loc --check`; the rest runs on the nightly. + - if: github.event_name == 'schedule' + run: pnpm --filter abyss-vanilla build + - if: github.event_name == 'schedule' + run: pnpm --filter abyss-framework build + - if: github.event_name == 'schedule' + run: pnpm exec vitest run scripts/__tests__/count-loc.spec.ts scripts/__tests__/score-blind.spec.ts - uses: ./.github/actions/playwright-chromium + if: github.event_name == 'schedule' # `test:browser` is the test-browser job's gate; running it again here bought nothing but # a second copy of the slowest step in the workflow. - - run: sh scripts/xvfb.sh pnpm exec playwright test --config=benchmark.playwright.config.ts + - if: github.event_name == 'schedule' + run: sh scripts/xvfb.sh pnpm exec playwright test --config=benchmark.playwright.config.ts # visuals is NOT a job here any more. It captures frames from a WebGPU canvas, and this runner # has no GPU — its first execution in the repository's history failed on @@ -1424,8 +1437,9 @@ jobs: ctest --test-dir build/native-engine-wasm -L native-engine --no-tests=error --output-on-failure integration: - needs: scope - if: needs.scope.result == 'success' && fromJSON(needs.scope.outputs.plan).jobs.integration.required + needs: [scope, build-artifacts] + # `!cancelled()`: a skipped or failed build-artifacts only means each leg builds its own workspace. + if: ${{ !cancelled() && needs.scope.result == 'success' && fromJSON(needs.scope.outputs.plan).jobs.integration.required }} permissions: contents: read actions: read @@ -1435,6 +1449,7 @@ jobs: candidate_sha: ${{ needs.scope.outputs.candidate_sha }} runner_admission: ${{ needs.scope.outputs.runner_admission }} base_sha: ${{ needs.scope.outputs.base_sha }} + workspace_artifact: ${{ needs.build-artifacts.result == 'success' && 'workspace-packages' || '' }} supply-chain: needs: scope diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 9b72be8f21..21e0d0b487 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -16,6 +16,10 @@ on: runner_admission: type: string default: "" + # This run's compiled workspace from `build-artifacts`; empty builds it in the job. + workspace_artifact: + type: string + default: "" base_sha: type: string default: "" @@ -92,7 +96,7 @@ jobs: # Exact source filters; workflow-only changes compare each job and filter separately. TN_LANE_FILTERS: | animation ^packages/core/src/animation\.ts$|^packages/core/__tests__/animation[^/]*\.spec\.ts$|^examples/abyss-framework/src/render/animation-reversal[^/]*\.ts$|^examples/abyss-framework/src/animation-reversal-main\.ts$|^examples/abyss-framework/animation-reversal\.html$|^examples/abyss-framework/playtests/animation-reversal\.playtest\.json$|^scripts/verify-animation-reversal\.ts$|^packages/core/__tests__/vq-locomotion-blend-spaces\.spec\.ts$|^examples/abyss-framework/src/render/locomotion-[^/]*\.ts$|^examples/abyss-framework/src/render/vq-locomotion-[^/]*\.ts$|^examples/abyss-framework/src/vq-locomotion-main\.ts$|^examples/abyss-framework/playtests/vq-locomotion-blend-spaces\.playtest\.json$|^examples/abyss-framework/assets/mannequin\.glb$|^scripts/verify-vq-locomotion\.ts$ - cold-boot ^packages/create-threenative/(template-assets|templates/[^/]+/src/render)/(autoExposure|exposure|exposureGraph|exposureReadback|worldEnvironment)\.ts$|^packages/create-threenative/__tests__/auto-exposure[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/auto-exposure/.*$|^packages/core/src/.*$|^packages/core/patches/.*$|^packages/playtest/.*$|^packages/runtime-native/.*$|^pnpm-lock\.yaml$|^scripts/xvfb\.sh$|^\.github/actions/(workspace-dist|pnpm|playwright-chromium)/.*$ + cold-boot ^packages/create-threenative/(template-assets|templates/[^/]+/src/render)/(autoExposure|exposure|exposureGraph|exposureReadback|worldEnvironment)\.ts$|^packages/create-threenative/__tests__/auto-exposure[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/auto-exposure/.*$|^packages/core/src/(.*/)?[^/]*[Ee]xposure[^/]*$ exposure ^packages/create-threenative/(template-assets|templates/[^/]+/src/render)/(autoExposure|exposure|exposureGraph|exposureReadback|worldEnvironment)\.ts$|^packages/create-threenative/__tests__/auto-exposure[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/auto-exposure/.*$|^packages/core/src/.*$|^packages/core/patches/.*$|^packages/playtest/.*$|^packages/runtime-native/.*$|^pnpm-lock\.yaml$|^scripts/xvfb\.sh$|^\.github/actions/(workspace-dist|pnpm|playwright-chromium)/.*$ csg ^examples/integrations/csg/.*$ decals ^packages/playtest/.*$|^packages/create-threenative/templates/shooter/src/render/decals\.ts$|^packages/create-threenative/templates/shooter/src/scenes/Play\.ts$|^packages/create-threenative/__tests__/shooter-decals[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/bounded-decals/.*$|^packages/runtime-native/.*$|^scripts/xvfb\.sh$|^\.github/actions/workspace-dist/.*$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^pnpm-lock\.yaml$ @@ -230,6 +234,7 @@ jobs: - run: pnpm install --frozen-lockfile - uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} pack-archives: 'false' save-bundles: 'false' - uses: ./.github/actions/playwright-chromium @@ -281,6 +286,7 @@ jobs: - name: Build this source's public JavaScript packages uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} save-bundles: 'false' pack-archives: 'false' - name: Verify native preflight and capture rejection controls @@ -298,6 +304,7 @@ jobs: libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev + - uses: ./.github/actions/native-rust-cache - name: Build the ordinary ARM desktop host run: pnpm --filter @threenative/runtime-native native:build - name: Run the same packaged decal scenarios and unchanged pixel gates @@ -488,6 +495,7 @@ jobs: - uses: ./.github/actions/native-engine-wasm - uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} pack-archives: 'false' save-bundles: 'false' - uses: ./.github/actions/playwright-chromium @@ -545,6 +553,7 @@ jobs: - name: Build this source's public JavaScript packages uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} save-bundles: 'false' pack-archives: 'false' - name: Restore checksum-provisioned native dependencies @@ -560,6 +569,7 @@ jobs: libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev + - uses: ./.github/actions/native-rust-cache - name: Build the ordinary ARM desktop host from this source run: pnpm --filter @threenative/runtime-native native:build - name: Bundle the authored locomotion entry for the native host @@ -661,6 +671,7 @@ jobs: - name: Build this source's public JavaScript packages uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} save-bundles: 'false' pack-archives: 'false' - name: Verify native preflight and capture rejection controls @@ -678,6 +689,7 @@ jobs: libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev + - uses: ./.github/actions/native-rust-cache - name: Build the ordinary ARM desktop host run: pnpm --filter @threenative/runtime-native native:build - name: Verify the compiled surface acquisition status contract @@ -852,6 +864,7 @@ jobs: - name: Build this source's public JavaScript packages uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} save-bundles: 'false' pack-archives: 'false' - name: Verify native preflight and capture rejection controls @@ -869,6 +882,7 @@ jobs: libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev + - uses: ./.github/actions/native-rust-cache - name: Build the ordinary ARM desktop host from this source run: pnpm --filter @threenative/runtime-native native:build - name: Run the same authored four-arm proof and missing-gate control @@ -930,6 +944,7 @@ jobs: - name: Build this commit's JavaScript packages uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} save-bundles: 'false' pack-archives: 'false' - name: Validate real compressed-source normalization and capture guards @@ -951,6 +966,7 @@ jobs: libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev + - uses: ./.github/actions/native-rust-cache - name: Build the QuickJS host required by the packaged fallback run: | pnpm --filter @threenative/runtime-native native:build @@ -1002,6 +1018,7 @@ jobs: - run: pnpm install --frozen-lockfile - uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} pack-archives: 'false' save-bundles: 'false' - uses: ./.github/actions/playwright-chromium @@ -1046,6 +1063,7 @@ jobs: - run: pnpm install --frozen-lockfile - uses: ./.github/actions/workspace-dist with: + shared-artifact: ${{ inputs.workspace_artifact }} pack-archives: 'false' save-bundles: 'false' - uses: ./.github/actions/playwright-chromium diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml index 2e87cd9f74..3dfa5b8310 100644 --- a/.github/workflows/native-platforms.yml +++ b/.github/workflows/native-platforms.yml @@ -10,16 +10,9 @@ name: Native platform evidence # whole 24-minute matrix twice per PR. # # This file is a reusable workflow called by ci.yml, where the `native-platforms` job is a required -# member of the primary board. Manual dispatch remains available for an isolated platform run. -# -# PRD-629: a push to main also starts it on its own. The release candidate needs a standalone run of -# this file on the candidate commit (its parity and provenance reports), and the manual -# `gh workflow run native-platforms.yml --ref main` that used to start it gave a new run id on every -# re-dispatch. A standalone push run has no caller plan, so it takes the same full-coverage path as a -# dispatch. +# member of the primary board. Manual dispatch remains available for an isolated platform run. The +# release candidate reads its parity and provenance reports from the main CI run that called this. on: - push: - branches: [main] workflow_dispatch: inputs: ios_only: @@ -1204,6 +1197,7 @@ jobs: - name: Zero the compiler cache counters shell: bash run: ccache --zero-stats + - uses: ./.github/actions/native-rust-cache - name: Build the native host run: pnpm --filter @threenative/runtime-native native:build - name: Verify the shipped Crowd rate scenario on desktop @@ -1488,6 +1482,7 @@ jobs: run: >- & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install "curl:x64-windows-static" "zlib:x64-windows-static" + - uses: ./.github/actions/native-rust-cache - name: Build native runtime with MSVC if: runner.os == 'Windows' shell: cmd @@ -2269,6 +2264,7 @@ jobs: - name: Zero the compiler cache counters shell: bash run: ccache --zero-stats + - uses: ./.github/actions/native-rust-cache - name: Build and pack the scaffold dependencies run: | pnpm --filter @threenative/runtime-native native:build @@ -2342,7 +2338,8 @@ jobs: sh scripts/xvfb.sh "$artifact/tn-native-engine-player-v8" "$artifact/game.js" --check-game 2>&1 | tee "$RUNNER_TEMP/starter-default-boot.log" grep -q 'TN_PLAYER_V8_GAME_CHECK: engine=native gameRuntime=v8 startup=passed' "$RUNNER_TEMP/starter-default-boot.log" - name: Compare paired starter JS frame duration on Linux ARM64 - if: matrix.platform == 'linux-arm64' + # Nightly only (owner, 2026-11-01): 18 job-minutes a board. + if: matrix.platform == 'linux-arm64' && github.event_name == 'schedule' shell: bash env: TN_PLAYTEST_ALLOW_SOFTWARE: "1" @@ -2410,7 +2407,7 @@ jobs: done done - name: Upload paired starter JS frame evidence - if: always() && matrix.platform == 'linux-arm64' + if: always() && matrix.platform == 'linux-arm64' && github.event_name == 'schedule' uses: actions/upload-artifact@v7 with: name: native-starter-js-frame-pair-linux-arm64 diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 6d00a90d94..7378e84e8c 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -1403,6 +1403,10 @@ jobs: pnpm --dir "$IOS_RELEASE_CONSUMER" build --target ios test -d "$IOS_RELEASE_CONSUMER/dist-native/threenative-ios-release-consumer.app" test ! -e "$TN_IOS_TOOLCHAIN_LOG" + # The packager stamps the consumer's own app id into the bundle, so the build report names it. + bundle_id=$(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).appId' "$IOS_RELEASE_CONSUMER/dist-native/threenative-ios-release-consumer.app.json") + test -n "$bundle_id" + echo "IOS_BUNDLE_ID=$bundle_id" >> "$GITHUB_ENV" - name: Boot a clean-consumer iOS simulator run: | device=$(xcrun simctl list devices available | awk -F '[()]' '/iPhone/{print $2; exit}') @@ -1416,13 +1420,13 @@ jobs: app="$IOS_RELEASE_CONSUMER/dist-native/threenative-ios-release-consumer.app" node packages/playtest/dist/runner/cli.js \ examples/native-smoke/playtests/device-smoke.playtest.json \ - --target ios --app "$app" --bundle-id dev.threenative.runtime \ + --target ios --app "$app" --bundle-id "$IOS_BUNDLE_ID" \ --timeout 30000 \ --artifacts packages/runtime-native/artifacts/ios/release-consumer-pass set +e node packages/playtest/dist/runner/cli.js \ examples/native-smoke/playtests/device-smoke-wrong-value.playtest.json \ - --target ios --app "$app" --bundle-id dev.threenative.runtime \ + --target ios --app "$app" --bundle-id "$IOS_BUNDLE_ID" \ --timeout 30000 \ --artifacts packages/runtime-native/artifacts/ios/release-consumer-wrong \ > "$RUNNER_TEMP/ios-wrong-value.log" 2>&1 @@ -1439,7 +1443,7 @@ jobs: scenario=$1 artifacts=$2 node packages/playtest/dist/runner/cli.js "$scenario" \ - --target ios --app "$app" --bundle-id dev.threenative.runtime \ + --target ios --app "$app" --bundle-id "$IOS_BUNDLE_ID" \ --timeout 30000 \ --artifacts "$artifacts" } @@ -1765,7 +1769,7 @@ jobs: finalize: # iOS is not a supported target (owner, 2026-09-23): its consumer proof stays as evidence but - # does not gate promotion, and it has never completed green in a tag run. + # does not gate promotion. # # A proof run rehearses this job too (PRD-629): it checks the same release and skips only the # `gh release edit` that would make a release public. diff --git a/AGENTS.md b/AGENTS.md index ca40d5f3a6..8192ef2f50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,7 @@ pnpm native:build # opt-in; downloads deps, compiles th pnpm native:verify:desktop # 300 native frames + a non-blank screenshot ``` -**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`), or only the generated speed-loop record (`docs/ci-speed/ledger.json`, `docs/ci-speed/runs/.jobs.json`), runs no product CI job. Ordinary PRs into develop use the complete candidate diff: CI-only changes run CI contracts, exact template changes run scaffolder contracts and affected templates, and shared executable inputs earn the thin review lane (PRD-731): scope, typecheck, lint, budgets, secrets scan, the unit tests that import a changed file, `test` and `build-artifacts` for a package change, and one template smoke, about 10 minutes; draft PRs run nothing. Template instruction and Markdown changes retain their contract lanes without adding runtime kits. Merge groups run the full selected board once, name the first red job in a PR comment and cancel the rest of the run, so a change pays for one board (PRD-550, PRD-731); `ci-required` rejects a review plan anywhere but a PR into develop, and `pnpm ci:local --affected` runs the queue's board. Develop pushes, explicit full runs, unknown inputs, main qualification and scheduled audits use fresh exhaustive qualification: all shipped templates, both golden journeys and the full supported native matrix. `ci-required` independently rejects a review plan for these events; Integration is a required reusable join on the same candidate, with each selected canonical job proven by a receipt from its latest attempt in the current run and an independently checked completed-success API job; missing, skipped, cancelled, stale or unmapped receipts fail closed. So a red job on an unchanged head needs only `gh run rerun --failed`, not a full run; a fix still needs a push. Native per-leg authoritative completion and cold-path queue feasibility remain follow-ups before deferring any Android PR coverage. Template scenarios run once per selected kit; develop changes run one representative golden journey and full qualification retains both starter and platformer journeys. `ci-required` rejects missing or failed selected checks; retained contexts include `supply-chain`, `template-nonvisual`, `golden-path`, and native `desktop-parity` when applicable. Native host/platform tests run for native or shared runtime consumers, main qualification and unresolved scopes. Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete coverage and per-leg routing exist. One exception (PRD-730): a merge group whose tree equals the tree of its pull request's head, which has a green full `ci.yml` run an author dispatched (a review-lane PR run never counts), reuses that run; `ci-required` re-decides it and writes a receipt, and any doubt runs the board. A new candidate runs fresh tests; only exact-run immutable build artifacts are shared. Details: `docs/PRDs/done/PRD-483-impact-driven-ci-without-repeated-template-setup.md` and `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`. +**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`), or only the generated speed-loop record (`docs/ci-speed/ledger.json`, `docs/ci-speed/runs/.jobs.json`), runs no product CI job. Ordinary PRs into develop use the complete candidate diff: CI-only changes run CI contracts, exact template changes run scaffolder contracts and affected templates, and shared executable inputs earn the thin review lane (PRD-731): scope, typecheck, lint, budgets, secrets scan, the unit tests that import a changed file, `test` and `build-artifacts` for a package change, and one template smoke, about 10 minutes; draft PRs run nothing. Template instruction and Markdown changes retain their contract lanes without adding runtime kits. Merge groups run the full selected board once, name the first red job in a PR comment and cancel the rest of the run, so a change pays for one board (PRD-550, PRD-731); `ci-required` rejects a review plan anywhere but a PR into develop, and `pnpm ci:local --affected` runs the queue's board. Develop pushes, explicit full runs, unknown inputs, main qualification and scheduled audits use fresh exhaustive qualification: all shipped templates, both golden journeys and the full supported native matrix. `ci-required` independently rejects a review plan for these events; Integration is a required reusable join on the same candidate, with each selected canonical job proven by a receipt from its latest attempt in the current run and an independently checked completed-success API job; missing, skipped, cancelled, stale or unmapped receipts fail closed. So a red job on an unchanged head needs only `gh run rerun --failed`, not a full run; a fix still needs a push. Native per-leg authoritative completion and cold-path queue feasibility remain follow-ups before deferring any Android PR coverage. Template scenarios run once per selected kit; develop changes run one representative golden journey and full qualification retains both starter and platformer journeys. **Nightly only (owner, 2026-11-01):** `template-engine-cost`, the starter's scenarios and engine parity (the board keeps its pristine check and golden path), scenarios of 500 or more scripted ticks on shooter, collectathon, rain, adventure, platformer and kart, the `benchmark` job past `count-loc --check`, and the Linux ARM64 starter JS frame comparison. `auto-exposure cold boot` runs when exposure code changes and nightly. The release candidate reads its parity and provenance reports from the main CI run; `native-platforms.yml` has no push trigger. `ci-required` rejects missing or failed selected checks; retained contexts include `supply-chain`, `template-nonvisual`, `golden-path`, and native `desktop-parity` when applicable. Native host/platform tests run for native or shared runtime consumers, main qualification and unresolved scopes. Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete coverage and per-leg routing exist. One exception (PRD-730): a merge group whose tree equals the tree of its pull request's head, which has a green full `ci.yml` run an author dispatched (a review-lane PR run never counts), reuses that run; `ci-required` re-decides it and writes a receipt, and any doubt runs the board. A new candidate runs fresh tests; only exact-run immutable build artifacts are shared. Details: `docs/PRDs/done/PRD-483-impact-driven-ci-without-repeated-template-setup.md` and `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`. Registry commands read auth from `~/.npmrc`; pass a checkout-local untracked `.npmrc` with `npm --userconfig .npmrc `. Never print either file. TypeScript 7 `strict` (the native `tsc`); `typescript` imports use the official TypeScript 6 compatibility API for AST scripts and tsup declarations, **ESM only**; relative imports end in `.js`. Versions come from the `catalog:` in `pnpm-workspace.yaml`, templates excepted. Biome formats — never hand-format. Interfaces are `I`-prefixed; classes and type aliases are not. Unit tests: `/__tests__/*.spec.ts`, vitest, node environment, DOM and GPU stubbed. diff --git a/CLAUDE.md b/CLAUDE.md index 5bd642d1ca..ac3349a559 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ pnpm native:build # opt-in; downloads deps, compiles th pnpm native:verify:desktop # 300 native frames + a non-blank screenshot ``` -**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`), or only the generated speed-loop record (`docs/ci-speed/ledger.json`, `docs/ci-speed/runs/.jobs.json`), runs no product CI job. Ordinary PRs into develop use the complete candidate diff: CI-only changes run CI contracts, exact template changes run scaffolder contracts and affected templates, and shared executable inputs earn the thin review lane (PRD-731): scope, typecheck, lint, budgets, secrets scan, the unit tests that import a changed file, `test` and `build-artifacts` for a package change, and one template smoke, about 10 minutes; draft PRs run nothing. Template instruction and Markdown changes retain their contract lanes without adding runtime kits. Merge groups run the full selected board once, name the first red job in a PR comment and cancel the rest of the run, so a change pays for one board (PRD-550, PRD-731); `ci-required` rejects a review plan anywhere but a PR into develop, and `pnpm ci:local --affected` runs the queue's board. Develop pushes, explicit full runs, unknown inputs, main qualification and scheduled audits use fresh exhaustive qualification: all shipped templates, both golden journeys and the full supported native matrix. `ci-required` independently rejects a review plan for these events; Integration is a required reusable join on the same candidate, with each selected canonical job proven by a receipt from its latest attempt in the current run and an independently checked completed-success API job; missing, skipped, cancelled, stale or unmapped receipts fail closed. So a red job on an unchanged head needs only `gh run rerun --failed`, not a full run; a fix still needs a push. Native per-leg authoritative completion and cold-path queue feasibility remain follow-ups before deferring any Android PR coverage. Template scenarios run once per selected kit; develop changes run one representative golden journey and full qualification retains both starter and platformer journeys. `ci-required` rejects missing or failed selected checks; retained contexts include `supply-chain`, `template-nonvisual`, `golden-path`, and native `desktop-parity` when applicable. Native host/platform tests run for native or shared runtime consumers, main qualification and unresolved scopes. Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete coverage and per-leg routing exist. One exception (PRD-730): a merge group whose tree equals the tree of its pull request's head, which has a green full `ci.yml` run an author dispatched (a review-lane PR run never counts), reuses that run; `ci-required` re-decides it and writes a receipt, and any doubt runs the board. A new candidate runs fresh tests; only exact-run immutable build artifacts are shared. Details: `docs/PRDs/done/PRD-483-impact-driven-ci-without-repeated-template-setup.md` and `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`. +**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`), or only the generated speed-loop record (`docs/ci-speed/ledger.json`, `docs/ci-speed/runs/.jobs.json`), runs no product CI job. Ordinary PRs into develop use the complete candidate diff: CI-only changes run CI contracts, exact template changes run scaffolder contracts and affected templates, and shared executable inputs earn the thin review lane (PRD-731): scope, typecheck, lint, budgets, secrets scan, the unit tests that import a changed file, `test` and `build-artifacts` for a package change, and one template smoke, about 10 minutes; draft PRs run nothing. Template instruction and Markdown changes retain their contract lanes without adding runtime kits. Merge groups run the full selected board once, name the first red job in a PR comment and cancel the rest of the run, so a change pays for one board (PRD-550, PRD-731); `ci-required` rejects a review plan anywhere but a PR into develop, and `pnpm ci:local --affected` runs the queue's board. Develop pushes, explicit full runs, unknown inputs, main qualification and scheduled audits use fresh exhaustive qualification: all shipped templates, both golden journeys and the full supported native matrix. `ci-required` independently rejects a review plan for these events; Integration is a required reusable join on the same candidate, with each selected canonical job proven by a receipt from its latest attempt in the current run and an independently checked completed-success API job; missing, skipped, cancelled, stale or unmapped receipts fail closed. So a red job on an unchanged head needs only `gh run rerun --failed`, not a full run; a fix still needs a push. Native per-leg authoritative completion and cold-path queue feasibility remain follow-ups before deferring any Android PR coverage. Template scenarios run once per selected kit; develop changes run one representative golden journey and full qualification retains both starter and platformer journeys. **Nightly only (owner, 2026-11-01):** `template-engine-cost`, the starter's scenarios and engine parity (the board keeps its pristine check and golden path), scenarios of 500 or more scripted ticks on shooter, collectathon, rain, adventure, platformer and kart, the `benchmark` job past `count-loc --check`, and the Linux ARM64 starter JS frame comparison. `auto-exposure cold boot` runs when exposure code changes and nightly. The release candidate reads its parity and provenance reports from the main CI run; `native-platforms.yml` has no push trigger. `ci-required` rejects missing or failed selected checks; retained contexts include `supply-chain`, `template-nonvisual`, `golden-path`, and native `desktop-parity` when applicable. Native host/platform tests run for native or shared runtime consumers, main qualification and unresolved scopes. Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete coverage and per-leg routing exist. One exception (PRD-730): a merge group whose tree equals the tree of its pull request's head, which has a green full `ci.yml` run an author dispatched (a review-lane PR run never counts), reuses that run; `ci-required` re-decides it and writes a receipt, and any doubt runs the board. A new candidate runs fresh tests; only exact-run immutable build artifacts are shared. Details: `docs/PRDs/done/PRD-483-impact-driven-ci-without-repeated-template-setup.md` and `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`. Registry commands read auth from `~/.npmrc`; pass a checkout-local untracked `.npmrc` with `npm --userconfig .npmrc `. Never print either file. TypeScript 7 `strict` (the native `tsc`); `typescript` imports use the official TypeScript 6 compatibility API for AST scripts and tsup declarations, **ESM only**; relative imports end in `.js`. Versions come from the `catalog:` in `pnpm-workspace.yaml`, templates excepted. Biome formats — never hand-format. Interfaces are `I`-prefixed; classes and type aliases are not. Unit tests: `/__tests__/*.spec.ts`, vitest, node environment, DOM and GPU stubbed. diff --git a/docs/PRDs/CI/PRD-776-ci-runs-each-test-once-per-input-and-retries-only-the-red-leg.md b/docs/PRDs/CI/PRD-776-ci-runs-each-test-once-per-input-and-retries-only-the-red-leg.md new file mode 100644 index 0000000000..42d0fd8eb9 --- /dev/null +++ b/docs/PRDs/CI/PRD-776-ci-runs-each-test-once-per-input-and-retries-only-the-red-leg.md @@ -0,0 +1,151 @@ +# PRD-776 — CI runs each test once per input, and a red leg retries alone + +**Status:** IN PROGRESS +**Priority:** P1 — the release of 0.3.7 (PR #595) cost 3 full boards and about 6 h because AC-1 to AC-3 are open: one red leg reruns the whole board, and each develop push runs the board twice. +**Complexity:** 5 (MEDIUM); risk override: HIGH — a wrong reuse key or receipt rule turns main green on untested code, and main feeds the npm release. +**Owner:** CI tooling +**Depends on:** None. It builds on PRD-481 (tree reuse), PRD-730 (queue reuse) and PRD-731 (one board per queue entry). PRD-628 (dependency and build caches) is a separate layer and does not block this PRD. + +## Context + +Owner, 2026-10-31: "im tired of having to redo this whole shit over and over. please rethink the +architecture of the CI, see if we can use caching heavily to only run whats absolutely necessary, +mayeb split into smaller jobs too ... goal is to run this pipeline as quickyl ass posbile". + +Measured on the 0.3.7 promotion, head `813c0166a`, PR #595. Both runs are attempt 1: + +| Fact | Number | Source | +|---|---|---| +| Wall time of one full board | 125 min | CI run 41262008820 (pull_request to main) | +| Jobs per board, run minutes | 89 jobs, 742 min | same run | +| Minutes the jobs waited for a runner | 2,488 min (3.4x the work) | same run, `started_at - created_at` | +| Same tree, same time, second full board | 89 jobs, 706 min | CI run 41262005879 (push to develop) | +| Peak concurrent jobs, both boards | 19 (17 hosted Linux, 4 macOS, 2 Windows, 5 tn-local) | both runs; GitHub caps hosted jobs at about 20 | +| Longest wait | `template-engine-cost (rain)` waited 89 min, then ran 28 min | run 41262008820 | +| Longest legs | Android emulator parity 41 min, Windows desktop core 41 min, `template-nonvisual (shooter)` 35 min, `template-engine-cost` 28-30 min | same run | +| Cost of one red leg | a whole new board: 3 `--failed` attempts could not qualify | run 41262008820 attempt 3: `CI_INTEGRATION_RECEIPT_IDENTITY: required join has no exact current-attempt receipt`, `CI_NATIVE_RECEIPT_IDENTITY: missing exact current-attempt completion receipt` | + +Three causes, in order of cost: + +1. **Each tree is tested twice at once.** `findReusableRun` (`scripts/ci-change-scope.mjs:583`) + skips every develop push as a "warm run" (`isWarmRun`, `:569`), but develop pushes now run the + full qualification board. The promotion PR starts its own board at the same second, finds no + finished run of the tree, and runs everything again. Two boards share 20 hosted slots, so each + one waits for the other. +2. **One red leg costs a full board.** `scripts/ci-required.mjs:186-193` demands that the + integration join receipt carries the current `GITHUB_RUN_ATTEMPT`, and + `validateNativeReceipts` (`scripts/ci-native-qualification.mjs:223-232`) does the same for the + native join. A `gh run rerun --failed` does not rerun a green join, so its receipt keeps + attempt 1 and the verdict fails. The native release rehearsal has a second trap: attempt 1's + `cleanup-rehearsal` deletes the draft release that a partial rerun needs. +3. **Unchanged work runs again.** A qualification run (develop push, main) runs all 16 templates + twice (`template-nonvisual`, `template-engine-cost`), even when no input of a template changed. + Reuse today is all-or-nothing on the whole tree (PRD-481), so a one-line CI script edit reruns + about 700 runner-minutes of template, native and integration legs. + +Files inspected: `scripts/ci-change-scope.mjs` (`isWarmRun`, `findReusableRun`, `reuseOrKeep`), +`scripts/ci-required.mjs` (receipt identity), `scripts/ci-native-qualification.mjs` +(`validateNativeReceipts`, `nativeJobIdentity`), `scripts/ci-integration-scope.mjs` +(`validateIntegrationReceipts`, which already takes each job's latest attempt since 28a1ca590), +`scripts/ci-attempt-receipts.mjs`, `.github/workflows/ci.yml` (job graph), the open boxes of +PRD-481, PRD-550 and PRD-628. + +## Solution + +Move the unit of reuse from the whole tree to the single job, and make every verdict read the +latest attempt of each job. One board per tree, each leg once per input, and a retry costs only the +red leg. + +```mermaid +flowchart LR + P[push or PR event] --> S[scope: plan + input key per leg] + S -->|same tree has a finished or running board| W[wait for it, reuse its verdict] + S --> K{green receipt for this leg's key?} + K -->|yes| R[reuse receipt, leg skipped] + K -->|no or unknown input| X[run the leg, write receipt keyed by input] + R & X --> V[ci-required: every selected leg has a green receipt from its latest attempt] +``` + +1. **One board per tree (phase 1).** `findReusableRun` accepts a develop push run when its plan was + a full qualification board, and keeps the weaker-tier check that already exists. When a CI run + of the same tree is still in progress, the second run's scope job waits for it (bounded, then it + runs its own board) instead of starting a parallel board. +2. **Latest-attempt verdicts (phase 1).** `ci-required` and the native validator accept a join + receipt from the join job's own latest attempt, which can be older than the current attempt. + They still reject a receipt when the job ran again in a later attempt. This rule already exists + for the per-job integration receipts (`ci-integration-scope.mjs:418-440`). The native rehearsal + keeps its draft release until the run concludes green or is superseded, so a partial rerun + finds it. +3. **Per-leg input keys (phase 2).** The scope job computes one key per leg from git tree hashes of + the leg's declared input closure, plus `pnpm-lock.yaml`, the job's own workflow text, the + runner image and the toolchain versions. The PRD-483 impact map already names each template's + inputs, so the closure reuses that map. A green leg writes a receipt under its key. A later run + with the same key skips the leg and carries the receipt. A path that no closure claims makes the + key unknown, and an unknown key runs the leg (fail closed). +4. **Shorter critical path (phase 3).** Split the legs over 20 min into shards: shooter scenarios, + engine-cost rows, the Android emulator parity cases. Create the longest legs first, so they take + the first free slots. + +Risks: +- A missing input in a closure gives a false green. Mitigation: unknown paths run the leg. The + scheduled weekly audit already runs cold, and this PRD adds a key-coverage check to it: a cold + verdict that differs from a cached verdict for the same key fails the audit and names the key. +- The wait in item 1 can hold a runner slot. Mitigation: the wait runs in the scope job on the + light lane, with a time limit. +- Reuse across commits changes a written rule: root `AGENTS.md` says "A new candidate runs fresh + tests" and "Unsafe matrix/reusable verdict reuse stays disabled until authoritative complete + coverage and per-leg routing exist." Phase 2 supplies that coverage and edits the rule text in + the same commit. + +## Execution Phases + +#### Phase 1: one board per tree, and a red leg retries alone +**Status:** NOT STARTED +**Files:** `scripts/ci-change-scope.mjs` (develop push runs become reusable; wait for an in-flight +run of the same tree), `scripts/ci-required.mjs` and `scripts/ci-native-qualification.mjs` +(latest-attempt join receipts), `.github/workflows/native-release.yml` (rehearsal draft survives a +partial rerun), root `AGENTS.md` (the `--failed` sentence). +**Verification:** replay specs in `scripts/__tests__/` with recorded attempt listings from run +41262008820, then the two live runs below. + +- [ ] A red leg on a full board turns `ci-required` green with `gh run rerun --failed` alone, and the retry wall time is the red leg plus at most 10 min. proof: a CI run id with attempt 2 green and attempt 1 red. +- [ ] `ci-required` accepts a join receipt from the join's own latest attempt and rejects one when the join ran again later. proof: `pnpm exec vitest run scripts/__tests__/ci-required-attempts.spec.ts`, red on the current rule first. +- [ ] A promotion PR to main whose tree a develop push run already qualified runs no test leg and reports `reused` in at most 10 min. proof: CI run id of the promotion PR. + +#### Phase 2: each leg runs once per input +**Status:** NOT STARTED +**Files:** `scripts/ci-change-scope.mjs` (per-leg keys from the PRD-483 impact map), new +`scripts/ci-leg-receipts.mjs` (write and find a green receipt by key), `scripts/ci-required.mjs` +(accept a carried receipt), `.github/workflows/ci.yml` (each leg skips on a hit), root `AGENTS.md` +(the reuse rule). +**Verification:** key specs first, then live runs. + +- [ ] A changed file inside a leg's closure changes that leg's key, a file outside it does not, and a path no closure claims makes every key unknown. proof: `pnpm exec vitest run scripts/__tests__/ci-leg-keys.spec.ts`. +- [ ] A change to one CI script on develop runs no template leg, and a change inside one template runs only that template's legs. proof: two CI run ids and their job lists. +- [ ] `pnpm release:ship` refuses to tag a main commit whose exact tree has no cold green board (one with no carried receipt). proof: `pnpm exec vitest run scripts/__tests__/release-ship.spec.ts`, plus a dry `pnpm release:ship` on a carried-only commit that names the missing cold board. +- [ ] The weekly cold audit compares each cold verdict with the cached verdict of the same key and fails on a mismatch, naming the key. proof: one scheduled audit run id, plus a spec that feeds it a planted mismatch. + +#### Phase 3: no leg holds the board +**Status:** NOT STARTED +**Files:** `.github/workflows/ci.yml` (shard matrices, longest legs first), the shooter and +engine-cost scenario lists, `.github/workflows/native-platforms.yml` (Android parity shards). +**Verification:** one cold full board (no reuse) on a release-candidate head. + +- [ ] No CI leg runs longer than 20 min. proof: the job list of one cold full board. +- [ ] A cold full board, with nothing to reuse, finishes in at most 45 min wall (today 125 min). proof: CI run id. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| Tree reuse of develop push runs | `ci.yml` scope job → `reuseOrKeep` (`scripts/ci-change-scope.mjs`) | `isWarmRun` exclusion deleted for full-board push runs | Phase 1 box 3 | +| Latest-attempt join receipts | `ci-required` job → `scripts/ci-required.mjs`, `validateNativeReceipts` | current-attempt equality deleted | Phase 1 boxes 1-2 | +| Per-leg reuse | `ci.yml` scope job → per-leg key → each leg's `if:` | whole-tree-only reuse stays as the fast path | Phase 2 boxes 1-2 | +| Cold audit cross-check | scheduled `ci.yml` run | extends the existing scheduled audit | Phase 2 box 3 | + +## Decisions +- 2026-11-01 (owner): cut this list first, on `ci/prd-776-cuts` with no new PR: the standalone native evidence run, the engine-cost row, cold boot, the benchmark extras, the ARM frame comparison, long scenarios on six kits and the starter scenarios. All but the evidence run move to the nightly. The boxes below stay open: these cuts do not build the leg receipts. +- 2026-10-31 (owner, option 1): merges and develop and main qualification use carried leg receipts. Each release tag gets one cold board on its exact tree, with no reuse. Phase 3 brings that board to at most 45 min. Phase 2 makes `release:ship` refuse a tag whose tree has no cold green board. +- 2026-10-31 (owner): plan the CI rework for speed now, with heavy caching, minimal runs and smaller jobs. Planning only: no implementation until the owner starts it. +- 2026-10-31 (planner): leave PRD-628 (dependency and build caches) separate. It speeds up a leg that must run, and this PRD stops legs that need not run. They do not share files. +- 2026-10-31 (planner): do not add runner capacity in this PRD. The measured waits come from duplicate and unchanged work on about 20 hosted slots. Removing that work is free, and more slots cost money each month. diff --git a/examples/abyss-framework/playtests/replay.playtest.json b/examples/abyss-framework/playtests/replay.playtest.json index 2061d847ee..f39e697d7c 100644 --- a/examples/abyss-framework/playtests/replay.playtest.json +++ b/examples/abyss-framework/playtests/replay.playtest.json @@ -17,7 +17,7 @@ "world": { "runtime": { "agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36", - "core": "0.3.7", + "core": "0.3.8", "randomState": 90210, "rapier": null, "step": 0.016666666666666666 diff --git a/packages/animation-mcp/package.json b/packages/animation-mcp/package.json index 334f3ea9ca..c5f1928b23 100644 --- a/packages/animation-mcp/package.json +++ b/packages/animation-mcp/package.json @@ -1,6 +1,6 @@ { "name": "threenative-animation-mcp", - "version": "0.1.4", + "version": "0.1.5", "license": "MIT", "description": "Author, repair and verify character animation through one MCP: inspect rigs, attach props, edit and verify clips, and open a calibrator view.", "keywords": [ diff --git a/packages/assets/package.json b/packages/assets/package.json index b8c56bd8f0..134b3b1f97 100644 --- a/packages/assets/package.json +++ b/packages/assets/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/assets", - "version": "0.3.8", + "version": "0.3.9", "license": "MIT", "description": "Content-addressed asset compile step for ThreeNative games.", "keywords": ["threenative", "three", "threejs", "assets", "gltf", "asset-pipeline", "webgpu"], diff --git a/packages/blender-bridge/package.json b/packages/blender-bridge/package.json index 4083ca6ba3..83db6d47ba 100644 --- a/packages/blender-bridge/package.json +++ b/packages/blender-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/blender-bridge", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "description": "Runs an installed Blender headlessly so a downloaded .fbx becomes a GLB a ThreeNative game loads. Ships no Blender.", "keywords": ["threenative", "blender", "fbx", "gltf"], diff --git a/packages/core/mcp/engine-server.mjs b/packages/core/mcp/engine-server.mjs index 487ec0add3..32f1b09029 100755 --- a/packages/core/mcp/engine-server.mjs +++ b/packages/core/mcp/engine-server.mjs @@ -1171,7 +1171,7 @@ async function handleLine(line2, manifestFile) { capabilities: { tools: { listChanged: false } }, instructions: AUTHORING_INSTRUCTIONS, protocolVersion: "2025-06-18", - serverInfo: { name: "threenative-engine-mcp", version: "0.2.7" } + serverInfo: { name: "threenative-engine-mcp", version: "0.2.8" } }); } if (request.method === "tools/list") { diff --git a/packages/core/mcp/servers.mjs b/packages/core/mcp/servers.mjs index 43e122b3b5..f65c869aab 100644 --- a/packages/core/mcp/servers.mjs +++ b/packages/core/mcp/servers.mjs @@ -47,7 +47,7 @@ export const MCP_SERVERS = Object.freeze({ export const MCP_PACKAGES = Object.freeze({ assets: Object.freeze({ name: "threenative-asset-mcp", version: "0.15.0" }), sculpt: Object.freeze({ name: "threenative-sculpt-mcp", version: "0.2.2" }), - engine: Object.freeze({ name: "threenative-engine-mcp", version: "0.2.7" }), + engine: Object.freeze({ name: "threenative-engine-mcp", version: "0.2.8" }), animation: Object.freeze({ name: "threenative-animation-mcp", version: "0.1.3" }), // `args` fixes the launch for every host. The Chrome flags are `WEBGPU_BROWSER_ARGS` from // `@threenative/playtest`, so a DevTools trace profiles the same adapter a playtest does and not diff --git a/packages/core/package.json b/packages/core/package.json index 0d8faa5fe5..598aba1700 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/core", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT AND GPL-2.0-or-later", "description": "The small vanilla Three.js game runtime foundation.", "keywords": [ @@ -110,7 +110,7 @@ "@typescript/native": "catalog:" }, "peerDependencies": { - "@threenative/playtest": ">=0.3.7 <0.4.0", + "@threenative/playtest": ">=0.3.8 <0.4.0", "react": ">=19.2.0 <20", "react-reconciler": ">=0.33.0 <0.34" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dfb9de17d6..e5c3199577 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,7 +6,7 @@ * unavoidable here — core is bundled for browsers and cannot read `package.json` at runtime — so * the spec now asserts this equals the manifest instead of asserting a number somebody typed. */ -export const version = "0.3.7"; +export const version = "0.3.8"; /** * Play a skinned or sprite animation from game code. diff --git a/packages/core/src/version.ts b/packages/core/src/version.ts index 0acc3ebe50..49f5a05f66 100644 --- a/packages/core/src/version.ts +++ b/packages/core/src/version.ts @@ -1,2 +1,2 @@ // Generated by scripts/generate-version.mjs - do not edit. Source: package.json. -export const CORE_VERSION = "0.3.7"; +export const CORE_VERSION = "0.3.8"; diff --git a/packages/create-threenative/animation-mcp-tools.json b/packages/create-threenative/animation-mcp-tools.json index 3f0132d528..501e9d2f8e 100644 --- a/packages/create-threenative/animation-mcp-tools.json +++ b/packages/create-threenative/animation-mcp-tools.json @@ -1,6 +1,6 @@ { - "comment": "The animation MCP surface a generated project actually gets. `tools` is set-equal to the live tools/list response of version 0.1.4, packed from this workspace and installed from that tarball into a clean directory — never read from src/. `recommended` is the ordered loop the shipped agent docs describe. Regenerate with `pnpm tsx scripts/capture-mcp-tools.ts`.", - "version": "0.1.4", + "comment": "The animation MCP surface a generated project actually gets. `tools` is set-equal to the live tools/list response of version 0.1.5, packed from this workspace and installed from that tarball into a clean directory — never read from src/. `recommended` is the ordered loop the shipped agent docs describe. Regenerate with `pnpm tsx scripts/capture-mcp-tools.ts`.", + "version": "0.1.5", "serverInfo": { "name": "threenative-animation", "version": "0.1.3" diff --git a/packages/create-threenative/engine-mcp-tools.json b/packages/create-threenative/engine-mcp-tools.json index ec7822b56b..8936f4aaa1 100644 --- a/packages/create-threenative/engine-mcp-tools.json +++ b/packages/create-threenative/engine-mcp-tools.json @@ -1,6 +1,6 @@ { "comment": "The engine capability MCP surface shipped through @threenative/core. The golden path also calls a mechanically explicit cross-system request so a packaged consumer proves full-request discovery without repository source.", - "version": "0.2.7", + "version": "0.2.8", "tools": [ "engine_capability_detail", "engine_reference_detail", diff --git a/packages/create-threenative/package.json b/packages/create-threenative/package.json index 9bf005afe5..129ac24612 100644 --- a/packages/create-threenative/package.json +++ b/packages/create-threenative/package.json @@ -1,6 +1,6 @@ { "name": "create-threenative", - "version": "0.2.11", + "version": "0.2.12", "license": "MIT", "description": "Scaffold a readable ThreeNative game project.", "keywords": [ @@ -102,6 +102,6 @@ "@types/estree": "1.0.8" }, "peerDependencies": { - "@threenative/assets": ">=0.3.8 <0.4.0" + "@threenative/assets": ">=0.3.9 <0.4.0" } } diff --git a/packages/create-threenative/src/doctor.ts b/packages/create-threenative/src/doctor.ts index ec3360ec82..aa9d801db8 100644 --- a/packages/create-threenative/src/doctor.ts +++ b/packages/create-threenative/src/doctor.ts @@ -209,7 +209,7 @@ interface IMcpServerSpec { * resolves, so it must name core — and core's own version, which `doctor.spec.ts` holds equal to * `packages/core/package.json`. */ const CORE_PACKAGE_NAME = "@threenative/core"; -const CORE_PACKAGE_VERSION = "0.3.7"; +const CORE_PACKAGE_VERSION = "0.3.8"; const CORE_RESOLVED_SERVERS: ReadonlySet = new Set([ "threenative-engine", "threenative-animation", @@ -648,7 +648,7 @@ function probeMcpServer(projectRoot: string, spec: IMcpServerSpec): IMcpServerHe method: "initialize", params: { capabilities: {}, - clientInfo: { name: "threenative-doctor", version: "0.3.7" }, + clientInfo: { name: "threenative-doctor", version: "0.3.8" }, protocolVersion: "2025-06-18", }, }); diff --git a/packages/create-threenative/templates/action-rpg/package.json b/packages/create-threenative/templates/action-rpg/package.json index a9390f18f3..67e6023a0a 100644 --- a/packages/create-threenative/templates/action-rpg/package.json +++ b/packages/create-threenative/templates/action-rpg/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -29,13 +29,13 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/adventure/package.json b/packages/create-threenative/templates/adventure/package.json index e359c512fd..b4a6fe6e93 100644 --- a/packages/create-threenative/templates/adventure/package.json +++ b/packages/create-threenative/templates/adventure/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -30,11 +30,11 @@ "@tailwindcss/vite": "4.3.3", "@vitejs/plugin-react": "6.0.5", "@types/three": "0.185.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/collectathon/package.json b/packages/create-threenative/templates/collectathon/package.json index e359c512fd..b4a6fe6e93 100644 --- a/packages/create-threenative/templates/collectathon/package.json +++ b/packages/create-threenative/templates/collectathon/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -30,11 +30,11 @@ "@tailwindcss/vite": "4.3.3", "@vitejs/plugin-react": "6.0.5", "@types/three": "0.185.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/kart/package.json b/packages/create-threenative/templates/kart/package.json index 2071299745..11b547dc19 100644 --- a/packages/create-threenative/templates/kart/package.json +++ b/packages/create-threenative/templates/kart/package.json @@ -19,8 +19,8 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -29,11 +29,11 @@ "@tailwindcss/vite": "4.3.3", "@vitejs/plugin-react": "6.0.5", "@types/three": "0.185.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/minimal/package.json b/packages/create-threenative/templates/minimal/package.json index 78b9084776..c9c556ffa0 100644 --- a/packages/create-threenative/templates/minimal/package.json +++ b/packages/create-threenative/templates/minimal/package.json @@ -19,15 +19,15 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", "three": "0.185.1" }, "devDependencies": { - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/three": "0.185.3", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "typescript": "7.0.2", @@ -37,7 +37,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/platformer/package.json b/packages/create-threenative/templates/platformer/package.json index e359c512fd..b4a6fe6e93 100644 --- a/packages/create-threenative/templates/platformer/package.json +++ b/packages/create-threenative/templates/platformer/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -30,11 +30,11 @@ "@tailwindcss/vite": "4.3.3", "@vitejs/plugin-react": "6.0.5", "@types/three": "0.185.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/puzzle/package.json b/packages/create-threenative/templates/puzzle/package.json index 3e19305000..20539d7cb5 100644 --- a/packages/create-threenative/templates/puzzle/package.json +++ b/packages/create-threenative/templates/puzzle/package.json @@ -18,9 +18,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -28,13 +28,13 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/racing/package.json b/packages/create-threenative/templates/racing/package.json index 43d4d2dca1..9ae508ae6e 100644 --- a/packages/create-threenative/templates/racing/package.json +++ b/packages/create-threenative/templates/racing/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -29,13 +29,13 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/rain/package.json b/packages/create-threenative/templates/rain/package.json index 357ad88eff..7698306370 100644 --- a/packages/create-threenative/templates/rain/package.json +++ b/packages/create-threenative/templates/rain/package.json @@ -18,8 +18,8 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "react-reconciler": "0.33.0", @@ -27,13 +27,13 @@ }, "devDependencies": { "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/rts/package.json b/packages/create-threenative/templates/rts/package.json index 5542bf81d0..41bf90be98 100644 --- a/packages/create-threenative/templates/rts/package.json +++ b/packages/create-threenative/templates/rts/package.json @@ -19,8 +19,8 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -28,13 +28,13 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/runner/package.json b/packages/create-threenative/templates/runner/package.json index 3e19305000..20539d7cb5 100644 --- a/packages/create-threenative/templates/runner/package.json +++ b/packages/create-threenative/templates/runner/package.json @@ -18,9 +18,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -28,13 +28,13 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/sailing/package.json b/packages/create-threenative/templates/sailing/package.json index fe9e1212b3..7510655630 100644 --- a/packages/create-threenative/templates/sailing/package.json +++ b/packages/create-threenative/templates/sailing/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "react-reconciler": "0.33.0", @@ -29,13 +29,13 @@ }, "devDependencies": { "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -46,7 +46,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/shooter/package.json b/packages/create-threenative/templates/shooter/package.json index b81f4b6a85..9ca4087f7b 100644 --- a/packages/create-threenative/templates/shooter/package.json +++ b/packages/create-threenative/templates/shooter/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -30,12 +30,12 @@ "@tailwindcss/vite": "4.3.3", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "playwright": "1.62.1", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "tailwindcss": "4.3.3", "typescript": "7.0.2", @@ -45,7 +45,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/snow/package.json b/packages/create-threenative/templates/snow/package.json index c04648331c..0e254a5e4d 100644 --- a/packages/create-threenative/templates/snow/package.json +++ b/packages/create-threenative/templates/snow/package.json @@ -19,9 +19,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "react-reconciler": "0.33.0", @@ -29,13 +29,13 @@ }, "devDependencies": { "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -46,7 +46,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/starter/package.json b/packages/create-threenative/templates/starter/package.json index ad1f911e4d..df5aaa3378 100644 --- a/packages/create-threenative/templates/starter/package.json +++ b/packages/create-threenative/templates/starter/package.json @@ -20,9 +20,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "react-reconciler": "0.33.0", @@ -30,13 +30,13 @@ }, "devDependencies": { "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -47,7 +47,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/create-threenative/templates/tower-defense/package.json b/packages/create-threenative/templates/tower-defense/package.json index 3e19305000..20539d7cb5 100644 --- a/packages/create-threenative/templates/tower-defense/package.json +++ b/packages/create-threenative/templates/tower-defense/package.json @@ -18,9 +18,9 @@ "format:check": "biome format src" }, "dependencies": { - "@threenative/core": "0.3.7", - "@threenative/physics": "0.3.7", - "@threenative/ui": "0.3.7", + "@threenative/core": "0.3.8", + "@threenative/physics": "0.3.8", + "@threenative/ui": "0.3.8", "react": "19.2.0", "react-dom": "19.2.0", "three": "0.185.1" @@ -28,13 +28,13 @@ "devDependencies": { "@gltf-transform/cli": "4.4.2", "@tailwindcss/vite": "4.3.3", - "@threenative/assets": "0.3.8", - "@threenative/playtest": "0.3.7", + "@threenative/assets": "0.3.9", + "@threenative/playtest": "0.3.8", "@types/react": "19.2.0", "@types/react-dom": "19.2.0", "@types/three": "0.185.3", "@vitejs/plugin-react": "6.0.5", - "create-threenative": "0.2.11", + "create-threenative": "0.2.12", "esbuild": "0.27.0", "playwright": "1.62.1", "tailwindcss": "4.3.3", @@ -44,7 +44,7 @@ "eslint": "10.13.0" }, "optionalDependencies": { - "@threenative/runtime-native": "0.3.7" + "@threenative/runtime-native": "0.3.8" }, "overrides": { "sharp": ">=0.35.4", diff --git a/packages/engine-mcp/__tests__/server.spec.ts b/packages/engine-mcp/__tests__/server.spec.ts index 152727947f..99431a033f 100644 --- a/packages/engine-mcp/__tests__/server.spec.ts +++ b/packages/engine-mcp/__tests__/server.spec.ts @@ -32,7 +32,7 @@ describe("threenative-engine-mcp stdio contract", async () => { capabilities: { tools: { listChanged: false } }, instructions: expect.stringContaining("threenative-capabilities"), protocolVersion: "2025-06-18", - serverInfo: { name: "threenative-engine-mcp", version: "0.2.7" }, + serverInfo: { name: "threenative-engine-mcp", version: "0.2.8" }, }, }); }); diff --git a/packages/engine-mcp/package.json b/packages/engine-mcp/package.json index 818861be6d..72dcb27179 100644 --- a/packages/engine-mcp/package.json +++ b/packages/engine-mcp/package.json @@ -1,6 +1,6 @@ { "name": "threenative-engine-mcp", - "version": "0.2.7", + "version": "0.2.8", "license": "MIT", "description": "Offline MCP capability discovery for ThreeNative authoring agents.", "keywords": [ diff --git a/packages/engine-mcp/src/index.ts b/packages/engine-mcp/src/index.ts index 120427cdd0..d4d36414ce 100644 --- a/packages/engine-mcp/src/index.ts +++ b/packages/engine-mcp/src/index.ts @@ -1193,7 +1193,7 @@ export async function handleLine(line: string, manifestFile: string): Promise=0.3.7 <0.4.0", + "@threenative/core": ">=0.3.8 <0.4.0", "three": ">=0.185.0 <0.186.0" }, "scripts": { diff --git a/packages/playtest/__tests__/desktop-mailbox-lock.spec.ts b/packages/playtest/__tests__/desktop-mailbox-lock.spec.ts index 3dcc2df71f..5b26289062 100644 --- a/packages/playtest/__tests__/desktop-mailbox-lock.spec.ts +++ b/packages/playtest/__tests__/desktop-mailbox-lock.spec.ts @@ -1,10 +1,19 @@ import { beforeEach, expect, test, vi } from "vitest"; -const fs = vi.hoisted(() => ({ readFile: vi.fn(), rm: vi.fn() })); +const fs = vi.hoisted(() => ({ + mkdir: vi.fn(), + readFile: vi.fn(), + rename: vi.fn(), + rm: vi.fn(), + writeFile: vi.fn(), +})); vi.mock("node:fs/promises", async (importOriginal) => ({ ...(await importOriginal()), + mkdir: fs.mkdir, readFile: fs.readFile, + rename: fs.rename, rm: fs.rm, + writeFile: fs.writeFile, })); const { LocalDeviceMailbox } = await import("../src/runner/desktop.js"); @@ -14,6 +23,8 @@ const coded = (code: string) => Object.assign(new Error(code), { code }); beforeEach(() => { fs.readFile.mockReset(); fs.rm.mockReset(); + fs.rename.mockReset(); + fs.writeFile.mockReset(); }); // CI run 35818304207: the Windows starter HUD playtest died on EPERM lstat of @@ -40,3 +51,23 @@ test("removing the mailbox file surfaces a permanent error without retrying", as expect(fs.rm).toHaveBeenCalledTimes(1); expect(fs.rm).toHaveBeenCalledWith("response.json", { force: true }); }); + +// CI run 41348117470: the Windows startup sample died on a path-bearing error while every other +// mailbox operation already retried a lock. A scanner or a pending delete can hold the target. +test("writing a mailbox request retries a Windows lock on the rename", async () => { + fs.rename + .mockRejectedValueOnce(coded("EPERM")) + .mockRejectedValueOnce(coded("EBUSY")) + .mockResolvedValueOnce(undefined); + await new LocalDeviceMailbox().write("mailbox/request.json", "{}"); + expect(fs.rename).toHaveBeenCalledTimes(3); + expect(fs.rename).toHaveBeenLastCalledWith("mailbox/request.json.tmp", "mailbox/request.json"); +}); + +test("writing a mailbox request surfaces a permanent error without retrying", async () => { + fs.rename.mockRejectedValueOnce(coded("ENOSPC")); + await expect(new LocalDeviceMailbox().write("mailbox/request.json", "{}")).rejects.toMatchObject({ + code: "ENOSPC", + }); + expect(fs.rename).toHaveBeenCalledTimes(1); +}); diff --git a/packages/playtest/__tests__/frame-budget.spec.ts b/packages/playtest/__tests__/frame-budget.spec.ts index 61811494ae..84c8e5f364 100644 --- a/packages/playtest/__tests__/frame-budget.spec.ts +++ b/packages/playtest/__tests__/frame-budget.spec.ts @@ -101,10 +101,17 @@ describe("frame budget from the adapter", () => { }); it("refuses a looser number without a reason at load", () => { - expect(() => evaluate({ maxFrameMsP95: 33 }, frames(33.3, 33), RTX_2080)).toThrow(/maxFrameMsReason/u); + expect(() => evaluate({ maxFrameMsP95: 34 }, frames(33.3, 33), RTX_2080)).toThrow(/maxFrameMsReason/u); expect(() => evaluate({ maxFrameMsP95: 33, maxFrameMsReason: " " }, frames(33.3, 33), RTX_2080)).toThrow(/maxFrameMsReason/u); }); + it("keeps a game scaffolded before 0.3.7 passing, and the report says what its 33 ms pin hides", () => { + const { budget, frame } = evaluate({ maxFrameMsP95: 33 }, frames(30, 33), RTX_2080); + expect(budget).toMatchObject({ maxFrameMsP95: 33, source: "scenario" }); + expect((budget as { reason: string }).reason).toContain("passes at 30 fps"); + expect(frame?.pass).toBe(true); + }); + it("accepts a tighter number without a reason", () => { expect(() => evaluate({ maxFrameMsP95: 12 }, frames(16.7, 4), RTX_2080)).not.toThrow(); }); diff --git a/packages/playtest/package.json b/packages/playtest/package.json index 94043934d2..96b1c516bd 100644 --- a/packages/playtest/package.json +++ b/packages/playtest/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/playtest", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT", "description": "Scenario-driven playtest harness for Three.js games. Runs on plain Three.js with zero ThreeNative dependencies.", "keywords": [ diff --git a/packages/playtest/src/runner/desktop.ts b/packages/playtest/src/runner/desktop.ts index 7087196944..27ba103bef 100644 --- a/packages/playtest/src/runner/desktop.ts +++ b/packages/playtest/src/runner/desktop.ts @@ -46,22 +46,16 @@ export class LocalDeviceMailbox { async remove(path: string): Promise { // Node retries EPERM/EBUSY only with recursive mode, which could remove a directory here. - for (let retry = 0; ; retry += 1) { - try { - await rm(path, { force: true }); - return; - } catch (error) { - if (retry >= 10 || !isTransientMailboxLock(error)) throw error; - await delay(20); - } - } + await retryMailboxLock(() => rm(path, { force: true })); } async write(path: string, contents: string): Promise { await mkdir(dirname(path), { recursive: true }); const temporary = `${path}.tmp`; - await writeFile(temporary, contents, "utf8"); - await rename(temporary, path); + await retryMailboxLock(async () => { + await writeFile(temporary, contents, "utf8"); + await rename(temporary, path); + }); } } @@ -357,6 +351,19 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } +/** Windows holds a file through a scan or a pending delete. Two seconds, then the error is real. */ +async function retryMailboxLock(operation: () => Promise): Promise { + for (let retry = 0; ; retry += 1) { + try { + await operation(); + return; + } catch (error) { + if (retry >= 100 || !isTransientMailboxLock(error)) throw error; + await delay(20); + } + } +} + function isTransientMailboxLock(error: unknown): boolean { const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code diff --git a/packages/playtest/src/scenario/schema-accessors.ts b/packages/playtest/src/scenario/schema-accessors.ts index d2b7af62e7..f93f79d06d 100644 --- a/packages/playtest/src/scenario/schema-accessors.ts +++ b/packages/playtest/src/scenario/schema-accessors.ts @@ -302,6 +302,10 @@ export function validateDeviceMetricsAssertion(value: unknown, scenarioPath: str return assertion; } +const LEGACY_SCAFFOLD_FRAME_MS = 33; +const LEGACY_SCAFFOLD_FRAME_REASON = + "Scaffolded before 0.3.7 with a 33 ms pin, which passes at 30 fps. Drop maxFrameMsP95 to hold this game to 16.7 ms."; + export function validatePerformanceAssertion(value: unknown, scenarioPath: string, objectPath: string): IPlaytestPerformanceAssertion { const record = requireRecord(value, scenarioPath, objectPath); rejectUnknownKeys(record, ["maxDrawCalls", "maxFrameMsP95", "maxFrameMsReason", "maxPassDrawCalls", "maxPassTriangles", "maxPhaseMsP95", "maxTriangles", "minFps"], scenarioPath, objectPath); @@ -315,6 +319,11 @@ export function validatePerformanceAssertion(value: unknown, scenarioPath: strin ...present("maxTriangles", optionalNonNegativeNumber(record, "maxTriangles", scenarioPath, objectPath)), ...present("minFps", optionalNonNegativeNumber(record, "minFps", scenarioPath, objectPath)), }; + // Every scaffold up to 0.3.6 pins exactly this number with no reason. Those games keep passing + // after an upgrade, and the report's budget reason says what the pin hides. + if (assertion.maxFrameMsP95 === LEGACY_SCAFFOLD_FRAME_MS && record.maxFrameMsReason === undefined) { + return { ...assertion, maxFrameMsReason: LEGACY_SCAFFOLD_FRAME_REASON }; + } if (assertion.maxFrameMsP95 !== undefined && assertion.maxFrameMsP95 > 16.7 && (assertion.maxFrameMsReason ?? "").trim() === "") { throw invalidScenario( scenarioPath, diff --git a/packages/procedural-animals/package.json b/packages/procedural-animals/package.json index 53a361207f..555fbdc0ca 100644 --- a/packages/procedural-animals/package.json +++ b/packages/procedural-animals/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/procedural-animals", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "description": "Optional, bake-first procedural animal content for ThreeNative.", "homepage": "https://github.com/ThreeNativeHQ/threenative#readme", diff --git a/packages/raw-unreal/package.json b/packages/raw-unreal/package.json index 1249ef1feb..713f1a6860 100644 --- a/packages/raw-unreal/package.json +++ b/packages/raw-unreal/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/raw-unreal", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "description": "Loads raw Unreal editor .uasset static meshes directly into Three.js geometry \u2014 UE5 FMeshDescription and UE4.18 FRawMesh layouts, with no interchange conversion.", "keywords": ["threenative", "unreal", "uasset", "static-mesh", "three", "threejs", "loader"], diff --git a/packages/runtime-native/package.json b/packages/runtime-native/package.json index b8854d4fde..cd9ca62ba2 100644 --- a/packages/runtime-native/package.json +++ b/packages/runtime-native/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/runtime-native", - "version": "0.3.7", + "version": "0.3.8", "description": "ThreeNative-owned native host for desktop and Android", "keywords": [ "threenative", diff --git a/packages/runtime-native/scripts/profile-production.mjs b/packages/runtime-native/scripts/profile-production.mjs index 6a5dd1bf42..bb10e86eb8 100644 --- a/packages/runtime-native/scripts/profile-production.mjs +++ b/packages/runtime-native/scripts/profile-production.mjs @@ -3,7 +3,7 @@ import { createServer } from 'node:http'; import { execFile, execFileSync, spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; @@ -861,7 +861,7 @@ async function runDesktopBridgeScenario(project, scenarioPath, artifactDirectory const runner = await import(pathToFileURL(modulePath).href); const mailbox = new runner.LocalDeviceMailbox(); const innerTransport = new runner.DeviceMailboxTransport(mailbox, { request: requestPath, response: responsePath }); - const driver = createDesktopDriver(artifactPath, project, options, mailboxRoot); + const driver = createDesktopDriver(artifactPath, project, options, mailboxRoot, mailbox); const transport = { capabilities: innerTransport.capabilities, call: innerTransport.call.bind(innerTransport), @@ -916,6 +916,9 @@ export function desktopFailureRun(error, consoleOutput, elapsedMs, cleanupError try { return sanitizeManifest(value); } catch { return fallback; } }; const diagnostic = cleanupError?.diagnostic ?? error?.diagnostic; + // The code and the syscall name carry no path, so a redacted failure can still name its cause. + const cause = [/^(?:E[A-Z]{2,}|TN_[A-Z0-9_]+)\b/u.exec(error?.message ?? '')?.[0], /^[a-z]+$/u.exec(error?.syscall ?? '')?.[0]] + .filter(Boolean).join(' '); return { elapsedMs, status: 2, screenshot: undefined, series: undefined, report: { @@ -923,7 +926,7 @@ export function desktopFailureRun(error, consoleOutput, elapsedMs, cleanupError diagnostics: [retain(diagnostic ?? { code: 'TN_PROD_PLAYTEST_FAILED', severity: 'error', message: error instanceof Error ? error.message : String(error), - }, { code: 'TN_PROD_REDACTION', severity: 'error', message: 'Unsafe native error details withheld.' })], + }, { code: 'TN_PROD_REDACTION', severity: 'error', message: `Unsafe native error details withheld${cause ? ` (${cause})` : ''}.` })], observations: { console: consoleOutput.map((entry) => retain(entry, { type: 'error', text: 'TN_PROD_REDACTION: unsafe native console entry withheld.', })) }, @@ -961,7 +964,7 @@ async function readResolutionScaleSetting(project, options) { return rendererResolutionScaleSetting(config, options.target); } -function createDesktopDriver(artifactPath, project, options, mailboxRoot) { +function createDesktopDriver(artifactPath, project, options, mailboxRoot, mailbox) { let child; let output = ''; let stopping; @@ -982,12 +985,16 @@ function createDesktopDriver(artifactPath, project, options, mailboxRoot) { readFile: async (path) => readFile(path, 'utf8').catch((error) => error?.code === 'ENOENT' ? undefined : Promise.reject(error)), removeFile: async (path) => rm(path, { force: true }).catch(() => undefined), screenshot: async (path) => { - const temporary = `${screenshotRequestPath}.tmp`; - await writeFile(temporary, path, 'utf8'); - await rename(temporary, screenshotRequestPath); + await mailbox.write(screenshotRequestPath, path); const deadline = Date.now() + DESKTOP_SCREENSHOT_TIMEOUT_MS; while (Date.now() < deadline) { if (await nonBlankPng(path)) return; + // The host consumed the one request, so a blank capture stays blank until asked again. A frame read while + // pipelines still compile can be all zero (hosted Metal, run 41356903254). + if (await blankPng(path)) { + await rm(path, { force: true }).catch(() => undefined); + await mailbox.write(screenshotRequestPath, path); + } await new Promise((resolve) => setTimeout(resolve, 25)); } throw new Error('TN_PROD_NATIVE_SCREENSHOT_UNAVAILABLE'); @@ -2458,6 +2465,16 @@ async function nonBlankPng(path) { } } +async function blankPng(path) { + try { + const value = await readFile(path); + PNG.sync.read(value); + return !isNonBlankFrame(value); + } catch { + return false; + } +} + function isNonBlankFrame(value) { if (!Buffer.isBuffer(value)) return false; try { diff --git a/packages/runtime-native/tests/native-platform-workflow.test.mjs b/packages/runtime-native/tests/native-platform-workflow.test.mjs index 62a51a156e..850425d962 100644 --- a/packages/runtime-native/tests/native-platform-workflow.test.mjs +++ b/packages/runtime-native/tests/native-platform-workflow.test.mjs @@ -83,9 +83,8 @@ test('green native platform lane is required by primary CI', () => { expect(nativeJob).toContain('uses: ./.github/workflows/native-platforms.yml'); expect(nativeJob).not.toMatch(/^\s+continue-on-error:/mu); expect(workflow).toContain('workflow_call:'); - // PRD-629: the only push trigger is main, so the release candidate gets a standalone run. - expect(workflow).not.toMatch(/\n\x20{2}(?:pull_request|schedule):/u); - expect(workflow).toMatch(/\n\x20{2}push:\n\x20{4}branches: \[main\]\n/u); + // The release candidate reads its reports from the main CI run, so nothing starts this alone. + expect(workflow).not.toMatch(/\n\x20{2}(?:pull_request|schedule|push):/u); expect(ciWorkflow).toContain( "cancel-in-progress: ${{ github.event_name == 'pull_request' }}", ); @@ -389,6 +388,11 @@ test('iOS consumer launches the bundle identifier produced by its packager', () expect(workflow).toContain('report="$app.json"'); expect(workflow).toContain('--bundle-id "$bundle_id"'); expect(workflow).not.toContain('--bundle-id dev.threenative.runtime'); + // The release consumer hit this same mismatch on every tag run: the simulator never listed the id. + const release = readFileSync(new URL('../../../.github/workflows/native-release.yml', import.meta.url), 'utf8'); + expect(release).toContain('echo "IOS_BUNDLE_ID=$bundle_id" >> "$GITHUB_ENV"'); + expect(release).toContain('--bundle-id "$IOS_BUNDLE_ID"'); + expect(release).not.toContain('--bundle-id dev.threenative.runtime'); }); test('iOS builds selected React UI from installed packages and checks full-screen pixels', () => { diff --git a/packages/runtime-native/tests/production-profile.test.mjs b/packages/runtime-native/tests/production-profile.test.mjs index abd8aa4f06..563688a749 100644 --- a/packages/runtime-native/tests/production-profile.test.mjs +++ b/packages/runtime-native/tests/production-profile.test.mjs @@ -73,6 +73,14 @@ test('desktop runner exceptions retain failed evidence rather than disappearing' const unsafe = desktopFailureRun(error, [{ text: '/home/operator/private/build', type: 'log' }], 123); assert.equal(JSON.stringify(unsafe.report).includes('/home/'), false); assert.match(unsafe.report.observations.console[0].text, /TN_PROD_REDACTION/u); + // Run 41348117470 could not name its failure: redaction must drop the path and keep the cause. + const locked = desktopFailureRun( + Object.assign(new Error("EPERM: operation not permitted, rename 'D:\\a\\request.tmp' -> 'D:\\a\\request'"), { syscall: 'rename' }), + [], + 123, + ); + assert.match(locked.report.diagnostics[0].message, /withheld \(EPERM rename\)/u); + assert.equal(JSON.stringify(locked.report).includes('request.tmp'), false); const source = readFileSync(new URL('../scripts/profile-production.mjs', import.meta.url), 'utf8'); assert.match(source, /return desktopFailureRun\(error, await driver.captureConsole\(\),/u); }); @@ -220,6 +228,39 @@ test('native report retention redacts unsafe host console paths without dropping assert.equal(report.observations.console[1].text, 'TN_NATIVE_SMOKE_READY:webgpu'); }); +test('a blank desktop capture asks for the next frame instead of waiting out the deadline on a consumed request', async () => { + // Hosted macOS, run 41356903254: the host took the one request, read an all-zero frame while pipelines still compiled, + // and drew 5 more seconds of good frames that nobody asked to capture, so the cold start failed for a healthy game. + const source = readFileSync(new URL('../scripts/profile-production.mjs', import.meta.url), 'utf8'); + const driverSource = sliceSource(source, 'function createDesktopDriver(', 'export async function installNativeProfileEntry('); + let capture = 'none'; + const writes = []; + const removed = []; + const context = { + join, + process: { platform: 'linux', env: {} }, + blankPng: async () => capture === 'blank', + nonBlankPng: async () => capture === 'frame', + rm: async (path) => { + removed.push(path); + capture = 'none'; + }, + setTimeout, + DESKTOP_SCREENSHOT_TIMEOUT_MS: 500, + }; + runInNewContext(driverSource, context); + const mailbox = { + write: async (path) => { + writes.push(path); + capture = writes.length === 1 ? 'blank' : 'frame'; + }, + }; + const driver = context.createDesktopDriver('/fixture/mystral', '/fixture/scaffold', {}, '/fixture/mailbox', mailbox); + await driver.screenshot('/fixture/capture.png'); + assert.equal(writes.length, 2); + assert.deepEqual(removed, ['/fixture/capture.png']); +}); + test('desktop child receives the transport mailbox root and writes a raw post-present screenshot request', async () => { const source = readFileSync(new URL('../scripts/profile-production.mjs', import.meta.url), 'utf8'); const driverSource = sliceSource(source, 'function createDesktopDriver(', 'export async function installNativeProfileEntry('); @@ -239,23 +280,19 @@ test('desktop child receives the transport mailbox root and writes a raw post-pr queueMicrotask(() => child.emit('spawn')); return child; }, - writeFile: async (path, contents) => writes.push({ contents, path }), - rename: async (from, to) => writes.push({ from, to }), nonBlankPng: async () => true, DESKTOP_SCREENSHOT_TIMEOUT_MS: 100, }; runInNewContext(driverSource, context); - const driver = context.createDesktopDriver('/fixture/mystral', project, { renderSize: { width: 1920, height: 1080 } }, mailboxRoot); + const mailbox = { write: async (path, contents) => writes.push({ contents, path }) }; + const driver = context.createDesktopDriver('/fixture/mystral', project, { renderSize: { width: 1920, height: 1080 } }, mailboxRoot, mailbox); await driver.launch(); assert.equal(childOptions.env.TN_PLAYTEST_MAILBOX_ROOT, mailboxRoot); assert.equal(childOptions.cwd, project); assert.deepEqual(Array.from(childArgs), ['--width', '1920', '--height', '1080', '--headless']); await driver.screenshot('/fixture/capture.png'); - assert.deepEqual(writes, [ - { contents: '/fixture/capture.png', path: `${screenshotRequestPath}.tmp` }, - { from: `${screenshotRequestPath}.tmp`, to: screenshotRequestPath }, - ]); - assert.match(source, /const driver = createDesktopDriver\(artifactPath, project, options, mailboxRoot\)/u); + assert.deepEqual(writes, [{ contents: '/fixture/capture.png', path: screenshotRequestPath }]); + assert.match(source, /const driver = createDesktopDriver\(artifactPath, project, options, mailboxRoot, mailbox\)/u); }); test('production desktop mailbox uses atomic request writes', () => { @@ -1998,7 +2035,7 @@ test('desktop screenshot evidence uses the host post-present mailbox protocol', assert.doesNotMatch(profile, /runNativeScreenshot/u); assert.doesNotMatch(profile, /spawnNative\([^\n]+,\s*true\)/u); assert.match(profile, /tn-playtest-screenshot-request\.txt/u); - assert.match(profile, /writeFile\(temporary, path, 'utf8'\)/u); + assert.match(profile, /mailbox\.write\(screenshotRequestPath, path\)/u); assert.match(profile, /rm\(join\(root, 'tn-playtest-screenshot-request\.txt'\), \{ force: true \}\)/u); assert.match(profile, /screenshot: async \(path\)/u); assert.doesNotMatch(profile, /tn-production-screenshot-request\.json|nativeHost\?\.playtest\?\.receive|captureScreenshot/u); diff --git a/packages/runtime-native/tests/starter-desktop.test.mjs b/packages/runtime-native/tests/starter-desktop.test.mjs index d962e9be9b..66bc41f408 100644 --- a/packages/runtime-native/tests/starter-desktop.test.mjs +++ b/packages/runtime-native/tests/starter-desktop.test.mjs @@ -384,9 +384,8 @@ test('primary CI invokes the native lane on pull requests and pushes', () => { const native = readFileSync('../../.github/workflows/native-platforms.yml', 'utf8'); const nativeTriggers = native.slice(native.indexOf('\non:'), native.indexOf('concurrency:')); assert.match(nativeTriggers, /workflow_call:/u); - assert.doesNotMatch(nativeTriggers, /(?:pull_request|schedule):/u); - // A push to main starts the run the release-candidate gate needs (PRD-629); no other branch does. - assert.match(nativeTriggers, /push:\s*\n\s*branches:\s*\[main\]/u); + // ci.yml owns when this lane runs. The release candidate reads its reports from that CI run. + assert.doesNotMatch(nativeTriggers, /(?:pull_request|push|schedule):/u); }); test('native workflow verifies a freshly scaffolded starter on Linux', () => { diff --git a/packages/terrain/package.json b/packages/terrain/package.json index 85f7656cfd..3c59522f5e 100644 --- a/packages/terrain/package.json +++ b/packages/terrain/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/terrain", - "version": "0.1.3", + "version": "0.1.4", "license": "MIT", "description": "Headless terrain authoring \u2014 noise, erosion, sculpting, roads, rivers, material splats and scatter \u2014 evaluated into ordinary typed arrays and indexed Three.js geometry.", "keywords": ["threenative", "terrain", "heightfield", "three", "threejs", "procedural", "webgpu"], diff --git a/packages/three-native/__tests__/browser-renderer.spec.ts b/packages/three-native/__tests__/browser-renderer.spec.ts index 45eac31e8e..e42be1e7ee 100644 --- a/packages/three-native/__tests__/browser-renderer.spec.ts +++ b/packages/three-native/__tests__/browser-renderer.spec.ts @@ -620,6 +620,28 @@ describe("the Wasm engine's WebGPURenderer sample count", () => { errors.mockRestore(); }); + it("splits an overlay draw on the overlay's own counts, so its main pass is never negative", async () => { + // Field 0 answers for the renderer that drew last (the overlay's few draws) and field 27 for the world's renderer + // (its shadow pass), so a loading cover over a shadowed world read 2 - 15 = -13 main draws, and one negative pass + // made a playtest throw away the whole render sample series. + const { Renderer, canvas, scene, camera } = host((field) => + field === 0 ? 2 : field === 1 ? 6 : field === 27 ? 15 : 0, + ); + const renderer = new Renderer({ canvas }) as { + info: { engine: { frameSplit: Record } }; + init(): Promise; + renderOverlay(scene: unknown, camera: unknown): void; + }; + await renderer.init(); + renderer.renderOverlay(scene, camera); + expect(renderer.info.engine.frameSplit).toEqual({ + mainDraws: 2, + mainTriangles: 6, + shadowDraws: 0, + shadowTriangles: 0, + }); + }); + it("draws renderOverlay as the host's overlay, so the canvas layer skips the world's post and temporal state", async () => { // Through render(), core's loading screen ran the world renderer's post chain and temporal step: the world // came back ghosted and blurred after the cover lifted (Lantern Vale, Hop Garden). diff --git a/packages/three-native/package.json b/packages/three-native/package.json index 1a748cd00a..800802da4d 100644 --- a/packages/three-native/package.json +++ b/packages/three-native/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/three-native", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT", "description": "The native engine's machine-readable compatibility catalog and the generators that adapt it to TypeScript declarations and the C binding ABI.", "keywords": ["threenative", "three", "native", "abi", "gamedev"], diff --git a/packages/three-native/src/browser-renderer.ts b/packages/three-native/src/browser-renderer.ts index 90a88285ca..25641726aa 100644 --- a/packages/three-native/src/browser-renderer.ts +++ b/packages/three-native/src/browser-renderer.ts @@ -625,7 +625,8 @@ export function defineWebRenderer( shadows.keptMaps = module._tnw_web_frame(40); this.info.engine.uniformBytes = module._tnw_web_frame(41); const split = this.info.engine.frameSplit; - split.shadowDraws = shadows.shadowDraws; + // Field 0 answers for the renderer that drew last and field 27 for the world's: an overlay has no shadow pass of its own. + split.shadowDraws = this.#overlay ? 0 : shadows.shadowDraws; split.shadowTriangles = module._tnw_web_frame(85); split.mainDraws = this.info.render.frameCalls - split.shadowDraws; split.mainTriangles = lastTriangles - split.shadowTriangles; diff --git a/packages/ueformat/package.json b/packages/ueformat/package.json index 4cd9b25b43..fa243d4c75 100644 --- a/packages/ueformat/package.json +++ b/packages/ueformat/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/ueformat", - "version": "0.1.7", + "version": "0.1.8", "license": "MIT", "description": "UEFormat v10 .uemodel parser and Three.js loader for meshes exported from Unreal packages by CUE4Parse/FModel.", "keywords": ["threenative", "unreal", "ueformat", "uemodel", "three", "threejs", "loader"], diff --git a/packages/ui/package.json b/packages/ui/package.json index 7ab48b6482..484084f101 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@threenative/ui", - "version": "0.3.7", + "version": "0.3.8", "license": "MIT", "description": "React bindings for mounting a ThreeNative game and reading throttled state.", "keywords": ["threenative", "react", "tailwind", "hud", "three", "threejs", "gamedev"], @@ -30,7 +30,7 @@ }, "sideEffects": false, "peerDependencies": { - "@threenative/core": ">=0.3.7 <0.4.0", + "@threenative/core": ">=0.3.8 <0.4.0", "react": ">=19.2.0 <20", "react-dom": ">=19.2.0 <20" }, diff --git a/scripts/__tests__/ci-efficiency.spec.ts b/scripts/__tests__/ci-efficiency.spec.ts index 2bbf1731ae..d340959102 100644 --- a/scripts/__tests__/ci-efficiency.spec.ts +++ b/scripts/__tests__/ci-efficiency.spec.ts @@ -561,7 +561,10 @@ describe("PRD-373 fail-closed required verdict", () => { // When the plan exempts it the job is skipped and ci-required.mjs treats an exempt skip as a // pass, so the merge waits only for the cases the classifier could not clear. expect(declaredNeeds(job("ci-required"))).toContain("native-platforms"); - const requiredCoverage = coverage.filter((name) => jobs[name]?.required); + // Only the nightly's plan requires the engine-cost row, and the verdict needs it there too. + const requiredCoverage = coverage.filter( + (name) => jobs[name]?.required || name === "template-engine-cost", + ); expect( declaredNeeds(job("ci-required")) .filter((name) => name !== "scope") diff --git a/scripts/__tests__/ci-integration-scope.spec.ts b/scripts/__tests__/ci-integration-scope.spec.ts index daf5a91370..630ef27269 100644 --- a/scripts/__tests__/ci-integration-scope.spec.ts +++ b/scripts/__tests__/ci-integration-scope.spec.ts @@ -140,7 +140,6 @@ describe("integration work applies to the changed source", () => { "fluid-particles": true, "fluid-native": true, exposure: true, - "cold-boot": true, }); }); it("retains both native integration consumers for native host changes", () => { @@ -149,7 +148,6 @@ describe("integration work applies to the changed source", () => { decals: true, "fluid-native": true, exposure: true, - "cold-boot": true, }); }); it("retains shared playtest and workspace action consumers", () => { @@ -162,14 +160,12 @@ describe("integration work applies to the changed source", () => { "fluid-particles": true, "fluid-native": true, exposure: true, - "cold-boot": true, }); expect(select(workflow, [".github/actions/workspace-dist/action.yml"])).toEqual({ ...none, decals: true, "fluid-native": true, exposure: true, - "cold-boot": true, }); expect(select(workflow, [".github/actions/playwright-chromium/action.yml"])).toEqual({ ...none, @@ -178,7 +174,6 @@ describe("integration work applies to the changed source", () => { tone: true, "fluid-particles": true, exposure: true, - "cold-boot": true, }); }); it("retains every rendering consumer of the shared display harness", () => { @@ -188,7 +183,6 @@ describe("integration work applies to the changed source", () => { tone: true, "fluid-particles": true, exposure: true, - "cold-boot": true, }); }); it("retains exposure coverage for each canonical or generated exposure module", () => { @@ -343,6 +337,41 @@ describe("integration exact Git source selection", () => { expect(inventory.jobs).toContain("fluid-collision-native"); } expect(preflight.jobs).toEqual(["csg"]); + // A push runs cold boot only when its own diff reaches exposure code; the nightly always does. + const pushPlan = selectionPlan("full", "push", [], head, true, 0, "main", true); + const inventory = (eventName: string) => + integrationCandidatePreflight({ plan: pushPlan, eventName, target: "main" }).lanes; + expect(inventory("push")["cold-boot"]).toBe(false); + expect(inventory("workflow_dispatch")["cold-boot"]).toBe(true); + mkdirSync(path.join(root, "packages/core/src/look"), { recursive: true }); + writeFileSync(path.join(root, "packages/core/src/look/exposure-graph.ts"), ""); + git("add", "."); + git("commit", "-qm", "exposure"); + const exposureHead = git("rev-parse", "HEAD"); + expect( + integrationCandidatePreflight({ + plan: selectionPlan("full", "push", [], exposureHead, true, 0, "main", true), + eventName: "push", + target: "main", + }).lanes["cold-boot"], + ).toBe(true); + // Every integration leg writes its receipt from a one-commit checkout with no parent to read. + const shallow = `${root}-shallow`; + git("clone", "-q", "--depth", "1", `file://${root}`, shallow); + process.chdir(shallow); + try { + expect( + integrationCandidatePreflight({ + plan: selectionPlan("full", "push", [], exposureHead, true, 0, "main", true), + eventName: "push", + target: "main", + }).lanes["cold-boot"], + ).toBe(true); + } finally { + process.chdir(root); + rmSync(shallow, { recursive: true, force: true }); + } + git("checkout", "-q", head); const reviewPlan = selectionPlan( "ci", "review exemption", @@ -417,7 +446,6 @@ describe("integration exact Git source selection", () => { "fluid-native": true, "fluid-particles": true, exposure: true, - "cold-boot": true, }); git("rm", "scripts/verify-fluid-collision.ts"); git("commit", "-qm", "delete browser verifier"); @@ -446,7 +474,7 @@ describe("merged world capture lane relevance", () => { expect(select(workflow, [file])).toEqual({ ...none, "world-capture": true, - ...(file.startsWith("packages/core/src/") ? { exposure: true, "cold-boot": true } : {}), + ...(file.startsWith("packages/core/src/") ? { exposure: true } : {}), }); } }); diff --git a/scripts/__tests__/ci-native-chain.spec.ts b/scripts/__tests__/ci-native-chain.spec.ts index 2c8a2f81d1..06434cc44c 100644 --- a/scripts/__tests__/ci-native-chain.spec.ts +++ b/scripts/__tests__/ci-native-chain.spec.ts @@ -265,7 +265,27 @@ describe("actual native receipt writer, collector and protected verdict", () => f.publish(); const scheduledVerdict = () => f.run("ci-required.mjs", [], { - TN_CI_NEEDS: JSON.stringify(needs), + TN_CI_NEEDS: JSON.stringify({ + ...needs, + scope: { + result: "success", + outputs: { + plan: JSON.stringify( + selectionPlan( + "full", + "CPU qualification", + [], + f.sha, + true, + 0, + "develop", + true, + true, + ), + ), + }, + }, + }), TN_CI_NATIVE_RECEIPT: summary, TN_CI_EVENT: "schedule", TN_CI_HEAD_SHA: "", diff --git a/scripts/__tests__/ci-needs.spec.ts b/scripts/__tests__/ci-needs.spec.ts index 5cfd7414c4..5b41d2873c 100644 --- a/scripts/__tests__/ci-needs.spec.ts +++ b/scripts/__tests__/ci-needs.spec.ts @@ -968,7 +968,11 @@ describe("a develop push qualifies its tree and still warms the caches every pul .filter(([, job]) => job.required) .map(([name]) => name) .sort(), - ).toEqual(Object.keys(jobs).sort()); + ).toEqual( + Object.keys(jobs) + .filter((name) => name !== "template-engine-cost") + .sort(), + ); expect(plan.nativeTier).toBe("full"); }); diff --git a/scripts/__tests__/ci-qualification.spec.ts b/scripts/__tests__/ci-qualification.spec.ts index a6ea2decf8..9ccbec9159 100644 --- a/scripts/__tests__/ci-qualification.spec.ts +++ b/scripts/__tests__/ci-qualification.spec.ts @@ -129,8 +129,11 @@ describe("exact-candidate qualification minimum", () => { expect(plan.templateMatrix.template).toEqual(TEMPLATE_NAMES); expect(plan.goldenMatrix.template).toEqual(["starter", "platformer"]); expect(plan.unitMatrix.shard).toEqual(["1/4", "2/4", "3/4", "4/4"]); + // Every job but the engine-cost row, which only the nightly owes. + const { "template-engine-cost": engineCost, ...others } = plan.jobs; + expect(engineCost.required).toBe(false); expect( - Object.values(plan.jobs).every((job: unknown) => (job as { required: boolean }).required), + Object.values(others).every((job: unknown) => (job as { required: boolean }).required), ).toBe(true); expect(validatePlan(plan)).toEqual(plan); } finally { @@ -434,3 +437,27 @@ describe("PRD-731 the review lane", () => { expect(coverageMiss(view(pull), { profile: queue, jobs: [] })).not.toContain("lane"); }); }); + +describe("the nightly owns the engine-cost row", () => { + const sha = "a".repeat(40); + const plan = (nightly: boolean) => + selectionPlan("full", "fixture", [], sha, true, 0, "", true, nightly); + it("requires the row on a scheduled run and on nothing else", () => { + expect(plan(true).jobs["template-engine-cost"].required).toBe(true); + expect(plan(false).jobs["template-engine-cost"].required).toBe(false); + expect(validateEventPlan(plan(true), { eventName: "schedule" }).nightly).toBe(true); + expect(validateEventPlan(plan(false), { eventName: "push", baseRef: "main" }).nightly).toBe( + false, + ); + }); + it("refuses a scheduled run that does not owe the row", () => { + expect(() => validateEventPlan(plan(false), { eventName: "schedule" })).toThrow( + "CI_REQUIRED_QUALIFICATION_MINIMUM", + ); + }); + it("still refuses a push that waives any other job", () => { + const waived = plan(false); + waived.jobs.benchmark.required = false; + expect(() => validatePlan(waived)).toThrow("CI_SCOPE_INVALID_PLAN"); + }); +}); diff --git a/scripts/__tests__/ci-structure.spec.ts b/scripts/__tests__/ci-structure.spec.ts index a9da3aaf02..a98981217a 100644 --- a/scripts/__tests__/ci-structure.spec.ts +++ b/scripts/__tests__/ci-structure.spec.ts @@ -808,9 +808,8 @@ describe("CI pipeline structure", () => { expect(triggers).toContain("workflow_dispatch:"); expect(triggers).toContain("workflow_call:"); expect(triggers).toContain("ios_only:"); - // PRD-629: the one push trigger is main, so the release candidate gets a standalone run. - expect(triggers).not.toMatch(/\n\s{2}(?:pull_request|schedule):/u); - expect(triggers).toMatch(/\n\s{2}push:\n\s{4}branches: \[main\]\n/u); + // The release candidate reads its reports from the main CI run, so nothing starts this alone. + expect(triggers).not.toMatch(/\n\s{2}(?:pull_request|schedule|push):/u); }); it("never lets a draft pull request run cancel a ready one", async () => { @@ -2425,8 +2424,7 @@ describe("CI pipeline structure", () => { "utf8", ); expect(triggerSection(native)).toContain("workflow_call:"); - expect(triggerSection(native)).not.toMatch(/\n\s{2}(?:pull_request|schedule):/u); - expect(triggerSection(native)).toMatch(/\n\s{2}push:\n\s{4}branches: \[main\]\n/u); + expect(triggerSection(native)).not.toMatch(/\n\s{2}(?:pull_request|schedule|push):/u); }); it("both emulator lanes share the KVM provisioning commands", async () => { @@ -3229,9 +3227,8 @@ describe("CI pipeline structure", () => { // The reusable workflow is invoked by ci.yml for every non-prose board selection. const triggers = triggerSection(native); expect(triggers).toContain("workflow_call:"); - // PRD-629: the one push trigger is main, so the release candidate gets a standalone run. - expect(triggers).not.toMatch(/\n\s{2}(?:pull_request|schedule):/u); - expect(triggers).toMatch(/\n\s{2}push:\n\s{4}branches: \[main\]\n/u); + // The release candidate reads its reports from the main CI run, so nothing starts this alone. + expect(triggers).not.toMatch(/\n\s{2}(?:pull_request|schedule|push):/u); const android = requiredJob(native, "android-emulator-parity"); expect(android).not.toContain("continue-on-error: true"); diff --git a/scripts/__tests__/exposure-integration-routing.spec.ts b/scripts/__tests__/exposure-integration-routing.spec.ts index 42a29b3f04..dbc13e402a 100644 --- a/scripts/__tests__/exposure-integration-routing.spec.ts +++ b/scripts/__tests__/exposure-integration-routing.spec.ts @@ -23,14 +23,16 @@ it.each(["exposure", "cold-boot"])("routes actual exposure producers to %s", asy file, ).toBe(true); } + // Cold boot runs when exposure code changes and nightly (owner, 2026-11-01), not on every consumer. for (const file of [ "packages/core/src/game.ts", "packages/core/src/renderer-config.ts", "packages/playtest/src/runner/bridgeClient.ts", "packages/runtime-native/src/webgpu/bindings_resources.cpp", ]) { - expect(selected(file), file).toBe(true); + expect(selected(file), file).toBe(lane === "exposure"); } + expect(selected("packages/core/src/look/exposure-graph.ts")).toBe(true); expect(selected("packages/create-threenative/template-assets/vegetation.ts")).toBe(false); expect(selected("packages/create-threenative/templates/starter/src/render/lighting.ts")).toBe( false, diff --git a/scripts/__tests__/non-visual-scenarios.spec.ts b/scripts/__tests__/non-visual-scenarios.spec.ts new file mode 100644 index 0000000000..aae77b8b8f --- /dev/null +++ b/scripts/__tests__/non-visual-scenarios.spec.ts @@ -0,0 +1,47 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { expect, it } from "vitest"; +import { makeTempDirSync } from "../../test-support/temp-dir.js"; + +const script = path.join(import.meta.dirname, "../non-visual-scenarios.mjs"); +const list = (root: string, ...args: string[]) => + spawnSync("node", [script, root, ...args], { encoding: "utf8" }); + +it("leaves long scenarios of a trimmed kit to the nightly, and only on a board", () => { + const root = makeTempDirSync("non-visual-"); + try { + mkdirSync(path.join(root, "playtests")); + const write = (name: string, scenario: object) => + writeFileSync( + path.join(root, "playtests", `${name}.playtest.json`), + JSON.stringify(scenario), + ); + write("short", { warmupFrames: 10, steps: [{ holdTicks: 489 }] }); + write("long", { warmupFrames: 10, steps: [{ holdTicks: 90 }, { waitTicks: 400 }] }); + + const all = ["playtests/long.playtest.json", "playtests/short.playtest.json"].join("\n"); + expect(list(root).stdout).toBe(all); + expect(list(root, "--board", "minimal").stdout).toBe(all); + const board = list(root, "--board", "shooter"); + expect(board.stdout).toBe("playtests/short.playtest.json"); + expect(board.stderr).toContain("1 left to the nightly (long.playtest.json)"); + expect(list(root, "--board").status).toBe(2); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +it("fails closed when a trimmed kit would keep no scenario", () => { + const root = makeTempDirSync("non-visual-"); + try { + mkdirSync(path.join(root, "playtests")); + writeFileSync( + path.join(root, "playtests/long.playtest.json"), + JSON.stringify({ steps: [{ waitTicks: 500 }] }), + ); + expect(list(root, "--board", "rain").status).toBe(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/__tests__/prepare-release.spec.ts b/scripts/__tests__/prepare-release.spec.ts index 8fe48942b6..33c7a7e62d 100644 --- a/scripts/__tests__/prepare-release.spec.ts +++ b/scripts/__tests__/prepare-release.spec.ts @@ -29,6 +29,15 @@ describe("release preparation", () => { ]); }); + it("bumps an absent cohort when told to, because a runtime tag can hold a release npm cannot see", () => { + const selected = selectReleaseVersions( + [{ name: "@threenative/core", version: "0.3.7" }], + () => ({ state: "absent" }), + true, + ); + expect([...selected.values()]).toEqual([{ current: "0.3.7", next: "0.3.8", published: false }]); + }); + it("bumps the entire cohort and skips occupied patch versions", () => { const packages = [ { name: "@threenative/core", version: "0.3.0" }, diff --git a/scripts/__tests__/release-candidate-gate.spec.ts b/scripts/__tests__/release-candidate-gate.spec.ts index f1e5c23612..e7ff724a96 100644 --- a/scripts/__tests__/release-candidate-gate.spec.ts +++ b/scripts/__tests__/release-candidate-gate.spec.ts @@ -76,15 +76,6 @@ function candidate(overrides: Partial = {}): IReleaseCandidat headSha: CANDIDATE_SHA, workflowPath: ".github/workflows/ci.yml", }, - native: { - databaseId: 102, - status: "completed", - conclusion: "success", - event: "push", - headBranch: "main", - headSha: CANDIDATE_SHA, - workflowPath: ".github/workflows/native-platforms.yml", - }, }, parity: { reportSchemaVersion: 1, @@ -92,8 +83,8 @@ function candidate(overrides: Partial = {}): IReleaseCandidat candidateSha: CANDIDATE_SHA, verdict: "PASS", reportSha256: "b".repeat(64), - sourceRunId: 102, - sourceWorkflowPath: ".github/workflows/native-platforms.yml", + sourceRunId: 101, + sourceWorkflowPath: ".github/workflows/ci.yml", artifactName: "native-release-parity", artifactPath: "reports/parity.json", subjects: expectedReleaseReportSubjects("parity"), @@ -104,8 +95,8 @@ function candidate(overrides: Partial = {}): IReleaseCandidat candidateSha: CANDIDATE_SHA, verdict: "PASS", reportSha256: "c".repeat(64), - sourceRunId: 102, - sourceWorkflowPath: ".github/workflows/native-platforms.yml", + sourceRunId: 101, + sourceWorkflowPath: ".github/workflows/ci.yml", artifactName: "native-release-provenance", artifactPath: "reports/provenance.json", subjects: expectedReleaseReportSubjects("provenance"), @@ -137,16 +128,15 @@ function candidate(overrides: Partial = {}): IReleaseCandidat } describe("release candidate gate", () => { - it("accepts a dispatched native-platforms run, the only standalone run that workflow can have", () => { + it("reads every report from the main push CI run, and refuses a second evidence run", () => { const base = candidate(); - const dispatched = { - ...base, - requiredRuns: { - ...base.requiredRuns, - native: { ...base.requiredRuns.native, event: "workflow_dispatch" }, - }, - }; - expect(validateReleaseCandidate(dispatched, "ThreeNativeHQ/threenative").errors).toEqual([]); + expect(validateReleaseCandidate(base, "ThreeNativeHQ/threenative").errors).toEqual([]); + expect( + validateReleaseCandidate( + { ...base, requiredRuns: { ...base.requiredRuns, native: base.requiredRuns.ci } }, + "ThreeNativeHQ/threenative", + ).errors, + ).toContain("requiredRuns has unknown key 'native'."); const ciDispatched = { ...base, requiredRuns: { @@ -589,13 +579,13 @@ describe("release candidate gate", () => { candidateSha: CANDIDATE_SHA, runtimeVersion: RUNTIME_VERSION, releaseScope: FULL_RELEASE_SCOPE, - requiredRunIds: { ci: 201, native: 202 }, + requiredRunIds: { ci: 201 }, reportArtifacts: { parity: { - ...expectedReleaseReportReference("parity", 202), + ...expectedReleaseReportReference("parity", 201), }, provenance: { - ...expectedReleaseReportReference("provenance", 202), + ...expectedReleaseReportReference("provenance", 201), }, }, }; @@ -667,13 +657,13 @@ describe("release candidate gate", () => { candidateSha: CANDIDATE_SHA, runtimeVersion: RUNTIME_VERSION, releaseScope: FULL_RELEASE_SCOPE, - requiredRunIds: { ci: 201, native: 202 }, + requiredRunIds: { ci: 201 }, reportArtifacts: { parity: { - ...expectedReleaseReportReference("parity", 202), + ...expectedReleaseReportReference("parity", 201), }, provenance: { - ...expectedReleaseReportReference("provenance", 202), + ...expectedReleaseReportReference("provenance", 201), }, }, }; diff --git a/scripts/__tests__/release-ship.spec.ts b/scripts/__tests__/release-ship.spec.ts index 922be2a361..ca4e40c8ee 100644 --- a/scripts/__tests__/release-ship.spec.ts +++ b/scripts/__tests__/release-ship.spec.ts @@ -48,7 +48,6 @@ function world(over: Partial> = {}) { candidate: [] as IFakeRun[], ci: [run(1, "push")] as IFakeRun[], main: SHA, - native: [run(2, "workflow_dispatch")] as IFakeRun[], npm: [] as string[], release: null as null | { isDraft: boolean; isPrerelease: boolean }, tagCommit: undefined as string | undefined, @@ -64,7 +63,6 @@ function world(over: Partial> = {}) { const workflow = args[args.indexOf("--workflow") + 1]; const table: Record = { "ci.yml": state.ci, - "native-platforms.yml": state.native, "release-candidate.yml": state.candidate, "native-release.yml": state.tagRuns, }; @@ -106,7 +104,7 @@ function world(over: Partial> = {}) { } describe("pnpm release:ship --dry-run", () => { - it("finds both run ids, builds a request the gate accepts and dispatches nothing", async () => { + it("finds the CI run id, builds a request the gate accepts and dispatches nothing", async () => { const first = world(); const acted = await ship(first.deps, { dryRun: true, sha: SHA }); expect(acted).toEqual([]); @@ -117,11 +115,11 @@ describe("pnpm release:ship --dry-run", () => { expect(request).toMatchObject({ candidateSha: SHA, repository: REPOSITORY, - requiredRunIds: { ci: 1, native: 2 }, + requiredRunIds: { ci: 1 }, runtimeVersion: VERSION, tag: TAG, }); - expect(request.reportArtifacts.parity).toEqual(expectedReleaseReportReference("parity", 2)); + expect(request.reportArtifacts.parity).toEqual(expectedReleaseReportReference("parity", 1)); expect(first.logs).toContain(`next: dispatch release-candidate.yml for ${SHA}.`); const second = world(); @@ -132,9 +130,7 @@ describe("pnpm release:ship --dry-run", () => { it("reports the run that blocks the plan, and a real run refuses to start", async () => { const queued = world({ ci: [run(1, "push", { conclusion: "", status: "queued" })] }); await ship(queued.deps, { dryRun: true, sha: SHA }); - expect(queued.logs).toContain( - "next: blocked: CI is not green (run 1 is queued); native-platforms is green (run 2).", - ); + expect(queued.logs).toContain("next: blocked: CI is not green (run 1 is queued)."); await expect(ship(queued.deps, { dryRun: false, sha: SHA })).rejects.toThrow( /TN_SHIP_EVIDENCE/u, ); @@ -145,7 +141,7 @@ describe("pnpm release:ship --dry-run", () => { await expect(ship(world().deps, { dryRun: false, sha: OTHER })).rejects.toThrow( /TN_SHIP_CHECKOUT/u, ); - const { deps, logs } = world({ ci: [run(1, "push", { headSha: OTHER })], native: [] }); + const { deps, logs } = world({ ci: [run(1, "push", { headSha: OTHER })] }); await ship(deps, { dryRun: true, sha: OTHER }); expect(logs[0]).toMatch(/^note: TN_SHIP_CHECKOUT/u); }); @@ -228,6 +224,29 @@ describe("pnpm release:ship resumes", () => { expect(calls).toEqual(["publish"]); }); + // The 0.3.5, 0.3.6 and 0.3.7 tag runs concluded failure on the non-gating iOS job, so reading the + // run's own conclusion made `release:ship` stop on a release `finalize` had already promoted. + it("publishes a promoted release even when the iOS evidence job failed the run", async () => { + const { calls, deps } = world({ + candidate: [run(10, "workflow_dispatch")], + release: { isDraft: false, isPrerelease: false }, + tagCommit: SHA, + tagRuns: [run(20, "push", { conclusion: "failure", headBranch: TAG })], + }); + expect(await ship(deps, { dryRun: false, sha: SHA })).toEqual(["publish"]); + expect(calls).toEqual(["publish"]); + }); + + it("stops on a failed tag run that never promoted its release", async () => { + const { deps } = world({ + candidate: [run(10, "workflow_dispatch")], + release: { isDraft: false, isPrerelease: true }, + tagCommit: SHA, + tagRuns: [run(20, "push", { conclusion: "failure", headBranch: TAG })], + }); + await expect(ship(deps, { dryRun: false, sha: SHA })).rejects.toThrow("TN_SHIP_TAG_RUN_FAILED"); + }); + it("only verifies when the cohort is already on npm", async () => { const { calls, deps } = world({ candidate: [run(10, "workflow_dispatch")], npm: [VERSION] }); expect(await ship(deps, { dryRun: false, sha: SHA })).toEqual(["verify"]); @@ -258,7 +277,6 @@ function stateOf( }, ci: 1, mainHead: SHA, - native: 2, npmPublished: false, release: "none", tag: {}, diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index f410f995af..4dbc85e08a 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -197,6 +197,7 @@ export function selectionPlan( reusedRunId = 0, target = "", qualification = selection === "full" && target !== "develop", + nightly = false, ) { const full = selection === "full"; const reused = selection === "reused"; @@ -230,22 +231,20 @@ export function selectionPlan( // producers; neither widens one, so any other job is exempt exactly as it is for prose. // The engine job is the one lane a full board owes only conditionally: when the diff reaches the // engine tree, or when the run qualifies a whole candidate. Every other full diff reads as before. + // The engine-cost row is nightly only (owner, 2026-11-01): it costs 180 job-minutes a board. const required = - name === "native-engine" - ? engine || (full && (qualification || files.some(isEnginePath))) - : (full && (name !== "test-native" || native)) || - ((ciLane || template) && CI_JOBS.has(name)) || - (template && - [ - "template-nonvisual", - "template-engine-cost", - "golden-path-template", - "golden-path", - ].includes(name)) || - (review && - (REVIEW_JOBS.includes(name) || - (name === "test" && files.some((file) => file.startsWith("packages/"))))) || - (warm && WARM_JOBS.includes(name)); + name === "template-engine-cost" + ? nightly && (full || template) + : name === "native-engine" + ? engine || (full && (qualification || files.some(isEnginePath))) + : (full && (name !== "test-native" || native)) || + ((ciLane || template) && CI_JOBS.has(name)) || + (template && + ["template-nonvisual", "golden-path-template", "golden-path"].includes(name)) || + (review && + (REVIEW_JOBS.includes(name) || + (name === "test" && files.some((file) => file.startsWith("packages/"))))) || + (warm && WARM_JOBS.includes(name)); return [ name, { @@ -326,6 +325,7 @@ export function selectionPlan( return { version: 2, qualification, + nightly, files, reason, scope: selection, @@ -368,6 +368,7 @@ export function validatePlan(value) { typeof value !== "object" || value.version !== 2 || typeof value.qualification !== "boolean" || + typeof value.nightly !== "boolean" || (value.qualification && (value.selection !== "full" || !value.native || value.reusedRunId !== 0)) || !SELECTIONS.has(value.selection) || @@ -406,6 +407,7 @@ export function validatePlan(value) { value.reusedRunId, value.target, value.qualification, + value.nightly, ); for (const field of [ "scope", @@ -449,7 +451,10 @@ export function validateEventPlan(value, { eventName, baseRef, forceFull = false !plan.native || plan.nativeTier !== "full" || plan.reusedRunId !== 0 || - Object.values(plan.jobs).some((job) => !job.required) || + // Only the nightly owes the engine-cost row. + Object.entries(plan.jobs).some( + ([name, job]) => !job.required && (name !== "template-engine-cost" || event === "schedule"), + ) || JSON.stringify(plan.templateMatrix.template) !== JSON.stringify(TEMPLATE_NAMES) || JSON.stringify(plan.goldenMatrix.template) !== JSON.stringify(["starter", "platformer"]) || JSON.stringify(plan.unitMatrix.shard) !== JSON.stringify(["1/4", "2/4", "3/4", "4/4"]) @@ -990,6 +995,7 @@ export function classify(input) { 0, target, qualification, + options.eventName === "schedule", ), options, candidateSha, @@ -1266,6 +1272,7 @@ function reuseOrKeep(plan, options, candidateSha) { 0, plan.target, plan.qualification, + plan.nightly, ); // PRD-730: the queue takes its own narrow rule, never the generic cross-commit one below. if (options.eventName === "merge_group") { diff --git a/scripts/ci-integration-scope.mjs b/scripts/ci-integration-scope.mjs index 8a7ce9c2d9..60dba32720 100644 --- a/scripts/ci-integration-scope.mjs +++ b/scripts/ci-integration-scope.mjs @@ -13,7 +13,6 @@ const SELECTOR = "scripts/ci-integration-scope.mjs"; const PACKAGE_LANES = [ "animation", "exposure", - "cold-boot", "decals", "fog", "fluid-particles", @@ -64,7 +63,7 @@ function dependencyLanes(file, current) { if (/^packages\/core\/src\//u.test(file)) { const module = /^packages\/core\/src\/([^/]+)\.ts$/u.exec(file)?.[1]; return INDEPENDENT_CORE_MODULES.includes(module) - ? ["exposure", "cold-boot", ...(file.includes("/world-") ? ["world-capture"] : [])] + ? ["exposure", ...(file.includes("/world-") ? ["world-capture"] : [])] : ["fluid-particles", "fluid-native"]; } if (PACKAGE_CONTRACT.test(file)) { @@ -273,7 +272,9 @@ export function integrationSelection({ files, before, after }) { if ( dependency === PACKAGE_LANES && [...current.filters.keys()].some( - (lane) => !PACKAGE_LANES.includes(lane) && !["csg", "ik", "vegetation"].includes(lane), + (lane) => + !PACKAGE_LANES.includes(lane) && + !["csg", "ik", "vegetation", "cold-boot"].includes(lane), ) ) return all("unclassified additive producer consumer"); @@ -375,6 +376,17 @@ export function integrationCandidatePreflight({ const selection = plan.qualification ? integrationSelection({ files: [SELECTOR], before: "", after }) : integrationGitSelection(baseSha, candidate); + // Owner cut (2026-11-01): cold boot runs when exposure code changes and nightly. A push states no + // base, so its own diff is against its first parent: the squash on develop, the promotion on main. + if (plan.qualification && eventName === "push" && selection.lanes["cold-boot"]) { + // A leg's one-commit checkout has no parent, and a missing base keeps cold boot selected. Only + // that leg's receipt writer reads this; the paths job and both joins fetch full history. + let parent = ""; + try { + parent = git("rev-parse", "--verify", "--quiet", `${candidate}^1`).trim(); + } catch {} + selection.lanes["cold-boot"] = integrationGitSelection(parent, candidate).lanes["cold-boot"]; + } return { candidateSha: candidate, lanes: selection.lanes, diff --git a/scripts/kit-contract-row.ts b/scripts/kit-contract-row.ts index 307a310543..359f117a1c 100644 --- a/scripts/kit-contract-row.ts +++ b/scripts/kit-contract-row.ts @@ -5,7 +5,7 @@ import { loadKitContract } from "./kit-contract.js"; /** * Writes one kit contract row's scenario into a scaffolded kit's playtests, so a CI job that runs the - * kit's own scenarios runs the row too (PRD-748: `engine-cost` in template-engine-cost, every PR). + * kit's own scenarios runs the row too (PRD-748: `engine-cost` in template-engine-cost, nightly). * A kit that opts out prints its reason and gets no file; an undeclared row throws, as the sweep does. * * tsx scripts/kit-contract-row.ts diff --git a/scripts/non-visual-scenarios.mjs b/scripts/non-visual-scenarios.mjs index 3a4e281116..77ec947739 100644 --- a/scripts/non-visual-scenarios.mjs +++ b/scripts/non-visual-scenarios.mjs @@ -9,11 +9,32 @@ import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; -const [projectRoot] = process.argv.slice(2); -if (projectRoot === undefined) { - process.stderr.write("usage: non-visual-scenarios.mjs \n"); +const [projectRoot, boardFlag, boardKit] = process.argv.slice(2); +if ( + projectRoot === undefined || + (boardFlag !== undefined && (boardFlag !== "--board" || !boardKit)) +) { + process.stderr.write("usage: non-visual-scenarios.mjs [--board ]\n"); process.exit(2); } +// Owner cut (2026-11-01): on these kits a board leaves its longest scenarios to the nightly, which +// runs every scenario. Frames, not files, cost the time, and a scenario's scripted ticks predict +// its frames (run 41325468129: shooter debug-endurance 6866 ticks, 6846 frames). +const BOARD_TRIMMED_KITS = new Set([ + "shooter", + "collectathon", + "rain", + "adventure", + "platformer", + "kart", +]); +const BOARD_TICK_LIMIT = 500; +const scriptedTicks = (scenario) => + (scenario.warmupFrames ?? 0) + + (scenario.steps ?? []).reduce( + (total, step) => total + (step.holdTicks ?? 0) + (step.waitTicks ?? 0), + 0, + ); const directory = path.join(projectRoot, "playtests"); const entries = readdirSync(directory).filter((name) => name.endsWith(".playtest.json")); if (entries.length === 0) { @@ -60,13 +81,20 @@ const visual = (source) => { const kept = []; const skipped = []; +const nightly = []; for (const name of entries.sort()) { - const file = path.join(directory, name); - (visual(readFileSync(file, "utf8")) ? skipped : kept).push(name); + const source = readFileSync(path.join(directory, name), "utf8"); + if (visual(source)) skipped.push(name); + else if ( + BOARD_TRIMMED_KITS.has(boardKit) && + scriptedTicks(JSON.parse(source)) >= BOARD_TICK_LIMIT + ) + nightly.push(name); + else kept.push(name); } // Never a silent narrowing: what this drops is printed where the job's log will carry it. process.stderr.write( - `non-visual scenarios: ${kept.length} kept, ${skipped.length} left to the lanes with hardware (${skipped.join(", ")})\n`, + `non-visual scenarios: ${kept.length} kept, ${skipped.length} left to the lanes with hardware (${skipped.join(", ")}), ${nightly.length} left to the nightly (${nightly.join(", ")})\n`, ); if (kept.length === 0) { process.stderr.write("every scenario is visual; nothing would run\n"); diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts index c1f12f181b..def5db78bd 100644 --- a/scripts/prepare-release.ts +++ b/scripts/prepare-release.ts @@ -100,12 +100,13 @@ function registryState( export function selectReleaseVersions( packages: readonly Pick[], lookup: RegistryLookup, + force = false, ): ReadonlyMap { const states = packages.map((item) => ({ item, state: registryState(item.name, item.version, lookup), })); - const bump = states.some(({ state }) => state === "present"); + const bump = force || states.some(({ state }) => state === "present"); const selected = new Map(); for (const { item, state } of states) { let candidate = bump ? nextPatch(item.version) : item.version; @@ -301,7 +302,12 @@ function run(command: string, args: readonly string[], label: string): void { async function main(): Promise { const packages = publishSet(REPO); - const selected = selectReleaseVersions(packages, npmLookup(REPO)); + // `--bump`: npm cannot see a version whose runtime tag already holds a public GitHub release. + const selected = selectReleaseVersions( + packages, + npmLookup(REPO), + process.argv.includes("--bump"), + ); // Before a single file is written: 1.0.0 is refused while PRD-446's upgrade proof is still open, // so the version bump nobody wants to walk back never lands in the first place. if ([...selected.values()].some((state) => state.next === "1.0.0")) diff --git a/scripts/release-candidate-gate.ts b/scripts/release-candidate-gate.ts index b865a7f8db..434ebccb27 100644 --- a/scripts/release-candidate-gate.ts +++ b/scripts/release-candidate-gate.ts @@ -42,7 +42,6 @@ export const REQUIRED_HOSTED_CAPABILITIES = [ const REQUIRED_RUN_WORKFLOWS = { ci: ".github/workflows/ci.yml", - native: ".github/workflows/native-platforms.yml", } as const; type CredentialName = (typeof REQUIRED_CREDENTIALS)[number]; @@ -188,7 +187,7 @@ export interface IReleaseCandidate { readonly candidateSha: string; readonly runtimeVersion: string; readonly packageCohort: readonly IReleasePackage[]; - readonly requiredRuns: { readonly ci: IReleaseRun; readonly native: IReleaseRun }; + readonly requiredRuns: { readonly ci: IReleaseRun }; readonly parity: IReleaseReport & { readonly verdict: "PASS" }; readonly provenance: IReleaseReport & { readonly subjects: readonly string[] }; readonly subjects: { readonly github: readonly string[]; readonly npm: readonly string[] }; @@ -225,7 +224,7 @@ export interface IReleaseCandidateRequest { readonly tag: string; readonly candidateSha: string; readonly runtimeVersion: string; - readonly requiredRunIds: { readonly ci: number; readonly native: number }; + readonly requiredRunIds: { readonly ci: number }; readonly reportArtifacts: { readonly parity: IReleaseReportReference; readonly provenance: IReleaseReportReference; @@ -487,14 +486,7 @@ function validateRun( errors.push(`${where}.headSha must equal candidateSha.`); if (value.status !== "completed") errors.push(`${where}.status must be 'completed'.`); if (value.conclusion !== "success") errors.push(`${where}.conclusion must be 'success'.`); - // native-platforms.yml never runs on push on its own: ci.yml calls it as a reusable workflow, so - // the only standalone run of it is a manual dispatch on main. Demanding `push` for it made every - // release candidate unsatisfiable. - const events = workflowPath.endsWith("native-platforms.yml") - ? ["push", "workflow_dispatch"] - : ["push"]; - if (typeof value.event !== "string" || !events.includes(value.event)) - errors.push(`${where}.event must be ${events.map((event) => `'${event}'`).join(" or ")}.`); + if (value.event !== "push") errors.push(`${where}.event must be 'push'.`); if (value.headBranch !== "main") errors.push(`${where}.headBranch must be 'main'.`); if (value.workflowPath !== workflowPath) errors.push(`${where}.workflowPath must be '${workflowPath}'.`); @@ -719,14 +711,14 @@ const RELEASE_REPORT_CONTRACTS: Readonly(); if (isRecord(input.requiredRuns)) { - checkKeys(input.requiredRuns, ["ci", "native"], "requiredRuns", errors); + checkKeys(input.requiredRuns, ["ci"], "requiredRuns", errors); const ci = input.requiredRuns.ci; - const native = input.requiredRuns.native; validateRun(ci, "requiredRuns.ci", candidateSha, REQUIRED_RUN_WORKFLOWS.ci, errors); - validateRun(native, "requiredRuns.native", candidateSha, REQUIRED_RUN_WORKFLOWS.native, errors); if (isRecord(ci) && typeof ci.databaseId === "number" && typeof ci.workflowPath === "string") evidenceRuns.set(ci.databaseId, ci.workflowPath); - if ( - isRecord(native) && - typeof native.databaseId === "number" && - typeof native.workflowPath === "string" - ) - evidenceRuns.set(native.databaseId, native.workflowPath); - } else errors.push("requiredRuns must contain ci and native objects."); + } else errors.push("requiredRuns must contain a ci object."); validateReport(input.parity, "parity", candidateSha, "parity", errors); validateReport(input.provenance, "provenance", candidateSha, "provenance", errors); validateReportSource(input.parity, "parity", evidenceRuns, errors); @@ -1079,19 +1063,13 @@ function parseRequestIdentity( return { candidateSha, repository, runtimeVersion, tag }; } -function parseRequestRunIds( - value: unknown, - errors: string[], -): { readonly ci: number | undefined; readonly native: number | undefined } { +function parseRequestRunIds(value: unknown, errors: string[]): { readonly ci: number | undefined } { if (!isRecord(value)) { - errors.push("request.requiredRunIds must contain ci and native IDs."); - return { ci: undefined, native: undefined }; + errors.push("request.requiredRunIds must contain the ci ID."); + return { ci: undefined }; } - checkKeys(value, ["ci", "native"], "request.requiredRunIds", errors); - return { - ci: positiveId(value.ci, "request.requiredRunIds.ci", errors), - native: positiveId(value.native, "request.requiredRunIds.native", errors), - }; + checkKeys(value, ["ci"], "request.requiredRunIds", errors); + return { ci: positiveId(value.ci, "request.requiredRunIds.ci", errors) }; } function parseRequestArtifacts( @@ -1136,7 +1114,7 @@ export function parseReleaseCandidateRequest( tag: identity.tag as string, candidateSha: identity.candidateSha as string, runtimeVersion: identity.runtimeVersion as string, - requiredRunIds: { ci: requiredRunIds.ci as number, native: requiredRunIds.native as number }, + requiredRunIds: { ci: requiredRunIds.ci as number }, reportArtifacts: { parity: reportArtifacts.parity as IReleaseReportReference, provenance: reportArtifacts.provenance as IReleaseReportReference, @@ -2086,7 +2064,7 @@ function resolveRunResolver( async function resolveRequiredRuns( request: IReleaseCandidateRequest, runResolver: ReturnType, -): Promise<{ readonly ci: IReleaseRun; readonly native: IReleaseRun }> { +): Promise<{ readonly ci: IReleaseRun }> { const ci = assertResolvedRun( await runResolver( request.repository, @@ -2099,19 +2077,7 @@ async function resolveRequiredRuns( request.candidateSha, REQUIRED_RUN_WORKFLOWS.ci, ); - const native = assertResolvedRun( - await runResolver( - request.repository, - request.requiredRunIds.native, - request.candidateSha, - REQUIRED_RUN_WORKFLOWS.native, - ), - "requiredRuns.native", - request.requiredRunIds.native, - request.candidateSha, - REQUIRED_RUN_WORKFLOWS.native, - ); - return { ci, native }; + return { ci }; } function resolveReportResolver( diff --git a/scripts/release-ship.ts b/scripts/release-ship.ts index 019aa5c3ca..48d574dc16 100644 --- a/scripts/release-ship.ts +++ b/scripts/release-ship.ts @@ -70,7 +70,6 @@ export interface IShipState { }; readonly ci: number | string; readonly mainHead: string; - readonly native: number | string; readonly npmPublished: boolean; readonly release: ReleaseState; readonly tag: { readonly commit?: string }; @@ -159,9 +158,7 @@ export function readShipState( runtimeVersion: string, ): IShipState { const ciAll = runs(deps, "ci.yml", sha); - const nativeAll = runs(deps, "native-platforms.yml", sha); const ci = greenRun(ciAll, sha, ["push"]); - const native = greenRun(nativeAll, sha, ["push", "workflow_dispatch"]); const candidateRuns = runs(deps, "release-candidate.yml", sha).filter( (run) => run.headBranch === "main" && run.event === "workflow_dispatch", ); @@ -189,7 +186,6 @@ export function readShipState( }, ci: ci?.databaseId ?? describeMissing(ciAll, sha), mainHead: deps.gh(["api", `repos/${deps.repository}/commits/main`, "--jq", ".sha"]).trim(), - native: native?.databaseId ?? describeMissing(nativeAll, sha), npmPublished: deps.npmVersions("@threenative/runtime-native").includes(runtimeVersion), release: releaseState(deps, tag), tag: { commit: tagCommit(deps, tag) }, @@ -202,7 +198,7 @@ export function buildCandidateRequest( repository: string, sha: string, runtimeVersion: string, - runIds: { readonly ci: number; readonly native: number }, + ciRunId: number, ): IReleaseCandidateRequest { return { schemaVersion: 1, @@ -210,10 +206,10 @@ export function buildCandidateRequest( tag: `runtime-native-v${runtimeVersion}`, candidateSha: sha, runtimeVersion, - requiredRunIds: { ci: runIds.ci, native: runIds.native }, + requiredRunIds: { ci: ciRunId }, reportArtifacts: { - parity: expectedReleaseReportReference("parity", runIds.native), - provenance: expectedReleaseReportReference("provenance", runIds.native), + parity: expectedReleaseReportReference("parity", ciRunId), + provenance: expectedReleaseReportReference("provenance", ciRunId), }, releaseScope: SHIP_SCOPE, }; @@ -221,11 +217,13 @@ export function buildCandidateRequest( /** The first step that is not done. A second invocation resumes here. */ export function nextStep(state: IShipState, sha: string): ShipStep { - if (typeof state.ci !== "number" || typeof state.native !== "number") return "evidence"; + if (typeof state.ci !== "number") return "evidence"; if (state.npmPublished) return "verify"; if (state.candidate.successes === 0) return "candidate"; if (state.tag.commit !== sha) return "tag"; - if (state.release !== "public" || state.tagRun !== "success") return "wait"; + // Promotion is the verdict: `finalize` makes a release public only after its gating consumers pass. + // The run's own conclusion also carries the iOS evidence job, which does not gate (owner, 2026-09-23). + if (state.release !== "public") return "wait"; return "publish"; } @@ -259,7 +257,7 @@ const evidenceLabel = (value: number | string): string => function describeStep(step: ShipStep, state: IShipState, sha: string, tag: string): string { switch (step) { case "evidence": - return `blocked: CI is ${evidenceLabel(state.ci)}; native-platforms is ${evidenceLabel(state.native)}.`; + return `blocked: CI is ${evidenceLabel(state.ci)}.`; case "candidate": return `dispatch release-candidate.yml for ${sha}.`; case "tag": @@ -318,11 +316,8 @@ export async function ship(deps: IShipDeps, options: IShipOptions): Promise { const now = readShipState(deps, sha, tag, version); + if (now.release === "public") return true; if (now.tagRun === "failed") throw new Error( `TN_SHIP_TAG_RUN_FAILED: the ${tag} run failed. Fix it on main and ship the new commit.`, ); - return now.release === "public" && now.tagRun === "success"; + return false; }, 120, );