diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86232051c4..c9d51a229e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,13 +19,20 @@ permissions: contents: read jobs: - # ── Rust / CLI impact: conservative scheduling decision ──────────── - rust-impact: - name: Rust / CLI Impact + # ── Build impact: fast checks plus the smallest useful package set ── + build-impact: + name: Build Impact runs-on: ubuntu-latest timeout-minutes: 5 outputs: rust_required: ${{ steps.classify.outputs.rust_required }} + frontend_required: ${{ steps.classify.outputs.frontend_required }} + desktop_packages_impacted: ${{ steps.classify.outputs.desktop_packages_impacted }} + desktop_platforms: ${{ steps.classify.outputs.desktop_platforms }} + linux_binaries_required: ${{ steps.classify.outputs.linux_binaries_required }} + relay_image_required: ${{ steps.classify.outputs.relay_image_required }} + dsh_profile_required: ${{ steps.classify.outputs.dsh_profile_required }} + pr_producer_required: ${{ steps.classify.outputs.pr_producer_required }} reason: ${{ steps.classify.outputs.reason }} changed_count: ${{ steps.classify.outputs.changed_count }} steps: @@ -41,13 +48,13 @@ jobs: node-version-file: package.json package-manager-cache: false - - name: Classify Rust and CLI impact + - name: Classify build impact id: classify env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} RANGE_MODE: ${{ github.event_name == 'pull_request' && 'merge-base' || 'direct' }} - run: node scripts/ci/classify-rust-impact.mjs --base "$BASE_SHA" --head "$HEAD_SHA" --range-mode "$RANGE_MODE" + run: node scripts/ci/classify-build-impact.mjs --base "$BASE_SHA" --head "$HEAD_SHA" --range-mode "$RANGE_MODE" # ── Shell deploy assets: LF endings + syntax ─────────────────────── # Relay one-click deploy embeds these scripts into the Desktop binary and @@ -88,7 +95,7 @@ jobs: exit "$rc" - name: Verify release and version-generation contracts - run: node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs + run: node --test scripts/release-channel.test.mjs scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs - name: Verify minisign download fallback run: | @@ -109,10 +116,10 @@ jobs: # ── CLI: independent tests ───────────────────────────────────────── cli-test: name: CLI Tests (${{ matrix.os }}) - needs: rust-impact - if: ${{ !cancelled() && needs.rust-impact.outputs.rust_required != 'false' }} + needs: build-impact + if: ${{ !cancelled() && needs.build-impact.outputs.rust_required != 'false' }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -121,6 +128,8 @@ jobs: cache_key: ubuntu - os: macos-15 cache_key: macos + - os: windows-latest + cache_key: windows steps: - uses: actions/checkout@v5 @@ -141,11 +150,11 @@ jobs: - uses: swatinem/rust-cache@v2 with: - shared-key: "cli-ci-v2-${{ matrix.cache_key }}" + shared-key: "cli-ci-v3-${{ github.base_ref || github.ref_name }}-${{ matrix.cache_key }}" cache-bin: false # PRs restore trusted caches but never publish merge-ref artifacts. - save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - cache-on-failure: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/1.0.0-explore') }} + cache-on-failure: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/1.0.0-explore') }} - name: Run CLI and ACP tests on macOS if: runner.os == 'macOS' @@ -155,6 +164,13 @@ jobs: if: runner.os == 'Linux' run: cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime + # ConPTY is a Windows-only execution path. Keep its interactive contracts + # serialized: concurrent native PTYs contend on hosted runners and used to + # race action delivery, TUI restoration, and output observation in Nightly. + - name: Run Windows CLI terminal contracts + if: runner.os == 'Windows' + run: cargo test --locked -p bitfun-cli --test terminal_process_contracts -- --test-threads=1 + - name: Run SDK Host tests if: runner.os == 'Linux' run: cargo test --locked -p bitfun-sdk-host -p bitfun-sdk-host-app @@ -169,8 +185,8 @@ jobs: # ── Rust: build check ───────────────────────────────────────────── rust-build-check: name: Rust Build Check (${{ matrix.os }}) - needs: rust-impact - if: ${{ !cancelled() && needs.rust-impact.outputs.rust_required != 'false' }} + needs: build-impact + if: ${{ !cancelled() && needs.build-impact.outputs.rust_required != 'false' }} runs-on: ${{ matrix.os }} env: # Keep the workspace check plus desktop test profiles within hosted-runner disk limits. @@ -187,6 +203,12 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: package.json + package-manager-cache: false + # Tauri code generation only requires its configured resource roots to # exist during check/test; distributable assets remain frontend-build's owner. - name: Create Tauri resource directories @@ -228,12 +250,17 @@ jobs: - uses: swatinem/rust-cache@v2 with: - shared-key: "ci-check-v5-${{ runner.os }}-no-cargo-bin-v1" + shared-key: "ci-check-v6-${{ github.base_ref || github.ref_name }}-${{ runner.os }}-no-cargo-bin" cache-bin: false # PR caches are scoped to merge refs; trusted main pushes own shared # refreshes and retain completed dependency builds after late test failures. - save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - cache-on-failure: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + save-if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/1.0.0-explore') }} + cache-on-failure: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/1.0.0-explore') }} + + # Validate the committed manifests and lockfile before any release + # projection is allowed to rewrite workspace package entries. + - name: Verify committed Cargo metadata + run: cargo metadata --locked --no-deps # rust-cache prunes the workspace target directory before saving it, so # native libraries stored under target need an independent cache lifecycle. @@ -242,7 +269,7 @@ jobs: uses: actions/cache/restore@v5 with: path: target/sherpa-onnx-prebuilt - key: sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static + key: sherpa-onnx-v2-${{ github.base_ref || github.ref_name }}-${{ runner.os }}-${{ runner.arch }}-1.13.4-static - name: Repair missing Sherpa native state shell: bash @@ -260,12 +287,12 @@ jobs: - name: Save Sherpa native libraries if: >- github.event_name == 'push' && - github.ref == 'refs/heads/main' && + (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/1.0.0-explore') && steps.sherpa-native-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: target/sherpa-onnx-prebuilt - key: sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static + key: sherpa-onnx-v2-${{ github.base_ref || github.ref_name }}-${{ runner.os }}-${{ runner.arch }}-1.13.4-static # The installer is intentionally excluded from the root Cargo workspace, # so the workspace check above cannot catch drift in its shared Rust APIs. @@ -290,16 +317,10 @@ jobs: run: cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth # File watching is backed by a different OS API on every platform - # (ReadDirectoryChangesW / FSEvents / inotify), so watch registration - # regressions surface per-OS. The suite is behind a non-default feature - # and would otherwise never run anywhere. - # - # macOS is excluded: this suite already fails there before any of this - # branch's changes (the debounce/atomic-rename contracts time out under - # FSEvents coalescing), which is worth fixing separately rather than - # blocking unrelated work. + # (ReadDirectoryChangesW / FSEvents / inotify), so watch registration, + # debounce, and atomic-rename regressions must stay in the full OS matrix. + # The suite is behind a non-default feature and would otherwise never run. - name: Run file watch contract tests - if: runner.os != 'macOS' run: cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts # Search tools resolve paths and symlinks directly, which also differs @@ -309,32 +330,58 @@ jobs: - name: Run search tool tests run: "cargo test --locked -p tool-runtime --lib search::" - # Stable Rust/CLI result for CI runs: matrix jobs may be skipped for a proven - # Web-only change, but this result verifies why they ran or skipped. + # Call the standalone Linux/Relay workflow directly. Calling the Nightly + # artifact orchestrator would register a misleading skipped Desktop package + # check on every producer-selected PR even when that matrix is disabled. + package-impact-contract: + name: Impact-selected Linux / Relay Contract + needs: build-impact + if: >- + !cancelled() && + github.event_name == 'pull_request' && + needs.build-impact.result == 'success' && + needs.build-impact.outputs.pr_producer_required == 'true' + uses: ./.github/workflows/linux-binaries.yml + permissions: + contents: read + with: + checkout_ref: ${{ github.sha }} + version: 0.0.0-nightly.ci.${{ github.run_id }} + artifact_prefix: ci-${{ github.run_id }} + artifact_retention_days: 1 + validate_relay_image: ${{ needs.build-impact.outputs.relay_image_required == 'true' }} + upload_artifacts: false + cache_write: false + + # Stable required result: verify both fast Rust scheduling and whether the + # impact-selected PR producer contract ran or intentionally skipped. rust-validation-result: name: Rust / CLI Validation runs-on: ubuntu-latest timeout-minutes: 5 if: ${{ always() }} - needs: [rust-impact, cli-test, rust-build-check] + needs: [build-impact, cli-test, rust-build-check, package-impact-contract] steps: - name: Verify Rust and CLI result shell: pwsh env: - RUST_REQUIRED: ${{ needs.rust-impact.outputs.rust_required }} - IMPACT_RESULT: ${{ needs.rust-impact.result }} + RUST_REQUIRED: ${{ needs.build-impact.outputs.rust_required }} + PR_PRODUCER_REQUIRED: ${{ needs.build-impact.outputs.pr_producer_required }} + EVENT_NAME: ${{ github.event_name }} + IMPACT_RESULT: ${{ needs.build-impact.result }} CLI_RESULT: ${{ needs.cli-test.result }} RUST_RESULT: ${{ needs.rust-build-check.result }} + BUILD_RESULT: ${{ needs.package-impact-contract.result }} run: | $ErrorActionPreference = 'Stop' if ($env:IMPACT_RESULT -ne 'success') { - throw "Rust impact classification did not succeed: $env:IMPACT_RESULT" + throw "Build impact classification did not succeed: $env:IMPACT_RESULT" } switch ($env:RUST_REQUIRED) { 'false' { if ($env:CLI_RESULT -ne 'skipped' -or $env:RUST_RESULT -ne 'skipped') { - throw "Expected skipped Rust and CLI jobs for a Web-only change; CLI=$env:CLI_RESULT Rust=$env:RUST_RESULT" + throw "Expected skipped Rust/CLI jobs; CLI=$env:CLI_RESULT Rust=$env:RUST_RESULT" } } 'true' { @@ -347,6 +394,14 @@ jobs: } } + $producerExpected = $env:EVENT_NAME -eq 'pull_request' -and $env:PR_PRODUCER_REQUIRED -eq 'true' + if ($producerExpected -and $env:BUILD_RESULT -ne 'success') { + throw "Expected successful impact-selected producer jobs; Build=$env:BUILD_RESULT" + } + if (-not $producerExpected -and $env:BUILD_RESULT -ne 'skipped') { + throw "Expected producer jobs to be skipped; Build=$env:BUILD_RESULT" + } + # ── DeepSeek Harness bridge: profile packaging on Windows ────────── # `prepare:dsh-profile` runs from `frontend:build-all` / official desktop # packaging, not from desktop:dev or cargo check. Until this job existed, @@ -357,6 +412,8 @@ jobs: # platform whose path and process rules differ. dsh-profile-windows: name: DSH Profile Packaging (windows-latest) + needs: build-impact + if: ${{ !cancelled() && needs.build-impact.outputs.dsh_profile_required == 'true' }} runs-on: windows-latest timeout-minutes: 15 steps: @@ -408,6 +465,7 @@ jobs: runs-on: ubuntu-latest env: NODE_OPTIONS: --max-old-space-size=6144 + needs: build-impact steps: - uses: actions/checkout@v5 with: @@ -419,7 +477,7 @@ jobs: - uses: actions/setup-node@v5 with: node-version: 22 - cache: pnpm + package-manager-cache: false - name: Check repository hygiene run: pnpm run check:repo-hygiene @@ -433,13 +491,55 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Setup Bun for package resource preflight + if: needs.build-impact.outputs.frontend_required != 'false' + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + no-cache: true + + - name: Setup Rust for release projection preflight + if: needs.build-impact.outputs.frontend_required != 'false' + uses: dtolnay/rust-toolchain@stable + + - name: Verify committed release metadata + if: needs.build-impact.outputs.frontend_required != 'false' + run: cargo metadata --locked --no-deps + + - name: Verify Installer i18n projection + if: needs.build-impact.outputs.frontend_required != 'false' + run: pnpm --dir BitFun-Installer run sync:i18n + + - name: Verify Installer Tauri package alignment + if: needs.build-impact.outputs.frontend_required != 'false' + shell: bash + run: | + set -euo pipefail + info="$(pnpm --dir BitFun-Installer exec tauri info 2>&1)" + printf '%s\n' "$info" + if grep -Fq 'Error: Found version mismatched Tauri packages.' <<<"$info"; then + exit 1 + fi + + - name: Build plugin Host resources + if: needs.build-impact.outputs.frontend_required != 'false' + run: | + bun install --cwd src/apps/extension-host --frozen-lockfile + bun run --cwd src/apps/extension-host build + + - name: Generate web API bindings + if: needs.build-impact.outputs.frontend_required != 'false' + run: pnpm --dir src/web-ui run gen:types + - name: Validate PPT Live generated-file contract + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run test:ppt-live - name: Validate GitHub config run: pnpm run check:github-config - name: Select i18n contract profile + if: needs.build-impact.outputs.frontend_required != 'false' shell: bash run: | if git rev-parse HEAD^1 >/dev/null 2>&1 && @@ -448,34 +548,53 @@ jobs: fi - name: Validate i18n contract + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run i18n:contract:test:ci - name: Audit i18n resources + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run i18n:audit - name: Validate theme color audit contract + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run theme:color-audit:test - name: Audit theme color governance + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run theme:color-audit:all - name: Validate theme visual governance contract + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run theme:visual-contract - name: Validate WebKit compatibility gate + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run verify:webkit-compatibility:test - name: Lint web UI + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run lint:web - name: Run web UI tests + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm --dir src/web-ui run test:run - name: Build web UI + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run build:web - name: Type-check mobile web + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm --dir src/mobile-web run type-check - name: Build mobile web + if: needs.build-impact.outputs.frontend_required != 'false' run: pnpm run build:mobile-web + + - name: Project a Nightly version + if: needs.build-impact.outputs.frontend_required != 'false' + run: node scripts/set-build-version.mjs --version "0.0.0-nightly.ci.${{ github.run_id }}" + + - name: Verify projected release metadata + if: needs.build-impact.outputs.frontend_required != 'false' + run: cargo metadata --locked --no-deps diff --git a/.github/workflows/linux-binaries.yml b/.github/workflows/linux-binaries.yml index 6b7f98df0e..af21924880 100644 --- a/.github/workflows/linux-binaries.yml +++ b/.github/workflows/linux-binaries.yml @@ -15,6 +15,26 @@ on: description: "Stable prefix that isolates artifacts in the caller run." required: true type: string + artifact_retention_days: + description: "Retention for uploaded build artifacts." + required: false + default: 7 + type: number + upload_artifacts: + description: "Whether to upload archives for a publishing caller." + required: false + default: true + type: boolean + cache_write: + description: "Allow a trusted non-PR caller to refresh build caches." + required: false + default: false + type: boolean + validate_relay_image: + description: "Build the Relay runtime image for each native architecture without pushing it." + required: false + default: true + type: boolean secrets: release_signing_key: description: "Tauri/minisign private key, base64. Absent on forks: archives ship unsigned." @@ -47,9 +67,11 @@ jobs: - os: ubuntu-22.04 name: linux-x64 target: x86_64-unknown-linux-gnu + docker_arch: amd64 - os: ubuntu-22.04-arm name: linux-arm64 target: aarch64-unknown-linux-gnu + docker_arch: arm64 steps: - name: Checkout @@ -61,6 +83,13 @@ jobs: uses: oven-sh/setup-bun@v2 with: bun-version: "1.3.14" + no-cache: true + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: package.json + package-manager-cache: false - name: Build plugin Host resources run: | @@ -91,8 +120,13 @@ jobs: - name: Cache Rust build uses: swatinem/rust-cache@v2 with: - shared-key: "linux-binaries-v1-${{ matrix.platform.name }}" + shared-key: "linux-binaries-v2-${{ github.base_ref || github.ref_name }}-${{ matrix.platform.name }}" cache-bin: false + save-if: ${{ inputs.cache_write && github.event_name != 'pull_request' }} + cache-on-failure: ${{ inputs.cache_write && github.event_name != 'pull_request' }} + + - name: Verify committed Cargo metadata + run: cargo metadata --locked --no-deps - name: Patch build version shell: bash @@ -106,12 +140,10 @@ jobs: # the same way, which also keeps the two asset sets on one version string. ASSET_VERSION="${RELEASE_VERSION%%+*}" echo "ASSET_VERSION=${ASSET_VERSION}" >>"$GITHUB_ENV" - sed -i \ - "s/^version = \".*\" # x-release-please-version/version = \"${ASSET_VERSION}\" # x-release-please-version/" \ - Cargo.toml - sed -i \ - "s/^version = \".*\" # x-release-please-version/version = \"${ASSET_VERSION}\" # x-release-please-version/" \ - src/apps/relay-server/Cargo.toml + node scripts/set-build-version.mjs --version "$ASSET_VERSION" + + - name: Verify projected Cargo metadata + run: cargo metadata --locked --no-deps - name: Build CLI and Relay Server shell: bash @@ -121,7 +153,7 @@ jobs: # checksum-only. BITFUN_RELEASE_PUBKEY: ${{ secrets.release_pubkey }} run: | - cargo build --release \ + cargo build --locked --release \ --target ${{ matrix.platform.target }} \ -p bitfun-cli \ -p bitfun-relay-server \ @@ -179,12 +211,58 @@ jobs: "$CLI_ARCHIVE" "${CLI_ARCHIVE}.sha256" \ "$RELAY_ARCHIVE" "${RELAY_ARCHIVE}.sha256" + - name: Verify Linux binary outputs + shell: bash + env: + CLI_ARCHIVE: ${{ steps.cli-stage.outputs.archive }} + CLI_CHECKSUM: ${{ steps.cli-stage.outputs.checksum }} + RELAY_ARCHIVE: ${{ steps.relay-stage.outputs.archive }} + RELAY_CHECKSUM: ${{ steps.relay-stage.outputs.checksum }} + run: | + set -euo pipefail + test -s "$CLI_ARCHIVE" + test -s "$CLI_CHECKSUM" + test -s "$RELAY_ARCHIVE" + test -s "$RELAY_CHECKSUM" + sha256sum --check "$CLI_CHECKSUM" + sha256sum --check "$RELAY_CHECKSUM" + + - name: Stage Relay image validation context + if: ${{ inputs.validate_relay_image }} + shell: bash + env: + RELAY_ARCHIVE: ${{ steps.relay-stage.outputs.archive }} + run: | + set -euo pipefail + mkdir -p relay-image-context + cp "$RELAY_ARCHIVE" relay-image-context/ + cp src/apps/relay-server/Dockerfile.release relay-image-context/ + + - name: Set up Docker Buildx + if: ${{ inputs.validate_relay_image }} + uses: docker/setup-buildx-action@v4 + + - name: Validate Relay runtime image + if: ${{ inputs.validate_relay_image }} + uses: docker/build-push-action@v7 + with: + context: relay-image-context + file: relay-image-context/Dockerfile.release + platforms: linux/${{ matrix.platform.docker_arch }} + push: false + provenance: false + sbom: false + build-args: | + VERSION=${{ inputs.version }} + REVISION=${{ inputs.checkout_ref }} + - name: Upload Linux binary artifacts + if: ${{ inputs.upload_artifacts }} uses: actions/upload-artifact@v6 with: name: bitfun-linux-${{ inputs.artifact_prefix }}-${{ matrix.platform.name }} if-no-files-found: error - retention-days: 7 + retention-days: ${{ inputs.artifact_retention_days }} path: | ${{ steps.cli-stage.outputs.archive }} ${{ steps.cli-stage.outputs.checksum }} diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml new file mode 100644 index 0000000000..62dda2c5aa --- /dev/null +++ b/.github/workflows/nightly-artifacts.yml @@ -0,0 +1,369 @@ +name: Nightly Artifact Build + +on: + workflow_call: + inputs: + checkout_ref: + description: "Commit SHA or ref to build." + required: true + type: string + version: + description: "Prerelease version projected into every artifact producer." + required: true + type: string + artifact_prefix: + description: "Prefix isolating artifacts in the caller run." + required: true + type: string + artifact_retention_days: + description: "Retention for build artifacts." + required: false + default: 1 + type: number + build_desktop_packages: + description: "Whether to run Desktop/Installer package producers." + required: false + default: true + type: boolean + desktop_platforms: + description: "JSON array of Desktop/Installer platform names to package." + required: false + default: '["linux-x64","linux-arm64","macos-arm64","macos-x64","windows-x64"]' + type: string + build_linux_binaries: + description: "Whether to build Linux CLI and Relay archives." + required: false + default: true + type: boolean + build_relay_image: + description: "Whether to validate the Relay runtime image without publishing it." + required: false + default: true + type: boolean + upload_artifacts: + description: "Whether to upload package outputs for a publishing caller." + required: false + default: true + type: boolean + cache_write: + description: "Allow a trusted non-PR caller to refresh build caches." + required: false + default: false + type: boolean + secrets: + TAURI_SIGNING_PRIVATE_KEY: + required: false + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: + required: false + TAURI_UPDATER_PUBKEY: + required: false + +permissions: + contents: read + +jobs: + # ── Patch version for nightly ────────────────────────────────────── + package: + name: Package (${{ matrix.platform }}) + if: ${{ inputs.build_desktop_packages }} + runs-on: ${{ fromJSON('{"linux-x64":"ubuntu-latest","linux-arm64":"ubuntu-24.04-arm","macos-arm64":"macos-15","macos-x64":"macos-15-intel","windows-x64":"windows-latest"}')[matrix.platform] }} + env: + NODE_OPTIONS: --max-old-space-size=6144 + BITFUN_RELEASE_CHANNEL: nightly + # Nightly does not publish a Tauri latest.json feed yet. Preserve its + # existing stable updater endpoints until that publishing path exists. + TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json + TAURI_UPDATER_FALLBACK_ENDPOINT: https://openbitfun.com/release/latest.json + # Nightly relay archives are signed too (linux-binaries.yml receives the + # key), so nightly Desktop needs the same trust root to verify them. + BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + PACKAGE_TARGET: ${{ fromJSON('{"linux-x64":"x86_64-unknown-linux-gnu","linux-arm64":"aarch64-unknown-linux-gnu","macos-arm64":"aarch64-apple-darwin","macos-x64":"x86_64-apple-darwin","windows-x64":"x86_64-pc-windows-msvc"}')[matrix.platform] }} + + strategy: + fail-fast: false + matrix: + platform: ${{ fromJSON(inputs.desktop_platforms) }} + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.checkout_ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + no-cache: true + + - name: Install Linux system dependencies (Tauri bundler) + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + + if apt-cache show libwebkit2gtk-4.1-dev >/dev/null 2>&1; then + WEBKIT_PKG=libwebkit2gtk-4.1-dev + else + WEBKIT_PKG=libwebkit2gtk-4.0-dev + fi + + if apt-cache show libappindicator3-dev >/dev/null 2>&1; then + APPINDICATOR_PKG=libappindicator3-dev + else + APPINDICATOR_PKG=libayatana-appindicator3-dev + fi + + # Tauri pins AppImage GTK input methods to its bundled cache, so the + # fcitx5 GTK3 bridge must be present before linuxdeploy builds it. + sudo apt-get install -y --no-install-recommends \ + pkg-config \ + xdg-utils \ + libglib2.0-dev \ + libgtk-3-dev \ + fcitx5-frontend-gtk3 \ + libxdo-dev \ + "$WEBKIT_PKG" \ + "$APPINDICATOR_PKG" \ + librsvg2-dev \ + patchelf \ + fakeroot \ + rpm \ + libleptonica-dev \ + libtesseract-dev \ + tesseract-ocr \ + tesseract-ocr-eng + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ env.PACKAGE_TARGET }} + + - name: Cache Rust build + uses: swatinem/rust-cache@v2 + with: + shared-key: "nightly-v3-${{ github.base_ref || github.ref_name }}-${{ matrix.platform }}" + cache-bin: false + # A PR merge ref can never become a reusable base cache. Even a + # trusted caller cannot override this guard. + save-if: ${{ inputs.cache_write && github.event_name != 'pull_request' }} + cache-on-failure: ${{ inputs.cache_write && github.event_name != 'pull_request' }} + + - name: Verify committed Cargo metadata + run: cargo metadata --locked --no-deps + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify Installer i18n projection + if: runner.os == 'Windows' + run: pnpm --dir BitFun-Installer run sync:i18n + + - name: Verify Installer Tauri package alignment + if: runner.os == 'Windows' + shell: bash + run: | + set -euo pipefail + info="$(pnpm --dir BitFun-Installer exec tauri info 2>&1)" + printf '%s\n' "$info" + if grep -Fq 'Error: Found version mismatched Tauri packages.' <<<"$info"; then + exit 1 + fi + + - name: Build plugin Host resources + run: | + bun install --cwd src/apps/extension-host --frozen-lockfile + bun run --cwd src/apps/extension-host build + + - name: Generate web API bindings + run: pnpm --dir src/web-ui run gen:types + + - name: Type-check web UI + run: pnpm run type-check:web + + - name: Patch nightly version + shell: bash + env: + NIGHTLY_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + + echo "Patching version to $NIGHTLY_VERSION" + ASSET_VERSION="${NIGHTLY_VERSION%%+*}" + node scripts/set-build-version.mjs --version "$ASSET_VERSION" + + echo "package.json version: $(jq -r '.version' package.json)" + echo "Cargo.toml version: $(grep 'x-release-please-version' Cargo.toml)" + + - name: Verify projected Cargo metadata + run: cargo metadata --locked --no-deps + + - name: Run Windows CLI terminal contracts + if: runner.os == 'Windows' + env: + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + run: cargo test --locked -p bitfun-cli --test terminal_process_contracts -- --test-threads=1 + + - name: Build desktop app + shell: bash + run: | + case "${{ matrix.platform }}" in + linux-x64) + pnpm run desktop:build:linux -- --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage + ;; + linux-arm64) + CARGO_PROFILE_RELEASE_LTO=thin pnpm run desktop:build:linux -- --target aarch64-unknown-linux-gnu --bundles deb,rpm,appimage + ;; + macos-arm64) + pnpm run desktop:build:arm64 + ;; + macos-x64) + pnpm run desktop:build:x86_64 + ;; + windows-x64) + pnpm run installer:build + ;; + *) + echo "Unsupported package platform: ${{ matrix.platform }}" >&2 + exit 1 + ;; + esac + + - name: Package macOS CLI for SSH dispatch + if: runner.os == 'macOS' + id: macos-cli + shell: bash + env: + NIGHTLY_VERSION: ${{ inputs.version }} + TARGET: ${{ env.PACKAGE_TARGET }} + run: | + set -euo pipefail + ASSET_VERSION="${NIGHTLY_VERSION%%+*}" + cargo build --release --target "$TARGET" -p bitfun-cli + bash scripts/cli/package-unix.sh "$ASSET_VERSION" "$TARGET" + + - name: Sign macOS CLI archive and checksum + if: runner.os == 'macOS' + shell: bash + env: + BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} + run: | + set -euo pipefail + bash scripts/sign-release-assets.sh \ + "${{ steps.macos-cli.outputs.archive }}" \ + "${{ steps.macos-cli.outputs.checksum }}" + + - name: Verify AppImage fcitx5 GTK module + if: runner.os == 'Linux' + shell: bash + run: bash scripts/ci/verify-appimage-fcitx.sh "${{ env.PACKAGE_TARGET }}" + + # Keep output validation independent from artifact upload. PR callers do + # not upload packages, but still need evidence that every selected + # producer emitted the files its publishing caller will collect. + - name: Verify package outputs + shell: bash + run: | + set -euo pipefail + + find_bundle_file() { + local pattern="$1" + local root + for root in \ + "target/${PACKAGE_TARGET}/release/bundle" \ + "target/release/bundle" \ + "src/apps/desktop/target/${PACKAGE_TARGET}/release/bundle" \ + "src/apps/desktop/target/release/bundle"; do + if [[ -d "$root" ]] && find "$root" -type f -name "$pattern" -size +0c -print -quit | grep -q .; then + return 0 + fi + done + echo "Missing non-empty package output matching $pattern for ${{ matrix.platform }}" >&2 + return 1 + } + + find_bundle_dir() { + local pattern="$1" + local root + for root in \ + "target/${PACKAGE_TARGET}/release/bundle" \ + "target/release/bundle" \ + "src/apps/desktop/target/${PACKAGE_TARGET}/release/bundle" \ + "src/apps/desktop/target/release/bundle"; do + if [[ -d "$root" ]] && find "$root" -type d -name "$pattern" -print -quit | grep -q .; then + return 0 + fi + done + echo "Missing package directory matching $pattern for ${{ matrix.platform }}" >&2 + return 1 + } + + case "${{ matrix.platform }}" in + linux-*) + find_bundle_file '*.deb' + find_bundle_file '*.rpm' + find_bundle_file '*.AppImage' + ;; + macos-*) + find_bundle_dir '*.app' + find_bundle_file '*.dmg' + shopt -s nullglob + cli_archives=(bitfun-cli-*-"${PACKAGE_TARGET}".tar.gz) + [[ ${#cli_archives[@]} -eq 1 ]] + test -s "${cli_archives[0]}" + test -s "${cli_archives[0]}.sha256" + ;; + windows-x64) + test -s BitFun-Installer/src-tauri/target/release/bitfun-installer.exe + ;; + *) + echo "Unsupported package platform: ${{ matrix.platform }}" >&2 + exit 1 + ;; + esac + + - name: Upload bundles + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v6 + with: + name: bitfun-${{ inputs.artifact_prefix }}-${{ matrix.platform }}-bundle + if-no-files-found: error + retention-days: ${{ inputs.artifact_retention_days }} + path: | + target/*/release/bundle + target/release/bundle + src/apps/desktop/target/release/bundle + BitFun-Installer/src-tauri/target/release/bitfun-installer.exe + bitfun-cli-*-apple-darwin.tar.gz + bitfun-cli-*-apple-darwin.tar.gz.sha256 + bitfun-cli-*-apple-darwin.tar.gz.sig + bitfun-cli-*-apple-darwin.tar.gz.sha256.sig + + linux-binaries: + name: Linux CLI and Relay Server + if: ${{ inputs.build_linux_binaries || inputs.build_relay_image }} + uses: ./.github/workflows/linux-binaries.yml + secrets: + release_signing_key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + release_signing_password: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + release_pubkey: ${{ secrets.TAURI_UPDATER_PUBKEY }} + with: + checkout_ref: ${{ inputs.checkout_ref }} + version: ${{ inputs.version }} + artifact_prefix: ${{ inputs.artifact_prefix }} + artifact_retention_days: ${{ inputs.artifact_retention_days }} + upload_artifacts: ${{ inputs.upload_artifacts }} + cache_write: ${{ inputs.cache_write }} + validate_relay_image: ${{ inputs.build_relay_image }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 647d1c759c..4263689ec4 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -5,13 +5,24 @@ on: # Weekdays at 02:00 UTC - cron: "0 2 * * 1-5" workflow_dispatch: - + inputs: + build_only: + description: "Build and package every artifact without publishing it." + required: false + default: false + type: boolean + artifact_retention_days: + description: "Retention for build artifacts." + required: false + default: 7 + type: number permissions: - contents: write - packages: write + contents: read concurrency: - group: nightly + # The release, tag, and GHCR image tags are repository-wide resources. Keep + # manual runs from other refs serialized with the scheduled main run. + group: nightly-${{ github.repository }} cancel-in-progress: true env: @@ -60,223 +71,43 @@ jobs: echo "should_build=false" >> "$GITHUB_OUTPUT" fi - # ── Patch version for nightly ────────────────────────────────────── - package: - name: Package (${{ matrix.platform.name }}) - runs-on: ${{ matrix.platform.os }} + # ── Build every Nightly artifact through the reusable CI contract ── + build-artifacts: + name: Build Nightly Artifacts needs: check-changes if: needs.check-changes.outputs.should_build == 'true' - env: - NODE_OPTIONS: --max-old-space-size=6144 - BITFUN_RELEASE_CHANNEL: nightly - # Nightly does not publish a Tauri latest.json feed yet. Preserve its - # existing stable updater endpoints until that publishing path exists. - TAURI_UPDATER_ENDPOINT: https://github.com/GCWing/BitFun/releases/latest/download/latest.json - TAURI_UPDATER_FALLBACK_ENDPOINT: https://openbitfun.com/release/latest.json - # Nightly relay archives are signed too (linux-binaries.yml receives the - # key), so nightly Desktop needs the same trust root to verify them. - BITFUN_RELEASE_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} - - strategy: - fail-fast: false - matrix: - platform: - - os: ubuntu-latest - name: linux-x64 - target: x86_64-unknown-linux-gnu - build_command: pnpm run desktop:build:linux -- --target x86_64-unknown-linux-gnu --bundles deb,rpm,appimage - - os: ubuntu-24.04-arm - name: linux-arm64 - target: aarch64-unknown-linux-gnu - # Fat LTO exhausts the hosted ARM runner while linking bitfun-desktop. - build_command: CARGO_PROFILE_RELEASE_LTO=thin pnpm run desktop:build:linux -- --target aarch64-unknown-linux-gnu --bundles deb,rpm,appimage - - os: macos-15 - name: macos-arm64 - target: aarch64-apple-darwin - build_command: pnpm run desktop:build:arm64 - - os: macos-15-intel - name: macos-x64 - target: x86_64-apple-darwin - build_command: pnpm run desktop:build:x86_64 - - os: windows-latest - name: windows-x64 - target: x86_64-pc-windows-msvc - build_command: pnpm run installer:build - - steps: - - uses: actions/checkout@v5 - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.14" - - - name: Install Linux system dependencies (Tauri bundler) - if: runner.os == 'Linux' - shell: bash - run: | - sudo apt-get update - - if apt-cache show libwebkit2gtk-4.1-dev >/dev/null 2>&1; then - WEBKIT_PKG=libwebkit2gtk-4.1-dev - else - WEBKIT_PKG=libwebkit2gtk-4.0-dev - fi - - if apt-cache show libappindicator3-dev >/dev/null 2>&1; then - APPINDICATOR_PKG=libappindicator3-dev - else - APPINDICATOR_PKG=libayatana-appindicator3-dev - fi - - # Tauri pins AppImage GTK input methods to its bundled cache, so the - # fcitx5 GTK3 bridge must be present before linuxdeploy builds it. - sudo apt-get install -y --no-install-recommends \ - pkg-config \ - xdg-utils \ - libglib2.0-dev \ - libgtk-3-dev \ - fcitx5-frontend-gtk3 \ - libxdo-dev \ - "$WEBKIT_PKG" \ - "$APPINDICATOR_PKG" \ - librsvg2-dev \ - patchelf \ - fakeroot \ - rpm \ - libleptonica-dev \ - libtesseract-dev \ - tesseract-ocr \ - tesseract-ocr-eng - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: 22 - cache: pnpm - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.platform.target }} - - - name: Cache Rust build - uses: swatinem/rust-cache@v2 - with: - shared-key: "nightly-v2-${{ matrix.platform.name }}" - cache-bin: false - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build plugin Host resources - run: | - bun install --cwd src/apps/extension-host --frozen-lockfile - bun run --cwd src/apps/extension-host build - - - name: Generate web API bindings - run: pnpm --dir src/web-ui run gen:types - - - name: Type-check web UI - run: pnpm run type-check:web - - - name: Patch nightly version - shell: bash - env: - NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} - run: | - set -euo pipefail - - echo "Patching version to $NIGHTLY_VERSION" - ASSET_VERSION="${NIGHTLY_VERSION%%+*}" - node scripts/set-build-version.mjs --version "$ASSET_VERSION" - - echo "package.json version: $(jq -r '.version' package.json)" - echo "Cargo.toml version: $(grep 'x-release-please-version' Cargo.toml)" - - - name: Run Windows CLI terminal contracts - if: runner.os == 'Windows' - env: - CARGO_INCREMENTAL: "0" - CARGO_PROFILE_DEV_DEBUG: "0" - CARGO_PROFILE_TEST_DEBUG: "0" - run: | - cargo generate-lockfile - cargo test --locked -p bitfun-cli --test terminal_process_contracts - - - name: Build desktop app - run: ${{ matrix.platform.build_command }} - - - name: Package macOS CLI for SSH dispatch - if: runner.os == 'macOS' - id: macos-cli - shell: bash - env: - NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} - TARGET: ${{ matrix.platform.target }} - run: | - set -euo pipefail - ASSET_VERSION="${NIGHTLY_VERSION%%+*}" - cargo build --release --target "$TARGET" -p bitfun-cli - bash scripts/cli/package-unix.sh "$ASSET_VERSION" "$TARGET" - - - name: Sign macOS CLI archive and checksum - if: runner.os == 'macOS' - shell: bash - env: - BITFUN_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - BITFUN_SIGNING_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - BITFUN_SIGNING_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} - run: | - set -euo pipefail - bash scripts/sign-release-assets.sh \ - "${{ steps.macos-cli.outputs.archive }}" \ - "${{ steps.macos-cli.outputs.checksum }}" - - - name: Verify AppImage fcitx5 GTK module - if: runner.os == 'Linux' - shell: bash - run: bash scripts/ci/verify-appimage-fcitx.sh "${{ matrix.platform.target }}" - - - name: Upload bundles - uses: actions/upload-artifact@v6 - with: - name: bitfun-nightly-${{ matrix.platform.name }}-bundle - if-no-files-found: error - retention-days: 7 - path: | - target/*/release/bundle - target/release/bundle - src/apps/desktop/target/release/bundle - BitFun-Installer/src-tauri/target/release/bitfun-installer.exe - bitfun-cli-*-apple-darwin.tar.gz - bitfun-cli-*-apple-darwin.tar.gz.sha256 - bitfun-cli-*-apple-darwin.tar.gz.sig - bitfun-cli-*-apple-darwin.tar.gz.sha256.sig - - linux-binaries: - name: Linux CLI and Relay Server - needs: check-changes - if: needs.check-changes.outputs.should_build == 'true' - uses: ./.github/workflows/linux-binaries.yml + uses: ./.github/workflows/nightly-artifacts.yml + permissions: + contents: read secrets: - release_signing_key: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - release_signing_password: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - release_pubkey: ${{ secrets.TAURI_UPDATER_PUBKEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + TAURI_UPDATER_PUBKEY: ${{ secrets.TAURI_UPDATER_PUBKEY }} with: checkout_ref: ${{ github.sha }} version: ${{ needs.check-changes.outputs.nightly_version }} artifact_prefix: nightly + artifact_retention_days: ${{ fromJSON(inputs.artifact_retention_days || '7') }} + build_desktop_packages: true + desktop_platforms: '["linux-x64","linux-arm64","macos-arm64","macos-x64","windows-x64"]' + build_linux_binaries: true + build_relay_image: true + upload_artifacts: true + # Fork validation is intentionally cache-read-only. Only the upstream + # repository may seed long-lived Nightly caches. + cache_write: ${{ github.repository_owner == 'GCWing' }} # ── Publish nightly pre-release ──────────────────────────────────── publish-nightly: name: Publish Nightly - needs: [check-changes, package, linux-binaries] - if: needs.check-changes.outputs.should_build == 'true' + needs: [check-changes, build-artifacts] + if: >- + needs.check-changes.outputs.should_build == 'true' && + inputs.build_only != true runs-on: ubuntu-latest + permissions: + contents: write + packages: write steps: - uses: actions/checkout@v5 @@ -324,7 +155,9 @@ jobs: shell: bash env: NIGHTLY_VERSION: ${{ needs.check-changes.outputs.nightly_version }} - run: echo "asset_version=${NIGHTLY_VERSION%%+*}" >>"$GITHUB_OUTPUT" + run: | + echo "asset_version=${NIGHTLY_VERSION%%+*}" >>"$GITHUB_OUTPUT" + echo "image=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/bitfun-relay-server" >>"$GITHUB_OUTPUT" - name: Build and push multi-platform Relay image id: relay-image @@ -340,15 +173,16 @@ jobs: VERSION=${{ needs.check-changes.outputs.nightly_version }} REVISION=${{ github.sha }} tags: | - ghcr.io/gcwing/bitfun-relay-server:${{ env.NIGHTLY_TAG }} - ghcr.io/gcwing/bitfun-relay-server:${{ steps.nightly-image-meta.outputs.asset_version }} + ${{ steps.nightly-image-meta.outputs.image }}:${{ env.NIGHTLY_TAG }} + ${{ steps.nightly-image-meta.outputs.image }}:${{ steps.nightly-image-meta.outputs.asset_version }} - name: Smoke-test published Relay image on both platforms shell: bash env: IMAGE_DIGEST: ${{ steps.relay-image.outputs.digest }} - run: bash scripts/relay/smoke-image.sh \ - "ghcr.io/gcwing/bitfun-relay-server@${IMAGE_DIGEST}" + run: | + bash scripts/relay/smoke-image.sh \ + "${{ steps.nightly-image-meta.outputs.image }}@${IMAGE_DIGEST}" - name: Generate signed Relay image descriptor shell: bash @@ -363,7 +197,7 @@ jobs: asset_version="${NIGHTLY_VERSION%%+*}" [[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]] docker buildx imagetools inspect \ - "ghcr.io/gcwing/bitfun-relay-server@${IMAGE_DIGEST}" \ + "${{ steps.nightly-image-meta.outputs.image }}@${IMAGE_DIGEST}" \ --raw >relay-image-manifest.json jq -e ' [.manifests[].platform | .os + "/" + .architecture] as $platforms @@ -371,7 +205,7 @@ jobs: and ($platforms | index("linux/arm64")) != null ' relay-image-manifest.json >/dev/null jq -n \ - --arg image "ghcr.io/gcwing/bitfun-relay-server" \ + --arg image "${{ steps.nightly-image-meta.outputs.image }}" \ --arg tag "${NIGHTLY_TAG}" \ --arg version "${asset_version}" \ --arg digest "${IMAGE_DIGEST}" \ @@ -396,7 +230,7 @@ jobs: clean_config="$(mktemp -d)" trap 'rm -rf "$clean_config"' EXIT DOCKER_CONFIG="$clean_config" docker buildx imagetools inspect \ - "ghcr.io/gcwing/bitfun-relay-server@${IMAGE_DIGEST}" >/dev/null + "${{ steps.nightly-image-meta.outputs.image }}@${IMAGE_DIGEST}" >/dev/null - name: List release assets run: | @@ -421,7 +255,7 @@ jobs: --assets-dir linux-release-assets \ --version "${NIGHTLY_VERSION%%+*}" \ --tag "${{ env.NIGHTLY_TAG }}" \ - --repo "GCWing/BitFun" \ + --repo "${{ github.repository }}" \ --out linux-release-assets/linux-binaries.json - name: Prepare versioned Windows installer @@ -515,14 +349,14 @@ jobs: run: | set -euo pipefail curl -fsSL --retry 5 --retry-delay 3 \ - "https://github.com/GCWing/BitFun/releases/download/${NIGHTLY_TAG}/relay-image.json" \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${NIGHTLY_TAG}/relay-image.json" \ -o relay-image.published.json test "$(jq -r '.tag' relay-image.published.json)" = "${NIGHTLY_TAG}" test "$(jq -r '.version' relay-image.published.json)" = "${NIGHTLY_VERSION%%+*}" - test "$(jq -r '.image' relay-image.published.json)" = "ghcr.io/gcwing/bitfun-relay-server" + test "$(jq -r '.image' relay-image.published.json)" = "${{ steps.nightly-image-meta.outputs.image }}" jq -e '.digest | test("^sha256:[0-9a-f]{64}$")' relay-image.published.json >/dev/null curl -fsSL --retry 5 --retry-delay 3 \ - "https://github.com/GCWing/BitFun/releases/download/${NIGHTLY_TAG}/relay-image.json.sig" \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${NIGHTLY_TAG}/relay-image.json.sig" \ -o /dev/null - name: Verify published macOS CLI assets @@ -532,7 +366,7 @@ jobs: run: | set -euo pipefail ASSET_VERSION="${NIGHTLY_VERSION%%+*}" - RELEASE_ROOT="https://github.com/GCWing/BitFun/releases/download/${{ env.NIGHTLY_TAG }}" + RELEASE_ROOT="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${{ env.NIGHTLY_TAG }}" for target in aarch64-apple-darwin x86_64-apple-darwin; do archive="${RELEASE_ROOT}/bitfun-cli-${ASSET_VERSION}-${target}.tar.gz" curl -fsSL --retry 5 --retry-delay 3 "$archive" -o /dev/null diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index 75d411b6b7..b2d9bddae6 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -209,12 +209,12 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => ); const rustJob = workflow.jobs['rust-build-check']; const frontendJob = workflow.jobs['frontend-build']; - const trustedMain = - "${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}"; + const trustedBase = + "${{ github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/1.0.0-explore') }}"; assert.equal( rustJob.needs, - 'rust-impact', + 'build-impact', 'Rust validation must not wait for the frontend build', ); assert.equal( @@ -247,10 +247,26 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => false, `${jobName} must consume the committed Cargo.lock`, ); - assert.equal(cache?.with?.['save-if'], trustedMain); - assert.equal(cache?.with?.['cache-on-failure'], trustedMain); + assert.equal(cache?.with?.['save-if'], trustedBase); + assert.equal(cache?.with?.['cache-on-failure'], trustedBase); + assert.match( + cache?.with?.['shared-key'] ?? '', + /github\.base_ref \|\| github\.ref_name/, + `${jobName} must not mix main and explore build outputs`, + ); } + const cliJob = workflow.jobs['cli-test']; + assert.equal(cliJob['timeout-minutes'], 30); + assert.ok( + cliJob.strategy.matrix.include.some((entry) => entry.os === 'windows-latest'), + 'Windows ConPTY contracts must run before Nightly', + ); + assert.equal( + cliJob.steps.find((step) => step.name === 'Run Windows CLI terminal contracts')?.run, + 'cargo test --locked -p bitfun-cli --test terminal_process_contracts -- --test-threads=1', + ); + const rustCache = rustJob.steps.find((step) => step.uses?.startsWith('swatinem/rust-cache@'), ); @@ -273,7 +289,7 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => (step) => step.name === 'Save Sherpa native libraries', ); const sherpaCacheKey = - 'sherpa-onnx-v1-${{ runner.os }}-${{ runner.arch }}-1.13.4-static'; + 'sherpa-onnx-v2-${{ github.base_ref || github.ref_name }}-${{ runner.os }}-${{ runner.arch }}-1.13.4-static'; assert.equal(restoreSherpaCache?.uses, 'actions/cache/restore@v5'); assert.equal(restoreSherpaCache?.with?.path, 'target/sherpa-onnx-prebuilt'); @@ -288,7 +304,7 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => assert.equal(saveSherpaCache?.with?.key, sherpaCacheKey); assert.equal( saveSherpaCache?.if, - "github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.sherpa-native-cache.outputs.cache-hit != 'true'", + "github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/1.0.0-explore') && steps.sherpa-native-cache.outputs.cache-hit != 'true'", ); assert.ok( rustJob.steps.indexOf(restoreSherpaCache) < @@ -304,6 +320,14 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => const commandByStep = new Map( rustJob.steps.map((step) => [step.name, step.run]), ); + const verifyMetadata = rustJob.steps.find( + (step) => step.name === 'Verify committed Cargo metadata', + ); + assert.equal(verifyMetadata?.run, 'cargo metadata --locked --no-deps'); + assert.ok( + rustJob.steps.indexOf(verifyMetadata) < rustJob.steps.indexOf(checkCompilation), + 'CI must validate the committed Cargo.lock before the workspace check', + ); assert.equal( commandByStep.get('Run subscription authentication tests'), 'cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth', @@ -316,17 +340,25 @@ test('keeps Rust CI independent, restore-only on PRs, and target-focused', () => installerCheck?.run, 'cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml', ); + const fileWatchContracts = rustJob.steps.find( + (step) => step.name === 'Run file watch contract tests', + ); assert.equal( - commandByStep.get('Run file watch contract tests'), + fileWatchContracts?.run, 'cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts', ); + assert.equal( + fileWatchContracts?.if, + undefined, + 'file-watch contracts must exercise FSEvents on macOS', + ); assert.equal( commandByStep.get('Run search tool tests'), 'cargo test --locked -p tool-runtime --lib search::', ); }); -test('gates Rust and CLI validation behind one fail-closed impact decision', () => { +test('gates fast checks and PR packaging behind one fail-closed build decision', (t) => { const workflow = yaml.parse( readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'), ); @@ -337,9 +369,10 @@ test('gates Rust and CLI validation behind one fail-closed impact decision', () 'nested Markdown may be a Rust compile-time input and must trigger classification', ); } - const impactJob = workflow.jobs['rust-impact']; + const impactJob = workflow.jobs['build-impact']; const cliJob = workflow.jobs['cli-test']; const rustJob = workflow.jobs['rust-build-check']; + const buildJob = workflow.jobs['package-impact-contract']; const resultJob = workflow.jobs['rust-validation-result']; const frontendJob = workflow.jobs['frontend-build']; @@ -351,17 +384,69 @@ test('gates Rust and CLI validation behind one fail-closed impact decision', () frontendJob.steps.find((step) => step.name === 'Check core boundaries')?.run, 'pnpm run check:core-boundaries', ); + assert.equal(frontendJob.needs, 'build-impact'); + const frontendNode = frontendJob.steps.find((step) => + step.uses?.startsWith('actions/setup-node@')); + assert.equal(frontendNode?.with?.cache, undefined); + assert.equal(frontendNode?.with?.['package-manager-cache'], false); + const frontendGate = "needs.build-impact.outputs.frontend_required != 'false'"; + for (const stepName of [ + 'Verify committed release metadata', + 'Verify Installer i18n projection', + 'Verify Installer Tauri package alignment', + 'Build plugin Host resources', + 'Generate web API bindings', + 'Build web UI', + 'Build mobile web', + 'Project a Nightly version', + 'Verify projected release metadata', + ]) { + assert.equal( + frontendJob.steps.find((step) => step.name === stepName)?.if, + frontendGate, + `${stepName} must run for code changes and skip documentation-only changes`, + ); + } + const releaseMetadata = 'cargo metadata --locked --no-deps'; + assert.equal( + frontendJob.steps.find((step) => step.name === 'Verify committed release metadata')?.run, + releaseMetadata, + ); + assert.equal( + frontendJob.steps.find((step) => step.name === 'Verify projected release metadata')?.run, + releaseMetadata, + ); - assert.equal(impactJob.name, 'Rust / CLI Impact'); + assert.equal(impactJob.name, 'Build Impact'); assert.equal(impactJob['timeout-minutes'], 5); assert.equal( impactJob.outputs.rust_required, '${{ steps.classify.outputs.rust_required }}', ); + assert.equal( + impactJob.outputs.desktop_platforms, + '${{ steps.classify.outputs.desktop_platforms }}', + ); + assert.equal( + impactJob.outputs.desktop_packages_impacted, + '${{ steps.classify.outputs.desktop_packages_impacted }}', + ); + assert.equal( + impactJob.outputs.pr_producer_required, + '${{ steps.classify.outputs.pr_producer_required }}', + ); + assert.equal( + impactJob.outputs.relay_image_required, + '${{ steps.classify.outputs.relay_image_required }}', + ); + assert.equal( + impactJob.outputs.dsh_profile_required, + '${{ steps.classify.outputs.dsh_profile_required }}', + ); const checkout = impactJob.steps.find((step) => step.uses?.startsWith('actions/checkout@')); assert.equal(checkout?.with?.['fetch-depth'], 0); const classify = impactJob.steps.find((step) => step.id === 'classify'); - assert.match(classify?.run ?? '', /scripts\/ci\/classify-rust-impact\.mjs/); + assert.match(classify?.run ?? '', /scripts\/ci\/classify-build-impact\.mjs/); assert.equal( classify?.env?.BASE_SHA, '${{ github.event.pull_request.base.sha || github.event.before }}', @@ -377,58 +462,107 @@ test('gates Rust and CLI validation behind one fail-closed impact decision', () assert.match(classify?.run ?? '', /--range-mode "\$RANGE_MODE"/); for (const job of [cliJob, rustJob]) { - assert.equal(job.needs, 'rust-impact'); + assert.equal(job.needs, 'build-impact'); assert.match(job.if, /!cancelled\(\)/); assert.doesNotMatch(job.if, /always\(\)/); assert.match(job.if, /rust_required != 'false'/); } + assert.equal(buildJob.name, 'Impact-selected Linux / Relay Contract'); + assert.equal(buildJob.needs, 'build-impact'); + assert.equal(buildJob.uses, './.github/workflows/linux-binaries.yml'); + assert.match(buildJob.if, /github\.event_name == 'pull_request'/); + assert.match(buildJob.if, /pr_producer_required == 'true'/); + assert.deepEqual(buildJob.permissions, { contents: 'read' }); + assert.deepEqual(buildJob.with, { + checkout_ref: '${{ github.sha }}', + version: '0.0.0-nightly.ci.${{ github.run_id }}', + artifact_prefix: 'ci-${{ github.run_id }}', + artifact_retention_days: 1, + validate_relay_image: + "${{ needs.build-impact.outputs.relay_image_required == 'true' }}", + upload_artifacts: false, + cache_write: false, + }); + assert.equal(resultJob.name, 'Rust / CLI Validation'); assert.equal(resultJob.if, '${{ always() }}'); assert.deepEqual( [...resultJob.needs].sort(), - ['cli-test', 'rust-build-check', 'rust-impact'], + ['build-impact', 'cli-test', 'package-impact-contract', 'rust-build-check'], ); const verify = resultJob.steps.find((step) => step.name === 'Verify Rust and CLI result'); - assert.equal(verify?.env?.RUST_REQUIRED, '${{ needs.rust-impact.outputs.rust_required }}'); - assert.equal(verify?.env?.IMPACT_RESULT, '${{ needs.rust-impact.result }}'); + assert.equal(verify?.env?.RUST_REQUIRED, '${{ needs.build-impact.outputs.rust_required }}'); + assert.equal( + verify?.env?.PR_PRODUCER_REQUIRED, + '${{ needs.build-impact.outputs.pr_producer_required }}', + ); + assert.equal(verify?.env?.EVENT_NAME, '${{ github.event_name }}'); + assert.equal(verify?.env?.IMPACT_RESULT, '${{ needs.build-impact.result }}'); assert.equal(verify?.env?.CLI_RESULT, '${{ needs.cli-test.result }}'); assert.equal(verify?.env?.RUST_RESULT, '${{ needs.rust-build-check.result }}'); + assert.equal( + verify?.env?.BUILD_RESULT, + '${{ needs.package-impact-contract.result }}', + ); assert.equal(verify?.shell, 'pwsh'); - assert.match(verify?.run ?? '', /expected skipped Rust and CLI jobs/i); - assert.match(verify?.run ?? '', /expected successful Rust and CLI jobs/i); + assert.match(verify?.run ?? '', /successful impact-selected producer jobs/i); + assert.match(verify?.run ?? '', /successful Rust and CLI jobs/i); + + const dshJob = workflow.jobs['dsh-profile-windows']; + assert.equal(dshJob.needs, 'build-impact'); + assert.match(dshJob.if, /dsh_profile_required == 'true'/); const statuses = ['success', 'skipped', 'failure', 'cancelled']; const cases = []; for (const impactResult of statuses.filter((status) => status !== 'success')) { cases.push({ rustRequired: 'true', + producerRequired: 'true', + eventName: 'pull_request', impactResult, cliResult: 'success', rustResult: 'success', + buildResult: 'success', expectedSuccess: false, }); } for (const rustRequired of ['false', 'true']) { - for (const cliResult of statuses) { - for (const rustResult of statuses) { - cases.push({ - rustRequired, - impactResult: 'success', - cliResult, - rustResult, - expectedSuccess: rustRequired === 'false' - ? cliResult === 'skipped' && rustResult === 'skipped' - : cliResult === 'success' && rustResult === 'success', - }); + for (const producerRequired of ['false', 'true']) { + for (const eventName of ['pull_request', 'push']) { + for (const cliResult of statuses) { + for (const rustResult of statuses) { + for (const buildResult of statuses) { + const packageExpected = eventName === 'pull_request' + && producerRequired === 'true'; + cases.push({ + rustRequired, + producerRequired, + eventName, + impactResult: 'success', + cliResult, + rustResult, + buildResult, + expectedSuccess: (packageExpected + ? buildResult === 'success' + : buildResult === 'skipped') && (rustRequired === 'false' + ? cliResult === 'skipped' && rustResult === 'skipped' + : cliResult === 'success' && rustResult === 'success'), + }); + } + } + } } } } cases.push({ rustRequired: '', + producerRequired: 'false', + eventName: 'push', impactResult: 'success', cliResult: 'skipped', rustResult: 'skipped', + buildResult: 'success', expectedSuccess: false, }); const truthTable = spawnSync( @@ -445,9 +579,12 @@ ${verify.run} } foreach ($case in $cases) { $env:RUST_REQUIRED = [string]$case.rustRequired + $env:PR_PRODUCER_REQUIRED = [string]$case.producerRequired + $env:EVENT_NAME = [string]$case.eventName $env:IMPACT_RESULT = [string]$case.impactResult $env:CLI_RESULT = [string]$case.cliResult $env:RUST_RESULT = [string]$case.rustResult + $env:BUILD_RESULT = [string]$case.buildResult $succeeded = $true try { & $verify } catch { $succeeded = $false } if ($succeeded -ne [bool]$case.expectedSuccess) { @@ -461,22 +598,91 @@ foreach ($case in $cases) { encoding: 'utf8', }, ); + if (truthTable.error?.code === 'ENOENT') { + t.skip('pwsh is not installed; GitHub-hosted runners execute this truth table'); + return; + } assert.equal(truthTable.status, 0, `${truthTable.stdout}${truthTable.stderr}`); }); -test('generates web API bindings before nightly web type-check', () => { +test('nightly validates generated inputs and projected lockfiles before packaging', () => { const workflow = yaml.parse( - readFileSync(path.join(repoRoot, '.github/workflows/nightly.yml'), 'utf8'), + readFileSync( + path.join(repoRoot, '.github/workflows/nightly-artifacts.yml'), + 'utf8', + ), ); + const callInputs = workflow.on.workflow_call.inputs; const packageJob = workflow.jobs.package; + const linuxJob = workflow.jobs['linux-binaries']; const steps = packageJob.steps; + const committedMetadataIndex = steps.findIndex( + (step) => step.name === 'Verify committed Cargo metadata', + ); const generationIndex = steps.findIndex( (step) => step.name === 'Generate web API bindings', ); const typeCheckIndex = steps.findIndex( (step) => step.name === 'Type-check web UI', ); + const patchIndex = steps.findIndex( + (step) => step.name === 'Patch nightly version', + ); + const tauriAlignmentIndex = steps.findIndex( + (step) => step.name === 'Verify Installer Tauri package alignment', + ); + const installerI18nIndex = steps.findIndex( + (step) => step.name === 'Verify Installer i18n projection', + ); + const metadataIndex = steps.findIndex( + (step) => step.name === 'Verify projected Cargo metadata', + ); + const buildIndex = steps.findIndex( + (step) => step.name === 'Build desktop app', + ); + + assert.equal(callInputs.checkout_ref.required, true); + assert.equal(callInputs.version.required, true); + assert.equal(callInputs.artifact_prefix.required, true); + assert.equal(callInputs.artifact_retention_days.default, 1); + assert.equal(callInputs.build_desktop_packages.default, true); + assert.equal(callInputs.build_linux_binaries.default, true); + assert.equal(callInputs.build_relay_image.default, true); + assert.equal(callInputs.upload_artifacts.default, true); + assert.equal(callInputs.cache_write.default, false); + assert.equal( + callInputs.desktop_platforms.default, + '["linux-x64","linux-arm64","macos-arm64","macos-x64","windows-x64"]', + ); + assert.equal(workflow.permissions.contents, 'read'); + assert.equal( + linuxJob.if, + '${{ inputs.build_linux_binaries || inputs.build_relay_image }}', + ); + assert.equal( + linuxJob.with.validate_relay_image, + '${{ inputs.build_relay_image }}', + ); + const node = steps.find((step) => step.name === 'Setup Node.js'); + assert.equal(node?.with?.cache, undefined); + assert.equal(node?.with?.['package-manager-cache'], false); + const rustCache = steps.find((step) => + step.uses?.startsWith('swatinem/rust-cache@')); + const restoreOnlyOnPr = + "${{ inputs.cache_write && github.event_name != 'pull_request' }}"; + assert.equal(rustCache?.with?.['save-if'], restoreOnlyOnPr); + assert.equal(rustCache?.with?.['cache-on-failure'], restoreOnlyOnPr); + const upload = steps.find((step) => step.uses?.startsWith('actions/upload-artifact@')); + const verifyOutputs = steps.find((step) => step.name === 'Verify package outputs'); + assert.match(verifyOutputs?.run ?? '', /bitfun-installer\.exe/); + assert.match(verifyOutputs?.run ?? '', /\*\.AppImage/); + assert.ok(steps.indexOf(verifyOutputs) < steps.indexOf(upload)); + assert.equal(upload?.if, '${{ inputs.upload_artifacts }}'); + + assert.notEqual(committedMetadataIndex, -1); + assert.notEqual(installerI18nIndex, -1); + assert.notEqual(tauriAlignmentIndex, -1); assert.notEqual(generationIndex, -1); assert.notEqual(typeCheckIndex, -1); assert.equal( @@ -487,6 +693,222 @@ test('generates web API bindings before nightly web type-check', () => { generationIndex < typeCheckIndex, 'nightly must generate web API bindings before type-checking the web UI', ); + assert.ok( + committedMetadataIndex < patchIndex && + installerI18nIndex < patchIndex && + tauriAlignmentIndex < patchIndex && + typeCheckIndex < patchIndex && + patchIndex < metadataIndex && + metadataIndex < buildIndex, + 'nightly must verify the projected lockfile before nested locked build hooks run', + ); + const expectedMetadata = 'cargo metadata --locked --no-deps'; + assert.equal(steps[committedMetadataIndex].run, expectedMetadata); + assert.equal(steps[metadataIndex].run, expectedMetadata); + assert.equal(steps[installerI18nIndex].if, "runner.os == 'Windows'"); + assert.equal( + steps[installerI18nIndex].run, + 'pnpm --dir BitFun-Installer run sync:i18n', + ); + assert.equal(steps[tauriAlignmentIndex].if, "runner.os == 'Windows'"); + assert.match( + steps[tauriAlignmentIndex].run, + /Found version mismatched Tauri packages/, + ); + assert.equal( + steps.some((step) => step.run?.includes('cargo generate-lockfile')), + false, + 'nightly must not hide stale committed lockfiles by regenerating them ad hoc', + ); + assert.equal( + steps.find((step) => step.name === 'Run Windows CLI terminal contracts')?.run, + 'cargo test --locked -p bitfun-cli --test terminal_process_contracts -- --test-threads=1', + ); +}); + +test('nightly orchestrates the shared build before the separately privileged publish', () => { + const workflow = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/nightly.yml'), 'utf8'), + ); + const build = workflow.jobs['build-artifacts']; + const publish = workflow.jobs['publish-nightly']; + + assert.equal(workflow.on.workflow_call, undefined); + assert.deepEqual(workflow.concurrency, { + group: 'nightly-${{ github.repository }}', + 'cancel-in-progress': true, + }); + assert.equal(build.uses, './.github/workflows/nightly-artifacts.yml'); + assert.deepEqual(build.permissions, { contents: 'read' }); + assert.deepEqual(build.with, { + checkout_ref: '${{ github.sha }}', + version: '${{ needs.check-changes.outputs.nightly_version }}', + artifact_prefix: 'nightly', + artifact_retention_days: + "${{ fromJSON(inputs.artifact_retention_days || '7') }}", + build_desktop_packages: true, + desktop_platforms: + '["linux-x64","linux-arm64","macos-arm64","macos-x64","windows-x64"]', + build_linux_binaries: true, + build_relay_image: true, + upload_artifacts: true, + cache_write: "${{ github.repository_owner == 'GCWing' }}", + }); + assert.deepEqual(publish.needs, ['check-changes', 'build-artifacts']); + assert.match(publish.if, /inputs\.build_only != true/); + assert.deepEqual(publish.permissions, { + contents: 'write', + packages: 'write', + }); +}); + +test('Linux binary packaging uses the shared locked version projection contract', () => { + const workflow = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/linux-binaries.yml'), 'utf8'), + ); + const inputs = workflow.on.workflow_call.inputs; + const steps = workflow.jobs.build.steps; + const nodeIndex = steps.findIndex( + (step) => step.name === 'Setup Node.js', + ); + const committedIndex = steps.findIndex( + (step) => step.name === 'Verify committed Cargo metadata', + ); + const patchIndex = steps.findIndex( + (step) => step.name === 'Patch build version', + ); + const projectedIndex = steps.findIndex( + (step) => step.name === 'Verify projected Cargo metadata', + ); + const buildIndex = steps.findIndex( + (step) => step.name === 'Build CLI and Relay Server', + ); + + assert.equal(inputs.artifact_retention_days.default, 7); + assert.equal(inputs.upload_artifacts.default, true); + assert.equal(inputs.cache_write.default, false); + assert.equal(inputs.validate_relay_image.default, true); + assert.equal(steps[nodeIndex].uses, 'actions/setup-node@v5'); + assert.equal(steps[nodeIndex].with['node-version-file'], 'package.json'); + assert.ok( + nodeIndex < patchIndex && + committedIndex < patchIndex && + patchIndex < projectedIndex && + projectedIndex < buildIndex, + ); + assert.match(steps[patchIndex].run, /node scripts\/set-build-version\.mjs/); + assert.doesNotMatch(steps[patchIndex].run, /sed -i/); + assert.equal(steps[committedIndex].run, 'cargo metadata --locked --no-deps'); + assert.equal(steps[projectedIndex].run, 'cargo metadata --locked --no-deps'); + assert.match(steps[buildIndex].run, /cargo build --locked --release/); + const rustCache = steps.find((step) => + step.uses?.startsWith('swatinem/rust-cache@')); + const restoreOnlyOnPr = + "${{ inputs.cache_write && github.event_name != 'pull_request' }}"; + assert.equal(rustCache?.with?.['save-if'], restoreOnlyOnPr); + assert.equal(rustCache?.with?.['cache-on-failure'], restoreOnlyOnPr); + const upload = steps.find((step) => step.uses?.startsWith('actions/upload-artifact@')); + const verifyOutputs = steps.find((step) => step.name === 'Verify Linux binary outputs'); + const validateImage = steps.find((step) => step.name === 'Validate Relay runtime image'); + assert.match(verifyOutputs?.run ?? '', /sha256sum --check/); + assert.ok(steps.indexOf(verifyOutputs) < steps.indexOf(upload)); + assert.equal(validateImage?.if, '${{ inputs.validate_relay_image }}'); + assert.equal(validateImage?.with?.push, false); + assert.equal(validateImage?.with?.platforms, 'linux/${{ matrix.platform.docker_arch }}'); + assert.equal(upload?.if, '${{ inputs.upload_artifacts }}'); + assert.equal( + upload?.with?.['retention-days'], + '${{ inputs.artifact_retention_days }}', + ); +}); + +test('PR-capable release builds cannot save repository caches or upload CI packages', () => { + const ci = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), 'utf8'), + ); + const artifacts = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/nightly-artifacts.yml'), 'utf8'), + ); + const linux = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/linux-binaries.yml'), 'utf8'), + ); + + for (const workflow of [artifacts, linux]) { + for (const job of Object.values(workflow.jobs)) { + for (const cache of (job.steps ?? []).filter((step) => + step.uses?.startsWith('swatinem/rust-cache@'))) { + assert.match(cache.with['save-if'], /github\.event_name != 'pull_request'/); + assert.match(cache.with['cache-on-failure'], /github\.event_name != 'pull_request'/); + assert.match(cache.with['shared-key'], /github\.base_ref \|\| github\.ref_name/); + } + } + } + + for (const workflow of [ci, artifacts, linux]) { + for (const job of Object.values(workflow.jobs)) { + for (const bun of (job.steps ?? []).filter((step) => + step.uses?.startsWith('oven-sh/setup-bun@'))) { + assert.equal(bun.with?.['no-cache'], true); + } + } + } + + const packageCaller = ci.jobs['package-impact-contract']; + assert.equal(packageCaller.with.cache_write, false); + assert.equal(packageCaller.with.upload_artifacts, false); + const node = artifacts.jobs.package.steps.find( + (step) => step.name === 'Setup Node.js', + ); + assert.equal(node.with.cache, undefined); + assert.equal(node.with['package-manager-cache'], false); +}); + +test('nightly publishes and verifies the Relay image in the current repository owner scope', () => { + const workflow = yaml.parse( + readFileSync(path.join(repoRoot, '.github/workflows/nightly.yml'), 'utf8'), + ); + const steps = workflow.jobs['publish-nightly'].steps; + const metadata = steps.find( + (step) => step.name === 'Resolve nightly image metadata', + ); + const publish = steps.find( + (step) => step.name === 'Build and push multi-platform Relay image', + ); + const smoke = steps.find( + (step) => step.name === 'Smoke-test published Relay image on both platforms', + ); + const manifest = steps.find( + (step) => step.name === 'Generate Linux binaries manifest', + ); + const verifyDescriptor = steps.find( + (step) => step.name === 'Verify published Relay image descriptor', + ); + const verifyMacCli = steps.find( + (step) => step.name === 'Verify published macOS CLI assets', + ); + const image = '${{ steps.nightly-image-meta.outputs.image }}'; + + assert.match( + metadata?.run ?? '', + /image=ghcr\.io\/\$\{GITHUB_REPOSITORY_OWNER,,\}\/bitfun-relay-server/, + ); + assert.equal( + publish?.with?.tags, + `${image}:${'${{ env.NIGHTLY_TAG }}'}\n${image}:${'${{ steps.nightly-image-meta.outputs.asset_version }}'}\n`, + ); + assert.equal( + smoke?.run, + `bash scripts/relay/smoke-image.sh \\\n "${image}@\${IMAGE_DIGEST}"\n`, + ); + assert.match(manifest?.run ?? '', /--repo "\$\{\{ github\.repository \}\}"/); + assert.match( + verifyDescriptor?.run ?? '', + /\$\{GITHUB_SERVER_URL\}\/\$\{GITHUB_REPOSITORY\}\/releases\/download/, + ); + assert.match( + verifyMacCli?.run ?? '', + /\$\{GITHUB_SERVER_URL\}\/\$\{GITHUB_REPOSITORY\}\/releases\/download/, + ); }); test('passes the verification key when signing the versioned Windows installer', () => { @@ -659,29 +1081,36 @@ test('beta publishing cannot advance the Relay latest image tag', () => { }); test('nightly and beta use the shared build-version projection', () => { + const artifacts = yaml.parse( + readFileSync( + path.join(repoRoot, '.github/workflows/nightly-artifacts.yml'), + 'utf8', + ), + ); const nightly = yaml.parse( readFileSync(path.join(repoRoot, '.github/workflows/nightly.yml'), 'utf8'), ); - const patch = nightly.jobs.package.steps.find( + const patch = artifacts.jobs.package.steps.find( (step) => step.name === 'Patch nightly version', ); assert.match(patch.run, /node scripts\/set-build-version\.mjs/); - assert.equal(nightly.jobs.package.env.BITFUN_RELEASE_CHANNEL, 'nightly'); + assert.equal(artifacts.jobs.package.env.BITFUN_RELEASE_CHANNEL, 'nightly'); assert.equal( - nightly.jobs.package.env.TAURI_UPDATER_ENDPOINT, + artifacts.jobs.package.env.TAURI_UPDATER_ENDPOINT, 'https://github.com/GCWing/BitFun/releases/latest/download/latest.json', ); assert.equal( - nightly.jobs.package.env.TAURI_UPDATER_FALLBACK_ENDPOINT, + artifacts.jobs.package.env.TAURI_UPDATER_FALLBACK_ENDPOINT, 'https://openbitfun.com/release/latest.json', ); - assert.equal(nightly.jobs.package.env.BITFUN_ENABLE_UPDATER_ARTIFACTS, undefined); + assert.equal(artifacts.jobs.package.env.BITFUN_ENABLE_UPDATER_ARTIFACTS, undefined); const signingStep = nightly.jobs['publish-nightly'].steps.find( (step) => step.name === 'Sign installer packages', ); assert.match(signingStep.run, /write-minisign-public-key\.mjs/); }); + test('Linux Rust workflows do not install an unused native OpenSSL toolchain', () => { for (const workflowPath of [ '.github/workflows/ci.yml', diff --git a/scripts/ci/classify-build-impact.mjs b/scripts/ci/classify-build-impact.mjs new file mode 100644 index 0000000000..b17567d5fb --- /dev/null +++ b/scripts/ci/classify-build-impact.mjs @@ -0,0 +1,473 @@ +import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { dirname, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { rustWebUiSourceBoundaryRule } from '../core-boundaries/rules/source-rules.mjs'; +import { scanForbiddenContentUnder } from '../core-boundaries/source-content-checks.mjs'; + +function readArg(args, name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function changedPaths(base, head, rangeMode) { + const rangeArgs = rangeMode === 'merge-base' + ? [`${base}...${head}`] + : [base, head]; + const result = spawnSync( + 'git', + ['diff', '--no-renames', '--name-only', '-z', ...rangeArgs, '--'], + { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, + ); + if (result.status !== 0) { + throw new Error(result.stderr.trim() || 'git diff failed'); + } + return result.stdout.split('\0').filter(Boolean); +} + +const ALL_DESKTOP_PLATFORMS = [ + 'linux-x64', + 'linux-arm64', + 'macos-arm64', + 'macos-x64', + 'windows-x64', +]; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +// These inputs define repository-level release metadata, signing, staging, or +// scheduling contracts shared by more than one artifact producer. A focused +// platform match is not sufficient for them: validate the complete producer +// set so a release-contract change cannot merge on compile-only evidence. +const ALL_PACKAGE_INPUTS = new Set([ + 'scripts/ci/classify-build-impact.mjs', + 'scripts/collect-tauri-updater-assets.mjs', + 'scripts/generate-linux-binaries-manifest.mjs', + 'scripts/generate-tauri-latest-json.mjs', + 'scripts/generate-version.cjs', + 'scripts/openbitfun-release-sync.sh', + 'scripts/prepare-windows-installer-asset.mjs', + 'scripts/release-channel.mjs', + 'scripts/set-build-version.mjs', + 'scripts/sign-release-assets.sh', + 'scripts/stage-github-release-assets.mjs', + 'scripts/verify-release-version-sync.mjs', + 'scripts/verify-tauri-latest-json.mjs', + 'scripts/write-minisign-public-key.mjs', +]); + +// Inputs consumed by the Desktop/Tauri package producer itself. Keep this +// separate from ALL_PACKAGE_INPUTS: these paths do not affect the standalone +// Linux CLI/Relay archives, so selecting those producers would add cost +// without validating the changed input. +const DESKTOP_PACKAGE_INPUTS = collectLocalModuleInputs([ + 'scripts/desktop-tauri-build.mjs', + // Tauri runs this entrypoint through beforeBuildCommand rather than an + // import from desktop-tauri-build.mjs. + 'scripts/frontend-build-all.mjs', +]); + +const DESKTOP_PACKAGE_PREFIXES = [ + 'products/', + 'resources/flashgrep/', + 'src/apps/desktop/capabilities/', + 'src/apps/desktop/dmg/', + 'src/apps/desktop/icons/', + 'src/apps/desktop/resources/', + 'src/apps/desktop/scripts/', +]; + +// These product surfaces are validated by the frontend gate and do not need +// the Rust/CLI matrices on their own. Native mobile is intentionally filtered +// as neutral below because it has no supported CI toolchain or producer here. +const FRONTEND_ONLY_PREFIXES = [ + 'src/web-ui/', + 'src/mobile-web/', +]; + +const FRONTEND_ONLY_INPUTS = new Set([ + 'scripts/mobile-web-build.cjs', +]); + +function collectLocalModuleInputs(entryPaths) { + const inputs = new Set(); + const pending = entryPaths.map((entry) => resolve(REPO_ROOT, entry)); + + while (pending.length > 0) { + const absolutePath = pending.pop(); + const repoPath = relative(REPO_ROOT, absolutePath).split(sep).join('/'); + if (repoPath === '..' || repoPath.startsWith('../') || inputs.has(repoPath)) { + continue; + } + if (!existsSync(absolutePath)) { + throw new Error(`Missing package producer module: ${repoPath}`); + } + inputs.add(repoPath); + + const source = readFileSync(absolutePath, 'utf8'); + const localImport = /(?:\b(?:import|export)\s+(?:[^'"\n]*?\s+from\s*)?|\bimport\s*\(\s*)['"](\.[^'"]+)['"]/g; + for (const match of source.matchAll(localImport)) { + const importedPath = resolveLocalModule(dirname(absolutePath), match[1]); + if (importedPath) { + pending.push(importedPath); + } + } + } + + return inputs; +} + +function resolveLocalModule(parentDirectory, specifier) { + const base = resolve(parentDirectory, specifier); + const candidates = [ + base, + `${base}.mjs`, + `${base}.js`, + `${base}.cjs`, + resolve(base, 'index.mjs'), + resolve(base, 'index.js'), + resolve(base, 'index.cjs'), + ]; + return candidates.find((candidate) => + existsSync(candidate) && statSync(candidate).isFile()); +} + +export function classifyBuildImpact(paths) { + const result = ({ + rustRequired, + frontendRequired, + desktopPlatforms = [], + linuxBinariesRequired = false, + relayImageRequired = false, + dshProfileRequired = false, + reason, + }) => ({ + rustRequired, + frontendRequired, + desktopPackagesImpacted: desktopPlatforms.length > 0, + desktopPlatforms, + linuxBinariesRequired, + relayImageRequired, + dshProfileRequired, + // Desktop producers consume too many concurrent hosted runners for PRs. + // Keep their impact visible for Nightly ownership, while limiting automatic + // PR producers to the standalone Linux/Relay delivery contracts. + prProducerRequired: linuxBinariesRequired || relayImageRequired, + reason, + changedCount: paths.length, + }); + const failClosed = (reason) => result({ + rustRequired: true, + frontendRequired: true, + desktopPlatforms: ALL_DESKTOP_PLATFORMS, + linuxBinariesRequired: true, + relayImageRequired: true, + dshProfileRequired: true, + reason, + }); + if (paths.length === 0) { + return failClosed('no-changes'); + } + if (paths.some((file) => !isValidRepositoryPath(file))) { + return failClosed('invalid-path'); + } + + const activePaths = paths.filter((file) => !isKnownNeutralPath(file)); + if (activePaths.length === 0) { + return result({ + rustRequired: false, + frontendRequired: false, + reason: 'ci-ignored-only', + }); + } + + const rustRequired = activePaths.some(isRustBuildInput) + || !activePaths.every(isFrontendOnlyInput); + if (activePaths.some(isFullPackageInput)) { + return result({ + rustRequired, + frontendRequired: true, + desktopPlatforms: ALL_DESKTOP_PLATFORMS, + linuxBinariesRequired: true, + relayImageRequired: true, + dshProfileRequired: true, + reason: 'full-package-input', + }); + } + + const desktopPlatforms = new Set(); + let linuxBinariesRequired = false; + let relayImageRequired = false; + for (const file of activePaths) { + if (isDesktopPackageInput(file)) { + for (const platform of ALL_DESKTOP_PLATFORMS) { + desktopPlatforms.add(platform); + } + } + if (isWindowsPackageInput(file)) { + desktopPlatforms.add('windows-x64'); + } + if (isMacPackageInput(file)) { + desktopPlatforms.add('macos-arm64'); + desktopPlatforms.add('macos-x64'); + } + if (isLinuxDesktopPackageInput(file)) { + desktopPlatforms.add('linux-x64'); + desktopPlatforms.add('linux-arm64'); + } + if (isLinuxBinaryPackageInput(file)) { + linuxBinariesRequired = true; + } + if (isRelayImageInput(file)) { + // The image validation consumes the archive produced in the same matrix + // job, so selecting it necessarily selects Linux binary packaging too. + linuxBinariesRequired = true; + relayImageRequired = true; + } + } + + const selectedPlatforms = ALL_DESKTOP_PLATFORMS.filter((platform) => + desktopPlatforms.has(platform)); + const dshProfileRequired = activePaths.some(isDshProfileInput); + const hasPackageImpact = selectedPlatforms.length > 0 + || linuxBinariesRequired + || relayImageRequired; + const reason = hasPackageImpact + ? 'platform-package-input' + : rustRequired + ? (activePaths.some(isRustBuildInput) ? 'rust-build-input' : 'outside-web-ui') + : 'frontend-only'; + return result({ + rustRequired, + frontendRequired: true, + desktopPlatforms: selectedPlatforms, + linuxBinariesRequired, + relayImageRequired, + dshProfileRequired, + reason, + }); +} + +function isValidRepositoryPath(file) { + return typeof file === 'string' + && file.length > 0 + && !file.startsWith('/') + && !/^[A-Za-z]:/.test(file) + && !file.includes('\\') + && !/[\r\n\0]/.test(file) + && file.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'); +} + +function isRustBuildInput(file) { + const segments = file.split('/'); + const name = segments.at(-1); + return file.endsWith('.rs') + || name === 'Cargo.toml' + || name === 'Cargo.lock' + || name === 'build.rs' + || name === 'rust-toolchain' + || name === 'rust-toolchain.toml' + || segments.includes('.cargo'); +} + +function isFullPackageInput(file) { + const name = file.split('/').at(-1); + if ( + file === 'Cargo.toml' + || file === 'Cargo.lock' + || file === 'package.json' + || file === 'package-lock.json' + || file === 'pnpm-lock.yaml' + || file === 'pnpm-workspace.yaml' + || name === 'rust-toolchain' + || name === 'rust-toolchain.toml' + || file.startsWith('.cargo/') + ) { + return true; + } + if (name === 'Cargo.toml' && !file.startsWith('BitFun-Installer/')) { + return true; + } + if ( + file.startsWith('.github/workflows/') + || ALL_PACKAGE_INPUTS.has(file) + ) { + return true; + } + return false; +} + +function isDesktopPackageInput(file) { + return DESKTOP_PACKAGE_INPUTS.has(file) + || DESKTOP_PACKAGE_PREFIXES.some((prefix) => file.startsWith(prefix)) + || file === 'src/apps/desktop/build.rs' + || file.startsWith('src/apps/desktop/tauri.') + || file === 'src/web-ui/package.json'; +} + +function isFrontendOnlyInput(file) { + return FRONTEND_ONLY_INPUTS.has(file) + || FRONTEND_ONLY_PREFIXES.some((prefix) => file.startsWith(prefix)); +} + +function isWindowsPackageInput(file) { + return file.startsWith('BitFun-Installer/') + || file === 'scripts/cli/package-windows.ps1' + || file.startsWith('scripts/windows/') + || file.includes('/windows/') + || /(?:^|\/)(?:nsis|wix)(?:\/|\.|$)/i.test(file); +} + +function isMacPackageInput(file) { + return file === 'scripts/cli/package-unix.sh' + || file.startsWith('scripts/ci/setup-macos-signing.') + || file.startsWith('scripts/ci/verify-macos-signing.') + || file.startsWith('scripts/macos/') + || file.includes('/macos/') + || /(?:entitlements|Info\.plist)$/i.test(file); +} + +function isLinuxDesktopPackageInput(file) { + return file === 'scripts/ci/verify-appimage-fcitx.sh' + || file.startsWith('scripts/linux/') + || file.includes('/linux/') + || /(?:appimage|linuxdeploy|\.deb\b|\.rpm\b)/i.test(file); +} + +function isLinuxBinaryPackageInput(file) { + return file === 'scripts/ci/check-glibc-floor.sh' + || file.startsWith('scripts/cli/package-unix.') + || file.startsWith('scripts/relay/package-unix.') + || file.startsWith('scripts/cli/test-install-unix.'); +} + +function isRelayImageInput(file) { + return file === 'src/apps/relay-server/Dockerfile.release' + || file.startsWith('scripts/relay/package-unix.'); +} + +function isDshProfileInput(file) { + return file.startsWith('packages/dsh-acp/') + || file === 'scripts/prepare-dsh-profile.mjs'; +} + +function isKnownNeutralPath(file) { + const isKnownDocumentation = file.endsWith('.md') + && (!file.includes('/') || file.startsWith('docs/')); + return isKnownDocumentation + || file.startsWith('png/') + || file.startsWith('src/apps/mobile/'); +} + +export function run(args = process.argv.slice(2), env = process.env) { + const base = readArg(args, '--base'); + const head = readArg(args, '--head'); + const rangeMode = readArg(args, '--range-mode') ?? 'direct'; + if (!base || !head) { + throw new Error( + 'Usage: classify-build-impact.mjs --base --head ' + + '[--range-mode direct|merge-base]', + ); + } + + const boundaryFindings = scanForbiddenContentUnder( + process.cwd(), + rustWebUiSourceBoundaryRule, + ); + if (boundaryFindings.length > 0) { + const details = boundaryFindings + .slice(0, 20) + .map((finding) => `${finding.repoPath}:${finding.line}: ${finding.message}`) + .join('\n'); + throw new Error(`${rustWebUiSourceBoundaryRule.reason}\n${details}`); + } + + let paths = []; + let result; + if ( + !isUsableCommitSha(base) + || !isUsableCommitSha(head) + || !['direct', 'merge-base'].includes(rangeMode) + ) { + result = { rustRequired: true, reason: 'invalid-range', changedCount: 0 }; + } else { + try { + paths = changedPaths(base, head, rangeMode); + result = classifyBuildImpact(paths); + } catch { + result = classifyBuildImpact([]); + result.reason = 'unavailable-range'; + } + } + if (result?.reason === 'invalid-range') { + result = classifyBuildImpact([]); + result.reason = 'invalid-range'; + } + const lines = [ + `rust_required=${result.rustRequired}`, + `frontend_required=${result.frontendRequired}`, + `desktop_packages_impacted=${result.desktopPackagesImpacted}`, + `desktop_platforms=${JSON.stringify(result.desktopPlatforms)}`, + `linux_binaries_required=${result.linuxBinariesRequired}`, + `relay_image_required=${result.relayImageRequired}`, + `dsh_profile_required=${result.dshProfileRequired}`, + `pr_producer_required=${result.prProducerRequired}`, + `reason=${result.reason}`, + `changed_count=${result.changedCount}`, + ]; + if (env.GITHUB_OUTPUT) { + appendFileSync(env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); + } else { + process.stdout.write(`${lines.join('\n')}\n`); + } + if (env.GITHUB_STEP_SUMMARY) { + appendFileSync(env.GITHUB_STEP_SUMMARY, renderSummary(result, paths)); + } + return result; +} + +function isUsableCommitSha(value) { + return /^[0-9a-f]{40}$/i.test(value) && !/^0{40}$/.test(value); +} + +function renderSummary(result, paths) { + const shownPaths = paths.slice(0, 20); + const lines = [ + '### Build impact classification', + '', + `- Required: ${result.rustRequired}`, + `- Frontend: ${result.frontendRequired}`, + `- Desktop package impact (Nightly-owned): ${result.desktopPlatforms.join(', ') || 'none'}`, + `- Linux binaries: ${result.linuxBinariesRequired}`, + `- Relay image: ${result.relayImageRequired}`, + `- PR producer: ${result.prProducerRequired}`, + `- DSH profile: ${result.dshProfileRequired}`, + `- Reason: ${result.reason}`, + `- Changed files: ${result.changedCount}`, + ]; + if (shownPaths.length > 0) { + lines.push('', ...shownPaths.map((file) => `- ${escapeHtml(file)}`)); + } + if (paths.length > shownPaths.length) { + lines.push(`- ${paths.length - shownPaths.length} additional changed file(s) omitted`); + } + return `${lines.join('\n')}\n`; +} + +function escapeHtml(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('\r', '\\r') + .replaceAll('\n', '\\n'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + run(); + } catch (error) { + process.stderr.write(`${error.message || String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/ci/classify-build-impact.test.mjs b/scripts/ci/classify-build-impact.test.mjs new file mode 100644 index 0000000000..274cdc384f --- /dev/null +++ b/scripts/ci/classify-build-impact.test.mjs @@ -0,0 +1,408 @@ +import assert from 'node:assert/strict'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { classifyBuildImpact } from './classify-build-impact.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const scriptPath = path.join(repoRoot, 'scripts/ci/classify-build-impact.mjs'); +const allPlatforms = [ + 'linux-x64', + 'linux-arm64', + 'macos-arm64', + 'macos-x64', + 'windows-x64', +]; + +function expected({ + rustRequired, + frontendRequired = true, + desktopPlatforms = [], + linuxBinariesRequired = false, + relayImageRequired = false, + dshProfileRequired = false, + reason, + changedCount = 1, +}) { + return { + rustRequired, + frontendRequired, + desktopPackagesImpacted: desktopPlatforms.length > 0, + desktopPlatforms, + linuxBinariesRequired, + relayImageRequired, + dshProfileRequired, + prProducerRequired: linuxBinariesRequired || relayImageRequired, + reason, + changedCount, + }; +} + +function git(root, args) { + const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); +} + +function commit(root, message) { + git(root, ['add', '--all']); + git(root, [ + '-c', 'user.name=CI Impact Test', + '-c', 'user.email=ci-impact@example.invalid', + 'commit', '-m', message, + ]); + return git(root, ['rev-parse', 'HEAD']); +} + +function parseOutputs(file) { + return Object.fromEntries( + readFileSync(file, 'utf8').trim().split(/\r?\n/).map((line) => line.split('=', 2)), + ); +} + +function runClassifier(root, base, head, rangeMode = 'direct') { + const output = path.join(root, `github-output-${Date.now()}-${Math.random()}.txt`); + const summary = path.join(root, `github-summary-${Date.now()}-${Math.random()}.md`); + const result = spawnSync( + process.execPath, + [scriptPath, '--base', base, '--head', head, '--range-mode', rangeMode], + { + cwd: root, + env: { ...process.env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary }, + encoding: 'utf8', + }, + ); + return { + ...result, + outputs: result.status === 0 ? parseOutputs(output) : undefined, + summary: existsSync(summary) ? readFileSync(summary, 'utf8') : undefined, + }; +} + +test('maps representative changes to the smallest predictive validation', () => { + const cases = [ + { + paths: ['docs/review-notes.md', 'png/example.png'], + result: expected({ + rustRequired: false, + frontendRequired: false, + reason: 'ci-ignored-only', + changedCount: 2, + }), + }, + { + paths: ['src/web-ui/src/example.ts'], + result: expected({ rustRequired: false, reason: 'frontend-only' }), + }, + { + paths: ['src/mobile-web/src/App.tsx'], + result: expected({ rustRequired: false, reason: 'frontend-only' }), + }, + { + paths: [ + 'src/apps/mobile/android/app/src/main/kotlin/com/bitfun/MainActivity.kt', + 'src/apps/mobile/shared/core-feature/src/commonMain/kotlin/Feature.kt', + 'src/apps/mobile/ios/BitFun/App.swift', + 'src/apps/mobile/harmonyos/entry/src/main/ets/pages/Index.ets', + ], + result: expected({ + rustRequired: false, + frontendRequired: false, + reason: 'ci-ignored-only', + changedCount: 4, + }), + }, + { + paths: ['src/crates/services/services-core/src/lib.rs'], + result: expected({ rustRequired: true, reason: 'rust-build-input' }), + }, + { + paths: ['BitFun-Installer/scripts/build-installer.cjs'], + result: expected({ + rustRequired: true, + desktopPlatforms: ['windows-x64'], + reason: 'platform-package-input', + }), + }, + { + paths: ['scripts/ci/setup-macos-signing.sh'], + result: expected({ + rustRequired: true, + desktopPlatforms: ['macos-arm64', 'macos-x64'], + reason: 'platform-package-input', + }), + }, + { + paths: ['scripts/ci/verify-appimage-fcitx.sh'], + result: expected({ + rustRequired: true, + desktopPlatforms: ['linux-x64', 'linux-arm64'], + reason: 'platform-package-input', + }), + }, + ...[ + 'scripts/prepare-flashgrep-resource.mjs', + 'resources/flashgrep/VERSION.json', + 'src/apps/desktop/scripts/post-install-icons.sh', + 'src/apps/desktop/dmg/background.png', + 'scripts/product-customization/projections.mjs', + 'products/bitfun/product.jsonc', + ].map((desktopInput) => ({ + paths: [desktopInput], + result: expected({ + rustRequired: true, + desktopPlatforms: allPlatforms, + reason: 'platform-package-input', + }), + })), + { + paths: ['src/mobile-web/package.json'], + result: expected({ + rustRequired: false, + reason: 'frontend-only', + }), + }, + { + paths: ['scripts/mobile-web-build.cjs'], + result: expected({ + rustRequired: false, + reason: 'frontend-only', + }), + }, + { + paths: ['src/apps/relay-server/Dockerfile.release'], + result: expected({ + rustRequired: true, + linuxBinariesRequired: true, + relayImageRequired: true, + reason: 'platform-package-input', + }), + }, + { + paths: ['scripts/cli/package-unix.sh'], + result: expected({ + rustRequired: true, + desktopPlatforms: ['macos-arm64', 'macos-x64'], + linuxBinariesRequired: true, + reason: 'platform-package-input', + }), + }, + { + paths: ['packages/dsh-acp/src/index.ts'], + result: expected({ + rustRequired: true, + dshProfileRequired: true, + reason: 'outside-web-ui', + }), + }, + { + paths: ['Cargo.lock'], + result: expected({ + rustRequired: true, + desktopPlatforms: allPlatforms, + linuxBinariesRequired: true, + relayImageRequired: true, + dshProfileRequired: true, + reason: 'full-package-input', + }), + }, + { + paths: ['.github/workflows/nightly.yml'], + result: expected({ + rustRequired: true, + desktopPlatforms: allPlatforms, + linuxBinariesRequired: true, + relayImageRequired: true, + dshProfileRequired: true, + reason: 'full-package-input', + }), + }, + { + paths: ['scripts/sign-release-assets.sh'], + result: expected({ + rustRequired: true, + desktopPlatforms: allPlatforms, + linuxBinariesRequired: true, + relayImageRequired: true, + dshProfileRequired: true, + reason: 'full-package-input', + }), + }, + { + paths: ['scripts/ci/classify-build-impact.mjs'], + result: expected({ + rustRequired: true, + desktopPlatforms: allPlatforms, + linuxBinariesRequired: true, + relayImageRequired: true, + dshProfileRequired: true, + reason: 'full-package-input', + }), + }, + { + paths: ['scripts/cli/package-windows.ps1'], + result: expected({ + rustRequired: true, + desktopPlatforms: ['windows-x64'], + reason: 'platform-package-input', + }), + }, + ]; + + for (const { paths, result } of cases) { + assert.deepEqual(classifyBuildImpact(paths), result, paths.join(', ')); + } +}); + +test('combines mobile-web with other surfaces without native-mobile noise', () => { + assert.deepEqual( + classifyBuildImpact([ + 'src/mobile-web/src/App.tsx', + 'src/apps/mobile/android/app/src/main/kotlin/com/bitfun/MainActivity.kt', + ]), + expected({ + rustRequired: false, + reason: 'frontend-only', + changedCount: 2, + }), + ); + + assert.deepEqual( + classifyBuildImpact([ + 'src/mobile-web/src/App.tsx', + 'src/crates/services/services-core/src/lib.rs', + ]), + expected({ + rustRequired: true, + reason: 'rust-build-input', + changedCount: 2, + }), + ); + + assert.deepEqual( + classifyBuildImpact([ + 'src/mobile-web/src/App.tsx', + 'src/apps/mobile/shared/core-feature/src/commonMain/kotlin/Feature.kt', + 'src/apps/relay-server/Dockerfile.release', + ]), + expected({ + rustRequired: true, + linuxBinariesRequired: true, + relayImageRequired: true, + reason: 'platform-package-input', + changedCount: 3, + }), + ); +}); + +test('keeps nested Markdown fail-closed because it may be a compile-time input', () => { + assert.deepEqual( + classifyBuildImpact([ + 'src/web-ui/src/example.ts', + 'src/crates/assembly/agent-content/prompts/agents/example.md', + ]), + expected({ + rustRequired: true, + reason: 'outside-web-ui', + changedCount: 2, + }), + ); +}); + +test('uses a merge-base range for pull requests and emits every workflow output', (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-impact-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + git(root, ['init', '--initial-branch=main']); + writeFileSync(path.join(root, 'README.md'), 'baseline\n'); + commit(root, 'baseline'); + + git(root, ['switch', '-c', 'feature']); + const installer = path.join(root, 'BitFun-Installer/scripts/build-installer.cjs'); + mkdirSync(path.dirname(installer), { recursive: true }); + writeFileSync(installer, 'export {};\n'); + const head = commit(root, 'installer change'); + + git(root, ['switch', 'main']); + writeFileSync(path.join(root, 'Cargo.lock'), 'version = 4\n'); + const currentBase = commit(root, 'base dependency change'); + + const pullRequest = runClassifier(root, currentBase, head, 'merge-base'); + assert.equal(pullRequest.status, 0, pullRequest.stderr); + assert.deepEqual(pullRequest.outputs, { + rust_required: 'true', + frontend_required: 'true', + desktop_packages_impacted: 'true', + desktop_platforms: '["windows-x64"]', + linux_binaries_required: 'false', + relay_image_required: 'false', + dsh_profile_required: 'false', + pr_producer_required: 'false', + reason: 'platform-package-input', + changed_count: '1', + }); + assert.match(pullRequest.summary, /Build impact classification/); + assert.match( + pullRequest.summary, + /Desktop package impact \(Nightly-owned\):<\/strong> windows-x64/, + ); + + const push = runClassifier(root, currentBase, head, 'direct'); + assert.deepEqual(JSON.parse(push.outputs.desktop_platforms), allPlatforms); + assert.equal(push.outputs.pr_producer_required, 'true'); + assert.equal(push.outputs.reason, 'full-package-input'); +}); + +test('fails closed when paths or event ranges are invalid or unavailable', (t) => { + for (const paths of [[], ['src/web-ui/../apps/desktop/src/lib.rs'], ['src\\web-ui\\x.ts']]) { + const result = classifyBuildImpact(paths); + assert.equal(result.rustRequired, true); + assert.equal(result.frontendRequired, true); + assert.deepEqual(result.desktopPlatforms, allPlatforms); + assert.equal(result.linuxBinariesRequired, true); + assert.equal(result.relayImageRequired, true); + assert.equal(result.dshProfileRequired, true); + } + + const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-impact-range-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + git(root, ['init', '--initial-branch=main']); + writeFileSync(path.join(root, 'README.md'), 'baseline\n'); + const head = commit(root, 'baseline'); + + for (const [base, reason] of [ + ['0'.repeat(40), 'invalid-range'], + ['f'.repeat(40), 'unavailable-range'], + ]) { + const result = runClassifier(root, base, head); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.outputs.reason, reason); + assert.equal(result.outputs.pr_producer_required, 'true'); + assert.deepEqual(JSON.parse(result.outputs.desktop_platforms), allPlatforms); + } +}); + +test('rejects tracked Rust sources that reference the Web UI source tree', (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-impact-boundary-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + git(root, ['init', '--initial-branch=main']); + writeFileSync(path.join(root, 'README.md'), 'baseline\n'); + const base = commit(root, 'baseline'); + const rustFile = path.join(root, 'src/lib.rs'); + mkdirSync(path.dirname(rustFile), { recursive: true }); + writeFileSync(rustFile, 'const WEB: &str = include_dir!("../web-ui/src");\n'); + const head = commit(root, 'forbidden Rust input'); + + const result = runClassifier(root, base, head); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Rust source must not reference the Web UI source tree/); +}); diff --git a/scripts/ci/classify-rust-impact.mjs b/scripts/ci/classify-rust-impact.mjs deleted file mode 100644 index 17fb2301aa..0000000000 --- a/scripts/ci/classify-rust-impact.mjs +++ /dev/null @@ -1,174 +0,0 @@ -import { appendFileSync } from 'node:fs'; -import { spawnSync } from 'node:child_process'; -import { pathToFileURL } from 'node:url'; -import { rustWebUiSourceBoundaryRule } from '../core-boundaries/rules/source-rules.mjs'; -import { scanForbiddenContentUnder } from '../core-boundaries/source-content-checks.mjs'; - -function readArg(args, name) { - const index = args.indexOf(name); - return index >= 0 ? args[index + 1] : undefined; -} - -function changedPaths(base, head, rangeMode) { - const rangeArgs = rangeMode === 'merge-base' - ? [`${base}...${head}`] - : [base, head]; - const result = spawnSync( - 'git', - ['diff', '--no-renames', '--name-only', '-z', ...rangeArgs, '--'], - { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }, - ); - if (result.status !== 0) { - throw new Error(result.stderr.trim() || 'git diff failed'); - } - return result.stdout.split('\0').filter(Boolean); -} - -export function classifyRustImpact(paths) { - const result = (rustRequired, reason) => ({ - rustRequired, - reason, - changedCount: paths.length, - }); - if (paths.length === 0) { - return result(true, 'no-changes'); - } - if (paths.some((file) => !isValidRepositoryPath(file))) { - return result(true, 'invalid-path'); - } - if (paths.some(isRustBuildInput)) { - return result(true, 'rust-build-input'); - } - - const activePaths = paths.filter((file) => !isKnownNeutralPath(file)); - if (activePaths.length === 0) { - return result(false, 'ci-ignored-only'); - } - if (activePaths.every((file) => file.startsWith('src/web-ui/'))) { - return result(false, 'web-ui-only'); - } - return result(true, 'outside-web-ui'); -} - -function isValidRepositoryPath(file) { - return typeof file === 'string' - && file.length > 0 - && !file.startsWith('/') - && !/^[A-Za-z]:/.test(file) - && !file.includes('\\') - && !/[\r\n\0]/.test(file) - && file.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..'); -} - -function isRustBuildInput(file) { - const segments = file.split('/'); - const name = segments.at(-1); - return file.endsWith('.rs') - || name === 'Cargo.toml' - || name === 'Cargo.lock' - || name === 'build.rs' - || name === 'rust-toolchain' - || name === 'rust-toolchain.toml' - || segments.includes('.cargo'); -} - -function isKnownNeutralPath(file) { - const isKnownDocumentation = file.endsWith('.md') - && (!file.includes('/') || file.startsWith('docs/')); - return isKnownDocumentation || file.startsWith('png/'); -} - -export function run(args = process.argv.slice(2), env = process.env) { - const base = readArg(args, '--base'); - const head = readArg(args, '--head'); - const rangeMode = readArg(args, '--range-mode') ?? 'direct'; - if (!base || !head) { - throw new Error( - 'Usage: classify-rust-impact.mjs --base --head ' - + '[--range-mode direct|merge-base]', - ); - } - - const boundaryFindings = scanForbiddenContentUnder( - process.cwd(), - rustWebUiSourceBoundaryRule, - ); - if (boundaryFindings.length > 0) { - const details = boundaryFindings - .slice(0, 20) - .map((finding) => `${finding.repoPath}:${finding.line}: ${finding.message}`) - .join('\n'); - throw new Error(`${rustWebUiSourceBoundaryRule.reason}\n${details}`); - } - - let paths = []; - let result; - if ( - !isUsableCommitSha(base) - || !isUsableCommitSha(head) - || !['direct', 'merge-base'].includes(rangeMode) - ) { - result = { rustRequired: true, reason: 'invalid-range', changedCount: 0 }; - } else { - try { - paths = changedPaths(base, head, rangeMode); - result = classifyRustImpact(paths); - } catch { - result = { rustRequired: true, reason: 'unavailable-range', changedCount: 0 }; - } - } - const lines = [ - `rust_required=${result.rustRequired}`, - `reason=${result.reason}`, - `changed_count=${result.changedCount}`, - ]; - if (env.GITHUB_OUTPUT) { - appendFileSync(env.GITHUB_OUTPUT, `${lines.join('\n')}\n`); - } else { - process.stdout.write(`${lines.join('\n')}\n`); - } - if (env.GITHUB_STEP_SUMMARY) { - appendFileSync(env.GITHUB_STEP_SUMMARY, renderSummary(result, paths)); - } - return result; -} - -function isUsableCommitSha(value) { - return /^[0-9a-f]{40}$/i.test(value) && !/^0{40}$/.test(value); -} - -function renderSummary(result, paths) { - const shownPaths = paths.slice(0, 20); - const lines = [ - '### Rust/CLI impact classification', - '', - `- Required: ${result.rustRequired}`, - `- Reason: ${result.reason}`, - `- Changed files: ${result.changedCount}`, - ]; - if (shownPaths.length > 0) { - lines.push('', ...shownPaths.map((file) => `- ${escapeHtml(file)}`)); - } - if (paths.length > shownPaths.length) { - lines.push(`- ${paths.length - shownPaths.length} additional changed file(s) omitted`); - } - return `${lines.join('\n')}\n`; -} - -function escapeHtml(value) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('\r', '\\r') - .replaceAll('\n', '\\n'); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - try { - run(); - } catch (error) { - process.stderr.write(`${error.message || String(error)}\n`); - process.exitCode = 1; - } -} diff --git a/scripts/ci/classify-rust-impact.test.mjs b/scripts/ci/classify-rust-impact.test.mjs deleted file mode 100644 index 2daf5a3528..0000000000 --- a/scripts/ci/classify-rust-impact.test.mjs +++ /dev/null @@ -1,231 +0,0 @@ -import assert from 'node:assert/strict'; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { test } from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { classifyRustImpact } from './classify-rust-impact.mjs'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -const scriptPath = path.join(repoRoot, 'scripts/ci/classify-rust-impact.mjs'); - -function git(root, args) { - const result = spawnSync('git', args, { - cwd: root, - encoding: 'utf8', - }); - assert.equal(result.status, 0, result.stderr); - return result.stdout.trim(); -} - -function commit(root, message) { - git(root, ['add', '--all']); - git(root, [ - '-c', - 'user.name=CI Impact Test', - '-c', - 'user.email=ci-impact@example.invalid', - 'commit', - '-m', - message, - ]); - return git(root, ['rev-parse', 'HEAD']); -} - -function parseOutputs(file) { - return Object.fromEntries( - readFileSync(file, 'utf8') - .trim() - .split(/\r?\n/) - .map((line) => line.split('=', 2)), - ); -} - -function runClassifier(root, base, head, rangeMode = 'direct') { - const output = path.join(root, `github-output-${Date.now()}-${Math.random()}.txt`); - const summary = path.join(root, `github-summary-${Date.now()}-${Math.random()}.md`); - const result = spawnSync( - process.execPath, - [scriptPath, '--base', base, '--head', head, '--range-mode', rangeMode], - { - cwd: root, - env: { - ...process.env, - GITHUB_OUTPUT: output, - GITHUB_STEP_SUMMARY: summary, - }, - encoding: 'utf8', - }, - ); - return { - ...result, - outputs: result.status === 0 ? parseOutputs(output) : undefined, - summary: existsSync(summary) ? readFileSync(summary, 'utf8') : undefined, - }; -} - -test('skips Rust validation when a commit changes only Web UI files', (t) => { - const root = mkdtempSync(path.join(tmpdir(), 'bitfun-rust-impact-')); - t.after(() => rmSync(root, { recursive: true, force: true })); - - git(root, ['init', '--initial-branch=main']); - writeFileSync(path.join(root, 'README.md'), 'baseline\n'); - const base = commit(root, 'baseline'); - - const webFile = path.join(root, 'src/web-ui/src/example.ts'); - mkdirSync(path.dirname(webFile), { recursive: true }); - writeFileSync(webFile, 'export const example = true;\n'); - const head = commit(root, 'web change'); - - const result = runClassifier(root, base, head); - - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.outputs, { - rust_required: 'false', - reason: 'web-ui-only', - changed_count: '1', - }); - assert.match(result.summary, /Rust\/CLI impact classification/); - assert.match(result.summary, /Required:<\/strong> false/); - assert.match(result.summary, /Reason:<\/strong> web-ui-only/); - assert.match(result.summary, /src\/web-ui\/src\/example\.ts<\/code>/); -}); - -test('uses a merge-base range for pull requests but a direct range for pushes', (t) => { - const root = mkdtempSync(path.join(tmpdir(), 'bitfun-rust-impact-diverged-')); - t.after(() => rmSync(root, { recursive: true, force: true })); - - git(root, ['init', '--initial-branch=main']); - writeFileSync(path.join(root, 'README.md'), 'baseline\n'); - commit(root, 'baseline'); - - git(root, ['switch', '-c', 'feature']); - const webFile = path.join(root, 'src/web-ui/src/example.ts'); - mkdirSync(path.dirname(webFile), { recursive: true }); - writeFileSync(webFile, 'export const example = true;\n'); - const head = commit(root, 'web change'); - - git(root, ['switch', 'main']); - const rustFile = path.join(root, 'src/apps/desktop/src/lib.rs'); - mkdirSync(path.dirname(rustFile), { recursive: true }); - writeFileSync(rustFile, 'pub fn base_change() {}\n'); - const currentBase = commit(root, 'base Rust change'); - - assert.deepEqual(runClassifier(root, currentBase, head, 'merge-base').outputs, { - rust_required: 'false', - reason: 'web-ui-only', - changed_count: '1', - }); - assert.deepEqual(runClassifier(root, currentBase, head, 'direct').outputs, { - rust_required: 'true', - reason: 'rust-build-input', - changed_count: '2', - }); -}); - -test('keeps workflow-ignored documentation neutral beside Web UI changes', () => { - assert.deepEqual( - classifyRustImpact([ - 'src/web-ui/src/example.ts', - 'src/web-ui/README.md', - 'docs/review-notes.md', - 'png/example/screenshot.png', - ]), - { - rustRequired: false, - reason: 'web-ui-only', - changedCount: 4, - }, - ); - assert.deepEqual(classifyRustImpact(['docs/review-notes.md', 'png/example.png']), { - rustRequired: false, - reason: 'ci-ignored-only', - changedCount: 2, - }); - assert.deepEqual( - classifyRustImpact([ - 'src/web-ui/src/example.ts', - 'src/crates/assembly/agent-content/prompts/agents/example.md', - ]), - { - rustRequired: true, - reason: 'outside-web-ui', - changedCount: 2, - }, - 'nested Markdown may be a Rust include input and must fail closed', - ); -}); - -test('requires Rust for ambiguous, native, or cross-boundary path sets', () => { - for (const [paths, reason] of [ - [[], 'no-changes'], - [['src/apps/desktop/src/lib.rs'], 'rust-build-input'], - [['src/web-ui/native/build.rs'], 'rust-build-input'], - [['src/web-ui/native/Cargo.toml'], 'rust-build-input'], - [['src/web-ui/src/example.ts', 'scripts/check-core-boundaries.mjs'], 'outside-web-ui'], - [['src/web-ui/../apps/desktop/src/lib.rs'], 'invalid-path'], - [['src\\web-ui\\src\\example.ts'], 'invalid-path'], - ]) { - assert.deepEqual(classifyRustImpact(paths), { - rustRequired: true, - reason, - changedCount: paths.length, - }); - } -}); - -test('requires Rust when the event range is invalid or unavailable', (t) => { - const root = mkdtempSync(path.join(tmpdir(), 'bitfun-rust-impact-range-')); - t.after(() => rmSync(root, { recursive: true, force: true })); - - git(root, ['init', '--initial-branch=main']); - writeFileSync(path.join(root, 'README.md'), 'baseline\n'); - const head = commit(root, 'baseline'); - - for (const [base, reason] of [ - ['0'.repeat(40), 'invalid-range'], - ['f'.repeat(40), 'unavailable-range'], - ]) { - const result = runClassifier(root, base, head); - assert.equal(result.status, 0, result.stderr); - assert.deepEqual(result.outputs, { - rust_required: 'true', - reason, - changed_count: '0', - }); - } - - const invalidMode = runClassifier(root, head, head, 'unsupported'); - assert.equal(invalidMode.status, 0, invalidMode.stderr); - assert.deepEqual(invalidMode.outputs, { - rust_required: 'true', - reason: 'invalid-range', - changed_count: '0', - }); -}); - -test('rejects tracked Rust sources that spell a Web UI input token', (t) => { - const root = mkdtempSync(path.join(tmpdir(), 'bitfun-rust-impact-boundary-')); - t.after(() => rmSync(root, { recursive: true, force: true })); - - git(root, ['init', '--initial-branch=main']); - writeFileSync(path.join(root, 'README.md'), 'baseline\n'); - const base = commit(root, 'baseline'); - - const rustFile = path.join(root, 'src/lib.rs'); - mkdirSync(path.dirname(rustFile), { recursive: true }); - writeFileSync(rustFile, 'const WEB: &str = include_dir!("../web-ui/src");\n'); - const head = commit(root, 'forbidden Rust input'); - - const result = runClassifier(root, base, head); - assert.notEqual(result.status, 0); - assert.match(result.stderr, /Rust source must not reference the Web UI source tree/); -});