diff --git a/.github/workflows/companion-signing-readiness.yml b/.github/workflows/companion-signing-readiness.yml new file mode 100644 index 00000000..2f6e48f8 --- /dev/null +++ b/.github/workflows/companion-signing-readiness.yml @@ -0,0 +1,98 @@ +name: Companion Signing Readiness + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + android-upload-key: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - name: Validate Android upload signing credentials + shell: pwsh + env: + STACKCHAN_ANDROID_KEYSTORE_B64: ${{ secrets.STACKCHAN_ANDROID_KEYSTORE_B64 }} + STACKCHAN_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.STACKCHAN_ANDROID_KEYSTORE_PASSWORD }} + STACKCHAN_ANDROID_KEY_ALIAS: ${{ secrets.STACKCHAN_ANDROID_KEY_ALIAS }} + STACKCHAN_ANDROID_KEY_PASSWORD: ${{ secrets.STACKCHAN_ANDROID_KEY_PASSWORD }} + run: | + $required = @( + "STACKCHAN_ANDROID_KEYSTORE_B64", + "STACKCHAN_ANDROID_KEYSTORE_PASSWORD", + "STACKCHAN_ANDROID_KEY_ALIAS", + "STACKCHAN_ANDROID_KEY_PASSWORD" + ) + foreach ($name in $required) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "$name is required for Android upload signing readiness." + } + } + + $keystorePath = Join-Path $env:RUNNER_TEMP "stackchan-companion-upload-readiness.jks" + $keystoreBytes = $null + try { + try { + $keystoreBytes = [Convert]::FromBase64String($env:STACKCHAN_ANDROID_KEYSTORE_B64) + } catch { + throw "STACKCHAN_ANDROID_KEYSTORE_B64 is not valid base64." + } + if ($keystoreBytes.Length -eq 0) { + throw "STACKCHAN_ANDROID_KEYSTORE_B64 decoded to an empty keystore." + } + [IO.File]::WriteAllBytes($keystorePath, $keystoreBytes) + $env:STACKCHAN_ANDROID_KEYSTORE = $keystorePath + ./tools/check_android_play_release_readiness.ps1 -RequireUploadSigning -Json + } finally { + if ($null -ne $keystoreBytes) { + [Array]::Clear($keystoreBytes, 0, $keystoreBytes.Length) + } + Remove-Item -LiteralPath $keystorePath -Force -ErrorAction SilentlyContinue + } + + windows-authenticode: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + + - name: Validate Windows Authenticode credentials + shell: pwsh + env: + STACKCHAN_WINDOWS_PFX_B64: ${{ secrets.STACKCHAN_WINDOWS_PFX_B64 }} + STACKCHAN_WINDOWS_PFX_PASSWORD: ${{ secrets.STACKCHAN_WINDOWS_PFX_PASSWORD }} + run: | + ./tools/check_desktop_release_signing_readiness.ps1 ` + -Platform windows ` + -RequireReady ` + -RequireNativeToolchain ` + -Json + + macos-developer-id: + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + + - name: Validate Developer ID and notarization credentials + shell: pwsh + env: + STACKCHAN_MACOS_CERTIFICATE_B64: ${{ secrets.STACKCHAN_MACOS_CERTIFICATE_B64 }} + STACKCHAN_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.STACKCHAN_MACOS_CERTIFICATE_PASSWORD }} + STACKCHAN_MACOS_SIGNING_IDENTITY: ${{ secrets.STACKCHAN_MACOS_SIGNING_IDENTITY }} + STACKCHAN_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_APPLE_ID }} + STACKCHAN_MACOS_NOTARIZATION_PASSWORD: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_PASSWORD }} + STACKCHAN_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_TEAM_ID }} + run: | + ./tools/check_desktop_release_signing_readiness.ps1 ` + -Platform macos ` + -RequireReady ` + -RequireNativeToolchain ` + -ValidateAppleNotaryCredentials ` + -Json diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 8fb183c0..7dc7b8f5 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -1,6 +1,7 @@ name: Firmware on: + workflow_dispatch: push: branches: [main] pull_request: @@ -10,6 +11,9 @@ permissions: contents: read pull-requests: read +env: + STACKCHAN_CI_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + jobs: changes: runs-on: ubuntu-latest @@ -17,30 +21,72 @@ jobs: companion: ${{ steps.filter.outputs.companion }} steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.STACKCHAN_CI_SOURCE_SHA }} - uses: dorny/paths-filter@v4 id: filter + if: github.event_name != 'workflow_dispatch' with: filters: | companion: + - '.gitignore' - '.github/workflows/firmware.yml' - 'companion/**' - 'protocol-fixtures/**' - 'docs/ANDROID_COMPANION_SPEC.md' + - 'docs/ANDROID_COMPANION_TEST_PLAN.md' + - 'docs/ANDROID_PLAY_RELEASE.md' + - 'docs/ANDROID_PLAY_POLICY_DECLARATIONS.md' + - 'docs/ANDROID_PLAY_PRIVACY_POLICY.md' + - 'docs/store-assets/play/**' + - 'site/**' - 'docs/BRIDGE_PROTOCOL.md' - 'docs/COMPANION_CROSS_PLATFORM_PLAN.md' + - 'docs/RELEASE_PROCESS.md' + - 'docs/RELEASE_QUICKSTART.md' - 'tools/check_android_toolchain.*' + - 'tools/check_android_play_release_readiness.*' + - 'tools/check_privacy_policy_deployment.*' + - 'tools/test_privacy_policy_deployment_contract.*' + - 'tools/test_android_upload_signing_contract.*' + - 'tools/test_android_emulator_launch.*' + - 'tools/check_android_emulator_release_evidence.*' + - 'tools/test_android_emulator_release_evidence_contract.*' - 'tools/check_companion_v1_readiness.*' - 'tools/check_companion_v1_evidence_bundle.*' - 'tools/test_companion_v1_evidence_bundle_contract.*' + - 'tools/verify_consumer_promotion.*' + - 'tools/test_consumer_promotion_contract.*' + - 'tools/start_hardware_evidence.*' - 'tools/check_desktop_v1_evidence_bundle.*' - 'tools/test_desktop_v1_evidence_bundle_contract.*' - 'tools/export_companion_release_evidence.*' + - 'tools/check_companion_release_version.*' + - 'tools/test_companion_release_version_contract.*' + - 'tools/prepare_desktop_python_runtime.*' + - 'tools/check_desktop_python_runtime_payload.*' + - 'tools/test_desktop_python_runtime_payload_contract.*' + - 'tools/export_desktop_package_evidence.*' + - 'tools/test_desktop_package_evidence_contract.*' + - 'tools/check_desktop_release_signing_readiness.*' + - 'tools/test_desktop_release_signing_readiness_contract.*' + - 'tools/check_release_credential_hygiene.*' + - 'tools/test_release_credential_hygiene_contract.*' + - 'tools/download_companion_ci_candidate.*' + - 'tools/test_companion_ci_candidate_contract.*' + - 'tools/install_desktop_companion_package.*' + - 'tools/check_desktop_target_install_evidence.*' + - 'tools/test_desktop_target_install_evidence_contract.*' + - '.github/workflows/companion-signing-readiness.yml' + - '.github/workflows/release.yml' bridge-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.STACKCHAN_CI_SOURCE_SHA }} - uses: actions/setup-python@v6 with: @@ -134,10 +180,12 @@ jobs: companion-tests: needs: changes - if: needs.changes.outputs.companion == 'true' + if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.companion == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.STACKCHAN_CI_SOURCE_SHA }} - uses: actions/setup-java@v5 with: @@ -146,7 +194,7 @@ jobs: - uses: android-actions/setup-android@v4 - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@v6 - name: Install companion Android SDK packages run: | @@ -158,14 +206,32 @@ jobs: cd companion ./gradlew check :app-desktop:c0Spike + - name: Check companion release version contract + shell: pwsh + run: | + ./tools/check_companion_release_version.ps1 -Json + ./tools/test_companion_release_version_contract.ps1 + - name: Run Android Play release readiness check shell: pwsh run: ./tools/check_android_play_release_readiness.ps1 -Json + - name: Run Android upload signing contract + shell: pwsh + run: ./tools/test_android_upload_signing_contract.ps1 + + - name: Run Android emulator release evidence contract + shell: pwsh + run: ./tools/test_android_emulator_release_evidence_contract.ps1 + - name: Run Android Play Store evidence contract shell: pwsh run: ./tools/test_android_play_store_evidence_contract.ps1 + - name: Run privacy policy deployment contract + shell: pwsh + run: ./tools/test_privacy_policy_deployment_contract.ps1 + - name: Run Android v1 evidence bundle contract shell: pwsh run: ./tools/test_android_v1_evidence_bundle_contract.ps1 @@ -174,6 +240,26 @@ jobs: shell: pwsh run: ./tools/test_desktop_python_runtime_payload_contract.ps1 + - name: Run native desktop package evidence contract + shell: pwsh + run: ./tools/test_desktop_package_evidence_contract.ps1 + + - name: Run desktop release signing readiness contract + shell: pwsh + run: ./tools/test_desktop_release_signing_readiness_contract.ps1 + + - name: Run release credential hygiene contract + shell: pwsh + run: ./tools/test_release_credential_hygiene_contract.ps1 + + - name: Run exact-source companion CI candidate contract + shell: pwsh + run: ./tools/test_companion_ci_candidate_contract.ps1 + + - name: Run native desktop target-install evidence contract + shell: pwsh + run: ./tools/test_desktop_target_install_evidence_contract.ps1 + - name: Run Desktop v1 evidence bundle contract shell: pwsh run: ./tools/test_desktop_v1_evidence_bundle_contract.ps1 @@ -182,15 +268,108 @@ jobs: shell: pwsh run: ./tools/test_companion_v1_evidence_bundle_contract.ps1 + - name: Run consumer promotion binding contract + shell: pwsh + run: ./tools/test_consumer_promotion_contract.ps1 + - name: Upload companion C0 spike report uses: actions/upload-artifact@v7 with: name: companion-c0-spike path: output/companion/c0-spike/SPIKE.md + companion-android-emulator-smoke: + needs: [changes, companion-platform-builds] + if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.companion == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ env.STACKCHAN_CI_SOURCE_SHA }} + + - uses: android-actions/setup-android@v4 + + - name: Download companion Android APK artifacts + uses: actions/download-artifact@v7 + with: + name: companion-android-apks + path: output/companion/ci-artifacts/companion-android-apks + + - name: Install emulator SDK packages + shell: bash + run: | + yes | sdkmanager --licenses || true + sdkmanager \ + "platform-tools" \ + "emulator" \ + "system-images;android-35;aosp_atd;x86_64" + + - name: Locate release APK + shell: pwsh + run: | + $releaseApks = @( + Get-ChildItem "output/companion/ci-artifacts/companion-android-apks" -Recurse -File -Filter "*.apk" | + Where-Object { $_.FullName -match '[\\/]release[\\/]' } + ) + if ($releaseApks.Count -ne 1) { + throw "Expected one release APK for emulator smoke; found $($releaseApks.Count)." + } + "STACKCHAN_EMULATOR_APK=$($releaseApks[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Start API 35 automated-test emulator + shell: bash + run: | + mkdir -p output/android-emulator-smoke/latest + export ANDROID_AVD_HOME="$RUNNER_TEMP/android-avd" + mkdir -p "$ANDROID_AVD_HOME" + echo no | avdmanager create avd \ + --force \ + --name stackchan_ci_api35_atd \ + --package "system-images;android-35;aosp_atd;x86_64" \ + --device pixel_2 + if [ -e /dev/kvm ]; then + sudo chmod 666 /dev/kvm + fi + nohup "$ANDROID_HOME/emulator/emulator" \ + -avd stackchan_ci_api35_atd \ + -no-window \ + -no-audio \ + -no-boot-anim \ + -gpu swiftshader_indirect \ + -no-snapshot \ + -wipe-data \ + -memory 2048 \ + -cores 2 \ + > output/android-emulator-smoke/latest/emulator.stdout.log \ + 2> output/android-emulator-smoke/latest/emulator.stderr.log & + timeout 180 adb wait-for-device + timeout 180 bash -c 'until [ "$(adb shell getprop sys.boot_completed | tr -d "\r")" = "1" ]; do sleep 2; done' + adb devices -l + + - name: Run Android emulator install and launch smoke + shell: pwsh + run: | + ./tools/test_android_emulator_launch.ps1 ` + -ApkPath "$env:STACKCHAN_EMULATOR_APK" ` + -OutputDir output/android-emulator-smoke/latest ` + -Json + + - name: Stop Android emulator + if: always() + shell: bash + run: adb emu kill || true + + - name: Upload Android emulator launch evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: companion-android-emulator-smoke + path: output/android-emulator-smoke/latest/** + if-no-files-found: error + companion-platform-builds: needs: changes - if: needs.changes.outputs.companion == 'true' + if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.companion == 'true' strategy: fail-fast: false matrix: @@ -199,13 +378,17 @@ jobs: os: ubuntu-latest setup_android: true setup_linux_packaging: false + setup_desktop_runtime: false + desktop_platform: '' + package_dir: '' + extension: '' shell: bash install_android_command: | yes | sdkmanager --licenses || true sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" command: | cd companion - ./gradlew :app-android:assembleDebug :app-android:assembleRelease :app-android:bundleRelease + ./gradlew :app-android:assembleDebug :app-android:assembleRelease :app-android:bundleRelease -Pstackchan.allowLabDebugReleaseSigning=true artifact_name: companion-android-apks artifact_path: | companion/app-android/build/outputs/apk/debug/*.apk @@ -215,6 +398,10 @@ jobs: os: ubuntu-latest setup_android: true setup_linux_packaging: true + setup_desktop_runtime: true + desktop_platform: linux + package_dir: deb + extension: deb shell: bash install_android_command: | yes | sdkmanager --licenses || true @@ -223,11 +410,19 @@ jobs: cd companion ./gradlew :app-desktop:packageDistributionForCurrentOS artifact_name: companion-desktop-linux - artifact_path: companion/app-desktop/build/compose/binaries/main/deb/*.deb + artifact_path: | + companion/app-desktop/build/compose/binaries/main/deb/*.deb + output/desktop-python-runtime/linux-prepare.json + output/desktop-python-runtime/linux-package-launch.json + output/desktop-python-runtime/linux-package-evidence.json - name: desktop-macos os: macos-latest setup_android: true setup_linux_packaging: false + setup_desktop_runtime: true + desktop_platform: macos + package_dir: dmg + extension: dmg shell: bash install_android_command: | yes | sdkmanager --licenses || true @@ -236,11 +431,19 @@ jobs: cd companion ./gradlew :app-desktop:packageDistributionForCurrentOS artifact_name: companion-desktop-macos - artifact_path: companion/app-desktop/build/compose/binaries/main/dmg/*.dmg + artifact_path: | + companion/app-desktop/build/compose/binaries/main/dmg/*.dmg + output/desktop-python-runtime/macos-prepare.json + output/desktop-python-runtime/macos-package-launch.json + output/desktop-python-runtime/macos-package-evidence.json - name: desktop-windows os: windows-latest setup_android: true setup_linux_packaging: false + setup_desktop_runtime: true + desktop_platform: windows + package_dir: msi + extension: msi shell: pwsh install_android_command: | 1..20 | ForEach-Object { 'y' } | sdkmanager --licenses @@ -249,11 +452,22 @@ jobs: cd companion .\gradlew.bat :app-desktop:packageDistributionForCurrentOS artifact_name: companion-desktop-windows - artifact_path: companion/app-desktop/build/compose/binaries/main/msi/*.msi + artifact_path: | + companion/app-desktop/build/compose/binaries/main/msi/*.msi + output/desktop-python-runtime/windows-prepare.json + output/desktop-python-runtime/windows-package-launch.json + output/desktop-python-runtime/windows-package-evidence.json name: companion-platform-builds (${{ matrix.name }}) runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.STACKCHAN_CI_SOURCE_SHA }} + + - uses: actions/setup-python@v6 + if: matrix.setup_desktop_runtime + with: + python-version: "3.12" - uses: actions/setup-java@v5 with: @@ -263,7 +477,7 @@ jobs: - uses: android-actions/setup-android@v4 if: matrix.setup_android - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@v6 - name: Install companion Android SDK packages if: matrix.setup_android && matrix.shell == 'bash' @@ -282,6 +496,19 @@ jobs: sudo apt-get update sudo apt-get install -y fakeroot + - name: Prepare managed Python runtime for desktop package + if: matrix.setup_desktop_runtime + shell: pwsh + run: | + $runtimeRoot = Join-Path "${{ github.workspace }}" "output/desktop-python-runtime/${{ matrix.desktop_platform }}" + ./tools/prepare_desktop_python_runtime.ps1 ` + -SourcePython (Get-Command python).Source ` + -RuntimeRoot $runtimeRoot ` + -SourceName "github-actions-pr-${{ runner.os }}-${{ runner.arch }}-python-3.12" ` + -Force ` + -Json | Set-Content "output/desktop-python-runtime/${{ matrix.desktop_platform }}-prepare.json" -Encoding utf8 + "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT=$runtimeRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Build companion platform artifact if: matrix.shell == 'bash' shell: bash @@ -335,6 +562,44 @@ jobs: exit $status } + - name: Launch exact native desktop package headlessly + if: matrix.setup_desktop_runtime + shell: pwsh + run: | + $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/${{ matrix.package_dir }}" -File -Filter "*.${{ matrix.extension }}") + if ($packages.Count -ne 1) { + throw "Expected one ${{ matrix.desktop_platform }} desktop package; found $($packages.Count)." + } + ./tools/test_desktop_package_launch.ps1 ` + -Platform "${{ matrix.desktop_platform }}" ` + -PackagePath $packages[0].FullName ` + -ExtractionRoot (Join-Path $env:RUNNER_TEMP "stackchan-package-extraction-${{ matrix.desktop_platform }}") ` + -OutPath "output/desktop-python-runtime/${{ matrix.desktop_platform }}-package-launch.json" ` + -Json + + - name: Export native desktop package evidence + if: matrix.setup_desktop_runtime + shell: pwsh + run: | + $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/${{ matrix.package_dir }}" -File -Filter "*.${{ matrix.extension }}") + if ($packages.Count -ne 1) { + throw "Expected one ${{ matrix.desktop_platform }} desktop package; found $($packages.Count)." + } + ./tools/export_desktop_package_evidence.ps1 ` + -Platform "${{ matrix.desktop_platform }}" ` + -PackagePath $packages[0].FullName ` + -RuntimePrepareJsonPath "output/desktop-python-runtime/${{ matrix.desktop_platform }}-prepare.json" ` + -ProcessedRuntimeRoot "companion/app-desktop/build/generated/native-app-resources/common/python-runtime" ` + -PackageExtractionRoot (Join-Path $env:RUNNER_TEMP "stackchan-package-extraction-${{ matrix.desktop_platform }}") ` + -LaunchEvidencePath "output/desktop-python-runtime/${{ matrix.desktop_platform }}-package-launch.json" ` + -Version "${{ env.STACKCHAN_CI_SOURCE_SHA }}" ` + -Commit "${{ env.STACKCHAN_CI_SOURCE_SHA }}" ` + -OutPath "output/desktop-python-runtime/${{ matrix.desktop_platform }}-package-evidence.json" ` + -RequireInstallerPayload ` + -RequireLaunchEvidence ` + -UseExistingPackageExtraction ` + -Json + - name: Upload companion platform artifact uses: actions/upload-artifact@v7 with: @@ -343,11 +608,13 @@ jobs: if-no-files-found: error companion-release-evidence: - needs: [changes, companion-platform-builds] - if: needs.changes.outputs.companion == 'true' + needs: [changes, companion-platform-builds, companion-android-emulator-smoke] + if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.companion == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.STACKCHAN_CI_SOURCE_SHA }} - uses: actions/setup-java@v5 with: @@ -368,6 +635,12 @@ jobs: name: companion-android-apks path: output/companion/ci-artifacts/companion-android-apks + - name: Download Android emulator launch evidence + uses: actions/download-artifact@v7 + with: + name: companion-android-emulator-smoke + path: output/companion/ci-artifacts/companion-android-emulator-smoke + - name: Download companion Linux desktop artifacts uses: actions/download-artifact@v7 with: @@ -390,13 +663,17 @@ jobs: shell: pwsh run: | ./tools/export_companion_release_evidence.ps1 ` - -Version "${{ github.sha }}" ` - -Commit "${{ github.sha }}" ` + -Version "${{ env.STACKCHAN_CI_SOURCE_SHA }}" ` + -Commit "${{ env.STACKCHAN_CI_SOURCE_SHA }}" ` -AndroidArtifactRoot "output/companion/ci-artifacts/companion-android-apks" ` + -AndroidEmulatorEvidencePath "output/companion/ci-artifacts/companion-android-emulator-smoke/android_emulator_launch_smoke.json" ` -DesktopArtifactRoot "output/companion/ci-artifacts" ` + -DesktopPackageEvidenceRoot "output/companion/ci-artifacts" ` -ApkSignerPath "$env:ANDROID_HOME/build-tools/36.0.0/apksigner" ` -OutDir "output/companion/release-evidence/ci" ` -RequireArtifacts ` + -RequireAndroidEmulatorEvidence ` + -RequireDesktopPackageEvidence ` -Json - name: Upload companion release evidence @@ -412,6 +689,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.STACKCHAN_CI_SOURCE_SHA }} - uses: actions/setup-python@v6 with: @@ -432,6 +711,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v7 + with: + ref: ${{ env.STACKCHAN_CI_SOURCE_SHA }} - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..bcea811e --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,35 @@ +name: Deploy privacy policy + +on: + push: + branches: + - main + paths: + - "site/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v4 + with: + path: site + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c00a636..2bb22437 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,423 @@ on: permissions: contents: write + id-token: write + attestations: write jobs: + companion-android-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - uses: android-actions/setup-android@v4 + + - uses: gradle/actions/setup-gradle@v6 + + - name: Check companion release version + shell: pwsh + run: ./tools/check_companion_release_version.ps1 -ExpectedVersion "${{ github.ref_name }}" -Json + + - name: Install Android SDK packages + shell: bash + run: | + yes | sdkmanager --licenses || true + sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" + + - name: Prepare Android upload signing + shell: bash + env: + STACKCHAN_ANDROID_KEYSTORE_B64: ${{ secrets.STACKCHAN_ANDROID_KEYSTORE_B64 }} + STACKCHAN_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.STACKCHAN_ANDROID_KEYSTORE_PASSWORD }} + STACKCHAN_ANDROID_KEY_ALIAS: ${{ secrets.STACKCHAN_ANDROID_KEY_ALIAS }} + STACKCHAN_ANDROID_KEY_PASSWORD: ${{ secrets.STACKCHAN_ANDROID_KEY_PASSWORD }} + run: | + for name in STACKCHAN_ANDROID_KEYSTORE_B64 STACKCHAN_ANDROID_KEYSTORE_PASSWORD STACKCHAN_ANDROID_KEY_ALIAS STACKCHAN_ANDROID_KEY_PASSWORD; do + if [ -z "${!name}" ]; then + echo "::error::$name is required for a tagged companion release." + exit 1 + fi + done + keystore="$RUNNER_TEMP/stackchan-companion-upload.jks" + printf '%s' "$STACKCHAN_ANDROID_KEYSTORE_B64" | base64 --decode > "$keystore" + test -s "$keystore" + echo "STACKCHAN_ANDROID_KEYSTORE=$keystore" >> "$GITHUB_ENV" + echo "STACKCHAN_ANDROID_KEYSTORE_PASSWORD=$STACKCHAN_ANDROID_KEYSTORE_PASSWORD" >> "$GITHUB_ENV" + echo "STACKCHAN_ANDROID_KEY_ALIAS=$STACKCHAN_ANDROID_KEY_ALIAS" >> "$GITHUB_ENV" + echo "STACKCHAN_ANDROID_KEY_PASSWORD=$STACKCHAN_ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV" + + - name: Validate Android upload key + shell: pwsh + run: ./tools/check_android_play_release_readiness.ps1 -RequireUploadSigning -Json + + - name: Build upload-signed Android release + shell: bash + run: | + cd companion + ./gradlew :app-android:assembleDebug :app-android:assembleRelease :app-android:bundleRelease + + - name: Upload Android release inputs + uses: actions/upload-artifact@v7 + with: + name: companion-android-release + path: | + companion/app-android/build/outputs/apk/debug/*.apk + companion/app-android/build/outputs/apk/release/*.apk + companion/app-android/build/outputs/bundle/release/*.aab + if-no-files-found: error + + companion-android-emulator-smoke: + needs: companion-android-release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: android-actions/setup-android@v4 + + - name: Download upload-signed Android release + uses: actions/download-artifact@v7 + with: + name: companion-android-release + path: output/companion/release-input/android + + - name: Install emulator SDK packages + shell: bash + run: | + yes | sdkmanager --licenses || true + sdkmanager \ + "platform-tools" \ + "emulator" \ + "system-images;android-35;aosp_atd;x86_64" + + - name: Locate upload-signed release APK + shell: pwsh + run: | + $releaseApks = @( + Get-ChildItem "output/companion/release-input/android" -Recurse -File -Filter "*.apk" | + Where-Object { $_.FullName -match '[\\/]release[\\/]' } + ) + if ($releaseApks.Count -ne 1) { + throw "Expected one upload-signed release APK for emulator smoke; found $($releaseApks.Count)." + } + "STACKCHAN_EMULATOR_APK=$($releaseApks[0].FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Start API 35 automated-test emulator + shell: bash + run: | + mkdir -p output/android-emulator-smoke/latest + export ANDROID_AVD_HOME="$RUNNER_TEMP/android-avd" + mkdir -p "$ANDROID_AVD_HOME" + echo no | avdmanager create avd \ + --force \ + --name stackchan_release_api35_atd \ + --package "system-images;android-35;aosp_atd;x86_64" \ + --device pixel_2 + if [ -e /dev/kvm ]; then + sudo chmod 666 /dev/kvm + fi + nohup "$ANDROID_HOME/emulator/emulator" \ + -avd stackchan_release_api35_atd \ + -no-window \ + -no-audio \ + -no-boot-anim \ + -gpu swiftshader_indirect \ + -no-snapshot \ + -wipe-data \ + -memory 2048 \ + -cores 2 \ + > output/android-emulator-smoke/latest/emulator.stdout.log \ + 2> output/android-emulator-smoke/latest/emulator.stderr.log & + timeout 180 adb wait-for-device + timeout 180 bash -c 'until [ "$(adb shell getprop sys.boot_completed | tr -d "\r")" = "1" ]; do sleep 2; done' + adb devices -l + + - name: Run upload-signed Android emulator launch smoke + shell: pwsh + run: | + ./tools/test_android_emulator_launch.ps1 ` + -ApkPath "$env:STACKCHAN_EMULATOR_APK" ` + -OutputDir output/android-emulator-smoke/latest ` + -Json + + - name: Stop Android emulator + if: always() + shell: bash + run: adb emu kill || true + + - name: Upload Android emulator launch evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: companion-android-emulator-smoke + path: output/android-emulator-smoke/latest/** + if-no-files-found: error + + companion-desktop-release: + strategy: + fail-fast: false + matrix: + include: + - platform: linux + os: ubuntu-latest + package_dir: deb + extension: deb + - platform: macos + os: macos-latest + package_dir: dmg + extension: dmg + - platform: windows + os: windows-latest + package_dir: msi + extension: msi + name: companion-desktop-release (${{ matrix.platform }}) + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - uses: android-actions/setup-android@v4 + + - uses: gradle/actions/setup-gradle@v6 + + - name: Check companion release version + shell: pwsh + run: ./tools/check_companion_release_version.ps1 -ExpectedVersion "${{ github.ref_name }}" -Json + + - name: Install Android SDK packages + if: runner.os != 'Windows' + shell: bash + run: | + yes | sdkmanager --licenses || true + sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" + + - name: Install Android SDK packages on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + 1..20 | ForEach-Object { 'y' } | sdkmanager --licenses + sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" + + - name: Install Linux packaging prerequisites + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y fakeroot + + - name: Prepare managed Python runtime + shell: pwsh + run: | + $runtimeRoot = Join-Path "${{ github.workspace }}" "output/desktop-python-runtime/${{ matrix.platform }}" + ./tools/prepare_desktop_python_runtime.ps1 ` + -SourcePython (Get-Command python).Source ` + -RuntimeRoot $runtimeRoot ` + -SourceName "github-actions-${{ runner.os }}-${{ runner.arch }}-python-3.12" ` + -Force ` + -Json | Set-Content "output/desktop-python-runtime/${{ matrix.platform }}-prepare.json" -Encoding utf8 + "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT=$runtimeRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Validate production desktop signing credentials + if: matrix.platform == 'windows' || matrix.platform == 'macos' + shell: pwsh + env: + STACKCHAN_WINDOWS_PFX_B64: ${{ secrets.STACKCHAN_WINDOWS_PFX_B64 }} + STACKCHAN_WINDOWS_PFX_PASSWORD: ${{ secrets.STACKCHAN_WINDOWS_PFX_PASSWORD }} + STACKCHAN_MACOS_CERTIFICATE_B64: ${{ secrets.STACKCHAN_MACOS_CERTIFICATE_B64 }} + STACKCHAN_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.STACKCHAN_MACOS_CERTIFICATE_PASSWORD }} + STACKCHAN_MACOS_SIGNING_IDENTITY: ${{ secrets.STACKCHAN_MACOS_SIGNING_IDENTITY }} + STACKCHAN_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_APPLE_ID }} + STACKCHAN_MACOS_NOTARIZATION_PASSWORD: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_PASSWORD }} + STACKCHAN_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_TEAM_ID }} + run: | + $arguments = @{ + Platform = "${{ matrix.platform }}" + RequireReady = $true + RequireNativeToolchain = $true + Json = $true + } + if ("${{ matrix.platform }}" -eq "macos") { + $arguments.ValidateAppleNotaryCredentials = $true + } + ./tools/check_desktop_release_signing_readiness.ps1 @arguments + + - name: Prepare macOS Developer ID keychain + if: matrix.platform == 'macos' + shell: pwsh + env: + STACKCHAN_MACOS_CERTIFICATE_B64: ${{ secrets.STACKCHAN_MACOS_CERTIFICATE_B64 }} + STACKCHAN_MACOS_CERTIFICATE_PASSWORD: ${{ secrets.STACKCHAN_MACOS_CERTIFICATE_PASSWORD }} + STACKCHAN_MACOS_SIGNING_IDENTITY: ${{ secrets.STACKCHAN_MACOS_SIGNING_IDENTITY }} + STACKCHAN_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_APPLE_ID }} + STACKCHAN_MACOS_NOTARIZATION_PASSWORD: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_PASSWORD }} + STACKCHAN_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_TEAM_ID }} + run: | + $required = @( + "STACKCHAN_MACOS_CERTIFICATE_B64", + "STACKCHAN_MACOS_CERTIFICATE_PASSWORD", + "STACKCHAN_MACOS_SIGNING_IDENTITY", + "STACKCHAN_MACOS_NOTARIZATION_APPLE_ID", + "STACKCHAN_MACOS_NOTARIZATION_PASSWORD", + "STACKCHAN_MACOS_NOTARIZATION_TEAM_ID" + ) + foreach ($name in $required) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "$name is required for a tagged macOS companion release." + } + } + + $certificatePath = Join-Path $env:RUNNER_TEMP "stackchan-developer-id.p12" + $keychainPath = Join-Path $env:RUNNER_TEMP "stackchan-release.keychain-db" + $keychainPassword = [guid]::NewGuid().ToString("N") + [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($env:STACKCHAN_MACOS_CERTIFICATE_B64)) + Write-Output "::add-mask::$keychainPassword" + & security create-keychain -p $keychainPassword $keychainPath + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & security set-keychain-settings -lut 21600 $keychainPath + & security unlock-keychain -p $keychainPassword $keychainPath + & security import $certificatePath -k $keychainPath -P $env:STACKCHAN_MACOS_CERTIFICATE_PASSWORD -T /usr/bin/codesign -T /usr/bin/security + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $keychainPassword $keychainPath + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $existingKeychains = @(& security list-keychains -d user | ForEach-Object { $_.Trim().Trim('"') }) + & security list-keychains -d user -s $keychainPath @existingKeychains + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + "STACKCHAN_MACOS_CERTIFICATE_PATH=$certificatePath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "STACKCHAN_MACOS_KEYCHAIN=$keychainPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Build native Windows or Linux package + if: matrix.platform != 'macos' + shell: pwsh + working-directory: companion + run: | + if ($IsWindows) { + ./gradlew.bat :app-desktop:packageDistributionForCurrentOS + } else { + ./gradlew :app-desktop:packageDistributionForCurrentOS + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Build, sign, and notarize native macOS package + if: matrix.platform == 'macos' + shell: pwsh + working-directory: companion + env: + STACKCHAN_MACOS_SIGNING_IDENTITY: ${{ secrets.STACKCHAN_MACOS_SIGNING_IDENTITY }} + STACKCHAN_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_APPLE_ID }} + STACKCHAN_MACOS_NOTARIZATION_PASSWORD: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_PASSWORD }} + STACKCHAN_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.STACKCHAN_MACOS_NOTARIZATION_TEAM_ID }} + run: | + ./gradlew :app-desktop:notarizeDmg + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Authenticode sign Windows package + if: matrix.platform == 'windows' + shell: pwsh + env: + STACKCHAN_WINDOWS_PFX_B64: ${{ secrets.STACKCHAN_WINDOWS_PFX_B64 }} + STACKCHAN_WINDOWS_PFX_PASSWORD: ${{ secrets.STACKCHAN_WINDOWS_PFX_PASSWORD }} + run: | + foreach ($name in @("STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_WINDOWS_PFX_PASSWORD")) { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { + throw "$name is required for a tagged Windows companion release." + } + } + $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/msi" -File -Filter "*.msi") + if ($packages.Count -ne 1) { throw "Expected one Windows MSI; found $($packages.Count)." } + $pfxPath = Join-Path $env:RUNNER_TEMP "stackchan-authenticode.pfx" + try { + [IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:STACKCHAN_WINDOWS_PFX_B64)) + $signTool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -File -Filter signtool.exe | + Where-Object { $_.FullName -match '[\\/]x64[\\/]signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $signTool) { throw "signtool.exe was not found in the Windows SDK." } + & $signTool.FullName sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f $pfxPath /p $env:STACKCHAN_WINDOWS_PFX_PASSWORD /d "Stackchan Companion" /du "https://github.com/RobVanProd/stackchan_alive" $packages[0].FullName + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $signTool.FullName verify /pa /all /tw /v $packages[0].FullName + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { + Remove-Item -LiteralPath $pfxPath -Force -ErrorAction SilentlyContinue + } + + - name: Launch exact native desktop package headlessly + shell: pwsh + run: | + $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/${{ matrix.package_dir }}" -File -Filter "*.${{ matrix.extension }}") + if ($packages.Count -ne 1) { + throw "Expected one ${{ matrix.platform }} desktop package; found $($packages.Count)." + } + ./tools/test_desktop_package_launch.ps1 ` + -Platform "${{ matrix.platform }}" ` + -PackagePath $packages[0].FullName ` + -ExtractionRoot (Join-Path $env:RUNNER_TEMP "stackchan-package-extraction-${{ matrix.platform }}") ` + -OutPath "output/desktop-python-runtime/${{ matrix.platform }}-package-launch.json" ` + -Json + + - name: Export native desktop package evidence + shell: pwsh + run: | + $packages = @(Get-ChildItem "companion/app-desktop/build/compose/binaries/main/${{ matrix.package_dir }}" -File -Filter "*.${{ matrix.extension }}") + if ($packages.Count -ne 1) { + throw "Expected one ${{ matrix.platform }} desktop package; found $($packages.Count)." + } + $distributionTrustArguments = @{} + if (@("windows", "macos") -contains "${{ matrix.platform }}") { + $distributionTrustArguments.RequireDistributionTrust = $true + } + ./tools/export_desktop_package_evidence.ps1 ` + -Platform "${{ matrix.platform }}" ` + -PackagePath $packages[0].FullName ` + -RuntimePrepareJsonPath "output/desktop-python-runtime/${{ matrix.platform }}-prepare.json" ` + -ProcessedRuntimeRoot "companion/app-desktop/build/generated/native-app-resources/common/python-runtime" ` + -PackageExtractionRoot (Join-Path $env:RUNNER_TEMP "stackchan-package-extraction-${{ matrix.platform }}") ` + -LaunchEvidencePath "output/desktop-python-runtime/${{ matrix.platform }}-package-launch.json" ` + -Version "${{ github.ref_name }}" ` + -Commit "${{ github.sha }}" ` + -OutPath "output/desktop-python-runtime/${{ matrix.platform }}-package-evidence.json" ` + -RequireInstallerPayload ` + -RequireLaunchEvidence ` + @distributionTrustArguments ` + -UseExistingPackageExtraction ` + -Json + + - name: Remove macOS release keychain + if: always() && matrix.platform == 'macos' + shell: pwsh + run: | + if (-not [string]::IsNullOrWhiteSpace($env:STACKCHAN_MACOS_KEYCHAIN) -and (Test-Path -LiteralPath $env:STACKCHAN_MACOS_KEYCHAIN)) { + & security delete-keychain $env:STACKCHAN_MACOS_KEYCHAIN + } + if (-not [string]::IsNullOrWhiteSpace($env:STACKCHAN_MACOS_CERTIFICATE_PATH)) { + Remove-Item -LiteralPath $env:STACKCHAN_MACOS_CERTIFICATE_PATH -Force -ErrorAction SilentlyContinue + } + + - name: Upload desktop release input + uses: actions/upload-artifact@v7 + with: + name: companion-desktop-release-${{ matrix.platform }} + path: | + companion/app-desktop/build/compose/binaries/main/${{ matrix.package_dir }}/*.${{ matrix.extension }} + output/desktop-python-runtime/${{ matrix.platform }}-prepare.json + output/desktop-python-runtime/${{ matrix.platform }}-package-launch.json + output/desktop-python-runtime/${{ matrix.platform }}-package-evidence.json + if-no-files-found: error + release: + needs: [companion-android-release, companion-android-emulator-smoke, companion-desktop-release] runs-on: windows-latest steps: - uses: actions/checkout@v7 @@ -18,6 +432,47 @@ jobs: with: python-version: "3.12" + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - uses: android-actions/setup-android@v4 + + - name: Install Android signing verifier + shell: pwsh + run: sdkmanager "build-tools;36.0.0" + + - name: Download Android companion artifacts + uses: actions/download-artifact@v7 + with: + name: companion-android-release + path: output/companion/release-input/android + + - name: Download Android emulator launch evidence + uses: actions/download-artifact@v7 + with: + name: companion-android-emulator-smoke + path: output/companion/release-input/android-emulator + + - name: Download Linux companion artifact + uses: actions/download-artifact@v7 + with: + name: companion-desktop-release-linux + path: output/companion/release-input/linux + + - name: Download macOS companion artifact + uses: actions/download-artifact@v7 + with: + name: companion-desktop-release-macos + path: output/companion/release-input/macos + + - name: Download Windows companion artifact + uses: actions/download-artifact@v7 + with: + name: companion-desktop-release-windows + path: output/companion/release-input/windows + - name: Install tooling run: python -m pip install --upgrade pip platformio -r requirements-preview.txt @@ -42,10 +497,37 @@ jobs: shell: pwsh run: ./tools/verify_release_package.ps1 -Version "${{ github.ref_name }}" -ZipPath "output/release/stackchan_alive_${{ github.ref_name }}.zip" -ExpectedCommit "${{ github.sha }}" - - name: Create GitHub release + - name: Export strict companion release evidence + shell: pwsh + run: | + ./tools/export_companion_release_evidence.ps1 ` + -Version "${{ github.ref_name }}" ` + -Commit "${{ github.sha }}" ` + -PackageRoot "output/release/${{ github.ref_name }}" ` + -AndroidArtifactRoot "output/companion/release-input/android" ` + -AndroidEmulatorEvidencePath "output/companion/release-input/android-emulator/android_emulator_launch_smoke.json" ` + -DesktopArtifactRoot "output/companion/release-input" ` + -DesktopPackageEvidenceRoot "output/companion/release-input" ` + -ApkSignerPath "$env:ANDROID_HOME/build-tools/36.0.0/apksigner.bat" ` + -OutDir "output/companion/release-evidence/${{ github.ref_name }}" ` + -RequireArtifacts ` + -RequireUploadSigning ` + -RequireAndroidEmulatorEvidence ` + -RequireDesktopPackageEvidence ` + -RequireDesktopDistributionTrust ` + -Json + + - name: Upload companion release evidence + uses: actions/upload-artifact@v7 + with: + name: companion-release-evidence-${{ github.ref_name }} + path: | + output/companion/release-evidence/${{ github.ref_name }}/COMPANION_RELEASE_EVIDENCE.json + output/companion/release-evidence/${{ github.ref_name }}/COMPANION_RELEASE_EVIDENCE.md + if-no-files-found: error + + - name: Stage final release assets shell: pwsh - env: - GH_TOKEN: ${{ github.token }} run: | . ./tools/release_asset_contract.ps1 $version = "${{ github.ref_name }}" @@ -53,14 +535,68 @@ jobs: $zipPath = "output/release/stackchan_alive_$version.zip" $zipSidecarPath = "$zipPath.sha256" $stageDir = "output/release/workflow-assets-$version" - New-Item -ItemType Directory -Force -Path $stageDir | Out-Null + $companionStageDir = "output/release/companion-assets-$version" + New-Item -ItemType Directory -Force -Path $stageDir, $companionStageDir | Out-Null Copy-Item "$packageRoot/firmware/display_only/firmware.bin" "$stageDir/firmware-display-only.bin" Copy-Item "$packageRoot/firmware/servo_calibration/firmware.bin" "$stageDir/firmware-servo-calibration.bin" Copy-Item "$packageRoot/firmware/display_only/bootloader.bin" "$stageDir/bootloader.bin" Copy-Item "$packageRoot/firmware/display_only/partitions.bin" "$stageDir/partitions.bin" + + function Copy-SingleCompanionArtifact([string]$Pattern, [string]$Destination, [string]$RequiredPathFragment = "") { + $matches = @(Get-ChildItem "output/companion/release-input" -Recurse -File -Filter $Pattern | Where-Object { + [string]::IsNullOrWhiteSpace($RequiredPathFragment) -or $_.FullName.Replace('\', '/') -match $RequiredPathFragment + }) + if ($matches.Count -ne 1) { + throw "Expected one companion artifact matching $Pattern / $RequiredPathFragment; found $($matches.Count)." + } + Copy-Item -LiteralPath $matches[0].FullName -Destination $Destination + } + + Copy-SingleCompanionArtifact "*.apk" "$companionStageDir/stackchan-companion-android-$version.apk" "(^|/)release/" + Copy-SingleCompanionArtifact "*.aab" "$companionStageDir/stackchan-companion-android-$version.aab" + Copy-SingleCompanionArtifact "*.msi" "$companionStageDir/stackchan-companion-windows-$version.msi" + Copy-SingleCompanionArtifact "*.deb" "$companionStageDir/stackchan-companion-linux-$version.deb" + Copy-SingleCompanionArtifact "*.dmg" "$companionStageDir/stackchan-companion-macos-$version.dmg" + Copy-Item "output/companion/release-evidence/$version/COMPANION_RELEASE_EVIDENCE.json" $companionStageDir + Copy-Item "output/companion/release-evidence/$version/COMPANION_RELEASE_EVIDENCE.md" $companionStageDir + ./tools/verify_release_asset_contract.ps1 -Version $version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage $releaseAssetEntries = Get-ReleaseFinalAssetEntries -Version $version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage - $releaseAssetPaths = @($releaseAssetEntries | ForEach-Object { $_.Path }) + $companionAssetEntries = Get-ReleaseCompanionAssetEntries -Version $version -CompanionAssetRoot $companionStageDir + $releaseAssetPaths = @($releaseAssetEntries + $companionAssetEntries | ForEach-Object { $_.Path }) + if ($releaseAssetPaths.Count -lt 10) { + throw "Expected the complete staged release asset set; found $($releaseAssetPaths.Count)." + } + + - name: Attest final release assets + uses: actions/attest@v4 + with: + subject-path: | + output/release/stackchan_alive_${{ github.ref_name }}.zip + output/release/stackchan_alive_${{ github.ref_name }}.zip.sha256 + output/release/workflow-assets-${{ github.ref_name }}/* + output/release/companion-assets-${{ github.ref_name }}/* + output/release/${{ github.ref_name }}/media/* + output/release/${{ github.ref_name }}/media/voice/* + output/release/${{ github.ref_name }}/GITHUB_ACTIONS_STATUS.md + output/release/${{ github.ref_name }}/github_actions_status.json + output/release/${{ github.ref_name }}/release_assets.json + + - name: Create GitHub release + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + . ./tools/release_asset_contract.ps1 + $version = "${{ github.ref_name }}" + $packageRoot = "output/release/$version" + $zipPath = "output/release/stackchan_alive_$version.zip" + $zipSidecarPath = "$zipPath.sha256" + $stageDir = "output/release/workflow-assets-$version" + $companionStageDir = "output/release/companion-assets-$version" + $releaseAssetEntries = Get-ReleaseFinalAssetEntries -Version $version -PackageRoot $packageRoot -ZipPath $zipPath -ZipSidecarPath $zipSidecarPath -FirmwareAssetRoot $stageDir -FirmwareAssetPathMode Stage + $companionAssetEntries = Get-ReleaseCompanionAssetEntries -Version $version -CompanionAssetRoot $companionStageDir + $releaseAssetPaths = @($releaseAssetEntries + $companionAssetEntries | ForEach-Object { $_.Path }) gh release create "${{ github.ref_name }}" ` @releaseAssetPaths ` --repo "${{ github.repository }}" ` diff --git a/.gitignore b/.gitignore index 0753741c..76656419 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,14 @@ .env .env.* !.env.example +*.jks +*.keystore +*.p12 +*.pfx +*.pkcs12 +*.key +*.p8 +*.snk # Editor and OS files .vscode/ diff --git a/README.md b/README.md index 9e324c21..227319d7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ no face sprite sheets or character-image assets in the runtime. ## Project Status -Status as of July 12, 2026: **public v0.2 release candidate, physically validated on the +Status as of July 13, 2026: **public v0.2 release candidate, physically validated on the reference Stackchan**. What is working in the repository now: @@ -42,9 +42,9 @@ What is working in the repository now: Release notes: -- The exact paired candidate passed its formal one-hour actuator acceptance and continued through - more than five hours of the all-feature run with no strict hardware bad state. The repository - owner accepted that evidence for this release and waived the remaining duration requirement. +- The corrected exact paired candidate completed the full all-feature actuator soak for `28807 s` + with `5643/5643` successful polls and a `77/77` formal checker result. Motion, servo rail, + torque, and motion power authority were verified off after its bounded final stop. - The public build and release package contain no Wi-Fi credentials, OTA token, or camera pairing data. The active production RVC model and index are included as public release assets. - PC/mobile owner failover remains a companion-platform follow-up. diff --git a/bridge/persona_pack.py b/bridge/persona_pack.py index 5754efcf..223296bd 100644 --- a/bridge/persona_pack.py +++ b/bridge/persona_pack.py @@ -18,6 +18,7 @@ BEHAVIOR_SCHEMA = "stackchan.persona-behavior.v1" VOICE_PROVENANCE_SCHEMA = "stackchan.voice-source-provenance.v1" DEFAULT_PERSONA_ID = "spark" +PERSONA_HASH_TEXT_SUFFIXES = frozenset({".json", ".md", ".txt", ".yaml", ".yml"}) FOUNDATION_MAX_CHARS = 140 FOUNDATION_MAX_SENTENCES = 2 @@ -728,6 +729,13 @@ def check_expression_float(section: str, key: str, minimum: float, maximum: floa return issues +def _persona_asset_hash_bytes(path: Path) -> bytes: + data = path.read_bytes() + if path.suffix.casefold() in PERSONA_HASH_TEXT_SUFFIXES: + return data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + return data + + def persona_pack_sha256(pack_root: Path) -> str: root = pack_root.resolve() digest = hashlib.sha256() @@ -739,7 +747,7 @@ def persona_pack_sha256(pack_root: Path) -> str: raise PersonaPackError(f"persona pack asset escapes pack root: {path}") from exc digest.update(relative.encode("utf-8")) digest.update(b"\0") - digest.update(resolved.read_bytes()) + digest.update(_persona_asset_hash_bytes(resolved)) digest.update(b"\0") return digest.hexdigest() diff --git a/bridge/test_persona_pack.py b/bridge/test_persona_pack.py index e9a32058..51f861dd 100644 --- a/bridge/test_persona_pack.py +++ b/bridge/test_persona_pack.py @@ -87,6 +87,26 @@ def test_checked_in_persona_index_matches_bundled_packs(self): checked_in = json.loads((repo_root() / "data" / "persona_index.json").read_text(encoding="utf-8")) self.assertEqual(build_persona_index(), checked_in) + def test_persona_pack_sha256_normalizes_text_line_endings_only(self): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + lf_pack = root / "lf" + crlf_pack = root / "crlf" + lf_pack.mkdir() + crlf_pack.mkdir() + (lf_pack / "pack.yaml").write_bytes(b"schema: test\nid: line-endings\n") + (crlf_pack / "pack.yaml").write_bytes(b"schema: test\r\nid: line-endings\r\n") + (lf_pack / "prompt.md").write_bytes(b"hello\nworld\n") + (crlf_pack / "prompt.md").write_bytes(b"hello\rworld\r") + binary = b"\x00\r\n\xff" + (lf_pack / "earcon.bin").write_bytes(binary) + (crlf_pack / "earcon.bin").write_bytes(binary) + + self.assertEqual(persona_pack_sha256(lf_pack), persona_pack_sha256(crlf_pack)) + + (crlf_pack / "earcon.bin").write_bytes(b"\x00\n\xff") + self.assertNotEqual(persona_pack_sha256(lf_pack), persona_pack_sha256(crlf_pack)) + def test_persona_index_preserves_invalid_pack_without_activating_it(self): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) diff --git a/companion/app-android/build.gradle.kts b/companion/app-android/build.gradle.kts index 56af34d2..43ce6d7f 100644 --- a/companion/app-android/build.gradle.kts +++ b/companion/app-android/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.GradleException + plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) @@ -21,6 +23,10 @@ val hasPlayReleaseSigning = listOf( releaseKeyAlias, releaseKeyPassword, ).all { !it.isNullOrBlank() } +val allowLabDebugReleaseSigning = providers.gradleProperty("stackchan.allowLabDebugReleaseSigning") + .map(String::toBoolean) + .orElse(false) + .get() android { namespace = "dev.stackchan.companion.android" @@ -48,7 +54,11 @@ android { buildTypes { release { isDebuggable = false - signingConfig = signingConfigs.getByName(if (hasPlayReleaseSigning) "playRelease" else "debug") + signingConfig = when { + hasPlayReleaseSigning -> signingConfigs.getByName("playRelease") + allowLabDebugReleaseSigning -> signingConfigs.getByName("debug") + else -> null + } } } @@ -59,6 +69,33 @@ android { } } +val verifyReleaseSigning = tasks.register("verifyReleaseSigning") { + group = "verification" + description = "Fails release builds unless Play upload signing is configured or lab signing is explicitly allowed." + + doLast { + if (!hasPlayReleaseSigning && !allowLabDebugReleaseSigning) { + throw GradleException( + "Android release signing is not configured. Set STACKCHAN_ANDROID_KEYSTORE, " + + "STACKCHAN_ANDROID_KEYSTORE_PASSWORD, STACKCHAN_ANDROID_KEY_ALIAS, and " + + "STACKCHAN_ANDROID_KEY_PASSWORD. For a non-distributable lab build only, pass " + + "-Pstackchan.allowLabDebugReleaseSigning=true." + ) + } + logger.lifecycle( + if (hasPlayReleaseSigning) { + "Android release signing profile: Play upload key." + } else { + "Android release signing profile: LAB ONLY Android debug key." + } + ) + } +} + +tasks.matching { it.name in setOf("assembleRelease", "bundleRelease") }.configureEach { + dependsOn(verifyReleaseSigning) +} + dependencies { implementation(project(":core")) implementation(project(":ui")) diff --git a/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/AndroidSpeechTurnController.kt b/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/AndroidSpeechTurnController.kt index 31355fea..cdb233f0 100644 --- a/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/AndroidSpeechTurnController.kt +++ b/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/AndroidSpeechTurnController.kt @@ -85,6 +85,7 @@ class AndroidSpeechTurnController( putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault().toLanguageTag()) putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3) + putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true) } private fun bestTranscript(results: Bundle?): String = diff --git a/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/MainActivity.kt b/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/MainActivity.kt index 20a8b0a0..d3a6e821 100644 --- a/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/MainActivity.kt +++ b/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/MainActivity.kt @@ -349,6 +349,17 @@ class MainActivity : ComponentActivity() { }, ) }, + onOpenPrivacyPolicy = { + runCatching { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(CompanionIdentity.privacyPolicyUrl))) + }.onFailure { + Toast.makeText( + this@MainActivity, + "No browser is available to open the privacy policy.", + Toast.LENGTH_LONG, + ).show() + } + }, onClaimBrain = { coroutineScope.launch { val result = CompanionBridgeService.claimBrain() diff --git a/companion/app-desktop/build.gradle.kts b/companion/app-desktop/build.gradle.kts index 10dc5c0c..8e5f716c 100644 --- a/companion/app-desktop/build.gradle.kts +++ b/companion/app-desktop/build.gradle.kts @@ -52,12 +52,24 @@ val validateDesktopPythonRuntimePayload = tasks.register("validateDesktopPythonR val process = ProcessBuilder(command) .redirectErrorStream(true) .start() - val finished = process.waitFor(30, TimeUnit.SECONDS) + val output = StringBuilder() + val outputReader = Thread { + process.inputStream.bufferedReader().use { reader -> + output.append(reader.readText()) + } + }.apply { + name = "desktop-runtime-validator-output" + isDaemon = true + start() + } + val finished = process.waitFor(120, TimeUnit.SECONDS) if (!finished) { process.destroyForcibly() + outputReader.join(5_000) return 124 to "Command timed out: ${command.joinToString(" ")}" } - return process.exitValue() to process.inputStream.bufferedReader().readText() + outputReader.join(5_000) + return process.exitValue() to output.toString() } val checker = rootProject.layout.projectDirectory @@ -91,6 +103,24 @@ val validateDesktopPythonRuntimePayload = tasks.register("validateDesktopPythonR } } +val desktopNativeAppResourcesRoot = layout.buildDirectory.dir("generated/native-app-resources") +val prepareDesktopNativeAppResources = tasks.register("prepareDesktopNativeAppResources") { + group = "distribution" + description = "Stages executable managed-runtime files beside the native desktop application." + dependsOn(validateDesktopPythonRuntimePayload) + into(desktopNativeAppResourcesRoot) + + desktopPythonRuntimeRoot.orNull?.trim()?.takeIf { it.isNotBlank() }?.let { runtimeRoot -> + from(runtimeRoot) { + into("common/python-runtime") + } + } +} + +tasks.matching { it.name == "prepareAppResources" }.configureEach { + dependsOn(prepareDesktopNativeAppResources) +} + tasks.processResources { dependsOn(validateDesktopPythonRuntimePayload) inputs.property("desktopPythonRuntimeRoot", desktopPythonRuntimeRoot.orNull ?: "") @@ -135,21 +165,36 @@ tasks.processResources { include("stackchan_spark_safety.wav") into("brain/docs/media/voice") } - desktopPythonRuntimeRoot.orNull?.trim()?.takeIf { it.isNotBlank() }?.let { runtimeRoot -> - from(runtimeRoot) { - into("python-runtime") - } - } } compose.desktop { application { mainClass = "dev.stackchan.companion.desktop.MainKt" + dependsOn("prepareDesktopNativeAppResources") nativeDistributions { targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) packageName = "Stackchan Companion" packageVersion = "1.0.0" + appResourcesRootDir.set(desktopNativeAppResourcesRoot) + + macOS { + bundleID = "dev.stackchan.companion" + signing { + sign.set( + providers.environmentVariable("STACKCHAN_MACOS_SIGNING_IDENTITY") + .map { it.isNotBlank() } + .orElse(false) + ) + identity.set(providers.environmentVariable("STACKCHAN_MACOS_SIGNING_IDENTITY")) + keychain.set(providers.environmentVariable("STACKCHAN_MACOS_KEYCHAIN")) + } + notarization { + appleID.set(providers.environmentVariable("STACKCHAN_MACOS_NOTARIZATION_APPLE_ID")) + password.set(providers.environmentVariable("STACKCHAN_MACOS_NOTARIZATION_PASSWORD")) + teamID.set(providers.environmentVariable("STACKCHAN_MACOS_NOTARIZATION_TEAM_ID")) + } + } } } } diff --git a/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/DesktopBrainSupervisor.kt b/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/DesktopBrainSupervisor.kt index d2a7106c..cbd2a2d0 100644 --- a/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/DesktopBrainSupervisor.kt +++ b/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/DesktopBrainSupervisor.kt @@ -199,6 +199,7 @@ private fun defaultLanServiceArguments( internal fun resolveDesktopBrainPythonCommand(): String = desktopBrainPythonCandidates().firstOrNull { candidate -> + Path.of(candidate).takeIf(Files::isRegularFile)?.let(::ensureDesktopRuntimeExecutable) inspectDesktopPythonRuntime( pythonCommand = candidate, scriptPath = defaultDesktopBrainScriptPath(), @@ -261,6 +262,21 @@ internal fun desktopBrainManagedPythonBinaryCandidates(root: Path): List { return if ("win" in osName) windows + unix else unix + windows } +internal fun ensureDesktopRuntimeExecutable(path: Path): Boolean { + if (System.getProperty("os.name").lowercase().contains("win")) { + return true + } + val normalized = path.toAbsolutePath().normalize() + if (!Files.isRegularFile(normalized)) { + return false + } + val file = normalized.toFile() + if (!file.canExecute()) { + file.setExecutable(true, false) + } + return file.canExecute() +} + internal fun inspectDesktopManagedPythonRuntime( appHome: Path = desktopApplicationHome(), runtimeOverride: String? = System.getProperty("stackchan.brain.python.runtimeDir") diff --git a/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/Main.kt b/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/Main.kt index 01ab5907..21dd4d60 100644 --- a/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/Main.kt +++ b/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/Main.kt @@ -8,13 +8,35 @@ import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import dev.stackchan.companion.core.CompanionIdentity import dev.stackchan.companion.ui.CompanionConsole +import java.awt.Desktop import java.awt.FileDialog import java.awt.Frame +import java.net.URI import java.nio.file.Path import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.system.exitProcess + +fun main(args: Array) { + val packageSmokeOutput = args.firstOrNull { it.startsWith(PACKAGE_SMOKE_OUTPUT_PREFIX) } + ?.substringAfter(PACKAGE_SMOKE_OUTPUT_PREFIX) + ?.takeIf { it.isNotBlank() } + val packageSmokeContext = args.firstOrNull { it.startsWith(PACKAGE_SMOKE_CONTEXT_PREFIX) } + ?.substringAfter(PACKAGE_SMOKE_CONTEXT_PREFIX) + ?.takeIf { it.isNotBlank() } + ?: "package-extraction" + if (packageSmokeOutput != null || "--package-smoke" in args) { + val report = if (packageSmokeOutput == null) { + inspectPackagedRuntimeSmoke(launchContext = packageSmokeContext) + } else { + writePackagedRuntimeSmoke(Path.of(packageSmokeOutput), packageSmokeContext) + } + if (packageSmokeOutput == null) { + println(report.toJson()) + } + exitProcess(if (report.status == "ready") 0 else 2) + } -fun main() { val runtime = DesktopCompanionRuntime().start() Runtime.getRuntime().addShutdownHook(Thread { runtime.close() }) @@ -83,6 +105,9 @@ fun main() { onPrivacySettings = { runCatching { runtime.toggleDiagnosticsLogExport() } }, + onOpenPrivacyPolicy = { + runCatching { openPrivacyPolicy() } + }, onRunC6Rehearsal = { scope.launch { runCatching { runtime.runC6GuiRehearsal() } @@ -108,6 +133,16 @@ fun main() { } } +private const val PACKAGE_SMOKE_OUTPUT_PREFIX = "--package-smoke-output=" +private const val PACKAGE_SMOKE_CONTEXT_PREFIX = "--package-smoke-context=" + +private fun openPrivacyPolicy() { + check(Desktop.isDesktopSupported()) { "Desktop integration is unavailable." } + val desktop = Desktop.getDesktop() + check(desktop.isSupported(Desktop.Action.BROWSE)) { "Browser integration is unavailable." } + desktop.browse(URI(CompanionIdentity.privacyPolicyUrl)) +} + private fun choosePersonaImportZip(): Path? { val dialog = FileDialog(null as Frame?, "Import Stackchan persona", FileDialog.LOAD) dialog.file = "*.zip" diff --git a/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/PackagedRuntimeSmoke.kt b/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/PackagedRuntimeSmoke.kt new file mode 100644 index 00000000..64fc9eba --- /dev/null +++ b/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/PackagedRuntimeSmoke.kt @@ -0,0 +1,144 @@ +package dev.stackchan.companion.desktop + +import dev.stackchan.companion.core.CompanionIdentity +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal data class PackagedRuntimeSmokeReport( + val status: String, + val platform: String, + val appVersion: String, + val protocol: String, + val resourcesDirectory: Path, + val runtimeRoot: Path?, + val runtimeManifest: Path?, + val runtimeExecutable: Path?, + val runtimePresent: Boolean, + val pythonAvailable: Boolean, + val pythonVersion: String, + val brainScript: Path, + val brainScriptAvailable: Boolean, + val launchContext: String, + val issues: List, +) { + fun toJson(): String = + Json { prettyPrint = true }.encodeToString( + buildJsonObject { + put("schema", "stackchan.desktop-packaged-runtime-smoke.v1") + put("status", status) + put("platform", platform) + put("appVersion", appVersion) + put("protocol", protocol) + put("resourcesDirectory", resourcesDirectory.toString()) + put("runtimeRoot", runtimeRoot?.let { JsonPrimitive(it.toString()) } ?: JsonNull) + put("runtimeManifest", runtimeManifest?.let { JsonPrimitive(it.toString()) } ?: JsonNull) + put("runtimeExecutable", runtimeExecutable?.let { JsonPrimitive(it.toString()) } ?: JsonNull) + put("runtimePresent", runtimePresent) + put("pythonAvailable", pythonAvailable) + put("pythonVersion", pythonVersion) + put("brainScript", brainScript.toString()) + put("brainScriptAvailable", brainScriptAvailable) + put("launchContext", launchContext) + put( + "scope", + when (launchContext) { + "installed-package" -> "installed-native-package-headless-runtime-probe" + "package-extraction" -> "extracted-native-package-headless-runtime-probe" + else -> "invalid-native-package-headless-runtime-probe" + }, + ) + put("substitutesForTargetInstall", false) + put("issues", JsonArray(issues.map(::JsonPrimitive))) + }, + ) +} + +internal fun inspectPackagedRuntimeSmoke( + appHome: Path = desktopApplicationHome(), + brainScript: Path = defaultDesktopBrainScriptPath(), + launchContext: String = "package-extraction", +): PackagedRuntimeSmokeReport { + val normalizedAppHome = appHome.toAbsolutePath().normalize() + val expectedRuntimeRoot = normalizedAppHome.resolve("python-runtime") + val managed = inspectDesktopManagedPythonRuntime(appHome = normalizedAppHome) + val python = managed.pythonPath?.let { pythonPath -> + ensureDesktopRuntimeExecutable(pythonPath) + inspectDesktopPythonRuntime( + pythonCommand = pythonPath.toString(), + scriptPath = brainScript, + workingDirectory = brainScript.parent, + searchedCommands = listOf(pythonPath.toString()), + ) + } + return buildPackagedRuntimeSmokeReport(normalizedAppHome, brainScript, managed, python, launchContext) +} + +internal fun buildPackagedRuntimeSmokeReport( + appHome: Path, + brainScript: Path, + managed: DesktopManagedPythonRuntimeStatus, + python: DesktopPythonRuntimeStatus?, + launchContext: String = "package-extraction", +): PackagedRuntimeSmokeReport { + val normalizedAppHome = appHome.toAbsolutePath().normalize() + val expectedRuntimeRoot = normalizedAppHome.resolve("python-runtime") + val issues = buildList { + if (!managed.present) add(managed.detail) + if (managed.root?.toAbsolutePath()?.normalize() != expectedRuntimeRoot) { + add("Managed Python runtime was not resolved from native app resources.") + } + if (python?.available != true) { + add(python?.detail ?: "Managed Python runtime executable was not available.") + } + if (!Files.isRegularFile(brainScript)) { + add("Packaged brain entry point was not available.") + } + if (launchContext !in PACKAGED_RUNTIME_LAUNCH_CONTEXTS) { + add("Packaged runtime launch context is invalid: $launchContext") + } + } + return PackagedRuntimeSmokeReport( + status = if (issues.isEmpty()) "ready" else "not-ready", + platform = desktopHostPlatform(), + appVersion = CompanionIdentity.appVersion, + protocol = CompanionIdentity.protocol, + resourcesDirectory = normalizedAppHome, + runtimeRoot = managed.root, + runtimeManifest = managed.manifestPath, + runtimeExecutable = managed.pythonPath, + runtimePresent = managed.present, + pythonAvailable = python?.available == true, + pythonVersion = python?.version.orEmpty(), + brainScript = brainScript.toAbsolutePath().normalize(), + brainScriptAvailable = Files.isRegularFile(brainScript), + launchContext = launchContext, + issues = issues, + ) +} + +internal fun writePackagedRuntimeSmoke( + outputPath: Path, + launchContext: String = "package-extraction", +): PackagedRuntimeSmokeReport { + val report = inspectPackagedRuntimeSmoke(launchContext = launchContext) + outputPath.toAbsolutePath().normalize().parent?.let(Files::createDirectories) + Files.writeString(outputPath, report.toJson()) + return report +} + +private val PACKAGED_RUNTIME_LAUNCH_CONTEXTS = setOf("package-extraction", "installed-package") + +private fun desktopHostPlatform(): String { + val osName = System.getProperty("os.name").lowercase() + return when { + "win" in osName -> "windows" + "mac" in osName || "darwin" in osName -> "macos" + else -> "linux" + } +} diff --git a/companion/app-desktop/src/test/kotlin/dev/stackchan/companion/desktop/DesktopBrainSupervisorTest.kt b/companion/app-desktop/src/test/kotlin/dev/stackchan/companion/desktop/DesktopBrainSupervisorTest.kt index 233ee424..8cf78289 100644 --- a/companion/app-desktop/src/test/kotlin/dev/stackchan/companion/desktop/DesktopBrainSupervisorTest.kt +++ b/companion/app-desktop/src/test/kotlin/dev/stackchan/companion/desktop/DesktopBrainSupervisorTest.kt @@ -6,6 +6,7 @@ import java.time.Duration import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -207,6 +208,24 @@ class DesktopBrainSupervisorTest { assertTrue(present.detail.contains("Managed Python runtime payload present")) } + @Test + fun managedPythonRuntimeExecutableBitIsRestoredOnUnix() { + if (System.getProperty("os.name").lowercase().contains("win")) return + val runtimeRoot = Files.createTempDirectory("stackchan-python-runtime") + try { + val pythonPath = runtimeRoot.resolve("bin").resolve("python3") + Files.createDirectories(pythonPath.parent) + Files.writeString(pythonPath, "#!/usr/bin/env python3\n") + pythonPath.toFile().setExecutable(false, false) + + assertFalse(pythonPath.toFile().canExecute()) + assertTrue(ensureDesktopRuntimeExecutable(pythonPath)) + assertTrue(pythonPath.toFile().canExecute()) + } finally { + runtimeRoot.toFile().deleteRecursively() + } + } + private fun testConfig(script: Path, maxLogLines: Int = 20): DesktopBrainSupervisorConfig = DesktopBrainSupervisorConfig( pythonCommand = pythonCommand(), diff --git a/companion/app-desktop/src/test/kotlin/dev/stackchan/companion/desktop/PackagedRuntimeSmokeTest.kt b/companion/app-desktop/src/test/kotlin/dev/stackchan/companion/desktop/PackagedRuntimeSmokeTest.kt new file mode 100644 index 00000000..95a13801 --- /dev/null +++ b/companion/app-desktop/src/test/kotlin/dev/stackchan/companion/desktop/PackagedRuntimeSmokeTest.kt @@ -0,0 +1,105 @@ +package dev.stackchan.companion.desktop + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PackagedRuntimeSmokeTest { + @Test + fun readyReportRequiresExecutableRuntimeBesideApplication() { + val appHome = Files.createTempDirectory("stackchan-package-smoke") + try { + val runtimeRoot = Files.createDirectories(appHome.resolve("python-runtime")) + val manifest = Files.writeString(runtimeRoot.resolve("stackchan-python-runtime.json"), "{}") + val pythonPath = Files.writeString(runtimeRoot.resolve("python.exe"), "fixture") + val brainScript = Files.writeString(appHome.resolve("lan_service.py"), "# fixture") + val managed = DesktopManagedPythonRuntimeStatus(true, runtimeRoot, manifest, pythonPath, "ready") + val python = DesktopPythonRuntimeStatus( + command = pythonPath.toString(), + available = true, + version = "Python 3.12.4", + scriptAvailable = true, + workingDirectory = appHome, + detail = "ready", + searchedCommands = listOf(pythonPath.toString()), + managedRuntime = managed, + ) + + val report = buildPackagedRuntimeSmokeReport(appHome, brainScript, managed, python) + + assertEquals("ready", report.status) + assertTrue(report.runtimePresent) + assertTrue(report.pythonAvailable) + assertTrue(report.brainScriptAvailable) + assertEquals("package-extraction", report.launchContext) + assertTrue(report.toJson().contains("extracted-native-package-headless-runtime-probe")) + assertTrue(report.issues.isEmpty()) + } finally { + appHome.toFile().deleteRecursively() + } + } + + @Test + fun installedLaunchContextIsRecordedWithoutClaimingHumanAcceptance() { + val appHome = Files.createTempDirectory("stackchan-installed-package-smoke") + try { + val runtimeRoot = Files.createDirectories(appHome.resolve("python-runtime")) + val manifest = Files.writeString(runtimeRoot.resolve("stackchan-python-runtime.json"), "{}") + val pythonPath = Files.writeString(runtimeRoot.resolve("python.exe"), "fixture") + val brainScript = Files.writeString(appHome.resolve("lan_service.py"), "# fixture") + val managed = DesktopManagedPythonRuntimeStatus(true, runtimeRoot, manifest, pythonPath, "ready") + val python = DesktopPythonRuntimeStatus( + command = pythonPath.toString(), + available = true, + version = "Python 3.12.4", + scriptAvailable = true, + workingDirectory = appHome, + detail = "ready", + searchedCommands = listOf(pythonPath.toString()), + managedRuntime = managed, + ) + + val report = buildPackagedRuntimeSmokeReport(appHome, brainScript, managed, python, "installed-package") + + assertEquals("ready", report.status) + assertEquals("installed-package", report.launchContext) + assertTrue(report.toJson().contains("installed-native-package-headless-runtime-probe")) + assertTrue(report.toJson().contains("\"substitutesForTargetInstall\": false")) + } finally { + appHome.toFile().deleteRecursively() + } + } + + @Test + fun runtimeOutsideNativeAppResourcesIsRejected() { + val appHome = Files.createTempDirectory("stackchan-package-smoke-home") + val externalRoot = Files.createTempDirectory("stackchan-package-smoke-external") + try { + val manifest = Files.writeString(externalRoot.resolve("stackchan-python-runtime.json"), "{}") + val pythonPath = Files.writeString(externalRoot.resolve("python.exe"), "fixture") + val brainScript = Files.writeString(appHome.resolve("lan_service.py"), "# fixture") + val managed = DesktopManagedPythonRuntimeStatus(true, externalRoot, manifest, pythonPath, "ready") + val python = DesktopPythonRuntimeStatus( + command = pythonPath.toString(), + available = true, + version = "Python 3.12.4", + scriptAvailable = true, + workingDirectory = appHome, + detail = "ready", + searchedCommands = listOf(pythonPath.toString()), + managedRuntime = managed, + ) + + val report = buildPackagedRuntimeSmokeReport(appHome, brainScript, managed, python) + + assertEquals("not-ready", report.status) + assertFalse(report.issues.isEmpty()) + assertTrue(report.issues.any { "native app resources" in it }) + } finally { + appHome.toFile().deleteRecursively() + externalRoot.toFile().deleteRecursively() + } + } +} diff --git a/companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt b/companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt index d6775f3a..71fb3a4d 100644 --- a/companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt +++ b/companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt @@ -4,4 +4,5 @@ object CompanionIdentity { const val appVersion = "1.0.0" const val protocol = "stackchan.bridge.v1" const val displayName = "Stackchan Companion" + const val privacyPolicyUrl = "https://robvanprod.github.io/stackchan_alive/privacy/" } diff --git a/companion/core/src/commonTest/kotlin/dev/stackchan/companion/core/CompanionIdentityTest.kt b/companion/core/src/commonTest/kotlin/dev/stackchan/companion/core/CompanionIdentityTest.kt new file mode 100644 index 00000000..d8139fd3 --- /dev/null +++ b/companion/core/src/commonTest/kotlin/dev/stackchan/companion/core/CompanionIdentityTest.kt @@ -0,0 +1,16 @@ +package dev.stackchan.companion.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CompanionIdentityTest { + @Test + fun privacyPolicyUsesCanonicalPublicHttpsUrl() { + assertEquals( + "https://robvanprod.github.io/stackchan_alive/privacy/", + CompanionIdentity.privacyPolicyUrl, + ) + assertTrue(CompanionIdentity.privacyPolicyUrl.startsWith("https://")) + } +} diff --git a/companion/ui/src/commonMain/kotlin/dev/stackchan/companion/ui/CompanionConsole.kt b/companion/ui/src/commonMain/kotlin/dev/stackchan/companion/ui/CompanionConsole.kt index cf1e0164..1042def6 100644 --- a/companion/ui/src/commonMain/kotlin/dev/stackchan/companion/ui/CompanionConsole.kt +++ b/companion/ui/src/commonMain/kotlin/dev/stackchan/companion/ui/CompanionConsole.kt @@ -297,6 +297,7 @@ fun CompanionConsole( onSelectPersona: () -> Unit = {}, onSaveDisplaySettings: () -> Unit = {}, onPrivacySettings: () -> Unit = {}, + onOpenPrivacyPolicy: () -> Unit = {}, onClaimBrain: () -> Unit = {}, onReleaseBrain: () -> Unit = {}, onOpenWifiSettings: () -> Unit = {}, @@ -329,6 +330,7 @@ fun CompanionConsole( onSelectPersona = onSelectPersona, onSaveDisplaySettings = onSaveDisplaySettings, onPrivacySettings = onPrivacySettings, + onOpenPrivacyPolicy = onOpenPrivacyPolicy, onClaimBrain = onClaimBrain, onReleaseBrain = onReleaseBrain, onOpenWifiSettings = onOpenWifiSettings, @@ -363,6 +365,7 @@ fun CompanionConsole( onSelectPersona = onSelectPersona, onSaveDisplaySettings = onSaveDisplaySettings, onPrivacySettings = onPrivacySettings, + onOpenPrivacyPolicy = onOpenPrivacyPolicy, onClaimBrain = onClaimBrain, onReleaseBrain = onReleaseBrain, onOpenWifiSettings = onOpenWifiSettings, @@ -388,6 +391,7 @@ fun CompanionConsole( onSelectPersona = onSelectPersona, onSaveDisplaySettings = onSaveDisplaySettings, onPrivacySettings = onPrivacySettings, + onOpenPrivacyPolicy = onOpenPrivacyPolicy, onClaimBrain = onClaimBrain, onReleaseBrain = onReleaseBrain, onOpenWifiSettings = onOpenWifiSettings, @@ -423,6 +427,7 @@ private fun MobileConsole( onSelectPersona: () -> Unit, onSaveDisplaySettings: () -> Unit, onPrivacySettings: () -> Unit, + onOpenPrivacyPolicy: () -> Unit, onClaimBrain: () -> Unit, onReleaseBrain: () -> Unit, onOpenWifiSettings: () -> Unit, @@ -465,6 +470,7 @@ private fun MobileConsole( onSelectPersona = onSelectPersona, onSaveDisplaySettings = onSaveDisplaySettings, onPrivacySettings = onPrivacySettings, + onOpenPrivacyPolicy = onOpenPrivacyPolicy, onClaimBrain = onClaimBrain, onReleaseBrain = onReleaseBrain, ) @@ -506,6 +512,7 @@ private fun WideConsole( onSelectPersona: () -> Unit, onSaveDisplaySettings: () -> Unit, onPrivacySettings: () -> Unit, + onOpenPrivacyPolicy: () -> Unit, onClaimBrain: () -> Unit, onReleaseBrain: () -> Unit, onOpenWifiSettings: () -> Unit, @@ -564,6 +571,7 @@ private fun WideConsole( onSelectPersona = onSelectPersona, onSaveDisplaySettings = onSaveDisplaySettings, onPrivacySettings = onPrivacySettings, + onOpenPrivacyPolicy = onOpenPrivacyPolicy, onClaimBrain = onClaimBrain, onReleaseBrain = onReleaseBrain, ) @@ -592,6 +600,7 @@ private fun TabletConsole( onSelectPersona: () -> Unit, onSaveDisplaySettings: () -> Unit, onPrivacySettings: () -> Unit, + onOpenPrivacyPolicy: () -> Unit, onClaimBrain: () -> Unit, onReleaseBrain: () -> Unit, onOpenWifiSettings: () -> Unit, @@ -642,6 +651,7 @@ private fun TabletConsole( onSelectPersona = onSelectPersona, onSaveDisplaySettings = onSaveDisplaySettings, onPrivacySettings = onPrivacySettings, + onOpenPrivacyPolicy = onOpenPrivacyPolicy, onClaimBrain = onClaimBrain, onReleaseBrain = onReleaseBrain, ) @@ -1221,6 +1231,7 @@ private fun BrainPanel( onSelectPersona: () -> Unit = {}, onSaveDisplaySettings: () -> Unit = {}, onPrivacySettings: () -> Unit = {}, + onOpenPrivacyPolicy: () -> Unit = {}, onClaimBrain: () -> Unit = {}, onReleaseBrain: () -> Unit = {}, ) { @@ -1289,6 +1300,7 @@ private fun BrainPanel( onSelectPersona = onSelectPersona, onSaveDisplaySettings = onSaveDisplaySettings, onPrivacySettings = onPrivacySettings, + onOpenPrivacyPolicy = onOpenPrivacyPolicy, ) Spacer(Modifier.height(14.dp)) ModelAssetPanel( @@ -1323,6 +1335,7 @@ private fun SettingsSurfacePanel( onSelectPersona: () -> Unit, onSaveDisplaySettings: () -> Unit, onPrivacySettings: () -> Unit, + onOpenPrivacyPolicy: () -> Unit, ) { Surface( color = Console, @@ -1350,7 +1363,8 @@ private fun SettingsSurfacePanel( FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { SmallCommand("Select persona", enabled = settings.writesEnabled, onClick = onSelectPersona) SmallCommand("Save display", enabled = settings.writesEnabled, onClick = onSaveDisplaySettings) - SmallCommand("Privacy", enabled = settings.writesEnabled, onClick = onPrivacySettings) + SmallCommand("Export logs", enabled = settings.writesEnabled, onClick = onPrivacySettings) + SmallCommand("Privacy policy", onClick = onOpenPrivacyPolicy) } } } diff --git a/data/persona_index.json b/data/persona_index.json index 0fdcf791..0a5d0956 100644 --- a/data/persona_index.json +++ b/data/persona_index.json @@ -16,7 +16,7 @@ "license": "Apache-2.0", "name": "Stackchan Glow", "path": "personas/glow", - "sha256": "c3e4157831c5ba4133a32507bfd1cac33f66c9fa984754105784287bbb2fb0fc", + "sha256": "011faf26ce68179268d428148b31c570f59e122ede4cf1d8f47acb3105754fa2", "valid": true, "version": "0.1.0" }, @@ -34,7 +34,7 @@ "license": "Apache-2.0", "name": "Stackchan Spark", "path": "personas/spark", - "sha256": "011adf4f5834d88a73a562978592a984b495448c744d132255f1b2deb56fa3ba", + "sha256": "e449d2cbe0fde8f74ad1ac22cab415ead9507708c77de59ea002810888e2002e", "valid": true, "version": "0.1.0" } diff --git a/docs/ANDROID_COMPANION_TEST_PLAN.md b/docs/ANDROID_COMPANION_TEST_PLAN.md index 2280e969..71e927b6 100644 --- a/docs/ANDROID_COMPANION_TEST_PLAN.md +++ b/docs/ANDROID_COMPANION_TEST_PLAN.md @@ -19,6 +19,17 @@ The app intentionally declares the bridge service as `connectedDevice` only. Do within Android's time-limited data-sync window. The bridge needs to stay reachable during screen-off robot sessions, which matches the connected-device foreground-service role. +CI also runs an API 35 AOSP automated-test emulator smoke against the exact lab-signed release APK +uploaded by the Android artifact build; it does not rebuild a second APK for this test. +`tools/test_android_emulator_launch.ps1` installs the APK, pre-grants notification permission for +a deterministic cold launch, verifies the package/version, waits for `MainActivity` and +`CompanionBridgeService`, checks that the process remains alive, and captures scoped post-launch +logcat evidence. `tools/check_android_emulator_release_evidence.ps1` then recomputes the release +APK SHA-256 and requires it to match that JSON before aggregate evidence can pass. Its JSON +explicitly sets `substitutesForPhysicalEvidence=false`: this catches +packaging and launch regressions, but it does not satisfy any target-phone, physical robot, +screen-off, microphone, Gemma accelerator, or Play internal-testing item in this plan. + ## Preflight - [ ] Phone and robot are on the same Wi-Fi/LAN segment. @@ -38,16 +49,33 @@ screen-off robot sessions, which matches the connected-device foreground-service When using adb, install the APK and capture the install evidence before discovery checks: -Build the lab-signed release APK from the source checkout first. The v1 PR/CI release APK -is signed with the Android debug key for physical testing, not for public distribution: +Use either a locally built lab-signed release APK or an exact-source CI candidate. To use CI, +download and verify the complete run before selecting its release APK: + +```powershell +.\tools\download_companion_ci_candidate.cmd ` + -RunId ` + -Commit <40-character-branch-head-sha> +``` + +Use only the release APK under that candidate's `artifacts/companion-android-apks/apk/release/` +and preserve its SHA-256 from `COMPANION_CI_CANDIDATE.json` in the phone evidence. The helper +rejects a PR artifact whose embedded evidence records a merge commit instead of the requested +branch head. CI candidate artifacts remain debug-key signed for physical testing, not public +distribution. + +To build the lab-signed release APK from the source checkout instead: ```powershell .\tools\check_companion_v1_readiness.cmd .\tools\check_android_toolchain.cmd cd companion -.\gradlew.bat :app-android:assembleRelease +.\gradlew.bat "-Pstackchan.allowLabDebugReleaseSigning=true" :app-android:assembleRelease ``` +The unqualified `.\gradlew.bat :app-android:assembleRelease` command is intentionally rejected +unless the production upload-key environment is configured. + The companion readiness check verifies the v1 companion plan, protocol fixtures, KMP source tree, CI hooks, Android foreground service, and pending hardware gates before phone-specific APK evidence starts. diff --git a/docs/ANDROID_PLAY_POLICY_DECLARATIONS.md b/docs/ANDROID_PLAY_POLICY_DECLARATIONS.md index 6e7fbaef..877796a1 100644 --- a/docs/ANDROID_PLAY_POLICY_DECLARATIONS.md +++ b/docs/ANDROID_PLAY_POLICY_DECLARATIONS.md @@ -8,6 +8,8 @@ Authoritative policy references checked for this source review: - Google Play Data safety form: https://support.google.com/googleplay/android-developer/answer/10787469 +- Google Play User Data policy: + https://support.google.com/googleplay/android-developer/answer/10144311 - Android foreground service type declaration: https://developer.android.com/develop/background-work/services/fgs/declare - Android 14+ foreground service type / Play Console declaration: @@ -17,10 +19,17 @@ Authoritative policy references checked for this source review: ## Play Console App Content -- Privacy policy URL: required before closed/open/production testing. The hosted - page must be derived from `docs/ANDROID_PLAY_PRIVACY_POLICY.md`, which is in - turn derived from `docs/PRIVACY.md` plus this declaration and names the - Android package `dev.stackchan.companion`. +- Privacy policy URL: + https://robvanprod.github.io/stackchan_alive/privacy/. The hosted page source + is `site/privacy/index.html`; it is reviewed with + `docs/ANDROID_PLAY_PRIVACY_POLICY.md` and names the Android package + `dev.stackchan.companion`. GitHub Pages build `1094346889` published the exact + source bytes from commit `afbebbd3429e00a6f76cb238788ce7664f1b6fda` on + July 14, 2026. The HTTPS response returned `200` and matched source SHA-256 + `28d1cca7889f8d95c0587025ee5d46c213a85ac814c538e3c36090b377fd1f47`. + `docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json` preserves the public + deployment identity; `tools/check_privacy_policy_deployment.ps1 -Json` + revalidates the current URL and exact bytes before a Play upload. - Ads: no ads. - App access: no login account. Access requires a Stack-chan robot on the same LAN for the meaningful connected flows. Review notes must explain that the app @@ -39,16 +48,23 @@ Current source behavior for `dev.stackchan.companion`: | Financial info | Not collected | No payments or purchases. | | Location | Not collected | The app uses local-network state and LAN IPs, not Android location permissions. | | Photos/videos | Not collected | No camera/gallery permissions or upload path. Store screenshots are operator-created evidence outside app runtime. | -| Audio files | Not collected | `RECORD_AUDIO` is used only after tapping Push-to-talk. Android SpeechRecognizer returns a transcript; raw microphone audio is not stored or exported by the app. | -| Voice or sound recordings | Not collected | The app does not persist raw microphone audio. Diagnostics redact the last text turn to a presence-only flag. | +| Audio files | Not collected | The app does not import, persist, or upload audio files. | +| Voice or sound recordings | Collected only for optional, ephemeral app functionality | `RECORD_AUDIO` is used only after tapping Push-to-talk. The configured Android SpeechRecognizer may transmit microphone audio to its provider even though the app requests offline recognition. The app does not retain raw audio or export it in diagnostics. Confirm the exact Play form selections against the final test device and recognizer provider. | | App activity | Not collected by developer | Local settings, saved robots, trusted endpoints, model asset state, and diagnostics remain on-device unless the user explicitly shares an export. | | App info and performance | Not collected by developer | Crash/log exports are not automatic. Logcat capture is an external arrival-day evidence tool, not app telemetry upload. | | Device or other IDs | Not collected by developer | The app stores local endpoint IDs, robot IDs, fingerprints, and bridge URLs on-device for pairing. They are not uploaded by the app. | -Data sharing: none by the app. The only external transfer is user-initiated sharing -from Android's share sheet when exporting diagnostics. That export is local JSON, -redacts transcript text, records model/provisioning state, and uses password -placeholders for Wi-Fi provisioning with `password_redacted=true`. +The app does not automatically send data to the developer or an analytics +service. User-initiated or user-configured transfers are limited to the Android +speech provider during Push-to-talk when it processes audio off-device, the +user's Stack-chan bridge, a diagnostics share destination selected by the user, +and the configured model host for an optional model download. The final Play +form must apply Google's current collection, sharing, service-provider, and +ephemeral-processing definitions to those paths. + +Diagnostics export is local JSON, redacts transcript text, records +model/provisioning state, and uses password placeholders for Wi-Fi provisioning +with `password_redacted=true`. Data deletion: users can remove saved robot rows and trusted companion rows from the app UI. Android app uninstall removes app-private local stores. Robot-side @@ -56,8 +72,10 @@ unpairing still requires firmware support and must not be implied in Play copy. Security practices: -- Data is transmitted only over the user's local network bridge, not to a cloud - endpoint controlled by this app. +- Local bridge traffic is sent to the endpoint configured by the user. The v1 + LAN bridge is not represented as end-to-end encrypted. +- The app requests offline speech recognition, but the selected Android speech + service may use network processing under that provider's privacy policy. - No Play release may claim consumer-ready status until real hardware evidence, bundled voice hashes and final policy review are complete. @@ -72,7 +90,7 @@ Security practices: | `POST_NOTIFICATIONS` | Shows the foreground bridge-service notification on Android 13+. | | `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | Optional arrival-day reliability prompt for screen-off bridge soak testing. Do not present this as mandatory for ordinary app browsing. | | `WAKE_LOCK` | Keeps the active robot bridge session from sleeping during connected testing. | -| `RECORD_AUDIO` | Enables explicit Push-to-talk. Denial leaves the turn unsent; raw audio is not exported in diagnostics. | +| `RECORD_AUDIO` | Enables explicit Push-to-talk. Denial leaves the turn unsent. The app requests offline recognition, but the configured speech service may process audio off-device; raw audio is not retained or exported in diagnostics. | Foreground service Play Console draft: diff --git a/docs/ANDROID_PLAY_PRIVACY_POLICY.md b/docs/ANDROID_PLAY_PRIVACY_POLICY.md index e5d4c706..32dba0aa 100644 --- a/docs/ANDROID_PLAY_PRIVACY_POLICY.md +++ b/docs/ANDROID_PLAY_PRIVACY_POLICY.md @@ -1,108 +1,138 @@ # Stackchan Companion Privacy Policy -This policy is the Play-facing privacy-policy draft for the Android app package -`dev.stackchan.companion`. It is derived from `docs/PRIVACY.md` and -`docs/ANDROID_PLAY_POLICY_DECLARATIONS.md`. +Effective date: July 14, 2026 +Last reviewed: July 14, 2026 -Before Play submission, publish this policy at the final privacy-policy URL, -replace the review placeholders below with the Play listing developer contact and -review date, and verify it against the exact uploaded Android build. +This policy applies to Stackchan Companion for Android +(`dev.stackchan.companion`) and the companion desktop applications. The +canonical public URL is +https://robvanprod.github.io/stackchan_alive/privacy/. - App name: Stackchan Companion -- Android package: `dev.stackchan.companion` -- Last reviewed: pending final Play upload -- Developer contact: use the contact address shown on the final Play listing +- Developer: RobVanProd +- Privacy inquiries: https://github.com/RobVanProd/stackchan_alive/issues +- Security reports: https://github.com/RobVanProd/stackchan_alive/security/policy ## Summary -Stackchan Companion is a local companion app for pairing with and operating a -Stack-chan robot on the user's own network. The app does not create accounts, -show ads, sell purchases, or upload companion data to a developer-controlled -cloud service. +Stackchan Companion pairs with and operates a Stack-chan robot on the user's +local network. The app does not create accounts, show ads, sell purchases, or +send companion data to a developer-controlled analytics service. Meaningful connected features require a Stack-chan robot or bridge on the same -local network. The app can also store local setup state so the user can reconnect -to saved robots. +local network. The app stores local setup state so the user can reconnect to +saved robots. -## Data The App Does Not Collect +## Data The Developer Does Not Collect -The app does not collect or upload these data categories to the developer: +The developer does not receive these categories from the app: - Personal information such as name, email, phone number, or address. - Financial information. - Photos, videos, or camera data. -- Raw microphone audio or voice recordings. - Crash reports or diagnostics automatically. -- Device identifiers for developer analytics or advertising. +- Device identifiers for analytics or advertising. - Location data. The app uses local network state and bridge URLs, not Android location permissions. +The app does not persist raw microphone audio or include raw audio in +diagnostics exports. Push-to-talk speech processing is described separately +below because the configured Android speech service may process audio away from +the phone. + ## Local Data Stored On The Device -The app may store local-only records in app-private storage: +The app may store these records in app-private storage: -- Saved robot rows and trusted companion endpoint records. +- Saved robot and trusted companion records. - Local bridge URLs, endpoint IDs, robot IDs, pairing fingerprints, and pairing state used to reconnect to a Stack-chan robot. -- App settings such as persona selection, display preferences, diagnostics export - preference, and model asset state. -- Optional local Mobile Brain model download state, including model path, byte - count, checksum status, and load/eject state. +- App settings such as persona selection, display preferences, diagnostics + export preference, and model asset state. +- Optional Mobile Brain model state, including model path, byte count, checksum + status, and load or eject state. -This information stays on the device unless the user explicitly shares a -diagnostics export through the Android share sheet. +This information remains on the device unless the user explicitly sends it to +a local Stack-chan bridge or shares a diagnostics export. ## Microphone And Speech -The app requests `RECORD_AUDIO` only for explicit Push-to-talk use. If the user -denies microphone permission, no transcript is sent. +The Android app requests `RECORD_AUDIO` only when the user invokes +Push-to-talk. If microphone permission is denied, the app does not start the +turn or send a transcript. + +The app asks the configured Android speech-recognition service to convert the +user's speech into text. Depending on the device and speech-service settings, +that service may process microphone audio on the device or transmit it to the +speech-service provider for ephemeral processing. That processing is governed +by the selected speech provider's privacy policy. Stackchan Companion requests +offline recognition when the service supports it, but cannot guarantee that +every installed speech service honors that preference. -When Push-to-talk is used, Android's speech recognizer handles speech capture -according to the user's device and speech-service settings, then returns text for -the current turn. The app sends that transcript through the local Stack-chan -bridge session. The app does not persist raw microphone audio and does not -include raw audio in diagnostics exports. +The recognizer returns text for the current turn. Stackchan Companion sends the +transcript to the user-configured Stack-chan bridge, normally on the local +network. The app does not persist raw microphone audio and does not include raw +audio or transcript text in diagnostics exports. ## Diagnostics Export -Diagnostics export is user initiated. The export is a local JSON file shared -through Android's share sheet only after the user requests it. +Diagnostics export is user initiated. The app creates a local JSON file and +opens the operating system share surface only after the user requests an +export. The user chooses whether and where to send the file. -Diagnostics are intended for support and validation. They include bridge status, -robot/session status, saved robot/trusted endpoint state, model asset state, and -provisioning state. The export redacts the last text turn to a presence-only -flag and records Wi-Fi provisioning with password placeholders and -`password_redacted=true`. +Diagnostics may include bridge and robot status, saved robot and trusted +endpoint state, model asset state, and provisioning state. The export redacts +the last text turn to a presence-only flag and records Wi-Fi provisioning with +password placeholders and `password_redacted=true`. ## Local Network And Model Downloads -The app uses local-network permissions to host and connect to the Stack-chan -bridge, advertise/discover local services, and keep the active bridge reachable -while the user is operating the robot. +The app uses network access to host or connect to a Stack-chan bridge, discover +local services, and keep an active robot session reachable. Local bridge +traffic can include pairing state, commands, settings, status, and the current +recognized transcript. The v1 local bridge uses the user's trusted LAN and is +not represented as end-to-end encrypted. If the user downloads the optional Mobile Brain model, the app connects to the -configured model artifact host to fetch the model file and verifies its size and -SHA-256 checksum before staging it. This download does not upload local robot, -conversation, microphone, or diagnostics data to the developer. +configured model host to fetch the file and verifies its size and SHA-256 +checksum before staging it. The host may receive ordinary request metadata such +as the user's IP address. The model request does not include local robot, +conversation, microphone, or diagnostics content. + +## When Data Leaves The App -## Data Sharing +The app does not automatically sell data or send data to the developer. Data +can leave the app in these limited cases: -The app does not share user data with the developer or third parties -automatically. Data leaves the app only when: +- The user starts Push-to-talk and the configured Android speech service uses + off-device processing. +- The user sends a recognized text turn or commands to a user-configured local + Stack-chan bridge. +- The user explicitly shares a diagnostics export with a destination they + select. +- The user downloads the optional model asset from its configured host. -- The user operates a local Stack-chan bridge on their own network. -- The user explicitly shares a diagnostics export. -- The user downloads the optional model asset from its configured artifact host. +## Security, Retention, And Deletion -## Deletion +Local records are stored in app-private storage. They remain until the user +removes the associated saved robot or trusted companion record, clears the +app's data, or uninstalls the app. Downloaded model assets remain until removed +or the app data is cleared. The app does not retain raw microphone audio. Users can remove saved robot and trusted companion records from the app UI. -Uninstalling the app removes app-private local stores from the phone. Robot-side -unpairing is managed by robot firmware and may require a separate robot-side -clear or pairing command. +Uninstalling the app removes app-private local stores from the device. +Robot-side unpairing is managed separately by robot firmware and may require a +robot-side clear or pairing command. ## Children Stackchan Companion is not directed to children. Connected operation requires Stack-chan hardware or a local bridge and is intended for robot setup, development, and operation by the device owner. + +## Changes And Contact + +Material policy changes will be published at the canonical URL with an updated +review date. Privacy questions may be submitted through the public issue +tracker. Security-sensitive reports should use the repository security policy +rather than a public issue. diff --git a/docs/ANDROID_PLAY_RELEASE.md b/docs/ANDROID_PLAY_RELEASE.md index 8cad6ab2..da4287e1 100644 --- a/docs/ANDROID_PLAY_RELEASE.md +++ b/docs/ANDROID_PLAY_RELEASE.md @@ -20,6 +20,9 @@ Authoritative Android release guidance: - Target SDK: `36` - Release artifact for Google Play: `app-android-release.aab` - Lab install artifact for arrival-day testing: `app-android-release.apk` +- CI runtime smoke: API 35 AOSP ATD install, cold launch, foreground bridge service, and crash check + against the exact release APK artifact, with SHA-256 binding in aggregate release evidence; this + remains separate from required target-phone and Play internal-testing evidence. Google Play requires Android App Bundles for new apps and requires Play App Signing. APKs remain useful for direct lab installation and robot-arrival testing, @@ -38,11 +41,94 @@ Gradle properties or environment variables are present: - `STACKCHAN_ANDROID_KEY_ALIAS` - `STACKCHAN_ANDROID_KEY_PASSWORD` -When they are absent, Gradle falls back to the Android debug signing config so the -CI/lab release APK remains installable before Play credentials exist. That fallback -is for testing only and must not be used for a public Play upload. +When they are absent, Gradle fails release APK/AAB tasks. A lab or PR build may opt in to +debug signing only by passing `-Pstackchan.allowLabDebugReleaseSigning=true`; that explicit +fallback must not be used for a public GitHub or Play upload. `tools/check_android_play_release_readiness.ps1` reports this as `source-ready-pending-upload-signing` until the upload-key environment is configured. +When all four values are present, that checker cryptographically validates the configured material: +it proves the alias is a private-key entry, verifies both passwords through a disposable PKCS#12 +copy, requires an RSA key of at least 4096 bits, rejects an Android debug certificate subject, +checks that the certificate is currently valid and expires after `2033-10-22`, and reports the +certificate SHA-256 fingerprint. Passwords are passed to `keytool` only by temporary environment +references, are never written to the report, and the disposable private-key copy is always removed. + +The tag workflow reads the same four values from GitHub Actions secrets. The keystore itself +is supplied as base64 in `STACKCHAN_ANDROID_KEYSTORE_B64`; the other three secret names match +the environment variables above. Before building, the tag workflow runs the same cryptographic +readiness checker against those secrets. `tools/export_companion_release_evidence.ps1 +-RequireUploadSigning -RequireAndroidEmulatorEvidence` rejects both APK and AAB evidence unless +the `upload-key` profile is recorded and the upload-signed release APK's API 35 launch evidence has +the same SHA-256. + +### One-Time Upload Key Provisioning + +Create the upload key interactively outside the repository. Do this once, record the owner +identity accurately when `keytool` prompts for the certificate fields, and use unique high-entropy +store and key passwords: + +```powershell +$secretDir = Join-Path $HOME "StackchanReleaseSecrets" +$keystore = Join-Path $secretDir "stackchan-upload.jks" +New-Item -ItemType Directory -Force $secretDir | Out-Null +keytool -genkeypair -v ` + -keystore $keystore ` + -storetype JKS ` + -alias stackchan-upload ` + -keyalg RSA ` + -keysize 4096 ` + -validity 10000 +keytool -list -v -keystore $keystore -alias stackchan-upload +``` + +After setting the four local values, run the same release preflight used by CI and compare the +reported certificate SHA-256 fingerprint with the offline key record before building: + +```powershell +tools/check_android_play_release_readiness.ps1 -Json +tools/test_android_upload_signing_contract.ps1 +``` + +The first command must report `source-ready`, with `play-upload-signing-environment` set to `pass`. +The second command uses only generated temporary keys to prove that valid material is accepted and +that missing, weak, debug, short-lived, wrong-alias, and wrong-password configurations are rejected. + +Before any Play upload, preserve two independent offline media copies of the encrypted JKS and its +password record. Losing the private upload key or its passwords breaks update continuity. Do not +move either copy under this repository or into `output/`. + +Before creating or provisioning the upload key, run +`tools/check_release_credential_hygiene.cmd -Json` from the source checkout. It verifies that JKS, +PFX, PKCS#12, Apple API key, and related private-key files remain ignored and untracked and rejects +tracked private-key markers without printing key contents. Every release package runs this gate +before building. + +From an authenticated checkout of the release repository, set the Actions secrets. The +password commands prompt interactively, keeping their values out of the shell command line and +history: + +```powershell +[Convert]::ToBase64String([IO.File]::ReadAllBytes($keystore)) | + gh secret set STACKCHAN_ANDROID_KEYSTORE_B64 --app actions +gh secret set STACKCHAN_ANDROID_KEYSTORE_PASSWORD --app actions +gh secret set STACKCHAN_ANDROID_KEY_ALIAS --app actions +gh secret set STACKCHAN_ANDROID_KEY_PASSWORD --app actions +gh secret list --app actions +``` + +Enter `stackchan-upload` when the alias secret prompts. The two password prompts must receive +the exact store and key passwords created above. `gh secret list` confirms names and update +times only; GitHub never returns secret values. After provisioning, run the read-only +**Companion Signing Readiness** workflow. Its Android job validates the configured keystore and +selected private key without building or publishing an artifact: + +```powershell +gh workflow run companion-signing-readiness.yml --ref +gh run watch (gh run list --workflow companion-signing-readiness.yml --limit 1 --json databaseId --jq '.[0].databaseId') --exit-status +``` + +Then verify the same key locally with the Play-ready build below and retain the APK/AAB signing +evidence before creating a tag. Example local Play-ready build: @@ -60,6 +146,13 @@ Expected outputs: - `companion/app-android/build/outputs/bundle/release/app-android-release.aab` - `companion/app-android/build/outputs/apk/release/app-android-release.apk` +Example lab-only release build: + +```powershell +cd companion +.\gradlew.bat "-Pstackchan.allowLabDebugReleaseSigning=true" :app-android:assembleRelease +``` + ## Store Listing Assets Prepared source-controlled assets: @@ -72,8 +165,20 @@ Prepared source-controlled assets: - Play feature graphic: `docs/store-assets/play/feature-graphic-1024x500.png` - Final screenshot capture plan: `docs/store-assets/play/SCREENSHOT_CAPTURE_PLAN.md` - Listing metadata: `fastlane/metadata/android/en-US/` -- Play policy/data-safety declaration draft: `docs/ANDROID_PLAY_POLICY_DECLARATIONS.md` -- Play-facing privacy policy draft: `docs/ANDROID_PLAY_PRIVACY_POLICY.md` +- Play policy/data-safety declarations: `docs/ANDROID_PLAY_POLICY_DECLARATIONS.md` +- Play-facing privacy policy: `docs/ANDROID_PLAY_PRIVACY_POLICY.md` +- Static privacy site source: `site/privacy/index.html` +- Canonical privacy URL: https://robvanprod.github.io/stackchan_alive/privacy/ +- Public deployment record: `docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json` +- Live deployment verifier: `tools/check_privacy_policy_deployment.ps1 -Json` +- Pages deployment workflow: `.github/workflows/pages.yml` + +The canonical URL was first deployed through the isolated `gh-pages` branch so the reviewed +policy could be hosted without merging the broader release-candidate branch. GitHub Pages build +`1094346889` published deployment commit `49cefe092920c0a12da50896356394d380df6904` and the served +bytes match `site/privacy/index.html` at SHA-256 +`28d1cca7889f8d95c0587025ee5d46c213a85ac814c538e3c36090b377fd1f47`. After the Pages workflow +lands on `main`, switch the Pages build source to GitHub Actions and refresh the deployment record. Screenshots still need to be captured from the final phone build after physical robot validation, because the store screenshots should show a real connected @@ -116,9 +221,11 @@ do not include a UTC upload timestamp. Use `docs/ANDROID_PLAY_POLICY_DECLARATIONS.md` as the source-side draft for the Play Console Data safety form, foreground-service declaration, and permission review. Use `docs/ANDROID_PLAY_PRIVACY_POLICY.md` as the source-side privacy -policy page content. Before upload, compare both documents against the exact -release build, publish the privacy policy at the final URL, and copy the final -reviewed answers into the Play evidence packet. +policy review record and `site/privacy/index.html` as the deployable public page. +Before upload, compare both against the exact release build, run +`tools/check_privacy_policy_deployment.ps1 -Json` against the canonical HTTPS URL, and copy +the final reviewed answers into the Play evidence packet. The Android and desktop +apps expose that same canonical URL from their Settings surface. Before upload, confirm that each Android permission maps to an app behavior visible in the release: @@ -133,7 +240,8 @@ in the release: reliability test path. - `RECORD_AUDIO`: explicit Push-to-talk action on the Talk screen. The app sends the recognized transcript through the local robot bridge and does not export raw microphone - audio in diagnostics. + audio in diagnostics. The app requests offline recognition, but the configured Android + speech service may process audio off-device under that provider's privacy policy. If a Play policy declaration is required for foreground service or battery optimization behavior, use the physical-test evidence and Android test plan as the @@ -150,9 +258,9 @@ Do not promote the app beyond internal testing until these are complete: - Physical robot connected-session evidence is captured. - Android screen-off bridge soak evidence is captured. - Store screenshots are captured from the final Android build. -- The privacy policy is hosted from the final reviewed - `docs/ANDROID_PLAY_PRIVACY_POLICY.md` content and the URL is recorded in the - Play evidence packet. +- `tools/check_privacy_policy_deployment.ps1 -Json` reports + `privacy-policy-deployment-ready` for the final reviewed policy bytes, and the URL is recorded + in the Play evidence packet. - Privacy/data-safety answers are reviewed against actual network, audio, and diagnostics behavior. - Microphone permission copy, denial behavior, and transcript handling are verified from diff --git a/docs/ARRIVAL_DAY_RUNBOOK.md b/docs/ARRIVAL_DAY_RUNBOOK.md index 6e2b7c0b..ce115ade 100644 --- a/docs/ARRIVAL_DAY_RUNBOOK.md +++ b/docs/ARRIVAL_DAY_RUNBOOK.md @@ -29,6 +29,39 @@ corrected continuation at ended the release gate and explicitly waived its remaining duration. Preserve that evidence as an accepted release run, not a formal eight-hour pass. +Overnight follow-up (2026-07-13): exact image SHA-256 +`c0314b375b86e8f350b6c4588422818526c54b2d6b0293b3a7233b95c06e740e` from source commit +`2d0e61e28416df376499acab744ea91a5d56c2d4` passed its `76/76` no-motion and `77/77` actuator +qualifications, then ran all features with actuators for `22953.5 s` at +`output\pc-brain\single-owner-release-servo-8hr-20260712-2325`. The runner stopped on one +recovered lifetime camera-timer maximum of `330105 us` against the `300000 us` limit. The final +capture was back to `15036 us`; camera frames and paired target updates kept advancing, and power, +thermal, display, bridge, audio, IMU, camera, and motion-session gates remained healthy. Treat the +summary as a failed duration gate caused by a camera-latency policy breach, not as a blackout, +reset, camera failure, or power fault. + +That historical exact image had two Core-1 tasks servicing the same HTTP server: priority-3 +`IntentTask` and priority-1 Arduino `loopTask`. This shared ownership had already produced one +recovered malformed debug response in earlier evidence and can inflate the combined camera timer when the lower- +priority caller is descheduled. Existing telemetry combines `esp_camera_fb_get()`, RGB565-to-gray +conversion, and scheduler delay, so do not claim which substage caused the historical `330105 us` +sample. + +Corrected exact-image acceptance (2026-07-13): clean source commit +`ce66f8a0fadfadbc07eb59124522267ba66ee70a`, firmware SHA-256 +`69d3db27f2d7197799fdc08ff3c1dc4d6e3011724fe29899367dc016e48ebfa8`, makes `IntentTask` the +single runtime owner and passed `261/261` native tests plus exact-image formal `76/76` no-motion and +`77/77` actuator qualifications. Its all-feature actuator soak at +`output\pc-brain\single-owner-runtime-servo-8hr-20260713-0750` passed for `28807 s` with +`5643/5643` good polls, zero failed polls, zero motion timeouts, VBUS floors `4913/4909 mV` +(sampled/reported), maximum temperature `66.5 C`, maximum display frame `41511 us`, maximum camera +capture `223997 us`, and zero hard-floor, PMIC protective, IMU exhaustion/failure, camera capture, +response-write, or authentication events. Motion, rail, torque, and motion power authority were +verified off after completion. The saved formal checker result is +`output\pc-brain\single-owner-runtime-servo-8hr-20260713-0750\checker.json` and passed `77/77`. +Use this exact SHA and evidence sequence as the current private paired hardware candidate; never +transfer its evidence to a different binary. + External touch, pickup, putdown, tilt, and shake events are intended IMU feature evidence. For an interaction-aware soak, pass `-AllowExternalImuEvents` through the warm-soak wrapper and formal checker. This does not relax IMU health: terminal read failures, three consecutive exhausted read @@ -176,8 +209,11 @@ If the Android companion is the bridge host, install the lab-signed release APK phone, open Stackchan Companion, allow notifications when prompted on Android 13 or newer, and allow the app to ignore battery optimizations if prompted for screen-off bench testing. Build the APK from the source checkout with `.\tools\check_android_toolchain.cmd` and then -`cd companion; .\gradlew.bat :app-android:assembleRelease`. The default lab release output +`cd companion; .\gradlew.bat "-Pstackchan.allowLabDebugReleaseSigning=true" :app-android:assembleRelease`. +This explicit property permits debug-certificate signing for lab evidence only. The output path is `companion\app-android\build\outputs\apk\release\app-android-release.apk`. +The unqualified `cd companion; .\gradlew.bat :app-android:assembleRelease` command is +intentionally rejected unless the production upload-key environment is configured. The toolchain check verifies `JAVA_HOME`/`java.exe`, Android SDK root, `platform-tools`/`adb.exe`, and SDK Platform 36 before Gradle starts. Confirm the foreground notification reports the bridge as ready and advertised. The phone diff --git a/docs/COMPANION_APP_GAP_ANALYSIS.md b/docs/COMPANION_APP_GAP_ANALYSIS.md index 88237ecf..1f40ea1c 100644 --- a/docs/COMPANION_APP_GAP_ANALYSIS.md +++ b/docs/COMPANION_APP_GAP_ANALYSIS.md @@ -8,6 +8,13 @@ current v1 companion branch. ## Current Status +- The native all-platform CI rehearsal at source commit + `732bfe4b797f7758946a1e11d2d75d24369a3356`, PR #197 run `29315427331`, passed all `11/11` + jobs: firmware, bridge, companion contracts, Android APK/AAB packaging and API 35 install/launch, + Windows MSI, Linux DEB, macOS DMG, installer-derived managed-runtime identity, and aggregate + companion release evidence. Source readiness is `141 passed / 0 failed / 14 owner-controlled or + physical gates pending`; a successful upload-signed tag and target-device/operator evidence are + still required. - G1 conversation surface is partially closed. The shared app now has a Talk panel on Android and desktop. Text turns are sent through the active `CompanionEndpointServer` session as `app_text_turn` response frames (`thinking`, `response_start`, @@ -120,12 +127,32 @@ current v1 companion branch. reload proof, robot-log password leakage, and stale Wi-Fi review commits. The `stackchan://pair` ticket intentionally carries only the bridge URL and pairing fields, not Wi-Fi credentials. -- G7 Play submission remains pending on upload signing, developer verification, - a hosted privacy policy URL, screenshots, Play Console upload, and closed testing. - Source-side Play prep now includes a policy/data-safety declaration draft for +- G7 Play submission remains pending on upload-signing credentials, developer verification, + screenshots, Play Console upload, and closed testing. The canonical privacy policy deployment + gate is now closed: GitHub Pages build `1094346889` serves the exact reviewed source bytes at + https://robvanprod.github.io/stackchan_alive/privacy/, and + `tools/check_privacy_policy_deployment.ps1` verifies HTTPS `200`, canonical final URL, source + SHA-256, and required disclosures against the tracked deployment record. + Release tasks now fail closed when upload-key properties are absent; debug signing requires + the explicit `stackchan.allowLabDebugReleaseSigning` Gradle property. The tag workflow consumes + four `STACKCHAN_ANDROID_*` Actions secrets, builds both APK and AAB, and the release evidence + gate requires the `upload-key` signing profile. The repository currently has no configured + Actions secrets, so a public companion tag remains externally blocked until they are provisioned. + All-platform credential provisioning now has a read-only, manually dispatched + `Companion Signing Readiness` workflow plus secret-safe checker/contracts. It validates the + Android upload keystore, selected private-key entry, passwords, RSA 4096-bit minimum, non-debug + subject, and Play certificate lifetime in temporary runner storage. It also validates the Windows + private code-signing certificate by signing and verifying a temporary executable, the macOS + Developer ID identity by signing and verifying a temporary hardened-runtime executable in a + temporary keychain, each desktop certificate's native trust chain, and live Apple notarization + authentication without publishing assets; the tag workflow repeats the preflights before + packaging. + Source-side Play prep now includes policy/data-safety declarations for `dev.stackchan.companion`, foreground-service `connectedDevice` justification, - microphone/battery/network permission review, a Play-facing privacy policy page derived - from the core privacy boundary, and improved Play evidence packet templates. The Play + microphone/battery/network permission review, a dated Play-facing privacy policy derived + from the core privacy boundary, the deployable `site/privacy/index.html` page, a Pages + workflow, Android and desktop in-app links, and improved Play evidence packet templates. + The Play evidence checker now requires a hosted HTTPS privacy-policy URL before marking internal testing evidence ready. The store asset packet now also defines a four-shot final-build screenshot plan covering pairing/setup, live dashboard, Brain/model controls, and @@ -134,9 +161,9 @@ current v1 companion branch. be explicitly marked `internal-testing-ready` with the Play Console release name, tester group, and UTC upload timestamp for the exact uploaded build. The Android v1 aggregate gate now rejects Play evidence whose uploaded `applicationId`, `versionName`, or - `versionCode` does not match the target-phone APK install report. Those answers, hosted - privacy URL, release identity fields, and screenshots still must be reviewed against the - exact uploaded build before submission. + `versionCode` does not match the target-phone APK install report. The policy deployment must be + rechecked, and the data-safety answers, release identity fields, and screenshots still must be + verified against the exact uploaded build before submission. - G8 Android field diagnostics export is partially closed. Android can now export `stackchan.android.diagnostics-export.v1` JSON from live bridge, robot, trust, saved-robot, and Gemma model state to `ANDROID_DIAGNOSTICS_EXPORT.json` and open the native share sheet. @@ -194,6 +221,16 @@ current v1 companion branch. rejects release evidence that lacks hashed package core files from the extracted release package, and verifies that the rollout report's strict hardware evidence root and hardware metadata commit match the final bundle. +- C8 source-side release packaging is implemented. `release.yml` verifies that the tag matches + every companion version source, builds upload-signed Android APK/AAB artifacts, creates managed + Python payloads on native Windows/macOS/Linux runners, packages MSI/DMG/DEB artifacts, exports + strict companion release evidence, Authenticode-signs and timestamps the MSI, Developer ID signs + and notarizes the DMG, creates GitHub provenance attestations for every final asset, and publishes + stable artifact names with the firmware release. `tools/verify_published_release.ps1` now verifies + those remote assets, native trust evidence, attestations, and release evidence. + A successful public tag run is still required before this can be called released. Automatic + desktop updates and an Android in-app updater are not implemented; current distribution is + manual GitHub Release installation, with Obtainium or Play as Android alternatives. - G9 desktop Python runtime detection is partially closed. The desktop supervisor now probes the configured Python command before PC Brain Mode starts, requires Python 3.10+, reports missing interpreters or missing brain script in the Brain panel, and includes the @@ -215,6 +252,14 @@ current v1 companion branch. `tools\test_desktop_python_runtime_payload_contract.ps1` covering those failure modes. It also emits the manifest platform, manifest/probed Python versions, runtime SHA-256, and runtime source into its JSON report. + Each native tag leg now runs `tools\export_desktop_package_evidence.ps1` after packaging. + That report records the MSI/DEB/DMG package SHA-256, recomputes the processed runtime SHA-256 + from Gradle resources, natively extracts the installer, and hashes `python-runtime/` directly + from the packaged application JAR. It also proves the packaged runtime manifest, platform + executable, and required bridge/provenance/voice resources. Strict companion release evidence + requires exactly one ready Windows, Linux, and macOS report and matches every package hash, + installer runtime hash, and payload summary. The published-release verifier repeats those + package and installer-derived evidence checks before accepting the release. The source tree now also includes `tools\check_desktop_v1_evidence_bundle.ps1`, which aggregates the desktop package hashes, C6 supervisor/GUI evidence, Windows/macOS/Linux managed runtime payload checks, PC Brain deploy audio evidence, quiet-soak evidence, @@ -229,8 +274,13 @@ current v1 companion branch. commit, so stale source or voice hash evidence cannot close the desktop bundle. The Desktop v1 aggregate checker emits that same `sourceCommit` so the final Companion v1 gate can reject stale desktop bundle evidence. - Supplying and shipping the actual managed Python binary payload for each desktop platform - remains open. + Native CI jobs now prepare and embed the actual managed Python binary payload for each desktop + platform. The Windows, Linux, and macOS package matrix passed installer-native extraction, + packaged-runtime identity, managed-Python probing, required brain-resource checks, and exact + package launch evidence. This closes the source/CI binary-payload gate. A tagged candidate must + still produce its own hash-bound package reports, and the three exact tagged packages still need + operator-workstation installation and human acceptance before the desktop aggregate gate can + close. - PC Brain live-deploy bring-up is now easier to exercise before the managed desktop runtime lands. Source/package tools can start the Python LAN bridge with an Ollama Character Lock runner and selected RVC voice sample TTS path, probe the WebSocket endpoint, flash/provision @@ -251,15 +301,17 @@ current v1 companion branch. ## Next Attack Order -1. Finish G1 with hardware push-to-talk/STT validation, run `tools\check_android_speech_evidence.cmd -SourceCommit -RequireReady -Json`, and attach transcript-redacted evidence. -2. Finish G3 with protected robot settings writes and manual brain handoff on physical hardware, then run `tools\check_android_controls_evidence.cmd -SourceCommit -RequireReady -Json`. -3. Finish G5 with robot QR/short-code UI entry and real hardware pairing evidence, then run `tools\check_android_pairing_evidence.cmd -SourceCommit -RequireReady -Json`. -4. Exercise G8 Android diagnostics export on hardware, run `tools\check_android_diagnostics_export_evidence.cmd -SourceCommit -RequireReady -Json`, and attach support-reviewed evidence. -5. Validate Gemma-4-E2B model download/load/eject, run the non-dry-run `gemma4-e2b-litert-lm` benchmark, and capture a real LiteRT turn on target Android hardware, then run `tools\check_android_gemma_evidence.cmd -SourceCommit -RequireReady -Json`. -6. Finish G6 with persistent robot-side Wi-Fi credential entry/provisioning UX and hardware proof, then run `tools\check_android_wifi_evidence.cmd -SourceCommit -RequireReady -Json`. -7. Run the target-phone screen-off bridge soak and `tools\check_android_screen_off_soak_evidence.cmd -SourceCommit -RequireReady -Json` before release promotion. -7a. Assemble the Android v1 evidence bundle and run `tools\check_android_v1_evidence_bundle.cmd -RequireReady -Json`; attach `ANDROID_V1_EVIDENCE_BUNDLE.json/md`, `ANDROID_V1_REVIEW.md`, and the `reports/` JSON outputs. -8. Exercise PC Brain Mode against the physical robot with `tools\start_pc_brain.cmd`, `tools\run_pc_brain_probe.cmd`, and `tools\collect_pc_brain_deploy_evidence.cmd -SourceCommit `; run `tools\check_pc_brain_deploy_evidence.cmd -RequireTests -RequireReady -Json`, then `tools\run_pc_brain_quiet_soak.cmd -DurationSeconds 600 -SourceCommit ` and `tools\check_pc_brain_quiet_soak_evidence.cmd -RequireReady -Json`; attach `PC_BRAIN_DEPLOY_EVIDENCE.json/md` and `PC_BRAIN_QUIET_SOAK.json/md` as lab evidence while keeping the managed runtime payload gate open. -9. Prepare platform-native desktop Python runtime payloads with `tools\prepare_desktop_python_runtime.cmd`, package desktop builds with `-Pstackchan.desktop.pythonRuntimeRoot=`, then run `tools\check_desktop_python_runtime_payload.cmd -RuntimeRoot -Json` and attach the resulting manifest/check output for each platform. -9a. Assemble the Desktop v1 evidence bundle and run `tools\check_desktop_v1_evidence_bundle.cmd -EvidenceRoot output\desktop-v1-evidence\latest -RequireReady -Json`; attach `DESKTOP_V1_EVIDENCE_BUNDLE.json/md`, `DESKTOP_V1_REVIEW.md`, and the `reports/` JSON outputs. The Windows/macOS/Linux runtime payload reports must include matching `platform`, valid `runtimeSha256`, non-placeholder `runtimeSource`, `pythonVersion`, and `probedPythonVersion` summaries. The PC Brain deploy and quiet-soak reports must carry the same `sourceCommit` as the desktop bundle. -10. Assemble the final Companion v1 evidence bundle and run `tools\check_companion_v1_evidence_bundle.cmd -EvidenceRoot output\companion-v1-evidence\latest -RequireReady -Json`; attach `COMPANION_V1_EVIDENCE_BUNDLE.json/md`, `COMPANION_V1_REVIEW.md`, and the `reports/` JSON outputs before calling v1 release-ready. +1. Provision the four `STACKCHAN_ANDROID_*`, two `STACKCHAN_WINDOWS_*`, and six + `STACKCHAN_MACOS_*` Actions secrets; back up all three signing identities; then run a prerelease + tag to bind upload-signed APK/AAB, timestamped Authenticode MSI, notarized/stapled DMG, + provenance-attested DEB, and native managed-runtime evidence to the exact release tag. +2. Finish G1 with hardware push-to-talk/STT validation, then run `tools\check_android_speech_evidence.cmd -SourceCommit -RequireReady -Json`. +3. Finish G3 physical settings writes/manual brain handoff and G5 hardware pairing, then run the strict controls and pairing evidence checks. +4. Exercise G8 Android diagnostics, Gemma-4-E2B download/load/eject, the real `gemma4-e2b-litert-lm` benchmark, and one target-device LiteRT turn. +5. Finish G6 persistent robot Wi-Fi provisioning UX and hardware proof, then run `tools\check_android_wifi_evidence.cmd -SourceCommit -RequireReady -Json`. +6. Run the target-phone screen-off bridge soak and `tools\check_android_screen_off_soak_evidence.cmd -SourceCommit -RequireReady -Json`. +7. Assemble the Android v1 evidence bundle and run `tools\check_android_v1_evidence_bundle.cmd -RequireReady -Json`. +8. Exercise PC Brain Mode against the physical robot with `tools\start_pc_brain.cmd`, the deploy evidence collector, and the strict 600-second quiet soak. +9. Use the tag matrix's native managed-runtime reports to assemble the Desktop v1 evidence bundle and run `tools\check_desktop_v1_evidence_bundle.cmd -EvidenceRoot output\desktop-v1-evidence\latest -RequireReady -Json`. +10. Verify the prerelease with `tools\verify_published_release.cmd -Version ` and retain its exact-commit companion evidence. +11. Assemble the final Companion v1 evidence bundle and run `tools\check_companion_v1_evidence_bundle.cmd -EvidenceRoot output\companion-v1-evidence\latest -RequireReady -Json` before calling v1 release-ready. diff --git a/docs/COMPANION_CROSS_PLATFORM_PLAN.md b/docs/COMPANION_CROSS_PLATFORM_PLAN.md index 0f16c09f..7e8a2530 100644 --- a/docs/COMPANION_CROSS_PLATFORM_PLAN.md +++ b/docs/COMPANION_CROSS_PLATFORM_PLAN.md @@ -1,8 +1,8 @@ # Stackchan: Alive Companion — Cross-Platform Build & Distribution Plan -Status: proposed implementation contract for building the `ANDROID_COMPANION_SPEC.md` app as -one codebase targeting Android + Windows + Linux (+ macOS), with a single release pipeline -that pushes updates to every platform from one git tag. +Status: implementation in progress. The shared Android + Windows + Linux + macOS source and +tag release pipeline are implemented. Public promotion still requires repository upload-signing +secrets, a successful tag run, and the remaining hardware/account evidence gates. This document is a sibling to `ANDROID_COMPANION_SPEC.md`. That spec defines *what* the companion is (protocol, owner semantics, settings surface, gates). This document defines @@ -18,14 +18,13 @@ companion is (protocol, owner semantics, settings surface, gates). This document - The existing Python bridge stays canonical for PC Brain Mode. The desktop app does not reimplement STT/LLM/TTS; it supervises `bridge/lan_service.py` as a subprocess and additionally connects to the robot as its own observer endpoint for settings/diagnostics. -- One `v*` tag → one GitHub Actions run → all artifacts: signed APK to GitHub Releases, - and self-updating Windows (MSIX), Linux (deb + apt repo), and macOS (Sparkle) packages - built by Hydraulic Conveyor from a single Linux runner. -- Update distribution answer (the "or that might not be possible idk" question): **desktop - updates are fully automatic** (MSIX background updates on Windows, apt on Linux, Sparkle - prompt on macOS). **Android cannot silently self-update outside a store**; the honest - ceiling is one-tap updates via an in-app updater or Obtainium, or full auto-update by - publishing a Play closed track later. Details in "Update Distribution". +- One `v*` tag runs the firmware release plus upload-signed Android APK/AAB packaging and + native Windows MSI, Linux DEB, and macOS DMG packaging. Desktop packages include the + platform-managed Python runtime payload used by PC Brain Mode. +- Current updates are manual GitHub Release installs on every platform. Hydraulic Conveyor, + MSIX/AppInstaller, apt metadata, Sparkle, and an in-app Android updater remain C8 follow-on + work. **Android cannot silently self-update outside a store**; Obtainium or a Play track can + improve that experience without overstating what the current app implements. - The Kotlin protocol module is validated against the same JSON fixtures and the same Python virtual robot (`bridge/hardware_simulator.py`) that already gate the firmware and bridge, so the two implementations cannot silently drift. @@ -213,111 +212,150 @@ Extend the existing workflows; do not create a second release universe. Current PR gate in `.github/workflows/firmware.yml`: companion changes run the shared `companion-tests` job plus `companion-platform-builds`, a four-leg matrix that builds -Android debug/release APKs on Ubuntu, a Linux `.deb` on Ubuntu, a macOS `.dmg` on macOS, -and a Windows `.msi` on Windows. Every leg provisions JDK 21 and Android SDK Platform 36 so -the shared KMP Android targets are configured consistently even during desktop packaging, -and every leg uploads its produced platform artifact with `if-no-files-found: error`. -A follow-on `companion-release-evidence` job downloads those four platform artifacts, -runs `tools/export_companion_release_evidence.ps1 -RequireArtifacts`, verifies the Android -release APK with `apksigner`, and uploads `COMPANION_RELEASE_EVIDENCE.json/md` with -artifact paths, byte counts, SHA256 hashes, the producing commit, Gradle toolchain pins, -and Android signing status. That evidence job fails if the manifest does not include both -Android debug/release APKs, a verified signed release APK, plus Linux `.deb`, macOS `.dmg`, -and Windows `.msi` desktop packages. - -Evidence snapshot: PR #194 run `28711092216` on 2026-07-04 passed `bridge-tests`, +Android debug/release APKs and AABs on Ubuntu, a Linux `.deb` on Ubuntu, a macOS `.dmg` on +macOS, and a Windows `.msi` on Windows. Every leg provisions JDK 21 and Android SDK Platform +36. Each native desktop leg also provisions Python 3.12, prepares and validates the managed +runtime payload, embeds that payload in the installer, and exports evidence binding the package +SHA-256 to the processed runtime SHA-256. PR builds pass +`-Pstackchan.allowLabDebugReleaseSigning=true` explicitly; this keeps +debug-signed release artifacts available for CI without allowing public release tasks to +silently fall back to the debug key. The companion test gate also verifies that every source +version agrees with the release tag convention. + +The same workflow supports `workflow_dispatch`; a manual run forces `companion-tests`, all four +native build legs, and aggregate package evidence even when path filtering would skip them. Use +it for a pushed candidate-branch rehearsal before creating a release tag. + +The Android build leg uploads one lab-signed release APK. A dependent API 35 AOSP ATD job installs +and cold-launches that exact artifact, verifies `MainActivity`, the foreground +`CompanionBridgeService`, and zero scoped fatal-process matches, then uploads bounded smoke JSON. +The aggregate gate recomputes the release APK SHA-256 and rejects stale or separately rebuilt +emulator evidence. This remains packaging/runtime smoke only and never substitutes for target-phone +or physical-robot qualification. + +A follow-on `companion-release-evidence` job downloads those platform artifacts and runs +`tools/export_companion_release_evidence.ps1 -RequireArtifacts +-RequireAndroidEmulatorEvidence -RequireDesktopPackageEvidence`. PR evidence records the lab +signing profile, exact release-APK emulator launch binding, package hashes, +and one ready native package/runtime report for Windows, Linux, and macOS, but it is not promotion +evidence. Public promotion additionally uses `-RequireUploadSigning`, which requires both Android +release artifacts to report the `upload-key` signing profile. + +Exact-source rehearsal handoff is now fail-closed. Every checkout and evidence exporter in the +Firmware workflow uses `STACKCHAN_CI_SOURCE_SHA`, resolving to the PR branch head for pull requests +and `github.sha` otherwise. `tools/download_companion_ci_candidate.ps1` accepts only a successful +11-job Firmware run whose reported head matches the requested 40-character commit, then downloads +the Android, emulator, Windows, macOS, Linux, and aggregate-evidence artifacts. It recomputes every +distribution hash and requires the embedded `COMPANION_RELEASE_EVIDENCE.json` commit and version +to match before writing `COMPANION_CI_CANDIDATE.json`. The manifest says explicitly that these are +lab rehearsal artifacts, not production-signed, tagged, or physical evidence. + +The correction was motivated by observed PR run `29318315665`: GitHub associated that successful +run with branch head `d68d10db5b00d36cd7d02922c0e2660838b24c9a`, while its downloadable +`COMPANION_RELEASE_EVIDENCE.json` recorded PR merge commit +`3343fef30ec0b65de08f2eab099044e1249d2220`. That run remains useful CI regression evidence but +must not be handed to physical testing as an exact-head binary set. The new downloader rejects +that mismatch. A later run is authoritative only when its candidate manifest passes. + +Historical evidence snapshot: PR #194 run `28711092216` on 2026-07-04 passed `bridge-tests`, `native-tests`, firmware `build`, `companion-tests`, all four platform artifact legs, and `companion-release-evidence`. Uploaded companion artifacts included `companion-android-apks`, `companion-desktop-linux`, `companion-desktop-macos`, `companion-desktop-windows`, and a complete `COMPANION_RELEASE_EVIDENCE.json/md` manifest. The release APK entry is `app-android-release.apk`; the signing evidence records APK Signature Scheme v2 with the -Android debug certificate for lab/arrival-day testing. - -**PR / push (`firmware.yml` additions, path-filtered to `companion/**` and -`protocol-fixtures/**`):** - -```yaml - companion-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v5 - with: { distribution: temurin, java-version: "21" } - - uses: gradle/actions/setup-gradle@v4 - - run: cd companion && ./gradlew check koverXmlReport - - name: Cross-implementation conformance - run: | - python -m unittest bridge.test_protocol_fixtures - cd companion && ./gradlew :core:jvmTest --tests "*FixtureConformance*" - - name: Kotlin endpoint vs Python virtual robot smoke - run: cd companion && ./gradlew :mockrobot:lanSmoke # drives hardware_simulator.py -``` - -**Tag `v*` (`release.yml` additions):** - -```yaml - companion-android: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v5 - with: { distribution: temurin, java-version: "21" } - - name: Decode keystore - run: echo "$ANDROID_KEYSTORE_B64" | base64 -d > companion/release.keystore - env: { ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} } - - run: cd companion && ./gradlew :app-android:assembleRelease - env: - KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - KEY_ALIAS: companion - - run: sha256sum companion/app-android/build/outputs/apk/release/*.apk > APK_SHA256.txt - - uses: softprops/action-gh-release@v2 - with: { files: "companion/app-android/build/outputs/apk/release/*.apk,APK_SHA256.txt" } - - companion-desktop: - runs-on: ubuntu-latest # Conveyor cross-builds Win/mac/Linux from Linux - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v5 - with: { distribution: temurin, java-version: "21" } - - run: cd companion && ./gradlew :app-desktop:jvmJar - - uses: hydraulic-software/conveyor/actions/build@master - with: - command: make copied-site - signing_key: ${{ secrets.CONVEYOR_SIGNING_KEY }} - agree_to_license: 1 - - uses: softprops/action-gh-release@v2 - with: { files: "output/*" } # packages + update metadata to the release -``` - -Secrets to provision once: `ANDROID_KEYSTORE_B64` + passwords (generate the keystore -locally, back it up — losing it breaks Android update continuity permanently), -`CONVEYOR_SIGNING_KEY` (self-signed root generated by `conveyor keys generate`). Optional -later: a real Windows code-signing cert and an Apple Developer ID for notarization. - -Every release job also emits `RELEASE_EVIDENCE.json` — artifact paths, sha256 sums, git -commit, toolchain pins — matching the provenance pattern the repo already uses. +Android debug certificate for lab/arrival-day testing. That run predates managed-runtime package +evidence and is not proof of the current native package gate; a new PR must establish it on all +three desktop operating systems. The current PR #197 rehearsal above supersedes that packaging +snapshot. + +**Tag `v*` (`release.yml`):** + +1. `tools/check_companion_release_version.ps1 -ExpectedVersion ` rejects source/tag drift. +2. `companion-android-release` decodes the upload keystore and builds upload-signed APK/AAB + artifacts. Missing or incomplete credentials fail before Gradle produces release output. +3. `companion-android-emulator-smoke` installs and cold-launches that exact upload-signed release + APK on an API 35 AOSP ATD emulator. The final manifest rejects evidence whose APK SHA-256 does + not match the release input. +4. `companion-desktop-release` runs natively on Ubuntu, macOS, and Windows. Each leg creates + and validates a managed Python payload, stages it as external native app resources, builds DEB, + DMG, or MSI output, then natively extracts and headlessly launches that exact package. Native + evidence ties the installer SHA-256, launcher probe, packaged JAR brain resources, processed + runtime, and manifest to one exact executable payload. It does not replace target installation + acceptance. +5. The final release job runs the firmware/package gates, exports strict companion evidence, + requires all three native package/runtime reports, stages stable artifact names, and publishes + the complete set in one GitHub Release. +6. `tools/verify_published_release.ps1` downloads the release and checks the tag commit, + expected asset set, hashes, package evidence, and upload-signing evidence. + +The tag is intentionally created as a GitHub prerelease. After it exists, +`tools/install_desktop_companion_package.ps1` installs the exact downloaded MSI, DEB, or DMG on a +matching workstation and launches the installed managed runtime. The standalone checker binds that +report to the package SHA-256 and source commit. The final Desktop v1 aggregate requires one +`operator-target-workstation` report for every desktop OS; CI rehearsal and extraction reports are +not accepted as substitutes, and human acceptance remains a separate review item. On Windows, a +pre-existing Stackchan installation must be explicitly replaced with `-AllowReplace`; the evidence +records the pre-install registration, its successful uninstall, and the post-install registration +so same-version MSI maintenance cannot be mistaken for a fresh exact-package install. + +Repository secrets required before the first public tag: + +- `STACKCHAN_ANDROID_KEYSTORE_B64` +- `STACKCHAN_ANDROID_KEYSTORE_PASSWORD` +- `STACKCHAN_ANDROID_KEY_ALIAS` +- `STACKCHAN_ANDROID_KEY_PASSWORD` +- `STACKCHAN_WINDOWS_PFX_B64` +- `STACKCHAN_WINDOWS_PFX_PASSWORD` +- `STACKCHAN_MACOS_CERTIFICATE_B64` +- `STACKCHAN_MACOS_CERTIFICATE_PASSWORD` +- `STACKCHAN_MACOS_SIGNING_IDENTITY` +- `STACKCHAN_MACOS_NOTARIZATION_APPLE_ID` +- `STACKCHAN_MACOS_NOTARIZATION_PASSWORD` +- `STACKCHAN_MACOS_NOTARIZATION_TEAM_ID` + +Back up the upload keystore outside the repository; losing it breaks Android update +continuity. Back up the Windows signing PFX and macOS Developer ID PKCS#12 independently as well. +Run `tools/check_release_credential_hygiene.cmd -Json` before provisioning or rotating those files; +it fails if private-key bundle extensions are not ignored, if one is tracked, or if a tracked text +file contains a private-key marker. Release packaging and companion CI enforce the same boundary, +and the checker reports paths only rather than credential contents. +The tag workflow now fails closed unless the MSI has a valid timestamped Authenticode signature and +the DMG contains a Developer ID signed app accepted by Gatekeeper plus a stapled Apple notarization +ticket. Every final asset also receives a GitHub Sigstore-backed provenance attestation before the +prerelease is created. The repository currently has none of these Actions secrets configured, so a +consumer tag remains externally blocked until the owner provisions and backs them up. +Once provisioned, the manual `Companion Signing Readiness` workflow validates the Android upload +keystore, selected private-key entry, passwords, RSA 4096-bit minimum, non-debug subject, and Play +certificate lifetime in temporary runner storage. It also uses the Windows private code-signing +certificate and native SignTool to sign and verify a temporary executable, imports the macOS +Developer ID identity into a temporary keychain to sign and verify a hardened-runtime probe, +requires each desktop certificate chain to terminate at a native-host trusted root, and validates +Apple notarization authentication without publishing any artifact. The tag workflow repeats these +preflights before building the AAB, MSI, or DMG. + +Every release job also emits `RELEASE_EVIDENCE.json` and +`COMPANION_RELEASE_EVIDENCE.json/md` with artifact paths, SHA-256 hashes, source commit, +toolchain pins, package-runtime evidence, Android signing status, and exact release-APK emulator +launch evidence. ## Update Distribution The direct answer to "distribute updates to all — or is that not possible": -| Platform | Mechanism | User experience after v1 install | Automatic? | +| Platform | Implemented v1 artifact | Current update path | Automatic? | | --- | --- | --- | --- | -| Windows | Conveyor MSIX + `.appinstaller` on GitHub Releases | Windows updates it in the background, even when not running | Yes, silent | -| Linux (Debian/Ubuntu) | Conveyor-generated deb + apt repo (flat files on the release/Pages) | Updates arrive with normal `apt upgrade` / Software Updater | Yes, with system updates | -| Linux (other) | Tarball | Manual download | No | -| macOS | Conveyor app bundle with Sparkle 2 | Sparkle prompts "update available", one click | Yes, prompted | -| Android (chosen path) | APK on GitHub Releases + in-app updater: check releases API → download → PackageInstaller session | App shows "update available", user taps once, system install dialog confirms | Semi — one tap per update, OS-enforced | -| Android (zero-code alt) | Obtainium pointed at the repo | Obtainium notifies and installs on tap | Semi | -| Android (full auto) | Play Store closed track | Silent background updates | Yes, but Play account, review, and data-safety forms | - -So: one tag updates everything, with exactly one caveat — Android's sandbox forbids silent -self-updates for sideloaded apps by design. One-tap is the honest ceiling until/unless a -Play track is worth the overhead. Recommendation: ship the in-app updater (it also verifies -the APK sha256 from `RELEASE_EVIDENCE.json` before invoking the installer) and document -Obtainium as the alternative. - -`conveyor.conf` starting point: +| Windows | Compose Desktop MSI | Download and install the newer GitHub Release MSI | No | +| Linux (Debian/Ubuntu) | Compose Desktop DEB | Download and install the newer GitHub Release DEB | No | +| macOS | Compose Desktop DMG | Download and install the newer GitHub Release DMG | No | +| Android direct | Upload-signed APK | Download/install from GitHub Releases, or track with Obtainium | No in app; Obtainium can notify | +| Android Play | Upload-signed AAB | Play closed track after account and policy gates | Yes after Play publication | + +One tag publishes packages for every supported platform, but it does not currently update +installed copies automatically. The app has no release API downloader or package installer. +Hydraulic Conveyor desktop updates and an Android in-app updater remain C8 candidates; neither +is a v1 source-complete claim. Android cannot silently self-update outside a store. + +The following `conveyor.conf` is a deferred starting point, not a file currently used by CI: ```hocon include required("/stdlib/jdk/21/openjdk.conf") @@ -333,11 +371,10 @@ app { } ``` -Known rough edges, stated up front rather than discovered later: self-signed Windows -packages trip SmartScreen on first install (users click through once; a paid cert removes -it); un-notarized macOS builds need right-click → Open (an Apple Developer ID, $99/yr, -removes it); the apt path covers Debian-family only. None of these block the lab/streaming -use case. +Known rough edges, stated up front rather than discovered later: secret-free PR/CI rehearsal +packages are intentionally unsigned and are not consumer candidates; a newly issued Windows +certificate can still need reputation before SmartScreen becomes quiet; and the DEB path covers +Debian-family systems only. Tagged consumer candidates cannot use the unsigned rehearsal path. **Out of scope, explicitly:** robot firmware updates. Those remain the PlatformIO/USB flow in `docs/RELEASE_PROCESS.md`. The companion spec grants the app no firmware-flash @@ -429,11 +466,19 @@ and benchmark gates as the PC path before the UI may label it a real brain candi Until then the label stays "fake engine". **C8 — Distribution hardening.** -Keystore + Conveyor keys provisioned; release workflow additions live; in-app Android -updater with sha256 verification; first end-to-end tagged release. -*Gate C8:* from tag push with no manual steps: APK installs and later self-update-prompts -on a phone; Windows package background-updates across two consecutive test tags; Linux apt -upgrade works on the workstation; `RELEASE_EVIDENCE.json` complete. +The native all-platform tag workflow, strict package evidence, and operator target-install +collector/checker contracts are source-complete. Remaining work is upload-keystore provisioning, +Windows/macOS signing credential provisioning, native tag evidence, executing the three target +installs and human reviews, and the first end-to-end tagged prerelease and promotion. Conveyor or another updater +may be evaluated only after the native packages are accepted. +*Gate C8:* one tag publishes verified APK/AAB/MSI/DEB/DMG artifacts, the Android artifacts +are upload-key signed, each desktop package contains its validated managed runtime, each package +hash and extracted application JAR are bound to its processed runtime hash by native evidence, +the MSI is timestamped Authenticode signed, the DMG is Developer ID signed and notarized with a +stapled ticket, every final asset has GitHub provenance, required brain resources are present, +target install checks pass, and +`RELEASE_EVIDENCE.json` plus `COMPANION_RELEASE_EVIDENCE.json` are complete. Automatic update +behavior requires a separate implemented and tested gate. Sequencing note: C1–C5 run entirely against simulators and need no hardware; C2's LAN leg and everything in C7–C8 want the real phone and workstation. Firmware-side work the spec diff --git a/docs/DESKTOP_PYTHON_RUNTIME.md b/docs/DESKTOP_PYTHON_RUNTIME.md index 1c200134..ccd41431 100644 --- a/docs/DESKTOP_PYTHON_RUNTIME.md +++ b/docs/DESKTOP_PYTHON_RUNTIME.md @@ -72,10 +72,12 @@ cd companion ``` CI or release scripts can use `STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT=` instead of the -Gradle property. When either value is present, `:app-desktop:processResources` first runs +Gradle property. When either value is present, `prepareDesktopNativeAppResources` first runs `validateDesktopPythonRuntimePayload`, which invokes -`tools/check_desktop_python_runtime_payload.ps1`, then copies the runtime into packaged app -resources as `python-runtime/`. If the runtime is missing a manifest, executable, or Python +`tools/check_desktop_python_runtime_payload.ps1`, then copies the runtime into native app +resources as an external `python-runtime/` directory beside the installed application. The +runtime is deliberately excluded from the application JAR because an interpreter cannot execute +from inside that archive. If the runtime is missing a manifest, executable, or Python 3.10+ probe, the desktop package build fails before an installer is produced. ## Validation @@ -102,15 +104,73 @@ Run the checker contract before relying on the payload gate in a release package The contract proves placeholder hashes, platform mismatches, and stale `pythonVersion` manifests fail while a minimal valid runtime payload reports `ready`. +After the native installer task finishes, bind the installer to the exact processed runtime +resource that Gradle copied: + +```powershell +$extractRoot = Join-Path $env:TEMP "stackchan-package-extraction-windows-" +.\tools\test_desktop_package_launch.ps1 -Platform windows -PackagePath -ExtractionRoot $extractRoot -OutPath output\desktop-python-runtime\windows-package-launch.json -Json +.\tools\export_desktop_package_evidence.ps1 -Platform windows -PackagePath -RuntimePrepareJsonPath -ProcessedRuntimeRoot companion\app-desktop\build\generated\native-app-resources\common\python-runtime -PackageExtractionRoot $extractRoot -LaunchEvidencePath output\desktop-python-runtime\windows-package-launch.json -Version -Commit -RequireInstallerPayload -RequireLaunchEvidence -UseExistingPackageExtraction -Json +``` + +Use `linux` with the DEB and `macos` with the DMG on their native runners. The exporter writes +`stackchan.desktop-package-evidence.v1`, records the package SHA-256 and processed runtime SHA-256, +then natively extracts the MSI, DEB, or DMG. It hashes the external `python-runtime/` native app +resources, validates their manifest and executable, and opens the installer application JAR only +to require the packaged bridge, provenance, and voice-proof resources and to reject a duplicate +JAR-embedded runtime. The launch helper invokes the exact extracted package launcher with a +headless probe and binds its ready Python/brain result to the package SHA-256. The report fails +when installer content differs from the validated prepare report, Gradle resources, or launch +evidence. This extracted-package smoke does not replace target-machine installation acceptance. +`tools/test_desktop_package_evidence_contract.ps1` covers extension, platform, processed-runtime, +and installer-runtime tampering failures. Public release evidence requires one ready native report +for every desktop platform and matches each report back to exactly one published package. +Keep the Windows extraction root short; MSI administrative extraction can still encounter the +legacy Windows path limit when both the checkout and destination are deeply nested. CI uses +`RUNNER_TEMP`, and omitting `-PackageExtractionRoot` creates a unique short temporary root. + +## Operator Target Installation Evidence + +After the tag workflow creates its prerelease artifacts, install each exact package on a real +workstation running the matching operating system. Run Windows from an elevated PowerShell +session; the Linux helper requires root or passwordless `sudo`; macOS copies the application from +the mounted DMG into `~/Applications` unless `-InstallRoot` is supplied: + +```powershell +.\tools\install_desktop_companion_package.ps1 -Platform windows -PackagePath -SourceCommit <40-character-commit> -OutputDir output\desktop-target-install\windows -Json +.\tools\install_desktop_companion_package.ps1 -Platform linux -PackagePath -SourceCommit <40-character-commit> -OutputDir output\desktop-target-install\linux -Json +.\tools\install_desktop_companion_package.ps1 -Platform macos -PackagePath -SourceCommit <40-character-commit> -OutputDir output\desktop-target-install\macos -Json +``` + +On Windows, the helper refuses to treat MSI maintenance mode as fresh exact-package evidence when +`Stackchan Companion` is already registered. Preserve the existing report or other required +evidence, then add `-AllowReplace`; the helper records the old registration, uninstalls only that +registered Stackchan MSI product, installs the exact requested MSI, and records the new +registration. A silent probe of an older executable cannot satisfy the checker. + +Each helper performs the native install, launches the installed application with the managed +runtime probe, and writes `stackchan.desktop-target-install-evidence.v1`. Validate each report +against the release artifact hash and commit before copying it into the desktop v1 packet: + +```powershell +.\tools\check_desktop_target_install_evidence.ps1 -EvidencePath -ExpectedPlatform -ExpectedPackageSha256 -ExpectedSourceCommit <40-character-commit> -RequireOperatorTarget -Json +.\tools\test_desktop_target_install_evidence_contract.cmd +``` + +`ci-native-runner` reports may be useful installer rehearsals, but the final desktop v1 gate +accepts only `operator-target-workstation` reports. Extraction evidence cannot satisfy this gate, +and successful installed-runtime probing does not substitute for the separate human review of +launch UX, display, audio, robot connection, and uninstall behavior. + The desktop app exports this state under `brain_service.python_runtime.managed_runtime` in diagnostics and C6 brain-supervisor evidence. ## Desktop V1 Evidence Bundle After Windows, macOS, and Linux runtime payloads are prepared and checked, copy those -checker JSON outputs into the desktop aggregate evidence packet together with C6 -supervisor/GUI evidence, package artifact hashes, PC Brain deploy audio proof, quiet-soak -proof, production voice-source readiness, and `DESKTOP_V1_REVIEW.md`: +checker JSON outputs and the three operator target-install reports into the desktop aggregate +evidence packet together with C6 supervisor/GUI evidence, package artifact hashes, PC Brain deploy +audio proof, quiet-soak proof, production voice-source readiness, and `DESKTOP_V1_REVIEW.md`: ```powershell .\tools\check_desktop_v1_evidence_bundle.cmd -EvidenceRoot output\desktop-v1-evidence\latest -WriteTemplate diff --git a/docs/DEVICE_BRINGUP.md b/docs/DEVICE_BRINGUP.md index 39f41819..0a463313 100644 --- a/docs/DEVICE_BRINGUP.md +++ b/docs/DEVICE_BRINGUP.md @@ -98,9 +98,12 @@ Build the Android companion APK from the source checkout first: ```powershell .\tools\check_android_toolchain.cmd cd companion -.\gradlew.bat :app-android:assembleRelease +.\gradlew.bat "-Pstackchan.allowLabDebugReleaseSigning=true" :app-android:assembleRelease ``` +The unqualified `.\gradlew.bat :app-android:assembleRelease` command is intentionally rejected +unless the production upload-key environment is configured. + The toolchain check verifies `JAVA_HOME`/`java.exe`, Android SDK root, `platform-tools`/`adb.exe`, and SDK Platform 36 before Gradle starts. diff --git a/docs/FIRST_DEPLOY_STATUS.md b/docs/FIRST_DEPLOY_STATUS.md index 7958b792..27b974b6 100644 --- a/docs/FIRST_DEPLOY_STATUS.md +++ b/docs/FIRST_DEPLOY_STATUS.md @@ -1,6 +1,6 @@ # Stackchan First Deploy Status -Status timestamp: 2026-07-12 17:10 America/New_York +Status timestamp: 2026-07-13 15:53 America/New_York ## Current Lead: Power-Coordinated Full-Online Accepted Lead @@ -64,6 +64,79 @@ support compiled with motion disabled at boot. requests autonomous ambient motion at boot through the existing power, thermal, audio, duty- cycle, IMU safety-hold, manual-stop, and session-watchdog controls. +### Overnight Exact-Image Result And HTTP Ownership Diagnosis (2026-07-13) + +#### Corrected Single-Owner Exact-Image Acceptance + +- The corrected exact installed candidate is clean source commit + `ce66f8a0fadfadbc07eb59124522267ba66ee70a`, firmware SHA-256 + `69d3db27f2d7197799fdc08ff3c1dc4d6e3011724fe29899367dc016e48ebfa8`, archived at + `output\private\firmware-candidates\single-owner-runtime-ce66f8a0-20260713-073852`. It gives + `IntentTask` sole ownership of bridge networking, bridge output polling, debug/camera HTTP + servicing, and wake-runtime startup. Native logic passed `261/261`; the exact image then passed + formal `76/76` no-motion qualification at + `output\pc-brain\single-owner-runtime-nomotion-2min-20260713-0742` and formal `77/77` actuator + qualification at `output\pc-brain\single-owner-runtime-servo-2min-20260713-0746`. +- Its all-feature actuator soak at + `output\pc-brain\single-owner-runtime-servo-8hr-20260713-0750` completed from + `2026-07-13 07:50:15` through `15:50:22` America/New_York with `status=pass` after `28807 s`. + All `5643/5643` polls succeeded, motion and unsuppressed-motion ratios were both `1.0`, all + `96/96` motion refreshes succeeded, maximum consecutive failed polls and motion-session + timeouts were zero, and RVC was ready for `472/472` worker polls. +- Power, thermal, display, and actuator invariants remained inside their strict gates: sampled + VBUS floor `4913 mV`, reported VBUS floor `4909 mV`, VSYS floor `4150 mV`, maximum chip + temperature `66.5 C`, maximum display frame `41511 us`, zero servo-rail-without-grant or + torque-without-rail samples, zero hard-floor entries, and zero new PMIC runtime or protective + events. +- RGB, touch, IMU, and paired camera/host vision remained active. The run recorded `366943` RGB + frames, `594390` touch samples, `565484` IMU samples, and `115019` camera frames; IMU retry/ + recovery accounting was `8573/5441` with zero exhaustions or terminal failures. Paired vision + made `57505` frame requests and `57500` target updates. Maximum camera capture time was + `223997 us`; camera capture, host-capture, response-write, authentication, and terminal + readiness failures were all zero. +- `tools\check_full_system_soak_evidence.ps1` passed `77/77` with zero failures or pending checks; + its saved JSON is `output\pc-brain\single-owner-runtime-servo-8hr-20260713-0750\checker.json`. + The runner's bounded final stop verified on its first attempt, and a subsequent live `/debug` + check confirmed motion request, servo rail, servo torque, and motion power authority all off. + +- The later exact installed image was clean source commit + `2d0e61e28416df376499acab744ea91a5d56c2d4`, firmware SHA-256 + `c0314b375b86e8f350b6c4588422818526c54b2d6b0293b3a7233b95c06e740e`. Its formal short + qualifications passed `76/76` no-motion and `77/77` actuator checks before the long run. +- The all-feature actuator soak at + `output\pc-brain\single-owner-release-servo-8hr-20260712-2325` collected `22953.5 s` + (`6 h 22 min 33 s`) before the fail-fast runner stopped it. The summary is correctly `fail`, not + an eight-hour pass: one lifetime camera timer maximum reached `330105 us` against the configured + `300000 us` ceiling. The immediately preceding lifetime maximum was `236000 us`, and the final + sampled capture had already recovered to `15036 us`. +- The camera continued working: `91100` captured-frame delta, `45494` host frame requests, + `45487` target updates, zero camera capture/authentication failures, and two isolated response- + write failures (`0.0044%`, maximum streak one). The run recorded `4497/4499` good polls, two + isolated failed polls with maximum streak one, `4461` motion samples (`99.2%`), zero motion + timeouts, VBUS floor `4632 mV`, maximum temperature `66.5 C`, maximum display frame `45045 us`, + zero hard-floor/PMIC protective events, and zero IMU exhaustions or terminal failures. This is + not evidence of a blackout, reset, power fault, thermal fault, or failed camera. +- The exact historical `330105 us` substage cannot be recovered from existing telemetry. The + stopwatch begins before blocking `esp_camera_fb_get()` and ends after RGB565-to-gray conversion, + so it includes camera acquisition, conversion, and time while the calling task is descheduled. + Do not claim a sensor/DMA stall or scheduler stall as an observed fact from this sample alone. +- A confirmed concurrency defect existed in that exact image: both priority-3 `IntentTask` and + Arduino's priority-1 `loopTask`, on Core 1, serviced the same debug/camera HTTP server and shared + response state. The earlier corrected continuation preserved a malformed `/debug` response with + an embedded NUL while the next poll recovered, matching this dual-owner path. Commit `3570b443` + had removed the duplicate loop servicing and added release guards; commit `6ec18215` reverted it + five minutes later without contrary technical evidence. This defect is the strongest supported + explanation for an inflated recovered camera timer, but the old combined timer prevents proving + which portion of this one sample was preemption. +- The single-owner correction described above was subsequently installed and qualified as its own + exact image; it does not inherit the preceding image's hardware evidence. The earlier public + `stackchan_release_full` build artifact was `2798688` bytes, SHA-256 + `F660291251A760C045C99E3B61D1AD332E4BBEA5F258A2D75B901B2CAD3F875B`; that public artifact is + distinct from the private paired binary and does not inherit its device-specific evidence. +- After the failed runner exited, `/motion-stop` was issued and post-stop evidence was saved as + `post-stop-debug-20260713-0952.json` in the run root. Motion request, rail, and torque were all + off while bridge/network, power, temperature, and display telemetry remained healthy. + ### Corrected PMIC And Bridge-Port Candidate (2026-07-12) - The apparent bridge regression in the first 4.60 V VINDPM builds is now explained by direct diff --git a/docs/HARDWARE_FEATURE_ROADMAP.md b/docs/HARDWARE_FEATURE_ROADMAP.md index 9d63a583..ac2271b1 100644 --- a/docs/HARDWARE_FEATURE_ROADMAP.md +++ b/docs/HARDWARE_FEATURE_ROADMAP.md @@ -6,12 +6,12 @@ then added one at a time so face, wake, bridge, voice, power, and motion stayed recoverable. The status paragraphs distinguish current implementation from remaining promotion evidence; the numbered design bullets remain useful when adapting the software to another unit. -Current status (2026-07-12): Gates 0-5 shipped in the Apache-2.0 `v0.2.0` release identified in -`FIRST_DEPLOY_STATUS.md`. The exact release image passed formal no-motion, short actuator, and -one-hour all-feature actuator qualifications; the one-hour result was `76/76` after `3601 s` with -`706/706` good polls and zero reset, motion-timeout, power, camera, or terminal IMU failures. The -owner accepted the subsequent interaction-aware run after more than five hours and explicitly -waived the remaining duration for `v0.2.0`; it is release evidence, not a formal eight-hour pass. +Current status (2026-07-13): Gates 0-5 shipped in the Apache-2.0 `v0.2.0` release identified in +`FIRST_DEPLOY_STATUS.md`. The corrected exact release image passed native `261/261`, formal +`76/76` no-motion, and formal `77/77` short actuator qualifications. It then completed the full +all-feature actuator soak for `28807 s` with `5643/5643` successful polls, no reset, +motion-timeout, power, camera, or terminal IMU failures, and a `77/77` formal checker result. +Motion, servo rail, torque, and motion power authority were verified off after completion. Wake, full reply audio, synchronized mouth motion, power-coordinated servos, flowing RGB, touch, pickup/orientation events, authenticated camera capture, YuNet face acquisition, and bounded diff --git a/docs/POWER_BLACKOUT_FORENSICS.md b/docs/POWER_BLACKOUT_FORENSICS.md index 27bfe0a2..dc9547cb 100644 --- a/docs/POWER_BLACKOUT_FORENSICS.md +++ b/docs/POWER_BLACKOUT_FORENSICS.md @@ -1,9 +1,10 @@ # Stackchan Power Blackout Forensics -Status: the `v0.2.0` exact-image candidate passed short no-motion, short actuator, and one-hour -actuator qualification. Its later all-feature actuator continuation ran for more than five hours -before the owner ended the gate and accepted it for release; that is not a formal eight-hour pass. -Historical full-off root cause remains unidentified. +Status: the corrected `v0.2.0` exact-image candidate passed native `261/261`, formal `76/76` +no-motion, and formal `77/77` short actuator qualification. Its exact installed firmware then +completed the full all-feature actuator soak for `28807 s` with `5643/5643` successful polls and a +`77/77` formal checker result; bounded final stop evidence verified motion, servo rail, torque, and +motion power authority off. Historical full-off root cause remains unidentified. ## What The Evidence Says diff --git a/docs/PRODUCTION_READINESS.md b/docs/PRODUCTION_READINESS.md index 04924041..8e71ab14 100644 --- a/docs/PRODUCTION_READINESS.md +++ b/docs/PRODUCTION_READINESS.md @@ -1,10 +1,12 @@ # Production Readiness -Current status (2026-07-12): the integrated physical release candidate passed exact-image -no-motion, actuator, voice, camera, body-sensor, OTA, and formal one-hour acceptance gates, then -completed more than five hours of extended all-feature operation without a strict hardware bad -state. The repository owner accepted that evidence and waived the remaining eight-hour duration. -The Apache-2.0 release includes the active production RVC model and index. +Current status (2026-07-13): the corrected single-owner exact-image physical candidate passed +native `261/261`, formal `76/76` no-motion, formal `77/77` short actuator, voice, camera, +body-sensor, and OTA gates. Its exact installed firmware then completed the full all-feature +actuator soak for `28807 s` with `5643/5643` successful polls and the formal checker passed +`77/77`; motion, rail, torque, and motion power authority were verified off afterward. The exact +source commit, firmware SHA-256, and evidence root are recorded in `FIRST_DEPLOY_STATUS.md`. The +Apache-2.0 release includes the active production RVC model and index. ## Proven Now @@ -44,10 +46,10 @@ The Apache-2.0 release includes the active production RVC model and index. - Servo-enabled flashing requires an explicit `-ConfirmServoRisk` operator acknowledgment. - Display rendering uses the M5 display backend, not a stub. - Preview media can be generated without hardware. -- The exact installed paired firmware passed a formally checked one-hour actuator acceptance with - every poll and motion sample good, no reset, timeout, power, camera, or peripheral fault, and a - verified motion stop. The exact SHA-256 and evidence root are recorded in - `FIRST_DEPLOY_STATUS.md`. +- The corrected exact installed paired firmware passed a formally checked `28807 s` all-feature + actuator soak with every one of `5643` polls successful, motion and unsuppressed-motion sample + ratios both `1.0`, no reset, timeout, power, camera, or peripheral fault, and a verified bounded + motion stop. The exact SHA-256 and evidence root are recorded in `FIRST_DEPLOY_STATUS.md`. - Physical wake, Whisper/Gemma/RVC reply transport, complete speaker playback, synchronized mouth motion, bounded camera following, power-coordinated servos, RGB behavior, touch, IMU interaction, and LAN OTA rollback/confirmation have all been exercised on the real robot. @@ -59,8 +61,8 @@ The Apache-2.0 release includes the active production RVC model and index. ## Post-Release Follow-Up -- Continue longer-duration community and lab runs as regression evidence; the owner waived the - remaining duration of the interrupted launch run for this release. +- Continue community and lab runs as regression evidence; the corrected exact-image candidate has + already completed the required eight-hour all-feature actuator duration. - Audit downloaded release assets against the published SHA-256 sidecars. - PC/mobile owner failover and target-store distribution evidence for whichever companion targets are presented as consumer-ready. These do not block the current PC-hosted public release. @@ -76,8 +78,8 @@ Do not call this consumer-ready until all of these are evidenced for the release motion, servos, camera following, RGB, touch, and IMU gates pass on the integrated candidate. 3. Power-cycle, OTA confirmation/rollback, actuator stop, power, thermal, and display recovery evidence pass without unsupported root-cause claims. -4. The formally checked one-hour actuator acceptance passes and the owner records the accepted - extended-run evidence for the release. +4. The formally checked actuator acceptance and required long-duration soak pass for the exact + installed firmware, with bounded final motion stop evidence. 5. The tested release ZIP is independently extracted and verified, with a matching `logs/package_verify.log`, manifest commit, and published-asset SHA-256. 6. Bundled production voice files match the recorded release hashes. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 4a073079..d8f8fe56 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -34,8 +34,81 @@ For the companion C8 distribution path, run `tools/export_companion_release_evid after Android APK or desktop package artifacts are built. It writes `COMPANION_RELEASE_EVIDENCE.json/md` with artifact SHA256s, git commit, `companion/gradle/libs.versions.toml` toolchain pins, and Android release APK signing -status from `apksigner`; use `-RequireArtifacts` when the signed release APK and desktop -packages are required for promotion. +status from `apksigner`. For promotion, use: + +```powershell +tools/export_companion_release_evidence.ps1 ` + -RequireArtifacts ` + -RequireUploadSigning ` + -RequireAndroidEmulatorEvidence ` + -RequireDesktopPackageEvidence ` + -RequireDesktopDistributionTrust +``` + +The strict gate requires upload-key-signed APK and AAB artifacts plus MSI, DEB, and DMG +packages. It also requires API 35 emulator launch evidence whose APK SHA-256 matches the release +APK. Stale, failed, lower-API, wrong-package, service-missing, fatal-process, or physical-evidence +substitution reports are rejected. The tag workflow prepares and validates a managed Python +runtime for each desktop package before staging it as external native app resources. It then +natively extracts each MSI, DEB, or DMG, runs the exact packaged launcher in headless probe mode, +and opens its installer application JAR to validate brain resources and exclude an embedded +interpreter. The native report binds the package SHA-256 and application-JAR SHA-256 to the launch +result, processed and installer-derived runtime SHA-256, manifest, file totals, and required brain +resources. Missing, duplicate, stale-commit, stale-package-launch, staging-only, or mismatched +native reports block release evidence. Extracted-package launch evidence does not substitute for +target-machine installation acceptance. Tagged Windows packages must additionally pass timestamped +SHA-256 Authenticode verification. Tagged macOS packages must be Developer ID signed, accepted by +Gatekeeper, notarized by Apple, and carry a stapled ticket. The final release job creates GitHub +Sigstore-backed provenance attestations for every staged firmware, companion, evidence, ZIP, and +checksum asset before publication. + +The tag workflow creates the GitHub Release with `--prerelease`. Before promotion, download the +exact MSI, DEB, and DMG and run the native installer helper on a matching operator workstation: + +```powershell +tools/install_desktop_companion_package.ps1 ` + -Platform ` + -PackagePath ` + -SourceCommit ` + -OutputDir output/desktop-target-install/ ` + -Json +``` + +Windows requires an elevated PowerShell session; Linux requires root or passwordless `sudo`; the +macOS helper performs the normal DMG application copy. Check each result with +`tools/check_desktop_target_install_evidence.ps1 -RequireOperatorTarget`, bind it to the published +package SHA-256 and tag commit, and copy all three reports into the Desktop v1 aggregate packet. +If Windows already has Stackchan Companion registered, preserve its evidence and rerun the helper +with `-AllowReplace`. The helper then records and removes only the existing Stackchan MSI product +before installing the exact release MSI; implicit same-version MSI maintenance is rejected. +CI-native installation rehearsals, MSI administrative extraction, and DMG/DEB extraction do not +satisfy this gate. Installed-launch evidence proves package installation and bundled runtime +startup only; the human Desktop v1 review remains required. + +Before tagging, push the exact candidate branch and dispatch the `Firmware` workflow from the +Actions UI or with `gh workflow run firmware.yml --ref `. A manual dispatch +forces companion tests plus the Android, Linux, macOS, and Windows package matrix and aggregate +evidence even when no companion path changed in the candidate commit. + +For a PR or manually dispatched Firmware run, download one exact-source rehearsal candidate with: + +```powershell +tools\download_companion_ci_candidate.cmd ` + -RunId ` + -Commit <40-character-branch-head-sha> ` + -Json +``` + +The Firmware workflow checks out `STACKCHAN_CI_SOURCE_SHA`, which is the PR branch head for pull +requests and `github.sha` for push or manual-dispatch runs. The downloader independently requires +all 11 jobs and all six companion artifact groups to be successful and present, rejects expired +artifacts, and recomputes every APK/AAB/MSI/DEB/DMG hash against the embedded +`COMPANION_RELEASE_EVIDENCE.json`. It writes +`output/companion/ci-candidates/-run-/COMPANION_CI_CANDIDATE.json` as the handoff to +phone or workstation testing. A PR status check associated with a branch head is not sufficient +when the downloaded evidence records a different merge commit; the helper rejects that mismatch. +The manifest is explicitly scoped to CI rehearsal and does not substitute for upload signing, +desktop production trust, a tagged release, or physical evidence. ## Exact Tested Lead Versus Public Package @@ -222,14 +295,16 @@ Before promoting a prerelease, verify the completed hardware evidence packet: .\tools\verify_hardware_evidence.cmd -EvidenceRoot output\hardware-evidence\ ``` -Then run the full release gate, which composes package verification, hardware evidence -verification, GitHub Actions status, and production voice hash verification: +Then run the full release gate, which composes package verification, strict hardware evidence, +the ready aggregate Companion v1 packet, GitHub Actions status, and production voice hash +verification: ```powershell .\tools\verify_consumer_promotion.cmd ` -Version ` -PackageZip output\release\stackchan_alive_.zip ` -EvidenceRoot output\hardware-evidence\ ` + -CompanionV1EvidenceRoot output\companion-v1-evidence\ ` -ExpectedCommit ` -ExpectedFirmwareSourceCommit ` -CameraFollowSummaryPath ` @@ -243,6 +318,14 @@ verification, GitHub Actions status, and production voice hash verification: the source used for the installed firmware. They default to the same value, but must be supplied separately when documentation or host-only changes follow the exact physical firmware build. +Final consumer promotion requires `-PackageZip`; `-PackageRoot` is insufficient for the terminal +decision because the exact archive SHA-256 must be bound to the aggregate evidence. The Companion +v1 checker must return `companion-v1-evidence-ready`. Promotion independently verifies that the +aggregate release source commit, exact installed `firmwareSourceCommit`, release version, release +ZIP hash, and hardware evidence root match the values being promoted. This makes the Android +real-device, Play, desktop target-install, hardware, CI, voice, and release evidence mandatory +inputs to the final decision instead of parallel records. + The promotion gate fails while the package voice source remains `pending-production-source` or while GitHub Actions status is not successful. By default it refreshes GitHub Actions status live for the exact release commit, after the tag-triggered `Release` workflow has finished. This avoids asking a ZIP created inside that workflow to prove its own future success. Pass `-ActionsStatusPath` only with a separately captured, exact-commit `stackchan.github-actions-status.v1` report. The status snapshot inside the ZIP remains package-generation provenance, not the terminal promotion decision. The `-AllowExternalAccountCiBlock` switch exists only for an explicit account-billing or pre-runner allocation outage exception and must be paired with `-ExternalAccountCiExceptionPath docs/CI_ACCOUNT_BLOCK_EXCEPTION_TEMPLATE.json` after that JSON is copied, completed, and approved for the exact release commit. The checked-in template is intentionally not approval-ready: its approval fields are `TBD` and every proof boolean is `false`. To start from the observed CI report, run `.\tools\new_ci_account_block_exception.cmd -ActionsStatusPath output\release\\github_actions_status.json -OutPath output\ci-exceptions\\CI_ACCOUNT_BLOCK_EXCEPTION_DRAFT.json`; the generated draft is also intentionally unapproved until the matching gate is genuinely satisfied. @@ -257,15 +340,72 @@ git tag git push origin ``` -The release workflow builds both firmware variants, runs native logic tests, compile-checks the embedded test firmware, renders preview media, creates and verifies an auditable package, and attaches the package, ZIP SHA256 sidecar, individual preview media, expression-sheet, and firmware files to a GitHub release. +The release workflow builds both firmware variants, runs native logic tests, compile-checks +the embedded test firmware, renders preview media, creates and verifies an auditable package, +builds upload-signed Android APK/AAB artifacts, builds runtime-bundled Windows MSI, Linux DEB, +and macOS DMG packages on their native runners, and attaches the complete verified asset set +to one GitHub release. Before the first public companion tag, provision the four +`STACKCHAN_ANDROID_*` Actions secrets documented in `ANDROID_PLAY_RELEASE.md`, plus: + +- `STACKCHAN_WINDOWS_PFX_B64` +- `STACKCHAN_WINDOWS_PFX_PASSWORD` +- `STACKCHAN_MACOS_CERTIFICATE_B64` +- `STACKCHAN_MACOS_CERTIFICATE_PASSWORD` +- `STACKCHAN_MACOS_SIGNING_IDENTITY` +- `STACKCHAN_MACOS_NOTARIZATION_APPLE_ID` +- `STACKCHAN_MACOS_NOTARIZATION_PASSWORD` +- `STACKCHAN_MACOS_NOTARIZATION_TEAM_ID` + +The Windows certificate must be a trusted code-signing certificate exported as a password-protected +PFX. The macOS certificate must be a Developer ID Application certificate exported as a +password-protected PKCS#12 file; notarization uses an app-specific Apple password. Keep independent +offline backups and do not add any certificate or password to the repository. The tag workflow +fails before publishing when any signing credential or native trust proof is absent. + +Before provisioning or rotating any signing material, run the source-checkout hygiene gate: + +```powershell +.\tools\check_release_credential_hygiene.cmd -Json +``` + +It verifies that Android keystores, Windows PFX, macOS PKCS#12, Apple API keys, and other private-key +bundle extensions remain ignored and untracked, and that no tracked text file contains a private-key +marker. `package_release.ps1` runs the same gate before building, while companion CI runs its +negative contract tests. The report exposes paths and status only, never credential contents. + +After provisioning all twelve companion signing secrets (four Android, two Windows, and six +macOS), run the manual **Companion Signing Readiness** workflow before creating a tag: + +```powershell +gh workflow run companion-signing-readiness.yml --ref +gh run watch (gh run list --workflow companion-signing-readiness.yml --limit 1 --json databaseId --jq '.[0].databaseId') --exit-status +``` -If GitHub Actions cannot run, publish the already verified package with the manual release helper: +That workflow has read-only repository permission and does not build, upload, or publish release +assets. Its Android job decodes the upload keystore only into runner temporary storage and verifies +the selected private-key entry, both passwords, RSA 4096-bit minimum, non-debug subject, and Play +certificate lifetime. Its native Windows job signs and verifies a temporary executable with the +private code-signing certificate and SignTool; its native macOS job imports the Developer ID +identity into a temporary keychain, signs and verifies a temporary hardened-runtime executable, +and asks Apple `notarytool` to authenticate the configured account, app-specific password, and team +ID. The native desktop jobs also require the certificate chain to terminate at a root trusted by +that host. The same strict preflights run again in the tag workflow before packaging. For a local +desktop credential-material check, set the corresponding environment variables and run +`tools\check_desktop_release_signing_readiness.cmd -Platform windows|macos -RequireReady -Json`; +add `-RequireNativeToolchain` only on the matching OS. The checker emits certificate metadata and +status only, never PKCS#12 bytes or passwords. + +If GitHub Actions cannot run, the manual helper remains available for a firmware-only recovery +publication: ```powershell .\tools\publish_release.cmd -Version -CreateTag -PushCurrentBranch -PushTag ``` -The manual helper verifies the local ZIP, uploads the same assets as the workflow, downloads the GitHub-hosted ZIP plus ZIP SHA256 sidecar, and verifies that remote copy against the tag commit. +The manual helper verifies the local ZIP, uploads the firmware package assets, downloads the +GitHub-hosted ZIP plus ZIP SHA256 sidecar, and verifies that remote copy against the tag commit. +It does not cross-build or upload companion packages and therefore cannot satisfy the full +companion release asset contract by itself. Audit an existing GitHub release after publication: @@ -273,7 +413,14 @@ Audit an existing GitHub release after publication: .\tools\verify_published_release.cmd -Version ``` -The published-release verifier checks the uploaded asset set, compares asset sizes and SHA256 digests against the local package, confirms the remote GitHub tag resolves to the expected package commit, downloads the GitHub ZIP plus ZIP SHA256 sidecar, validates the sidecar against the downloaded ZIP, and runs the package verifier on that downloaded copy. +The published-release verifier checks the firmware and companion asset set, compares sizes and +SHA256 digests against package and companion evidence, requires upload-key signing for Android, +requires ready Windows/Linux/macOS package evidence and matches each published desktop package +to its recorded processed runtime and exact-package launch hashes, +requires timestamped Authenticode evidence for Windows and Developer ID, Gatekeeper, and stapled +notarization evidence for macOS, verifies GitHub provenance attestations for the downloaded assets, +confirms the remote GitHub tag resolves to the expected package commit, downloads the GitHub ZIP +plus ZIP SHA256 sidecar, validates the sidecar, and runs the package verifier on that copy. For a single post-publish operator summary, run: diff --git a/docs/RELEASE_QUICKSTART.md b/docs/RELEASE_QUICKSTART.md index 85ba520c..b07c381b 100644 --- a/docs/RELEASE_QUICKSTART.md +++ b/docs/RELEASE_QUICKSTART.md @@ -91,8 +91,18 @@ Capture companion release evidence after APK or desktop package artifacts exist: This writes `COMPANION_RELEASE_EVIDENCE.json` and `COMPANION_RELEASE_EVIDENCE.md` with the artifact hashes, source commit, and companion toolchain provenance used by the release gate. -Before a signed C8 distribution is promoted, rerun it with `-RequireArtifacts` so missing -APK or desktop package hashes block the release evidence. +Before a signed C8 distribution is promoted, rerun the PowerShell exporter with all strict gates: + +```powershell +.\tools\export_companion_release_evidence.ps1 ` + -RequireArtifacts ` + -RequireUploadSigning ` + -RequireAndroidEmulatorEvidence ` + -RequireDesktopPackageEvidence +``` + +The emulator JSON must come from the exact release APK; the exporter recomputes its hash and +blocks stale or separately rebuilt launch evidence. Run the socket-level LAN bridge smoke report: @@ -318,9 +328,12 @@ Build the Android companion APK from the source checkout before this step: ```powershell .\tools\check_android_toolchain.cmd cd companion -.\gradlew.bat :app-android:assembleRelease +.\gradlew.bat "-Pstackchan.allowLabDebugReleaseSigning=true" :app-android:assembleRelease ``` +The unqualified `.\gradlew.bat :app-android:assembleRelease` command is intentionally rejected +unless the production upload-key environment is configured. + The toolchain check verifies `JAVA_HOME`/`java.exe`, Android SDK root, `platform-tools`/`adb.exe`, and SDK Platform 36 before Gradle starts. @@ -388,6 +401,30 @@ Then pass that root into desktop packaging with `-Pstackchan.desktop.pythonRuntimeRoot=` or `STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT=`. A platform runtime only validates the matching platform installer; Windows, macOS, and Linux require separate native payloads. +The package build stages this interpreter as external native app resources rather than placing +it inside the application JAR. On each native runner, run +`tools/test_desktop_package_launch.ps1` against the exact MSI, DEB, or DMG before exporting +desktop package evidence; the helper extracts and invokes the package launcher headlessly and +binds Python/brain readiness to the package SHA-256. This smoke complements, but does not replace, +target-machine installation acceptance. + +The tag workflow creates a GitHub prerelease. Download its exact MSI, DEB, and DMG to real +matching workstations and collect installed-launch evidence before promotion. Windows must run +from an elevated PowerShell session: + +```powershell +.\tools\install_desktop_companion_package.ps1 -Platform -PackagePath -SourceCommit <40-character-commit> -OutputDir output\desktop-target-install\ -Json +.\tools\check_desktop_target_install_evidence.ps1 -EvidencePath output\desktop-target-install\\-target-install.json -ExpectedPlatform -ExpectedPackageSha256 -ExpectedSourceCommit <40-character-commit> -RequireOperatorTarget -Json +``` + +For a pre-existing Windows installation, preserve the prior evidence and pass `-AllowReplace` to +the installer helper. It records the old product registration, removes only the registered +Stackchan MSI, installs the exact requested package, and records the replacement. The checker +rejects evidence that could have come from same-version MSI maintenance mode. + +Repeat on all three desktop operating systems. A CI runner report and an extracted launcher probe +are deliberately rejected by the final desktop v1 gate. Installed-launch evidence also does not +replace the human desktop review. For PC Brain Mode lab bring-up, start the local brain bridge and selected voice TTS path: @@ -425,9 +462,9 @@ It must report `pc-brain-quiet-soak-ready`, proving the bridge stays connected/r the full quiet window without parse/timeouts/playback errors or unexpected audio streams, and it must emit the same `sourceCommit` as the deploy packet and desktop v1 evidence bundle. -Assemble the final desktop/PC Brain aggregate packet after the three platform runtime -payload reports, package hashes, C6 evidence, deploy evidence, quiet-soak evidence, and -production voice-source readiness are captured: +Assemble the final desktop/PC Brain aggregate packet after the three platform runtime payload +reports, three operator target-install reports, package hashes, C6 evidence, deploy evidence, +quiet-soak evidence, and production voice-source readiness are captured: ```powershell .\tools\check_desktop_v1_evidence_bundle.cmd -EvidenceRoot output\desktop-v1-evidence\latest -WriteTemplate @@ -438,7 +475,8 @@ The checker must report `desktop-v1-evidence-ready` before treating desktop inst v1 release-ready. The `DESKTOP_V1_REVIEW.md` source commit must match `DESKTOP_V1_EVIDENCE_BUNDLE.json.sourceCommit`; the PC Brain deploy report, PC Brain quiet-soak report, and production voice-source readiness report must all carry that same -`sourceCommit`. +`sourceCommit`. Each target-install report must carry that commit and the SHA-256 of its matching +MSI, DEB, or DMG, and `DESKTOP_V1_REVIEW.md` must mark `Target installation decision: pass`. After Android v1, desktop v1, hardware, Play, production voice-source, release package, GitHub Actions, and rollout evidence are all ready for the same commit, assemble the final @@ -460,6 +498,13 @@ required dashboard media IDs from the final Android aggregate gate. The rollout point at the same strict hardware evidence packet and hardware metadata commit recorded in the final bundle. +The terminal consumer-promotion command must pass this packet as `-CompanionV1EvidenceRoot`. +It reruns the aggregate checker with `-RequireReady` and independently binds the source commit, +exact installed `firmwareSourceCommit`, release version, exact release ZIP SHA-256, and strict +hardware evidence root. A ready rollout report or completed hardware packet cannot substitute for +the Android real-device, Play, and three-platform desktop operator evidence carried by the +aggregate packet. + Open `BENCH_STATUS.md` in the evidence packet for the current next action, then `NEXT_STEPS.md` for the short bench run order and hard stops. The longer `README.md` remains the detailed reference. Only after display-only firmware boots cleanly and the body is on a clear surface, run: @@ -565,16 +610,20 @@ Then run: .\RUN_EVIDENCE_VERIFY.cmd ``` -After that passes, run the full consumer promotion gate: +After that passes, complete the aggregate Companion v1 packet and confirm its checker reports +`companion-v1-evidence-ready`, then run the full consumer promotion gate: ```powershell .\RUN_CONSUMER_PROMOTION_CHECK.cmd ``` -The generated command assumes the package commit and tested firmware source commit are identical. +The generated command uses `output\companion-v1-evidence\latest` by default and requires the exact +release ZIP. Pending aggregate evidence cannot pass consumer promotion. The generated command also +assumes the package commit and tested firmware source commit are identical. If documentation or host-only release commits were made after the exact firmware image was flashed, run `tools\verify_consumer_promotion.cmd` directly and pass both `-ExpectedCommit` for the package -and `-ExpectedFirmwareSourceCommit` for the physical camera, body-sensor, and soak evidence. +and `-ExpectedFirmwareSourceCommit` for the physical camera, body-sensor, and soak evidence, plus +`-CompanionV1EvidenceRoot` for the completed aggregate packet. That final gate also records GitHub Actions status. If hosted jobs cannot start because of account billing or runner allocation, preserve the local verification report and publish the limitation. diff --git a/docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json b/docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json new file mode 100644 index 00000000..a8d70008 --- /dev/null +++ b/docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json @@ -0,0 +1,21 @@ +{ + "schema": "stackchan.privacy-policy-deployment.v1", + "status": "deployed", + "canonicalUrl": "https://robvanprod.github.io/stackchan_alive/privacy/", + "sourcePath": "site/privacy/index.html", + "sourceCommit": "afbebbd3429e00a6f76cb238788ce7664f1b6fda", + "sourceSha256": "28d1cca7889f8d95c0587025ee5d46c213a85ac814c538e3c36090b377fd1f47", + "sourceGitBlob": "be9032e532f85cf38448d54f770ff9d915e27a54", + "deploymentMethod": "github-pages-branch", + "deploymentBranch": "gh-pages", + "deploymentCommit": "49cefe092920c0a12da50896356394d380df6904", + "pagesBuildId": 1094346889, + "pagesBuildStatus": "built", + "pagesBuiltAtUtc": "2026-07-14T05:05:42Z", + "verifiedAtUtc": "2026-07-14T05:06:53Z", + "httpStatus": 200, + "finalUrl": "https://robvanprod.github.io/stackchan_alive/privacy/", + "servedSha256": "28d1cca7889f8d95c0587025ee5d46c213a85ac814c538e3c36090b377fd1f47", + "httpsEnforced": true, + "notes": "Initial public deployment uses the isolated gh-pages branch. After the Pages workflow reaches main, switch the Pages build source to GitHub Actions and re-run the deployment checker." +} diff --git a/docs/store-assets/play/README.md b/docs/store-assets/play/README.md index 2d82e6e5..0c8b5318 100644 --- a/docs/store-assets/play/README.md +++ b/docs/store-assets/play/README.md @@ -4,8 +4,13 @@ - `icon-512.png` is the 512 x 512 PNG upload asset. - `feature-graphic-1024x500.png` is the Play feature graphic upload asset. - `SCREENSHOT_CAPTURE_PLAN.md` defines the required final-build screenshot coverage. -- `docs/ANDROID_PLAY_PRIVACY_POLICY.md` is the source-side privacy-policy page - content to host before Play Console submission. +- `docs/ANDROID_PLAY_PRIVACY_POLICY.md` is the reviewed policy record. +- `site/privacy/index.html` is the public policy page source for + https://robvanprod.github.io/stackchan_alive/privacy/. +- `PRIVACY_POLICY_DEPLOYMENT.json` binds the live HTTPS response to its source hash, + deployment commit, and GitHub Pages build. +- `tools/check_privacy_policy_deployment.ps1 -Json` re-fetches and verifies the live page. +- `.github/workflows/pages.yml` deploys the static policy site from `main`. The visual language matches the in-app Stack-chan face preview: dark square display, cyan display edge, white block eyes, black pupils, white brows, and pink diff --git a/site/.nojekyll b/site/.nojekyll new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/site/.nojekyll @@ -0,0 +1 @@ + diff --git a/site/index.html b/site/index.html new file mode 100644 index 00000000..c4d704f0 --- /dev/null +++ b/site/index.html @@ -0,0 +1,24 @@ + + + + + + Stackchan Companion + + + + +
+

Stackchan Companion

+

Official policy information for the Stackchan Companion applications.

+

Read the Stackchan Companion Privacy Policy

+
+ + diff --git a/site/privacy/index.html b/site/privacy/index.html new file mode 100644 index 00000000..be9032e5 --- /dev/null +++ b/site/privacy/index.html @@ -0,0 +1,83 @@ + + + + + + Stackchan Companion Privacy Policy + + + + +
+

Stackchan Companion Privacy Policy

+

Effective July 14, 2026. Last reviewed July 14, 2026.

+

This policy applies to Stackchan Companion for Android (dev.stackchan.companion) and the companion desktop applications. Stackchan Companion is developed by RobVanProd.

+

Privacy inquiries · Security reports

+ +

Summary

+

Stackchan Companion pairs with and operates a Stack-chan robot on the user's local network. The app does not create accounts, show ads, sell purchases, or send companion data to a developer-controlled analytics service. Meaningful connected features require a Stack-chan robot or bridge on the same local network.

+ +

Data The Developer Does Not Collect

+

The developer does not receive personal or financial information, photos, videos, camera data, automatic crash reports or diagnostics, advertising identifiers, or location data from the app. The app uses local network state and bridge URLs, not Android location permissions.

+

The app does not persist raw microphone audio or include raw audio in diagnostics exports. Push-to-talk speech processing is described below because the configured Android speech service may process audio away from the phone.

+ +

Local Data Stored On The Device

+

The app may store these records in app-private storage:

+
    +
  • Saved robot and trusted companion records.
  • +
  • Local bridge URLs, endpoint IDs, robot IDs, pairing fingerprints, and pairing state.
  • +
  • Persona, display, diagnostics export, and model asset settings.
  • +
  • Optional Mobile Brain model path, byte count, checksum, and load state.
  • +
+

This information remains on the device unless the user explicitly sends it to a local Stack-chan bridge or shares a diagnostics export.

+ +

Microphone And Speech

+

The Android app requests RECORD_AUDIO only when the user invokes Push-to-talk. If permission is denied, the app does not start the turn or send a transcript.

+

The app asks the configured Android speech-recognition service to convert speech into text. Depending on device and service settings, that service may process audio on-device or transmit it to the speech-service provider for ephemeral processing under that provider's privacy policy. Stackchan Companion requests offline recognition when supported but cannot guarantee every installed service honors that preference.

+

The app sends the returned transcript to the user-configured Stack-chan bridge, normally on the local network. It does not persist raw microphone audio or include raw audio or transcript text in diagnostics exports.

+ +

Diagnostics Export

+

Diagnostics export is user initiated. The app creates a local JSON file and opens the operating system share surface only after a request. The user chooses whether and where to send it.

+

Diagnostics may include bridge and robot status, saved robot and trusted endpoint state, model state, and provisioning state. The export reduces the last text turn to a presence-only flag and records Wi-Fi provisioning with password placeholders and password_redacted=true.

+ +

Local Network And Model Downloads

+

The app uses network access to host or connect to a Stack-chan bridge, discover local services, and keep an active robot session reachable. Local bridge traffic can include pairing state, commands, settings, status, and the current recognized transcript. The v1 local bridge uses the user's trusted LAN and is not represented as end-to-end encrypted.

+

If the user downloads the optional Mobile Brain model, the configured model host may receive ordinary request metadata such as the user's IP address. The request does not include local robot, conversation, microphone, or diagnostics content.

+ +

When Data Leaves The App

+
    +
  • The user starts Push-to-talk and the configured Android speech service uses off-device processing.
  • +
  • The user sends a recognized text turn or commands to a user-configured local Stack-chan bridge.
  • +
  • The user explicitly shares a diagnostics export with a destination they select.
  • +
  • The user downloads the optional model asset from its configured host.
  • +
+

The app does not automatically sell data or send data to the developer.

+ +

Security, Retention, And Deletion

+

Local records are stored in app-private storage until the user removes them, clears app data, or uninstalls. Downloaded models remain until removed or app data is cleared. The app does not retain raw microphone audio.

+

Users can remove saved robot and trusted companion records from the app UI. Robot-side unpairing is managed separately by robot firmware and may require a robot-side clear or pairing command.

+ +

Children

+

Stackchan Companion is not directed to children. It is intended for robot setup, development, and operation by the device owner.

+ +

Changes And Contact

+

Material changes will be published here with an updated review date. Submit privacy questions through the public issue tracker. Use the repository security policy for sensitive security reports.

+
+ + diff --git a/src/main.cpp b/src/main.cpp index dbb5f3e3..68388a91 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9496,10 +9496,8 @@ void loop() { samplePowerTelemetry(nowMs, false); applyManagedChargeCurrent(nowMs); #endif - updateBridgeNetwork(nowMs); - pollBridgeOutputs(nowMs); - pollBridgeDebugServer(nowMs); - ensureWakeSrStarted(nowMs); + // IntentTask is the single owner of bridge, debug HTTP, and wake runtime service. + // These paths use shared transports and response buffers and must not be polled here. #if STACKCHAN_ENABLE_PERIODIC_SERIAL_TELEMETRY if (lastHeartbeatMs == 0 || nowMs - lastHeartbeatMs >= 10000) { lastHeartbeatMs = nowMs; diff --git a/tools/check_android_emulator_release_evidence.cmd b/tools/check_android_emulator_release_evidence.cmd new file mode 100644 index 00000000..5b20f3af --- /dev/null +++ b/tools/check_android_emulator_release_evidence.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_android_emulator_release_evidence.ps1" %* +exit /b %ERRORLEVEL% diff --git a/tools/check_android_emulator_release_evidence.ps1 b/tools/check_android_emulator_release_evidence.ps1 new file mode 100644 index 00000000..05291317 --- /dev/null +++ b/tools/check_android_emulator_release_evidence.ps1 @@ -0,0 +1,139 @@ +param( + [Parameter(Mandatory = $true)] + [string]$EvidencePath, + [Parameter(Mandatory = $true)] + [string]$ReleaseApkPath, + [string]$ExpectedPackageName = "dev.stackchan.companion", + [int]$MinApiLevel = 35, + [switch]$Json +) + +$ErrorActionPreference = "Stop" +$issues = New-Object System.Collections.Generic.List[string] +$evidence = $null +$releaseApkSha256 = "" +$resolvedEvidencePath = "" +$resolvedReleaseApkPath = "" + +if ($MinApiLevel -lt 1) { + throw "MinApiLevel must be positive." +} + +if (-not (Test-Path -LiteralPath $EvidencePath -PathType Leaf)) { + $issues.Add("emulator evidence JSON is missing: $EvidencePath") +} else { + $resolvedEvidencePath = [string](Resolve-Path -LiteralPath $EvidencePath) + try { + $evidence = Get-Content -Raw -LiteralPath $resolvedEvidencePath | ConvertFrom-Json + } catch { + $issues.Add("emulator evidence JSON could not be parsed") + } +} + +if (-not (Test-Path -LiteralPath $ReleaseApkPath -PathType Leaf)) { + $issues.Add("release APK is missing: $ReleaseApkPath") +} else { + $resolvedReleaseApkPath = [string](Resolve-Path -LiteralPath $ReleaseApkPath) + $releaseApkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedReleaseApkPath).Hash.ToLowerInvariant() +} + +$summary = [ordered]@{ + schema = "" + status = "" + capturedUtc = "" + model = "" + apiLevel = "" + packageName = "" + versionName = "" + versionCode = "" + apkSha256 = "" + mainActivityResumed = $false + bridgeServicePresent = $false + fatalProcessMatches = $null + scope = "" + substitutesForPhysicalEvidence = $null +} + +if ($null -ne $evidence) { + foreach ($name in @("schema", "status", "capturedUtc", "model", "apiLevel", "packageName", "versionName", "versionCode", "apkSha256", "scope")) { + $summary[$name] = [string]$evidence.$name + } + $mainActivityResumed = $evidence.mainActivityResumed -is [bool] -and $evidence.mainActivityResumed -eq $true + $bridgeServicePresent = $evidence.bridgeServicePresent -is [bool] -and $evidence.bridgeServicePresent -eq $true + $doesNotSubstituteForPhysicalEvidence = $evidence.substitutesForPhysicalEvidence -is [bool] -and $evidence.substitutesForPhysicalEvidence -eq $false + $summary.mainActivityResumed = $mainActivityResumed + $summary.bridgeServicePresent = $bridgeServicePresent + $summary.fatalProcessMatches = $evidence.fatalProcessMatches + $summary.substitutesForPhysicalEvidence = $evidence.substitutesForPhysicalEvidence + + if ([string]$evidence.schema -ne "stackchan.android-emulator-launch-smoke.v1") { + $issues.Add("unexpected emulator evidence schema '$($evidence.schema)'") + } + if ([string]$evidence.status -ne "pass") { + $issues.Add("emulator smoke status is '$($evidence.status)', expected 'pass'") + } + if ([string]$evidence.packageName -ne $ExpectedPackageName) { + $issues.Add("emulator package '$($evidence.packageName)' does not match '$ExpectedPackageName'") + } + + $apiLevel = 0 + if (-not [int]::TryParse([string]$evidence.apiLevel, [ref]$apiLevel) -or $apiLevel -lt $MinApiLevel) { + $issues.Add("emulator API '$($evidence.apiLevel)' is below required API $MinApiLevel") + } + if ([string]::IsNullOrWhiteSpace([string]$evidence.versionName) -or [string]::IsNullOrWhiteSpace([string]$evidence.versionCode)) { + $issues.Add("emulator package version identity is incomplete") + } + if (-not $mainActivityResumed) { + $issues.Add("MainActivity was not resumed in emulator evidence") + } + if (-not $bridgeServicePresent) { + $issues.Add("CompanionBridgeService was not present in emulator evidence") + } + + $fatalMatches = -1 + if (-not [int]::TryParse([string]$evidence.fatalProcessMatches, [ref]$fatalMatches) -or $fatalMatches -ne 0) { + $issues.Add("emulator evidence fatalProcessMatches must be zero") + } + if ([string]$evidence.scope -ne "emulator-install-launch-service-smoke-only") { + $issues.Add("emulator evidence scope is not the bounded launch-smoke scope") + } + if (-not $doesNotSubstituteForPhysicalEvidence) { + $issues.Add("emulator evidence must explicitly set substitutesForPhysicalEvidence=false") + } + if (@($evidence.issues).Count -ne 0) { + $issues.Add("emulator evidence contains reported issues") + } + + $evidenceApkSha256 = ([string]$evidence.apkSha256).ToLowerInvariant() + if ($evidenceApkSha256 -notmatch '^[0-9a-f]{64}$') { + $issues.Add("emulator evidence APK SHA-256 is missing or malformed") + } elseif (-not [string]::IsNullOrWhiteSpace($releaseApkSha256) -and $evidenceApkSha256 -ne $releaseApkSha256) { + $issues.Add("emulator evidence APK SHA-256 does not match the release APK") + } +} + +$status = if ($issues.Count -eq 0) { "ready" } elseif ($null -eq $evidence -or [string]::IsNullOrWhiteSpace($resolvedReleaseApkPath)) { "pending" } else { "not-ready" } +$report = [ordered]@{ + schema = "stackchan.android-emulator-release-evidence-check.v1" + status = $status + evidencePath = $resolvedEvidencePath + releaseApkPath = $resolvedReleaseApkPath + releaseApkSha256 = $releaseApkSha256 + evidence = $summary + issues = @($issues) +} + +if ($Json) { + $report | ConvertTo-Json -Depth 6 +} else { + Write-Host "Android emulator release evidence: $status" + Write-Host "Release APK SHA-256: $releaseApkSha256" + foreach ($issue in $issues) { + Write-Host "Issue: $issue" + } +} + +if ($status -ne "ready") { + exit 2 +} +exit 0 diff --git a/tools/check_android_play_release_readiness.ps1 b/tools/check_android_play_release_readiness.ps1 index b7d9fceb..d8037f23 100644 --- a/tools/check_android_play_release_readiness.ps1 +++ b/tools/check_android_play_release_readiness.ps1 @@ -1,5 +1,6 @@ param( [string]$Root = "", + [switch]$RequireUploadSigning, [switch]$Json ) @@ -63,6 +64,49 @@ function Get-ConfiguredValue { } } +function Invoke-KeytoolCommand { + param( + [string]$KeytoolPath, + [string[]]$Arguments + ) + + $output = @() + $exitCode = -1 + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& $KeytoolPath @Arguments 2>&1) + $exitCode = $LASTEXITCODE + } catch { + $output = @($_.Exception.Message) + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + + return [pscustomobject]@{ + exitCode = $exitCode + output = @($output | ForEach-Object { [string]$_ }) + } +} + +function Get-CertificateFromPemOutput { + param([string[]]$Output) + + $text = $Output -join "`n" + $match = [regex]::Match( + $text, + "-----BEGIN CERTIFICATE-----\s*(?[A-Za-z0-9+/=\r\n]+?)\s*-----END CERTIFICATE-----", + [System.Text.RegularExpressions.RegexOptions]::Singleline + ) + if (-not $match.Success) { + throw "keytool did not return a PEM certificate." + } + + $base64 = $match.Groups["body"].Value -replace "\s", "" + $rawCertificate = [Convert]::FromBase64String($base64) + return New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList (, $rawCertificate) +} + function Test-PlayUploadSigningEnvironment { $requiredNames = @( "STACKCHAN_ANDROID_KEYSTORE", @@ -86,7 +130,7 @@ function Test-PlayUploadSigningEnvironment { -Name "Play upload signing environment" ` -Status "pending" ` -Evidence "STACKCHAN_ANDROID_KEYSTORE*" ` - -Detail ("Upload signing credentials are not configured yet; missing " + ($missing -join ", ") + ". Lab APK/AAB builds remain debug-certificate signed until these exist.") + -Detail ("Upload signing credentials are not configured yet; missing " + ($missing -join ", ") + ". Release tasks fail closed; lab debug signing requires the explicit stackchan.allowLabDebugReleaseSigning property.") return } @@ -102,12 +146,198 @@ function Test-PlayUploadSigningEnvironment { return } - Add-Check ` - -Id "play-upload-signing-environment" ` - -Name "Play upload signing environment" ` - -Status "pass" ` - -Evidence "STACKCHAN_ANDROID_KEYSTORE*" ` - -Detail "Upload signing variables are configured and the keystore file exists." + $keytool = Get-Command keytool -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $keytool) { + Add-Check ` + -Id "play-upload-signing-environment" ` + -Name "Play upload signing environment" ` + -Status "fail" ` + -Evidence "keytool" ` + -Detail "Upload signing variables are configured, but keytool is unavailable for cryptographic validation." + return + } + + $storePasswordEnvironmentName = "STACKCHAN_KEYTOOL_STORE_PASSWORD" + $keyPasswordEnvironmentName = "STACKCHAN_KEYTOOL_KEY_PASSWORD" + $destinationPasswordEnvironmentName = "STACKCHAN_KEYTOOL_DESTINATION_PASSWORD" + $temporaryEnvironment = [ordered]@{ + $storePasswordEnvironmentName = [string]$configured["STACKCHAN_ANDROID_KEYSTORE_PASSWORD"].value + $keyPasswordEnvironmentName = [string]$configured["STACKCHAN_ANDROID_KEY_PASSWORD"].value + $destinationPasswordEnvironmentName = ([guid]::NewGuid().ToString("N") + "Aa1!") + } + $previousEnvironment = @{} + $temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-upload-key-check-" + [guid]::NewGuid().ToString("N")) + $temporaryKeyStore = Join-Path $temporaryDirectory "private-key-check.p12" + $certificate = $null + $rsa = $null + $sha256 = $null + + try { + New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null + foreach ($entry in $temporaryEnvironment.GetEnumerator()) { + $previousEnvironment[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key) + [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value) + } + + $alias = [string]$configured["STACKCHAN_ANDROID_KEY_ALIAS"].value + $listResult = Invoke-KeytoolCommand -KeytoolPath $keytool.Source -Arguments @( + "-list", + "-v", + "-keystore", $resolvedKeystore, + "-storepass:env", $storePasswordEnvironmentName, + "-alias", $alias + ) + if ($listResult.exitCode -ne 0) { + Add-Check ` + -Id "play-upload-signing-environment" ` + -Name "Play upload signing environment" ` + -Status "fail" ` + -Evidence "keytool keystore/alias validation" ` + -Detail "The configured keystore could not be opened with the configured store password and alias." + return + } + + # Importing only the selected entry into a disposable store proves that it is a private key + # and that both the store and key passwords are correct without modifying the source keystore. + $privateKeyResult = Invoke-KeytoolCommand -KeytoolPath $keytool.Source -Arguments @( + "-importkeystore", + "-srckeystore", $resolvedKeystore, + "-srcstorepass:env", $storePasswordEnvironmentName, + "-srcalias", $alias, + "-srckeypass:env", $keyPasswordEnvironmentName, + "-destkeystore", $temporaryKeyStore, + "-deststoretype", "PKCS12", + "-deststorepass:env", $destinationPasswordEnvironmentName, + "-destkeypass:env", $destinationPasswordEnvironmentName, + "-noprompt" + ) + if ($privateKeyResult.exitCode -ne 0) { + Add-Check ` + -Id "play-upload-signing-environment" ` + -Name "Play upload signing environment" ` + -Status "fail" ` + -Evidence "keytool private-key validation" ` + -Detail "The configured alias is not an exportable private-key entry with the configured key password." + return + } + + $certificateResult = Invoke-KeytoolCommand -KeytoolPath $keytool.Source -Arguments @( + "-exportcert", + "-rfc", + "-keystore", $resolvedKeystore, + "-storepass:env", $storePasswordEnvironmentName, + "-alias", $alias + ) + if ($certificateResult.exitCode -ne 0) { + Add-Check ` + -Id "play-upload-signing-environment" ` + -Name "Play upload signing environment" ` + -Status "fail" ` + -Evidence "keytool certificate validation" ` + -Detail "The configured private-key certificate could not be exported for validation." + return + } + + $certificate = Get-CertificateFromPemOutput -Output $certificateResult.output + $issues = New-Object System.Collections.Generic.List[string] + $rsaKeySize = 0 + if ($certificate.PublicKey.Oid.Value -ne "1.2.840.113549.1.1.1") { + $issues.Add("certificate public key is not RSA") + } else { + try { + $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($certificate) + if ($null -eq $rsa) { + throw "RSA public key provider returned null." + } else { + $rsaParameters = $rsa.ExportParameters($false) + $rsaKeySize = [int]($rsaParameters.Modulus.Length * 8) + } + } catch { + $keytoolListText = $listResult.output -join "`n" + $keySizeMatch = [regex]::Match( + $keytoolListText, + '(?im)^\s*Subject Public Key Algorithm:\s*(?\d+)-bit RSA(?:\s+key)?\s*$' + ) + if ($keySizeMatch.Success) { + $rsaKeySize = [int]$keySizeMatch.Groups["bits"].Value + } else { + $issues.Add("certificate RSA public key size could not be read") + } + } + if ($rsaKeySize -gt 0 -and $rsaKeySize -lt 4096) { + $issues.Add("RSA key size is $rsaKeySize bits; project policy requires at least 4096 bits") + } + } + + if ($certificate.Subject -match "(?i)Android Debug") { + $issues.Add("certificate subject identifies an Android debug key") + } + + $nowUtc = [DateTime]::UtcNow + $notBeforeUtc = $certificate.NotBefore.ToUniversalTime() + $notAfterUtc = $certificate.NotAfter.ToUniversalTime() + $playExpiryFloorUtc = [DateTime]::ParseExact( + "2033-10-23T00:00:00Z", + "yyyy-MM-dd'T'HH:mm:ss'Z'", + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal + ).ToUniversalTime() + if ($notBeforeUtc -gt $nowUtc.AddMinutes(5)) { + $issues.Add("certificate is not valid yet") + } + if ($notAfterUtc -le $nowUtc) { + $issues.Add("certificate has expired") + } elseif ($notAfterUtc -lt $playExpiryFloorUtc) { + $issues.Add("certificate expires before the Google Play minimum of 2033-10-23 UTC") + } + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + $fingerprintBytes = $sha256.ComputeHash($certificate.RawData) + $fingerprint = ($fingerprintBytes | ForEach-Object { $_.ToString("X2") }) -join ":" + if ($issues.Count -gt 0) { + Add-Check ` + -Id "play-upload-signing-environment" ` + -Name "Play upload signing environment" ` + -Status "fail" ` + -Evidence "keytool certificate/private-key validation" ` + -Detail ("Upload key validation failed: " + ($issues -join "; ") + ".") + return + } + + Add-Check ` + -Id "play-upload-signing-environment" ` + -Name "Play upload signing environment" ` + -Status "pass" ` + -Evidence "keytool certificate/private-key validation" ` + -Detail ("Validated private-key entry: RSA $rsaKeySize bits, expires " + $notAfterUtc.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") + ", certificate SHA-256 $fingerprint.") + } catch { + Add-Check ` + -Id "play-upload-signing-environment" ` + -Name "Play upload signing environment" ` + -Status "fail" ` + -Evidence "keytool certificate/private-key validation" ` + -Detail "Upload signing material could not be validated. No credential values were emitted." + } finally { + if ($null -ne $sha256) { + $sha256.Dispose() + } + if ($null -ne $rsa) { + $rsa.Dispose() + } + if ($null -ne $certificate) { + $certificate.Dispose() + } + foreach ($entry in $temporaryEnvironment.GetEnumerator()) { + if ($null -eq $previousEnvironment[$entry.Key]) { + [Environment]::SetEnvironmentVariable($entry.Key, $null) + } else { + [Environment]::SetEnvironmentVariable($entry.Key, $previousEnvironment[$entry.Key]) + } + } + if (Test-Path -LiteralPath $temporaryDirectory) { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force + } + } } function Join-RootPath { @@ -226,7 +456,7 @@ Test-TextPatterns ` -Id "gradle-play-signing" ` -Name "Gradle Play upload signing inputs" ` -RelativePath "companion/app-android/build.gradle.kts" ` - -Patterns @("STACKCHAN_ANDROID_KEYSTORE", "STACKCHAN_ANDROID_KEYSTORE_PASSWORD", "STACKCHAN_ANDROID_KEY_ALIAS", "STACKCHAN_ANDROID_KEY_PASSWORD", "playRelease", "isDebuggable = false") + -Patterns @("STACKCHAN_ANDROID_KEYSTORE", "STACKCHAN_ANDROID_KEYSTORE_PASSWORD", "STACKCHAN_ANDROID_KEY_ALIAS", "STACKCHAN_ANDROID_KEY_PASSWORD", "playRelease", "isDebuggable = false", "stackchan.allowLabDebugReleaseSigning", "verifyReleaseSigning", "Android release signing is not configured") Test-PlayUploadSigningEnvironment @@ -236,12 +466,30 @@ Test-TextPatterns ` -RelativePath ".github/workflows/firmware.yml" ` -Patterns @(":app-android:bundleRelease", "companion/app-android/build/outputs/bundle/release/*.aab") +Test-TextPatterns ` + -Id "ci-android-emulator-launch-smoke" ` + -Name "CI runs Android emulator launch smoke" ` + -RelativePath ".github/workflows/firmware.yml" ` + -Patterns @("companion-android-emulator-smoke", "companion-android-apks", "actions/download-artifact@v7", "system-images;android-35;aosp_atd;x86_64", 'export ANDROID_AVD_HOME="$RUNNER_TEMP/android-avd"', "timeout 180 adb wait-for-device", "test_android_emulator_launch.ps1", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "output/android-emulator-smoke/latest/**") + +Test-TextPatterns ` + -Id "tag-android-emulator-release-gate" ` + -Name "Tag release validates upload key and exact release APK launch" ` + -RelativePath ".github/workflows/release.yml" ` + -Patterns @("Validate Android upload key", "check_android_play_release_readiness.ps1", "companion-android-emulator-smoke", "Download upload-signed Android release", 'export ANDROID_AVD_HOME="$RUNNER_TEMP/android-avd"', "timeout 180 adb wait-for-device", "Run upload-signed Android emulator launch smoke", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence") + Test-TextPatterns ` -Id "release-evidence-aab" ` -Name "Release evidence covers AAB signing" ` -RelativePath "tools/export_companion_release_evidence.ps1" ` -Patterns @("*.aab", "androidBundleSigning", "android-release-aab-signature", "jarsigner") +Test-TextPatterns ` + -Id "release-evidence-emulator-apk-binding" ` + -Name "Release evidence binds emulator smoke to the release APK" ` + -RelativePath "tools/export_companion_release_evidence.ps1" ` + -Patterns @("AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "check_android_emulator_release_evidence.ps1", "androidEmulatorEvidenceRequired", "android-emulator-release-apk-evidence") + Test-TextPatterns ` -Id "play-store-evidence-checker" ` -Name "Play Store evidence checker" ` @@ -252,19 +500,79 @@ Test-TextPatterns ` -Id "play-release-doc" ` -Name "Play release checklist" ` -RelativePath "docs/ANDROID_PLAY_RELEASE.md" ` - -Patterns @("Android Play Release Checklist", "app-android-release.aab", "Play App Signing", "feature-graphic-1024x500.png", "SCREENSHOT_CAPTURE_PLAN.md", "ANDROID_PLAY_POLICY_DECLARATIONS.md", "ANDROID_PLAY_PRIVACY_POLICY.md", "check_android_play_store_evidence.cmd", "Play Console internal testing", "RECORD_AUDIO") + -Patterns @("Android Play Release Checklist", "app-android-release.aab", "Play App Signing", "cryptographically validates", "test_android_upload_signing_contract.ps1", "certificate SHA-256 fingerprint", "CI runtime smoke", "API 35 AOSP ATD", "feature-graphic-1024x500.png", "SCREENSHOT_CAPTURE_PLAN.md", "ANDROID_PLAY_POLICY_DECLARATIONS.md", "ANDROID_PLAY_PRIVACY_POLICY.md", "site/privacy/index.html", ".github/workflows/pages.yml", "https://robvanprod.github.io/stackchan_alive/privacy/", "check_android_play_store_evidence.cmd", "Play Console internal testing", "RECORD_AUDIO") Test-TextPatterns ` -Id "play-policy-declarations" ` -Name "Play policy and data-safety declarations" ` -RelativePath "docs/ANDROID_PLAY_POLICY_DECLARATIONS.md" ` - -Patterns @("Google Play Data safety form", "Privacy policy URL", "ANDROID_PLAY_PRIVACY_POLICY.md", "Data Safety Draft", "Not collected", "RECORD_AUDIO", "raw microphone audio is not stored", "password_redacted=true", "Foreground service Play Console draft", "connectedDevice", "REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", "not directed to children") + -Patterns @("Google Play Data safety form", "Google Play User Data policy", "Privacy policy URL", "https://robvanprod.github.io/stackchan_alive/privacy/", "ANDROID_PLAY_PRIVACY_POLICY.md", "Data Safety Draft", "Collected only for optional, ephemeral app functionality", "RECORD_AUDIO", "configured Android SpeechRecognizer may transmit microphone audio", "password_redacted=true", "not represented as end-to-end encrypted", "Foreground service Play Console draft", "connectedDevice", "REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", "not directed to children") Test-TextPatterns ` -Id "play-privacy-policy-page" ` -Name "Play-facing privacy policy page" ` -RelativePath "docs/ANDROID_PLAY_PRIVACY_POLICY.md" ` - -Patterns @("Stackchan Companion Privacy Policy", "dev.stackchan.companion", "does not create accounts", "does not persist raw microphone audio", "diagnostics export", "password_redacted=true", "optional Mobile Brain model", "saved robot and trusted companion records", "not directed to children") + -Patterns @("Stackchan Companion Privacy Policy", "July 14, 2026", "https://robvanprod.github.io/stackchan_alive/privacy/", "dev.stackchan.companion", "does not create accounts", "does not persist raw microphone audio", "may process microphone audio", "diagnostics export", "password_redacted=true", "optional Mobile Brain model", "saved robot and trusted companion records", "not represented as end-to-end encrypted", "not directed to children") + +Test-TextPatterns ` + -Id "play-privacy-policy-site" ` + -Name "Deployable public privacy-policy site" ` + -RelativePath "site/privacy/index.html" ` + -Patterns @("Stackchan Companion Privacy Policy", "July 14, 2026", "dev.stackchan.companion", "Privacy inquiries", "configured Android speech-recognition service", "may process audio", "password_redacted=true", "not represented as end-to-end encrypted", "not directed to children") + +Test-TextPatterns ` + -Id "play-privacy-deployment-record" ` + -Name "Published privacy-policy deployment record" ` + -RelativePath "docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json" ` + -Patterns @("stackchan.privacy-policy-deployment.v1", "deployed", "https://robvanprod.github.io/stackchan_alive/privacy/", "site/privacy/index.html", "afbebbd3429e00a6f76cb238788ce7664f1b6fda", "49cefe092920c0a12da50896356394d380df6904", "1094346889", "28d1cca7889f8d95c0587025ee5d46c213a85ac814c538e3c36090b377fd1f47", "httpsEnforced") + +Test-TextPatterns ` + -Id "play-privacy-deployment-checker" ` + -Name "Published privacy-policy deployment checker" ` + -RelativePath "tools/check_privacy_policy_deployment.ps1" ` + -Patterns @("stackchan.privacy-policy-deployment-check.v1", "privacy-policy-deployment-ready", "live-https", "Published policy byte identity", "Published policy disclosures", "sourceSha256", "servedSha256") + +Test-TextPatterns ` + -Id "play-privacy-deployment-contract" ` + -Name "Privacy-policy deployment contract" ` + -RelativePath "tools/test_privacy_policy_deployment_contract.ps1" ` + -Patterns @("exact published privacy policy bytes are accepted", "tampered published privacy policy bytes are rejected", "noncanonical privacy policy URL is rejected", "stale privacy policy source hash is rejected", "pending privacy policy deployment status is rejected", "5/5") + +Test-TextPatterns ` + -Id "play-privacy-pages-workflow" ` + -Name "Privacy-policy Pages deployment workflow" ` + -RelativePath ".github/workflows/pages.yml" ` + -Patterns @("Deploy privacy policy", "main", "site/**", "pages: write", "id-token: write", "actions/configure-pages@v5", "actions/upload-pages-artifact@v4", "path: site", "actions/deploy-pages@v4") + +Test-TextPatterns ` + -Id "play-privacy-canonical-app-url" ` + -Name "Canonical privacy-policy URL in companion identity" ` + -RelativePath "companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt" ` + -Patterns @("privacyPolicyUrl", "https://robvanprod.github.io/stackchan_alive/privacy/") + +Test-TextPatterns ` + -Id "play-privacy-android-link" ` + -Name "Android in-app privacy-policy link" ` + -RelativePath "companion/app-android/src/main/kotlin/dev/stackchan/companion/android/MainActivity.kt" ` + -Patterns @("onOpenPrivacyPolicy", "Intent.ACTION_VIEW", "CompanionIdentity.privacyPolicyUrl") + +Test-TextPatterns ` + -Id "play-privacy-desktop-link" ` + -Name "Desktop in-app privacy-policy link" ` + -RelativePath "companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/Main.kt" ` + -Patterns @("onOpenPrivacyPolicy", "Desktop.Action.BROWSE", "CompanionIdentity.privacyPolicyUrl") + +Test-TextPatterns ` + -Id "play-privacy-ui-and-speech" ` + -Name "Privacy link UI and offline speech preference" ` + -RelativePath "companion/ui/src/commonMain/kotlin/dev/stackchan/companion/ui/CompanionConsole.kt" ` + -Patterns @("onOpenPrivacyPolicy", "Privacy policy", "Export logs") + +Test-TextPatterns ` + -Id "play-speech-offline-preference" ` + -Name "Android speech requests offline recognition" ` + -RelativePath "companion/app-android/src/main/kotlin/dev/stackchan/companion/android/AndroidSpeechTurnController.kt" ` + -Patterns @("RecognizerIntent.EXTRA_PREFER_OFFLINE", "true") foreach ($relativePath in @( "fastlane/metadata/android/en-US/title.txt", @@ -315,6 +623,7 @@ $report = [ordered]@{ passed = @($checks | Where-Object { $_.status -eq "pass" }).Count pending = $pendingChecks.Count failed = $failedChecks.Count + uploadSigningRequired = [bool]$RequireUploadSigning checks = @($checks) } @@ -332,3 +641,7 @@ if ($Json) { if ($failedChecks.Count -gt 0) { exit 1 } +if ($RequireUploadSigning -and $pendingChecks.Count -gt 0) { + exit 2 +} +exit 0 diff --git a/tools/check_android_play_store_evidence.ps1 b/tools/check_android_play_store_evidence.ps1 index 1a864b01..ac2e3381 100644 --- a/tools/check_android_play_store_evidence.ps1 +++ b/tools/check_android_play_store_evidence.ps1 @@ -157,7 +157,7 @@ function Write-PlayStoreEvidenceTemplate { sourceCommit = "<40-character git commit>" releaseAabSha256 = "<64-character app-android-release.aab sha256>" playSigningEnabled = $false - privacyPolicyUrl = "https://" + privacyPolicyUrl = "https://robvanprod.github.io/stackchan_alive/privacy/" privacyPolicySourcePath = "docs/ANDROID_PLAY_PRIVACY_POLICY.md" track = "internal" uploadStatus = "pending" diff --git a/tools/check_companion_release_version.cmd b/tools/check_companion_release_version.cmd new file mode 100644 index 00000000..3de4f7a8 --- /dev/null +++ b/tools/check_companion_release_version.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_companion_release_version.ps1" %* diff --git a/tools/check_companion_release_version.ps1 b/tools/check_companion_release_version.ps1 new file mode 100644 index 00000000..9121d337 --- /dev/null +++ b/tools/check_companion_release_version.ps1 @@ -0,0 +1,79 @@ +param( + [string]$Root = "", + [string]$ExpectedVersion = "", + [switch]$Json +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($Root)) { + $Root = Resolve-Path (Join-Path $PSScriptRoot "..") +} else { + $Root = Resolve-Path $Root +} + +function Read-SingleMatch { + param( + [string]$RelativePath, + [string]$Pattern, + [string]$Label + ) + + $path = Join-Path $Root $RelativePath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "$Label source is missing: $RelativePath" + } + $matches = [regex]::Matches((Get-Content -LiteralPath $path -Raw), $Pattern) + if ($matches.Count -ne 1) { + throw "Expected exactly one $Label declaration in $RelativePath; found $($matches.Count)." + } + return [string]$matches[0].Groups[1].Value +} + +$versions = [ordered]@{ + gradleProject = Read-SingleMatch "companion/build.gradle.kts" '(?m)^\s*version\s*=\s*"([^"]+)"\s*$' "Gradle project version" + android = Read-SingleMatch "companion/app-android/build.gradle.kts" '(?m)^\s*versionName\s*=\s*"([^"]+)"\s*$' "Android versionName" + desktop = Read-SingleMatch "companion/app-desktop/build.gradle.kts" '(?m)^\s*packageVersion\s*=\s*"([^"]+)"\s*$' "desktop packageVersion" + protocolIdentity = Read-SingleMatch "companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt" '(?m)^\s*const\s+val\s+appVersion\s*=\s*"([^"]+)"\s*$' "protocol appVersion" +} +$versionCodeText = Read-SingleMatch "companion/app-android/build.gradle.kts" '(?m)^\s*versionCode\s*=\s*([1-9]\d*)\s*$' "Android versionCode" +$normalizedExpected = $ExpectedVersion.Trim() +if ($normalizedExpected.StartsWith("v")) { + $normalizedExpected = $normalizedExpected.Substring(1) +} + +$issues = @() +$distinctVersions = @($versions.Values | Select-Object -Unique) +if ($distinctVersions.Count -ne 1) { + $issues += "Companion version declarations disagree: " + (($versions.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ", ") +} +$actualVersion = [string]$versions.gradleProject +if ($actualVersion -notmatch '^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$') { + $issues += "Companion version is not semantic version syntax: $actualVersion" +} +if (-not [string]::IsNullOrWhiteSpace($normalizedExpected) -and $actualVersion -ne $normalizedExpected) { + $issues += "Tag/version mismatch: expected $normalizedExpected from $ExpectedVersion, got $actualVersion." +} + +$status = if ($issues.Count -eq 0) { "ready" } else { "blocked-version-mismatch" } +$report = [ordered]@{ + schema = "stackchan.companion-release-version.v1" + status = $status + expectedVersion = $normalizedExpected + actualVersion = $actualVersion + versionCode = [int]$versionCodeText + declarations = $versions + issues = @($issues) +} + +if ($Json) { + $report | ConvertTo-Json -Depth 5 +} else { + Write-Host "Companion release version: $status" + Write-Host "Version: $actualVersion Android versionCode: $versionCodeText" + foreach ($issue in $issues) { Write-Host "- $issue" } +} + +if ($issues.Count -gt 0) { + exit 1 +} diff --git a/tools/check_companion_v1_evidence_bundle.ps1 b/tools/check_companion_v1_evidence_bundle.ps1 index b7750c4c..eff19c2c 100644 --- a/tools/check_companion_v1_evidence_bundle.ps1 +++ b/tools/check_companion_v1_evidence_bundle.ps1 @@ -667,8 +667,33 @@ function Test-DesktopArtifactHashesMatchReleaseEvidence { } } + if ((Get-Field $releaseReport "desktopDistributionTrustRequired") -ne $true) { + $issues += "Companion release evidence does not require native desktop distribution trust." + } + $desktopPackageEvidence = Get-Field $releaseReport "desktopPackageEvidence" + if ($null -eq $desktopPackageEvidence -or [string](Get-Field $desktopPackageEvidence "status") -ne "ready") { + $issues += "Companion release evidence does not contain ready desktop package evidence." + } else { + $platformSummaries = @((Get-Field $desktopPackageEvidence "platforms")) + $windowsTrust = @($platformSummaries | Where-Object { [string](Get-Field $_ "platform") -eq "windows" }) + $macosTrust = @($platformSummaries | Where-Object { [string](Get-Field $_ "platform") -eq "macos" }) + if ($windowsTrust.Count -ne 1 -or + [string](Get-Field $windowsTrust[0] "distributionTrustStatus") -ne "ready" -or + [string](Get-Field $windowsTrust[0] "distributionTrustPolicy") -ne "authenticode-sha256-timestamped" -or + (Get-Field $windowsTrust[0] "distributionTrustTimestamped") -ne $true) { + $issues += "Companion release evidence does not prove timestamped Windows Authenticode trust." + } + if ($macosTrust.Count -ne 1 -or + [string](Get-Field $macosTrust[0] "distributionTrustStatus") -ne "ready" -or + [string](Get-Field $macosTrust[0] "distributionTrustPolicy") -ne "developer-id-notarized-stapled" -or + (Get-Field $macosTrust[0] "distributionTrustNotarizationStapled") -ne $true -or + (Get-Field $macosTrust[0] "distributionTrustGatekeeperAccepted") -ne $true) { + $issues += "Companion release evidence does not prove macOS Developer ID and notarization trust." + } + } + if ($issues.Count -eq 0) { - Add-Check "desktop-v1-artifact-hashes-match" "Desktop v1 package hashes match release evidence" "pass" (Convert-ToRelativePath $releasePath) "Desktop package hashes match release evidence artifacts." + Add-Check "desktop-v1-artifact-hashes-match" "Desktop v1 package hashes match release evidence" "pass" (Convert-ToRelativePath $releasePath) "Desktop package hashes and native distribution trust match release evidence." } else { Add-Check "desktop-v1-artifact-hashes-match" "Desktop v1 package hashes match release evidence" "fail" (Convert-ToRelativePath $releasePath) ($issues -join " ") } @@ -755,7 +780,7 @@ function Test-RolloutHardwareEvidence { param( [object]$Reports, [string]$ExpectedHardwareRoot, - [string]$ExpectedCommit + [string]$ExpectedFirmwareCommit ) $relativePath = [string](Get-Field $Reports "rolloutStatusReport") @@ -795,14 +820,14 @@ function Test-RolloutHardwareEvidence { $evidence = Get-Field $report "evidence" $metadata = Get-Field $evidence "metadata" $actualEvidenceCommit = [string](Get-Field $metadata "commit") - if ([string]::IsNullOrWhiteSpace($ExpectedCommit) -or $ExpectedCommit -match "<|TBD|pending") { - Add-Check "rollout-hardware-commit-match" "Rollout hardware evidence commit matches bundle" "pending" "COMPANION_V1_EVIDENCE_BUNDLE.json" "Record sourceCommit before checking rollout hardware evidence consistency." + if ([string]::IsNullOrWhiteSpace($ExpectedFirmwareCommit) -or $ExpectedFirmwareCommit -match "<|TBD|pending") { + Add-Check "rollout-hardware-commit-match" "Rollout hardware evidence commit matches bundle" "pending" "COMPANION_V1_EVIDENCE_BUNDLE.json" "Record firmwareSourceCommit before checking rollout hardware evidence consistency." } elseif ([string]::IsNullOrWhiteSpace($actualEvidenceCommit)) { Add-Check "rollout-hardware-commit-match" "Rollout hardware evidence commit matches bundle" "fail" (Convert-ToRelativePath $path) "Rollout status report is missing evidence.metadata.commit." - } elseif ($actualEvidenceCommit -eq $ExpectedCommit) { - Add-Check "rollout-hardware-commit-match" "Rollout hardware evidence commit matches bundle" "pass" (Convert-ToRelativePath $path) "Rollout hardware evidence commit matches sourceCommit." + } elseif ($actualEvidenceCommit -eq $ExpectedFirmwareCommit) { + Add-Check "rollout-hardware-commit-match" "Rollout hardware evidence commit matches bundle" "pass" (Convert-ToRelativePath $path) "Rollout hardware evidence commit matches firmwareSourceCommit." } else { - Add-Check "rollout-hardware-commit-match" "Rollout hardware evidence commit matches bundle" "fail" (Convert-ToRelativePath $path) "Expected evidence.metadata.commit=$ExpectedCommit, got $actualEvidenceCommit." + Add-Check "rollout-hardware-commit-match" "Rollout hardware evidence commit matches bundle" "fail" (Convert-ToRelativePath $path) "Expected evidence.metadata.commit=$ExpectedFirmwareCommit, got $actualEvidenceCommit." } } @@ -814,6 +839,7 @@ function Write-CompanionV1EvidenceTemplate { schema = "stackchan.companion-v1-evidence-bundle.v1" status = "pending" sourceCommit = "<40-character git commit>" + firmwareSourceCommit = "<40-character installed firmware source commit; use sourceCommit when identical>" releaseVersion = "" releasePackage = [ordered]@{ path = "artifacts/stackchan_alive_.zip" @@ -861,6 +887,9 @@ Required ready statuses: must match a release evidence AAB artifact hash. - Desktop MSI, DMG, and DEB hashes must match release evidence package artifact hashes. - Release evidence ``packageEvidence`` must include hashed package core files from the extracted release package. +- ``sourceCommit`` identifies the release package source. ``firmwareSourceCommit`` identifies + the exact installed firmware source used by the hardware packet; use the same value only when + no host-only or documentation commits separate them. - Final release ZIP attachment with matching SHA-256, verified hardware evidence root, and ``COMPANION_V1_REVIEW.md`` Run: @@ -926,6 +955,16 @@ if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) { Add-Check "source-commit" "Source commit" "pending" "COMPANION_V1_EVIDENCE_BUNDLE.json" "Record a full 40-character source commit." } + $firmwareSourceCommit = [string](Get-Field $bundle "firmwareSourceCommit") + if ([string]::IsNullOrWhiteSpace($firmwareSourceCommit)) { + $firmwareSourceCommit = [string]$bundle.sourceCommit + } + if (Test-Commit $firmwareSourceCommit) { + Add-Check "firmware-source-commit" "Installed firmware source commit" "pass" "COMPANION_V1_EVIDENCE_BUNDLE.json" "Full installed firmware source commit recorded." + } else { + Add-Check "firmware-source-commit" "Installed firmware source commit" "pending" "COMPANION_V1_EVIDENCE_BUNDLE.json" "Record the full source commit used for the exact installed firmware image." + } + $releaseVersion = [string]$bundle.releaseVersion if ([string]::IsNullOrWhiteSpace($releaseVersion) -or $releaseVersion -match "<|TBD|pending") { Add-Check "release-version" "Release version" "pending" "COMPANION_V1_EVIDENCE_BUNDLE.json" "Record the final release version or tag." @@ -1002,7 +1041,7 @@ if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) { Test-AndroidReleaseAabHashMatchesReleaseEvidence $reports Test-DesktopArtifactHashesMatchReleaseEvidence $reports Test-ReleasePackageEvidencePresent $reports - Test-RolloutHardwareEvidence $reports ([string]$bundle.hardwareEvidenceRoot) ([string]$bundle.sourceCommit) + Test-RolloutHardwareEvidence $reports ([string]$bundle.hardwareEvidenceRoot) $firmwareSourceCommit $reviewPath = Resolve-EvidencePath ([string]$bundle.reviewPath) if ([string]::IsNullOrWhiteSpace([string]$bundle.reviewPath) -or -not (Test-Path -LiteralPath $reviewPath -PathType Leaf)) { @@ -1052,6 +1091,10 @@ $report = [ordered]@{ root = [string]$Root evidenceRoot = Convert-ToRelativePath $EvidenceRoot sourceCommit = if ($null -ne $bundle) { [string]$bundle.sourceCommit } else { "" } + firmwareSourceCommit = if ($null -ne $bundle) { + $value = [string](Get-Field $bundle "firmwareSourceCommit") + if ([string]::IsNullOrWhiteSpace($value)) { [string]$bundle.sourceCommit } else { $value } + } else { "" } releaseVersion = if ($null -ne $bundle) { [string]$bundle.releaseVersion } else { "" } androidGemmaBenchmarkProfile = $androidGemmaBenchmarkProfile androidGemmaBenchmarkMedianMs = $androidGemmaBenchmarkMedianMs diff --git a/tools/check_companion_v1_readiness.ps1 b/tools/check_companion_v1_readiness.ps1 index 0029f9f5..e25ed63c 100644 --- a/tools/check_companion_v1_readiness.ps1 +++ b/tools/check_companion_v1_readiness.ps1 @@ -13,19 +13,76 @@ if ([string]::IsNullOrWhiteSpace($Root)) { $checks = @() $pendingGates = @( - "physical-robot-hardware-validation", - "android-apk-install-on-target-phone", - "android-dashboard-connected-state-media", - "android-push-to-talk-stt-on-target-phone", - "android-settings-handoff-on-target-robot", - "android-qr-short-code-pairing-on-target-robot", - "android-wifi-provisioning-on-target-robot", - "screen-off-bridge-soak", - "google-play-store-screenshots", - "google-play-internal-testing-upload", - "gemma4-e2b-real-device-download-and-inference-validation", - "desktop-managed-python-runtime-binary-payload", - "c8-tagged-release-distribution" + [ordered]@{ + name = "physical-robot-hardware-validation" + evidence = "output/hardware-evidence/" + detail = "Complete the physical packet, then run tools\verify_hardware_evidence.cmd -EvidenceRoot and tools\export_rollout_status.cmd with the same exact release candidate." + }, + [ordered]@{ + name = "android-apk-install-on-target-phone" + evidence = "output/android-apk-install//android_apk_install.json" + detail = "Connect the target phone and run tools\install_android_companion_apk.cmd -ApkPath -SourceCommit <40-character-sha>." + }, + [ordered]@{ + name = "android-dashboard-connected-state-media" + evidence = "output/hardware-evidence//media/phone-live-dashboard*" + detail = "Capture the exact installed build connected to the robot, register media id phone-live-dashboard, and pass the strict Android dashboard evidence gate in tools\verify_hardware_evidence.cmd." + }, + [ordered]@{ + name = "android-push-to-talk-stt-on-target-phone" + evidence = "output/android-speech/" + detail = "Capture phone diagnostics/logcat and robot response frames, review ANDROID_SPEECH_REVIEW.md, then run tools\check_android_speech_evidence.cmd -SourceCommit <40-character-sha> -RequireReady -Json." + }, + [ordered]@{ + name = "android-settings-handoff-on-target-robot" + evidence = "output/android-controls/" + detail = "Capture settings_set, claim_brain, release_brain, owner_status, and pre-hello rejection, then run tools\check_android_controls_evidence.cmd -SourceCommit <40-character-sha> -RequireReady -Json." + }, + [ordered]@{ + name = "android-qr-short-code-pairing-on-target-robot" + evidence = "output/android-pairing/" + detail = "Capture QR/manual-code success, wrong-code rejection, trusted endpoint state, and setup media, then run tools\check_android_pairing_evidence.cmd -SourceCommit <40-character-sha> -RequireReady -Json." + }, + [ordered]@{ + name = "android-wifi-provisioning-on-target-robot" + evidence = "output/android-wifi/" + detail = "Capture persisted provisioning, power-cycle reload, clear behavior, and password redaction, then run tools\check_android_wifi_evidence.cmd -SourceCommit <40-character-sha> -RequireReady -Json." + }, + [ordered]@{ + name = "screen-off-bridge-soak" + evidence = "output/android-companion-soak/" + detail = "Run the target-phone screen-off soak, review its packet, then run tools\check_android_screen_off_soak_evidence.cmd -SourceCommit <40-character-sha> -RequireReady -Json." + }, + [ordered]@{ + name = "google-play-store-screenshots" + evidence = "output/android-play-store//screenshots" + detail = "Capture the four required final-build screenshot ids on the target phone and validate them with tools\check_android_play_store_evidence.cmd -EvidenceRoot -Json." + }, + [ordered]@{ + name = "google-play-internal-testing-upload" + evidence = "output/android-play-store//ANDROID_PLAY_STORE_EVIDENCE.json" + detail = "Provision upload signing, pass the read-only Companion Signing Readiness workflow, upload the exact AAB, install from the internal track, record the hosted privacy URL/release/tester/timestamp fields, and pass tools\check_android_play_store_evidence.cmd." + }, + [ordered]@{ + name = "gemma4-e2b-real-device-download-and-inference-validation" + evidence = "output/android-gemma/" + detail = "On the target phone complete verified download, load/eject/reload, non-dry-run benchmark, one LiteRT turn, and robot audio/TTS review; then run tools\check_android_gemma_evidence.cmd -SourceCommit <40-character-sha> -RequireReady -Json." + }, + [ordered]@{ + name = "desktop-target-installs-on-operator-workstations" + evidence = "output/desktop-target-install//-target-install.json" + detail = "Install the exact tagged MSI, DEB, and DMG with tools\install_desktop_companion_package.ps1 on native operator workstations, then pass tools\check_desktop_target_install_evidence.ps1 -RequireOperatorTarget for each package hash." + }, + [ordered]@{ + name = "desktop-production-signing-credentials" + evidence = "GitHub Actions secrets for Windows Authenticode and macOS Developer ID/notarization" + detail = "Provision the two STACKCHAN_WINDOWS_* and six STACKCHAN_MACOS_* release secrets, then run the Companion Signing Readiness workflow. The tag workflow fails closed until it can verify timestamped Authenticode, Developer ID, Gatekeeper, and stapled notarization evidence." + }, + [ordered]@{ + name = "c8-tagged-release-distribution" + evidence = "GitHub prerelease assets plus COMPANION_RELEASE_EVIDENCE.json and output/companion-v1-evidence/" + detail = "Create the exact upload-signed prerelease tag, run tools\verify_published_release.cmd -Version , assemble Android/Desktop/rollout evidence, and pass tools\check_companion_v1_evidence_bundle.cmd -EvidenceRoot -RequireReady -Json." + } ) function Add-Check { @@ -283,7 +340,7 @@ function Test-CompanionSourceTree { function Add-PendingGateChecks { foreach ($gate in $pendingGates) { - Add-Check ("pending-" + $gate) $gate "pending" "" "Requires target hardware, release signing/distribution, or production-source evidence outside this source/package check." + Add-Check ("pending-" + $gate.name) $gate.name "pending" $gate.evidence $gate.detail } } @@ -293,6 +350,30 @@ Test-TextEvidence ` -RelativePaths @("docs/COMPANION_CROSS_PLATFORM_PLAN.md") ` -Patterns @("Cross-Platform Build & Distribution Plan", "Kotlin Multiplatform", "Compose Multiplatform", "Hydraulic Conveyor", "C0", "C1", "C8", "RELEASE_EVIDENCE.json") +Test-TextEvidence ` + -Id "readme-current-eight-hour-evidence" ` + -Name "Public README carries corrected exact-image soak evidence" ` + -RelativePaths @("README.md") ` + -Patterns @("Status as of July 13, 2026", "corrected exact paired candidate", "28807 s", "5643/5643", "77/77", "bounded final stop") + +Test-TextEvidence ` + -Id "production-readiness-current-eight-hour-evidence" ` + -Name "Production readiness carries corrected exact-image soak evidence" ` + -RelativePaths @("docs/PRODUCTION_READINESS.md") ` + -Patterns @("Current status (2026-07-13)", "corrected single-owner exact-image", "28807 s", "5643/5643", "77/77", "bounded final motion stop evidence") + +Test-TextEvidence ` + -Id "hardware-roadmap-current-eight-hour-evidence" ` + -Name "Hardware roadmap carries corrected exact-image soak evidence" ` + -RelativePaths @("docs/HARDWARE_FEATURE_ROADMAP.md") ` + -Patterns @("Current status (2026-07-13)", "corrected exact release image", "28807 s", "5643/5643", "77/77", "verified off after completion") + +Test-TextEvidence ` + -Id "power-forensics-current-eight-hour-evidence" ` + -Name "Power forensics carries corrected exact-image soak evidence" ` + -RelativePaths @("docs/POWER_BLACKOUT_FORENSICS.md") ` + -Patterns @('corrected `v0.2.0` exact-image candidate', "28807 s", "5643/5643", "77/77", "bounded final stop evidence", "Historical full-off root cause remains unidentified") + Test-TextEvidence ` -Id "android-companion-spec" ` -Name "Android companion behavioral contract" ` @@ -303,7 +384,7 @@ Test-TextEvidence ` -Id "android-test-plan" ` -Name "Android physical test plan" ` -RelativePaths @("docs/ANDROID_COMPANION_TEST_PLAN.md") ` - -Patterns @("Android Companion Physical Test Plan", "lab-signed release APK", "app-android-release.apk", "check_android_toolchain.cmd", "RUN_ANDROID_APK_INSTALL.cmd", "RUN_ANDROID_COMPANION_PROBE.cmd", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd", "android/screen-off-soak/", "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "Android dashboard switches from waiting to connected", "Add your Stack-chan", "Wi-Fi bootstrap", "Open Wi-Fi settings", "Join Wi-Fi", "Start phone bridge", "Connect Stack-chan", "Confirm robot ready", "current next step", "Pair on Stack-chan", "Ready to test", "Robot Wi-Fi setup", "wifi set ssid """" pass """" url ""ws://:8765/bridge""", "tools\provision_stackchan_wifi.cmd", "wifi clear", "password redaction", "check_android_wifi_evidence.cmd", "android-wifi-ready", "ANDROID_WIFI_REVIEW.md", "robot_wifi_serial.log", "bridge_wifi_store_loads", "bridge_wifi_store_has_record=1", "pairing code", "phone fingerprint", "stackchan://pair", "endpoint_hello.pairing_code", "STACKCHAN_PAIRING_SHORT_CODE", "pairing code ", "pairing clear", "pairing_code_mismatch", "check_android_pairing_evidence.cmd", "android-pairing-ready", "ANDROID_PAIRING_REVIEW.md", "robot_pairing_serial.log", "android_pairing_setup.jpg", "bridge_url_applied", "saved robots", "waiting/setup action", "trusted companion nodes are stored", "raw WebSocket connection without the robot", "Talk screen enables text input", "Push-to-talk", "RECORD_AUDIO", "check_android_speech_evidence.cmd", "android-speech-ready", "ANDROID_SPEECH_REVIEW.md", "robot_speech_serial.log", "Gemma-4-E2B", "download, load, eject", "staging the verified asset", "real inference is gated on LiteRT runtime validation", "gemma-4-E2B-it.litertlm", "2588147712", "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c", "check_android_gemma_evidence.cmd", "android-gemma-real-device-ready", "mobile_brain_litert_turn", "mobile_brain_litert_error", "ANDROID_GEMMA_REVIEW.md", "persona import/export", "stackchan.persona-pack.v1", "app_text_turn", "audio_stream_start", "response_end", "settings, diagnostics, persona, and handoff status", "settings_set", "settings_result", "claim_brain", "release_brain", "owner_status", "check_android_controls_evidence.cmd", "android-controls-ready", "ANDROID_CONTROLS_REVIEW.md", "robot_controls_serial.log", "robot_hello_required", "Removing a stored trusted companion endpoint", "Forget removes", "ANDROID_DIAGNOSTICS_EXPORT.json", "stackchan.android.diagnostics-export.v1", "saved robot/trusted endpoint state", "redacts the last text turn") + -Patterns @("Android Companion Physical Test Plan", "API 35 AOSP automated-test emulator smoke", "test_android_emulator_launch.ps1", "substitutesForPhysicalEvidence=false", "lab-signed release APK", "app-android-release.apk", "check_android_toolchain.cmd", "RUN_ANDROID_APK_INSTALL.cmd", "RUN_ANDROID_COMPANION_PROBE.cmd", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd", "android/screen-off-soak/", "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "Android dashboard switches from waiting to connected", "Add your Stack-chan", "Wi-Fi bootstrap", "Open Wi-Fi settings", "Join Wi-Fi", "Start phone bridge", "Connect Stack-chan", "Confirm robot ready", "current next step", "Pair on Stack-chan", "Ready to test", "Robot Wi-Fi setup", "wifi set ssid """" pass """" url ""ws://:8765/bridge""", "tools\provision_stackchan_wifi.cmd", "wifi clear", "password redaction", "check_android_wifi_evidence.cmd", "android-wifi-ready", "ANDROID_WIFI_REVIEW.md", "robot_wifi_serial.log", "bridge_wifi_store_loads", "bridge_wifi_store_has_record=1", "pairing code", "phone fingerprint", "stackchan://pair", "endpoint_hello.pairing_code", "STACKCHAN_PAIRING_SHORT_CODE", "pairing code ", "pairing clear", "pairing_code_mismatch", "check_android_pairing_evidence.cmd", "android-pairing-ready", "ANDROID_PAIRING_REVIEW.md", "robot_pairing_serial.log", "android_pairing_setup.jpg", "bridge_url_applied", "saved robots", "waiting/setup action", "trusted companion nodes are stored", "raw WebSocket connection without the robot", "Talk screen enables text input", "Push-to-talk", "RECORD_AUDIO", "check_android_speech_evidence.cmd", "android-speech-ready", "ANDROID_SPEECH_REVIEW.md", "robot_speech_serial.log", "Gemma-4-E2B", "download, load, eject", "staging the verified asset", "real inference is gated on LiteRT runtime validation", "gemma-4-E2B-it.litertlm", "2588147712", "181938105e0eefd105961417e8da75903eacda102c4fce9ce90f50b97139a63c", "check_android_gemma_evidence.cmd", "android-gemma-real-device-ready", "mobile_brain_litert_turn", "mobile_brain_litert_error", "ANDROID_GEMMA_REVIEW.md", "persona import/export", "stackchan.persona-pack.v1", "app_text_turn", "audio_stream_start", "response_end", "settings, diagnostics, persona, and handoff status", "settings_set", "settings_result", "claim_brain", "release_brain", "owner_status", "check_android_controls_evidence.cmd", "android-controls-ready", "ANDROID_CONTROLS_REVIEW.md", "robot_controls_serial.log", "robot_hello_required", "Removing a stored trusted companion endpoint", "Forget removes", "ANDROID_DIAGNOSTICS_EXPORT.json", "stackchan.android.diagnostics-export.v1", "saved robot/trusted endpoint state", "redacts the last text turn") Test-TextEvidence ` -Id "robot-hello-write-gate" ` @@ -611,29 +692,77 @@ Test-TextEvidence ` -RelativePaths @("tools/test_desktop_python_runtime_payload_contract.ps1") ` -Patterns @("placeholder sha256 is rejected", "placeholder runtime source is rejected", "platform mismatch is rejected", "pythonVersion mismatch is rejected", "valid desktop runtime payload is accepted", "platform, pythonVersion, probedPythonVersion, runtimeSha256, and runtimeSource", "Desktop Python runtime payload contract tests passed") +Test-TextEvidence ` + -Id "desktop-package-evidence-export" ` + -Name "Native desktop package/runtime evidence exporter" ` + -RelativePaths @("tools/export_desktop_package_evidence.ps1") ` + -Patterns @("stackchan.desktop-package-evidence.v1", "stackchan.desktop-python-runtime-prepare.v1", "Get-RuntimePayloadHash", "native-app-resources", "Expand-DesktopPackage", "RequireInstallerPayload", "RequireLaunchEvidence", "launchEvidence", "processedPayloadSha256", "processedFileCount", "Installer runtime payload hash does not match processed Gradle resources") + +Test-TextEvidence ` + -Id "desktop-package-launch-smoke" ` + -Name "Exact native desktop package launch smoke" ` + -RelativePaths @("tools/test_desktop_package_launch.ps1", "tools/test_desktop_package_launch.cmd", "companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/PackagedRuntimeSmoke.kt") ` + -Patterns @("stackchan.desktop-package-launch-evidence.v1", "stackchan.desktop-packaged-runtime-smoke.v1", "exact-native-package-extraction-and-headless-launch", "extracted-native-package-headless-runtime-probe", "package-extraction", "substitutesForTargetInstall", "--package-smoke-output=", "--package-smoke-context=") + +Test-AggregateTextEvidence ` + -Id "desktop-target-install-evidence" ` + -Name "Native desktop operator target-install evidence" ` + -RelativePaths @("tools/install_desktop_companion_package.ps1", "tools/check_desktop_target_install_evidence.ps1") ` + -Patterns @("stackchan.desktop-target-install-evidence.v1", "stackchan.desktop-target-install-evidence-check.v1", "installed-and-ready", "operator-target-workstation", "ci-native-runner", "exact-native-package-install-and-headless-launch", "installed-native-package-headless-runtime-probe", "RequireOperatorTarget", "substitutesForHumanAcceptance", "preExistingRegistrations", "replacementRequested", "replacementPerformed", "uninstallAttempts", "postInstallRegistrations", "elevatedAdministrator", "windows-elevation", "windows-exact-package-replacement") + +Test-TextEvidence ` + -Id "desktop-target-install-evidence-contract" ` + -Name "Native desktop target-install evidence contract" ` + -RelativePaths @("tools/test_desktop_target_install_evidence_contract.ps1", "tools/test_desktop_target_install_evidence_contract.cmd") ` + -Patterns @("operator target-install evidence is accepted for Windows, Linux, and macOS", "explicit Windows replacement evidence is accepted", "implicit Windows maintenance-mode replacement is rejected", "non-elevated Windows install evidence is rejected", "stale target-install package hash is rejected", "CI native-runner evidence cannot replace operator target evidence", "package extraction cannot replace installed launcher evidence", "missing install and launch exit codes are rejected", "mismatched target-install source commit is rejected", "Desktop target install evidence contract tests passed") + +Test-TextEvidence ` + -Id "desktop-package-evidence-contract" ` + -Name "Native desktop package/runtime evidence contract test" ` + -RelativePaths @("tools/test_desktop_package_evidence_contract.ps1", "tools/test_desktop_package_evidence_contract.cmd") ` + -Patterns @("tagged release requires native signing, notarization, and provenance attestation", "complete installer-derived desktop package evidence is accepted", "installed launch context cannot replace package-extraction evidence", "installer runtime tampering is rejected", "JAR-embedded executable runtime is rejected", "aggregate companion evidence accepts all three native package reports", "aggregate companion evidence rejects installer-derived runtime mismatch", "aggregate companion evidence rejects stale exact-package launch evidence", "aggregate companion evidence rejects missing native distribution trust", "strict aggregate evidence rejects a missing native package report", "wrong platform package extension is rejected", "processed runtime tampering is rejected", "runtime prepare platform mismatch is rejected", "Desktop package evidence contract tests passed") + +Test-AggregateTextEvidence ` + -Id "desktop-release-signing-readiness" ` + -Name "Desktop release signing credential preflight" ` + -RelativePaths @("tools/check_desktop_release_signing_readiness.ps1", "tools/test_desktop_release_signing_readiness_contract.ps1", ".github/workflows/companion-signing-readiness.yml", ".github/workflows/firmware.yml") ` + -Patterns @("stackchan.desktop-signing-readiness.v1", "private code-signing certificate", "RequireNativeToolchain", "ValidateAppleNotaryCredentials", "does not chain to a root trusted by the native host", "temporary Authenticode signing probe", "temporary Developer ID signing probe", "manual signing readiness workflow validates without publishing", "tagged release runs native desktop signing preflight", "companion CI runs the desktop signing readiness contract", "invalid Windows PKCS12 base64 is rejected", "wrong Windows PKCS12 password is rejected", "Windows certificate without code-signing EKU is rejected", "near-expiry Windows certificate is rejected", "undersized Windows signing key is rejected", "mismatched macOS signing identity is rejected", "mismatched Apple team ID is rejected", "Desktop release signing readiness contract passed", "workflow_dispatch", "contents: read") + +Test-AggregateTextEvidence ` + -Id "release-credential-hygiene" ` + -Name "Release private signing credential hygiene" ` + -RelativePaths @("tools/check_release_credential_hygiene.ps1", "tools/test_release_credential_hygiene_contract.ps1") ` + -Patterns @("stackchan.release-credential-hygiene.v1", "release-credential-hygiene-ready", "blocked-release-credential-hygiene", "tracked-private-key-bundles", "tracked-private-key-markers", "ignore-pattern-pfx", "ignore-pattern-pkcs12", "current source tree has no tracked private signing credentials", "every release package runs credential hygiene before building", "companion CI runs the release credential hygiene contract", "tracked private-key bundle extensions are rejected", "tracked PEM private key markers are rejected", "missing private-key ignore patterns are rejected", "Release credential hygiene contract tests passed") + Test-TextEvidence ` -Id "desktop-v1-evidence-bundle-check" ` -Name "Desktop v1 aggregate evidence bundle check" ` -RelativePaths @("tools/check_desktop_v1_evidence_bundle.ps1") ` - -Patterns @("stackchan.desktop-v1-evidence-bundle.v1", "desktop-v1-evidence-ready", "pending-desktop-v1-evidence-bundle", "stackchan.desktop-python-runtime-payload.v1", "pc-brain-deploy-ready", "pc-brain-quiet-soak-ready", "production-voice-source-ready", "companion-readiness-source-commit-match", "pc-brain-deploy-commit-match", "pc-brain-quiet-soak-commit-match", "voice-source-commit-match", "sourceCommit", "windowsMsiSha256", "macosDmgSha256", "linuxDebSha256", "runtime-windows-summary", "runtime-macos-summary", "runtime-linux-summary", "Test-RuntimePayloadSummary", "runtimeSha256", "runtimeSource", "probedPythonVersion", "Get-ReviewSourceCommit", "Source commit:", "DESKTOP_V1_REVIEW.md", "RequireReady") + -Patterns @("stackchan.desktop-v1-evidence-bundle.v1", "desktop-v1-evidence-ready", "pending-desktop-v1-evidence-bundle", "stackchan.desktop-python-runtime-payload.v1", "stackchan.desktop-target-install-evidence.v1", "windowsTargetInstallReport", "macosTargetInstallReport", "linuxTargetInstallReport", "Test-DesktopTargetInstallReport", "RequireOperatorTarget", "Target installation decision: pass", "pc-brain-deploy-ready", "pc-brain-quiet-soak-ready", "production-voice-source-ready", "companion-readiness-source-commit-match", "pc-brain-deploy-commit-match", "pc-brain-quiet-soak-commit-match", "voice-source-commit-match", "sourceCommit", "windowsMsiSha256", "macosDmgSha256", "linuxDebSha256", "runtime-windows-summary", "runtime-macos-summary", "runtime-linux-summary", "Test-RuntimePayloadSummary", "runtimeSha256", "runtimeSource", "probedPythonVersion", "Get-ReviewSourceCommit", "Source commit:", "DESKTOP_V1_REVIEW.md", "RequireReady") Test-TextEvidence ` -Id "desktop-v1-evidence-bundle-contract" ` -Name "Desktop v1 aggregate evidence bundle contract test" ` -RelativePaths @("tools/test_desktop_v1_evidence_bundle_contract.ps1") ` - -Patterns @("placeholder Desktop v1 evidence bundle is pending", "complete Desktop v1 evidence bundle is accepted", "desktop package artifact hashes", "missing Desktop v1 runtime payload summary is rejected", "missing Desktop v1 runtime payload source is rejected", "mismatched Desktop v1 runtime payload platform is rejected", "mismatched Desktop v1 companion readiness source commit is rejected", "mismatched Desktop v1 review source commit is rejected", "mismatched Desktop v1 voice-source commit is rejected", "mismatched Desktop v1 PC Brain deploy commit is rejected", "mismatched Desktop v1 PC Brain quiet-soak commit is rejected", "desktop-v1-evidence-ready", "pending-desktop-v1-evidence-bundle", "Desktop v1 evidence bundle contract tests passed") + -Patterns @("placeholder Desktop v1 evidence bundle is pending", "complete Desktop v1 evidence bundle is accepted", "desktop package artifact hashes", "elevatedAdministrator", "postInstallRegistrations", "missing Desktop v1 runtime payload summary is rejected", "missing Desktop v1 runtime payload source is rejected", "mismatched Desktop v1 runtime payload platform is rejected", "stale Desktop v1 target-install package hash is rejected", "CI install rehearsal cannot replace Desktop v1 operator target evidence", "mismatched Desktop v1 companion readiness source commit is rejected", "mismatched Desktop v1 review source commit is rejected", "mismatched Desktop v1 voice-source commit is rejected", "mismatched Desktop v1 PC Brain deploy commit is rejected", "mismatched Desktop v1 PC Brain quiet-soak commit is rejected", "desktop-v1-evidence-ready", "pending-desktop-v1-evidence-bundle", "Desktop v1 evidence bundle contract tests passed") Test-TextEvidence ` -Id "companion-v1-evidence-bundle-check" ` -Name "Companion v1 aggregate evidence bundle check" ` -RelativePaths @("tools/check_companion_v1_evidence_bundle.ps1") ` - -Patterns @("stackchan.companion-v1-evidence-bundle.v1", "companion-v1-evidence-ready", "pending-companion-v1-evidence-bundle", "stackchan.android-v1-evidence-bundle-check.v1", "stackchan.desktop-v1-evidence-bundle-check.v1", "stackchan.rollout-status.v1", "consumer-promotion-ready", "companion-readiness-commit-match", "release-evidence-commit-match", "github-actions-commit-match", "rollout-status-version-match", "android-v1-commit-match", "android-v1-application-id-match", "android-v1-version-name-match", "android-v1-version-code-match", "android-v1-gemma-benchmark-summary", "android-v1-dashboard-media-summary", "gemmaBenchmarkProfile", "gemmaBenchmarkMedianMs", "androidGemmaBenchmarkProfile", "androidGemmaBenchmarkMedianMs", "androidDashboardMediaIds", "phone-live-dashboard", "android-v1-release-apk-hash-match", "android-v1-release-aab-hash-match", "desktop-v1-commit-match", "desktop-v1-artifact-hashes-match", "release-package-evidence-present", "voice-source-commit-match", "rollout-hardware-root-match", "rollout-hardware-commit-match", "sourceCommit", "releaseVersion", "applicationId", "apkSha256", "versionCode", "packageEvidence", "Get-ReviewSourceCommit", "Get-ReviewReleaseVersion", "Get-Sha256Text", "Convert-ToAndroidVersionName", "Get-AndroidSourceApplicationId", "Test-AndroidApplicationIdMatchesSource", "Get-AndroidSourceVersionCode", "Test-AndroidVersionCodeMatchesSource", "Test-AndroidV1EvidenceSummary", "Test-AndroidReleaseApkHashMatchesReleaseEvidence", "Test-AndroidReleaseAabHashMatchesReleaseEvidence", "Test-DesktopArtifactHashesMatchReleaseEvidence", "Test-ReleasePackageEvidencePresent", "Test-RolloutHardwareEvidence", "Source commit:", "Release version:", "COMPANION_V1_REVIEW.md", "RequireReady") + -Patterns @("stackchan.companion-v1-evidence-bundle.v1", "companion-v1-evidence-ready", "pending-companion-v1-evidence-bundle", "stackchan.android-v1-evidence-bundle-check.v1", "stackchan.desktop-v1-evidence-bundle-check.v1", "stackchan.rollout-status.v1", "consumer-promotion-ready", "companion-readiness-commit-match", "release-evidence-commit-match", "github-actions-commit-match", "rollout-status-version-match", "android-v1-commit-match", "android-v1-application-id-match", "android-v1-version-name-match", "android-v1-version-code-match", "android-v1-gemma-benchmark-summary", "android-v1-dashboard-media-summary", "gemmaBenchmarkProfile", "gemmaBenchmarkMedianMs", "androidGemmaBenchmarkProfile", "androidGemmaBenchmarkMedianMs", "androidDashboardMediaIds", "phone-live-dashboard", "android-v1-release-apk-hash-match", "android-v1-release-aab-hash-match", "desktop-v1-commit-match", "desktop-v1-artifact-hashes-match", "desktopDistributionTrustRequired", "authenticode-sha256-timestamped", "developer-id-notarized-stapled", "release-package-evidence-present", "voice-source-commit-match", "rollout-hardware-root-match", "rollout-hardware-commit-match", "sourceCommit", "firmwareSourceCommit", "firmware-source-commit", "releaseVersion", "applicationId", "apkSha256", "versionCode", "packageEvidence", "Get-ReviewSourceCommit", "Get-ReviewReleaseVersion", "Get-Sha256Text", "Convert-ToAndroidVersionName", "Get-AndroidSourceApplicationId", "Test-AndroidApplicationIdMatchesSource", "Get-AndroidSourceVersionCode", "Test-AndroidVersionCodeMatchesSource", "Test-AndroidV1EvidenceSummary", "Test-AndroidReleaseApkHashMatchesReleaseEvidence", "Test-AndroidReleaseAabHashMatchesReleaseEvidence", "Test-DesktopArtifactHashesMatchReleaseEvidence", "Test-ReleasePackageEvidencePresent", "Test-RolloutHardwareEvidence", "Source commit:", "Release version:", "COMPANION_V1_REVIEW.md", "RequireReady") Test-TextEvidence ` -Id "companion-v1-evidence-bundle-contract" ` -Name "Companion v1 aggregate evidence bundle contract test" ` -RelativePaths @("tools/test_companion_v1_evidence_bundle_contract.ps1") ` - -Patterns @("placeholder Companion v1 evidence bundle is pending", "complete Companion v1 evidence bundle is accepted", "stale Companion v1 Android evidence summary is rejected", "slow or incomplete Companion v1 Android evidence summary is rejected", "Android Gemma benchmark and dashboard media summaries", "mismatched Companion v1 release ZIP hash is rejected", "mismatched Companion v1 source-readiness commit is rejected", "mismatched Companion v1 hardware evidence root is rejected", "mismatched Companion v1 hardware evidence commit is rejected", "mismatched Companion v1 report commit is rejected", "mismatched Companion v1 Android bundle commit is rejected", "mismatched Companion v1 Android applicationId is rejected", "mismatched Companion v1 Android app version is rejected", "mismatched Companion v1 Android app versionCode is rejected", "mismatched Companion v1 Android release APK hash is rejected", "mismatched Companion v1 Android release AAB hash is rejected", "mismatched Companion v1 Desktop package hash is rejected", "mismatched Companion v1 Desktop bundle commit is rejected", "mismatched Companion v1 voice-source commit is rejected", "mismatched Companion v1 review source commit is rejected", "mismatched Companion v1 review release version is rejected", "companion-v1-evidence-ready", "pending-companion-v1-evidence-bundle", "Companion v1 evidence bundle contract tests passed") + -Patterns @("placeholder Companion v1 evidence bundle is pending", "complete Companion v1 evidence bundle with distinct release and firmware commits is accepted", "missing desktop distribution trust is rejected by final Companion v1 evidence", "legacy same-commit Companion v1 evidence bundle remains accepted", "stale Companion v1 Android evidence summary is rejected", "slow or incomplete Companion v1 Android evidence summary is rejected", "Android Gemma benchmark and dashboard media summaries", "mismatched Companion v1 release ZIP hash is rejected", "mismatched Companion v1 source-readiness commit is rejected", "mismatched Companion v1 hardware evidence root is rejected", "mismatched Companion v1 hardware evidence commit is rejected", "mismatched Companion v1 report commit is rejected", "mismatched Companion v1 Android bundle commit is rejected", "mismatched Companion v1 Android applicationId is rejected", "mismatched Companion v1 Android app version is rejected", "mismatched Companion v1 Android app versionCode is rejected", "mismatched Companion v1 Android release APK hash is rejected", "mismatched Companion v1 Android release AAB hash is rejected", "mismatched Companion v1 Desktop package hash is rejected", "mismatched Companion v1 Desktop bundle commit is rejected", "mismatched Companion v1 voice-source commit is rejected", "mismatched Companion v1 review source commit is rejected", "mismatched Companion v1 review release version is rejected", "companion-v1-evidence-ready", "pending-companion-v1-evidence-bundle", "Companion v1 evidence bundle contract tests passed") + +Test-TextEvidence ` + -Id "companion-v1-consumer-promotion-binding" ` + -Name "Consumer promotion binds aggregate Companion v1 evidence" ` + -RelativePaths @("tools/verify_consumer_promotion.ps1", "tools/test_consumer_promotion_contract.ps1", "tools/start_hardware_evidence.ps1", "docs/RELEASE_PROCESS.md") ` + -Patterns @("CompanionV1EvidenceRoot", "Assert-CompanionV1PromotionReady", "check_companion_v1_evidence_bundle.ps1", "-RequireReady", "companion-v1-evidence-ready", "Companion v1 aggregate source commit mismatch", "Companion v1 aggregate firmware source commit mismatch", "Companion v1 aggregate release version mismatch", "Companion v1 hardware evidence root does not match the packet being promoted", "Companion v1 release ZIP SHA-256 does not match the package being promoted", "Consumer promotion requires -PackageZip", "companionV1EvidenceRoot") Test-TextEvidence ` -Id "desktop-python-runtime-payload-prep-tool" ` @@ -645,7 +774,7 @@ Test-TextEvidence ` -Id "desktop-python-runtime-payload-packaging" ` -Name "Desktop managed Python runtime payload packaging hook" ` -RelativePaths @("companion/app-desktop/build.gradle.kts") ` - -Patterns @("stackchan.desktop.pythonRuntimeRoot", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "validateDesktopPythonRuntimePayload", "check_desktop_python_runtime_payload.ps1", "into(`"python-runtime`")", "desktopPythonRuntimeRoot") + -Patterns @("stackchan.desktop.pythonRuntimeRoot", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "validateDesktopPythonRuntimePayload", "prepareDesktopNativeAppResources", "appResourcesRootDir", "into(`"common/python-runtime`")", "desktopPythonRuntimeRoot") Test-TextEvidence ` -Id "desktop-packaged-brain-script" ` @@ -831,25 +960,109 @@ Test-TextEvidence ` -Id "android-play-release-prep" ` -Name "Android Play release preparation" ` -RelativePaths @("docs/ANDROID_PLAY_RELEASE.md", "provenance/docs/ANDROID_PLAY_RELEASE.md") ` - -Patterns @("Android Play Release Checklist", "app-android-release.aab", "Play App Signing", "STACKCHAN_ANDROID_KEYSTORE", "docs/store-assets/play/icon-512.png", "feature-graphic-1024x500.png", "SCREENSHOT_CAPTURE_PLAN.md", "fastlane/metadata/android/en-US/", "ANDROID_PLAY_POLICY_DECLARATIONS.md", "ANDROID_PLAY_PRIVACY_POLICY.md", "physical robot validation", "RECORD_AUDIO", "Play Console internal testing") + -Patterns @("Android Play Release Checklist", "app-android-release.aab", "Play App Signing", "STACKCHAN_ANDROID_KEYSTORE", "One-Time Upload Key Provisioning", "keytool -genkeypair", "cryptographically validates", "test_android_upload_signing_contract.ps1", "certificate SHA-256 fingerprint", "2033-10-22", "exact release APK artifact", "RequireAndroidEmulatorEvidence", "gh secret set STACKCHAN_ANDROID_KEYSTORE_B64", "gh secret list --app actions", "Companion Signing Readiness", "companion-signing-readiness.yml", "two independent offline media", "docs/store-assets/play/icon-512.png", "feature-graphic-1024x500.png", "SCREENSHOT_CAPTURE_PLAN.md", "fastlane/metadata/android/en-US/", "ANDROID_PLAY_POLICY_DECLARATIONS.md", "ANDROID_PLAY_PRIVACY_POLICY.md", "physical robot validation", "RECORD_AUDIO", "Play Console internal testing") Test-TextEvidence ` -Id "android-play-policy-declarations" ` -Name "Android Play policy and data-safety declarations" ` -RelativePaths @("docs/ANDROID_PLAY_POLICY_DECLARATIONS.md", "provenance/docs/ANDROID_PLAY_POLICY_DECLARATIONS.md") ` - -Patterns @("Google Play Data safety form", "Privacy policy URL", "ANDROID_PLAY_PRIVACY_POLICY.md", "Data Safety Draft", "Not collected", "RECORD_AUDIO", "raw microphone audio is not stored", "password_redacted=true", "Foreground service Play Console draft", "connectedDevice", "REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", "not directed to children") + -Patterns @("Google Play Data safety form", "Google Play User Data policy", "Privacy policy URL", "https://robvanprod.github.io/stackchan_alive/privacy/", "ANDROID_PLAY_PRIVACY_POLICY.md", "Data Safety Draft", "Collected only for optional, ephemeral app functionality", "RECORD_AUDIO", "configured Android SpeechRecognizer may transmit microphone audio", "password_redacted=true", "not represented as end-to-end encrypted", "Foreground service Play Console draft", "connectedDevice", "REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", "not directed to children") Test-TextEvidence ` -Id "android-play-privacy-policy-page" ` -Name "Android Play privacy policy page" ` -RelativePaths @("docs/ANDROID_PLAY_PRIVACY_POLICY.md", "provenance/docs/ANDROID_PLAY_PRIVACY_POLICY.md") ` - -Patterns @("Stackchan Companion Privacy Policy", "dev.stackchan.companion", "does not create accounts", "does not persist raw microphone audio", "diagnostics export", "password_redacted=true", "optional Mobile Brain model", "saved robot and trusted companion records", "not directed to children") + -Patterns @("Stackchan Companion Privacy Policy", "July 14, 2026", "https://robvanprod.github.io/stackchan_alive/privacy/", "dev.stackchan.companion", "does not create accounts", "does not persist raw microphone audio", "may process microphone audio", "diagnostics export", "password_redacted=true", "optional Mobile Brain model", "saved robot and trusted companion records", "not represented as end-to-end encrypted", "not directed to children") + +Test-TextEvidence ` + -Id "android-play-privacy-policy-site" ` + -Name "Deployable public privacy-policy site" ` + -RelativePaths @("site/privacy/index.html") ` + -Patterns @("Stackchan Companion Privacy Policy", "July 14, 2026", "dev.stackchan.companion", "Privacy inquiries", "configured Android speech-recognition service", "may process audio", "password_redacted=true", "not represented as end-to-end encrypted", "not directed to children") + +Test-TextEvidence ` + -Id "android-play-privacy-deployment-record" ` + -Name "Published privacy-policy deployment record" ` + -RelativePaths @("docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json") ` + -Patterns @("stackchan.privacy-policy-deployment.v1", "deployed", "https://robvanprod.github.io/stackchan_alive/privacy/", "site/privacy/index.html", "afbebbd3429e00a6f76cb238788ce7664f1b6fda", "49cefe092920c0a12da50896356394d380df6904", "1094346889", "28d1cca7889f8d95c0587025ee5d46c213a85ac814c538e3c36090b377fd1f47", "httpsEnforced") + +Test-TextEvidence ` + -Id "android-play-privacy-deployment-checker" ` + -Name "Published privacy-policy deployment checker" ` + -RelativePaths @("tools/check_privacy_policy_deployment.ps1", "provenance/tools/check_privacy_policy_deployment.ps1") ` + -Patterns @("stackchan.privacy-policy-deployment-check.v1", "privacy-policy-deployment-ready", "live-https", "Published policy byte identity", "Published policy disclosures", "sourceSha256", "servedSha256") + +Test-TextEvidence ` + -Id "android-play-privacy-deployment-contract" ` + -Name "Privacy-policy deployment contract" ` + -RelativePaths @("tools/test_privacy_policy_deployment_contract.ps1", "provenance/tools/test_privacy_policy_deployment_contract.ps1") ` + -Patterns @("exact published privacy policy bytes are accepted", "tampered published privacy policy bytes are rejected", "noncanonical privacy policy URL is rejected", "stale privacy policy source hash is rejected", "pending privacy policy deployment status is rejected", "5/5") + +Test-TextEvidence ` + -Id "android-play-privacy-pages-workflow" ` + -Name "Privacy-policy Pages deployment workflow" ` + -RelativePaths @(".github/workflows/pages.yml", "provenance/pages.yml") ` + -Patterns @("Deploy privacy policy", "main", "site/**", "pages: write", "id-token: write", "actions/configure-pages@v5", "actions/upload-pages-artifact@v4", "path: site", "actions/deploy-pages@v4") + +Test-TextEvidence ` + -Id "android-play-privacy-app-identity" ` + -Name "Canonical privacy-policy URL in companion identity" ` + -RelativePaths @("companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt", "provenance/companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt") ` + -Patterns @("privacyPolicyUrl", "https://robvanprod.github.io/stackchan_alive/privacy/") + +Test-TextEvidence ` + -Id "android-play-privacy-android-link" ` + -Name "Android in-app privacy-policy link" ` + -RelativePaths @("companion/app-android/src/main/kotlin/dev/stackchan/companion/android/MainActivity.kt", "provenance/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/MainActivity.kt") ` + -Patterns @("onOpenPrivacyPolicy", "Intent.ACTION_VIEW", "CompanionIdentity.privacyPolicyUrl") + +Test-TextEvidence ` + -Id "android-play-privacy-desktop-link" ` + -Name "Desktop in-app privacy-policy link" ` + -RelativePaths @("companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/Main.kt", "provenance/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/Main.kt") ` + -Patterns @("onOpenPrivacyPolicy", "Desktop.Action.BROWSE", "CompanionIdentity.privacyPolicyUrl") + +Test-TextEvidence ` + -Id "android-play-privacy-shared-ui" ` + -Name "Shared in-app privacy-policy command" ` + -RelativePaths @("companion/ui/src/commonMain/kotlin/dev/stackchan/companion/ui/CompanionConsole.kt", "provenance/companion/ui/src/commonMain/kotlin/dev/stackchan/companion/ui/CompanionConsole.kt") ` + -Patterns @("onOpenPrivacyPolicy", "Privacy policy", "Export logs") + +Test-TextEvidence ` + -Id "android-play-speech-offline-preference" ` + -Name "Android speech requests offline recognition" ` + -RelativePaths @("companion/app-android/src/main/kotlin/dev/stackchan/companion/android/AndroidSpeechTurnController.kt", "provenance/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/AndroidSpeechTurnController.kt") ` + -Patterns @("RecognizerIntent.EXTRA_PREFER_OFFLINE", "true") Test-TextEvidence ` -Id "android-play-readiness-check" ` -Name "Android Play source readiness check" ` -RelativePaths @("tools/check_android_play_release_readiness.ps1", "provenance/tools/check_android_play_release_readiness.ps1") ` - -Patterns @("stackchan.android-play-release-readiness.v1", "Play high-resolution icon", "Play screenshot capture plan", "Gradle Play upload signing inputs", "CI builds Android release bundle", "Release evidence covers AAB signing", "play-store-evidence-checker", "applicationId", "play-policy-declarations", "play-privacy-policy-page") + -Patterns @("stackchan.android-play-release-readiness.v1", "RequireUploadSigning", "uploadSigningRequired", "ExportParameters", "Play high-resolution icon", "Play screenshot capture plan", "Gradle Play upload signing inputs", "keytool private-key validation", "RSACertificateExtensions", "project policy requires at least 4096 bits", "2033-10-23 UTC", "certificate SHA-256", "CI builds Android release bundle", "CI runs Android emulator launch smoke", "Tag release validates upload key and exact release APK launch", "RequireAndroidEmulatorEvidence", "Release evidence covers AAB signing", "play-store-evidence-checker", "applicationId", "play-policy-declarations", "play-privacy-policy-page", "play-privacy-policy-site", "play-privacy-pages-workflow", "play-privacy-android-link", "play-privacy-desktop-link", "play-speech-offline-preference") + +Test-TextEvidence ` + -Id "android-upload-signing-contract" ` + -Name "Android upload signing contract test" ` + -RelativePaths @("tools/test_android_upload_signing_contract.ps1", "provenance/tools/test_android_upload_signing_contract.ps1") ` + -Patterns @("manual signing readiness workflow validates Android upload key without publishing", "tagged Android release requires upload signing readiness", "required upload signing credentials fail closed when missing", "valid 4096-bit private upload key is accepted", "missing upload-key alias is rejected", "wrong keystore password is rejected", "wrong private-key password is rejected", "weak 2048-bit upload key is rejected", "Android debug certificate subject is rejected", "upload certificate expiring before the Play minimum is rejected", "output exposed a contract credential") + +Test-TextEvidence ` + -Id "android-emulator-launch-smoke" ` + -Name "Android emulator install and launch smoke" ` + -RelativePaths @("tools/test_android_emulator_launch.ps1", "provenance/tools/test_android_emulator_launch.ps1") ` + -Patterns @("stackchan.android-emulator-launch-smoke.v1", "ro.kernel.qemu=1", "POST_NOTIFICATIONS", "MainActivity is not the top resumed activity", "CompanionBridgeService is absent after launch", "fatalProcessMatches", "substitutesForPhysicalEvidence", "emulator-install-launch-service-smoke-only") + +Test-TextEvidence ` + -Id "android-emulator-release-evidence-check" ` + -Name "Android emulator release APK evidence binding" ` + -RelativePaths @("tools/check_android_emulator_release_evidence.ps1", "provenance/tools/check_android_emulator_release_evidence.ps1") ` + -Patterns @("stackchan.android-emulator-release-evidence-check.v1", "stackchan.android-emulator-launch-smoke.v1", "MinApiLevel = 35", "dev.stackchan.companion", "MainActivity was not resumed", "CompanionBridgeService was not present", "fatalProcessMatches must be zero", "substitutesForPhysicalEvidence=false", "APK SHA-256 does not match the release APK") + +Test-TextEvidence ` + -Id "android-emulator-release-evidence-contract" ` + -Name "Android emulator release evidence contract test" ` + -RelativePaths @("tools/test_android_emulator_release_evidence_contract.ps1", "provenance/tools/test_android_emulator_release_evidence_contract.ps1") ` + -Patterns @("matching release APK evidence", "stale APK hash is rejected", "old emulator API is rejected", "failed launch smoke is rejected", "non-resumed activity is rejected", "missing bridge service is rejected", "fatal process match is rejected", "physical-evidence substitution is rejected", "wrong package identity is rejected", "9/9 passed") Test-TextEvidence ` -Id "android-play-store-evidence-check" ` @@ -915,13 +1128,43 @@ Test-TextEvidence ` -Id "ci-companion-tests" ` -Name "Companion CI pre-arrival checks" ` -RelativePaths @(".github/workflows/firmware.yml", "provenance/firmware.yml") ` - -Patterns @("companion-tests", "companion-platform-builds", "companion-release-evidence", "export_companion_release_evidence.ps1", "java-version: `"21`"", "android-actions/setup-android", "platforms;android-36", "build-tools;36.0.0", "./gradlew check :app-desktop:c0Spike", ":app-android:bundleRelease", "check_android_play_release_readiness.ps1") + -Patterns @("workflow_dispatch", "github.event_name != 'workflow_dispatch'", "github.event_name == 'workflow_dispatch'", "STACKCHAN_CI_SOURCE_SHA", "github.event.pull_request.head.sha", "companion-tests", "companion-android-emulator-smoke", "companion-platform-builds", "companion-release-evidence", "export_companion_release_evidence.ps1", "java-version: `"21`"", "python-version: `"3.12`"", "android-actions/setup-android", "gradle/actions/setup-gradle@v6", "platforms;android-36", "build-tools;36.0.0", "system-images;android-35;aosp_atd;x86_64", "ANDROID_AVD_HOME", "timeout 180 adb wait-for-device", "./gradlew check :app-desktop:c0Spike", ":app-android:bundleRelease", "stackchan.allowLabDebugReleaseSigning=true", "check_companion_release_version.ps1", "test_companion_release_version_contract.ps1", "check_android_play_release_readiness.ps1", "test_android_upload_signing_contract.ps1", "test_android_emulator_launch.ps1", "test_android_emulator_release_evidence_contract.ps1", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "test_desktop_package_evidence_contract.ps1", "test_desktop_package_launch.ps1", "prepare_desktop_python_runtime.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "RequireDesktopPackageEvidence") + +Test-AggregateTextEvidence ` + -Id "companion-ci-candidate-handoff" ` + -Name "Exact-source all-platform CI candidate handoff" ` + -RelativePaths @("tools/download_companion_ci_candidate.ps1", "tools/download_companion_ci_candidate.cmd", "tools/test_companion_ci_candidate_contract.ps1", "tools/test_companion_ci_candidate_contract.cmd") ` + -Patterns @("stackchan.companion-ci-candidate.v1", "companion-ci-candidate-ready", "exact-source-ci-rehearsal-artifacts", "substitutesForTaggedRelease", "substitutesForPhysicalEvidence", "publicReleaseReady", "COMPANION_RELEASE_EVIDENCE.json", "companion-android-apks", "companion-desktop-windows", "companion-desktop-macos", "companion-desktop-linux", "release evidence source mismatch", "does not match companion release evidence", "PR merge/head source mismatch is rejected", "downloaded artifact hash tampering is rejected", "Companion CI candidate contract tests passed") + +Test-TextEvidence ` + -Id "desktop-managed-runtime-native-package-matrix" ` + -Name "Desktop managed Python runtimes in native package matrix" ` + -RelativePaths @(".github/workflows/firmware.yml", ".github/workflows/release.yml") ` + -Patterns @("desktop-windows", "desktop-linux", "desktop-macos", "Prepare managed Python runtime for desktop package", "prepare_desktop_python_runtime.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "Export native desktop package evidence", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "windows-package-evidence.json", "linux-package-evidence.json", "macos-package-evidence.json") + +Test-TextEvidence ` + -Id "companion-release-version-gate" ` + -Name "Companion cross-platform release version gate" ` + -RelativePaths @("tools/check_companion_release_version.ps1") ` + -Patterns @("stackchan.companion-release-version.v1", "ExpectedVersion", "versionName", "versionCode", "packageVersion", "CompanionIdentity.kt", "appVersion", "blocked-version-mismatch", "Tag/version mismatch") + +Test-TextEvidence ` + -Id "companion-release-version-contract" ` + -Name "Companion release version contract test" ` + -RelativePaths @("tools/test_companion_release_version_contract.ps1", "tools/test_companion_release_version_contract.cmd") ` + -Patterns @("current companion declarations match", "mismatched release tag is rejected", "cross-platform version drift is rejected", "Companion release version contract tests passed") + +Test-TextEvidence ` + -Id "companion-tag-release-workflow" ` + -Name "Companion all-platform tag release workflow" ` + -RelativePaths @(".github/workflows/release.yml") ` + -Patterns @("companion-android-release", "companion-android-emulator-smoke", "companion-desktop-release", "gradle/actions/setup-gradle@v6", "STACKCHAN_ANDROID_KEYSTORE_B64", "STACKCHAN_ANDROID_KEYSTORE_PASSWORD", "STACKCHAN_ANDROID_KEY_ALIAS", "STACKCHAN_ANDROID_KEY_PASSWORD", "STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_WINDOWS_PFX_PASSWORD", "STACKCHAN_MACOS_CERTIFICATE_B64", "STACKCHAN_MACOS_CERTIFICATE_PASSWORD", "STACKCHAN_MACOS_SIGNING_IDENTITY", "STACKCHAN_MACOS_NOTARIZATION_APPLE_ID", "STACKCHAN_MACOS_NOTARIZATION_PASSWORD", "STACKCHAN_MACOS_NOTARIZATION_TEAM_ID", "check_android_play_release_readiness.ps1 -RequireUploadSigning -Json", "check_desktop_release_signing_readiness.ps1", "Validate production desktop signing credentials", "RequireNativeToolchain", "ValidateAppleNotaryCredentials", "ANDROID_AVD_HOME", "timeout 180 adb wait-for-device", "test_android_emulator_launch.ps1", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "prepare_desktop_python_runtime.ps1", "test_desktop_package_launch.ps1", "export_desktop_package_evidence.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "RequireInstallerPayload", "RequireLaunchEvidence", "RequireDistributionTrust", "RequireUploadSigning", "RequireDesktopPackageEvidence", "RequireDesktopDistributionTrust", ":app-desktop:notarizeDmg", "actions/attest@v4", "Get-ReleaseCompanionAssetEntries", "COMPANION_RELEASE_EVIDENCE.json") Test-TextEvidence ` -Id "companion-release-signing-evidence" ` - -Name "Companion release APK signing evidence" ` + -Name "Companion release signing and native trust evidence" ` -RelativePaths @("tools/export_companion_release_evidence.ps1") ` - -Patterns @("ApkSignerPath", "apksigner", "androidSigning", "android-release-apk-signature", "APK Signature Scheme v2", "androidBundleSigning", "android-release-aab-signature", "jarsigner", "DesktopPythonRuntimeRoot", "check_desktop_python_runtime_payload.ps1", "desktopPythonRuntime", "desktop-managed-python-runtime-payload") + -Patterns @("ApkSignerPath", "apksigner", "androidSigning", "android-release-apk-signature", "APK Signature Scheme v2", "androidBundleSigning", "android-release-aab-signature", "jarsigner", "RequireUploadSigning", "upload-key", "blocked-release-evidence", "blockingPending", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "androidEmulatorEvidenceRequired", "android-emulator-release-apk-evidence", "check_android_emulator_release_evidence.ps1", "DesktopPythonRuntimeRoot", "check_desktop_python_runtime_payload.ps1", "desktopPythonRuntime", "desktop-managed-python-runtime-payload", "DesktopPackageEvidenceRoot", "RequireDesktopPackageEvidence", "desktopPackageEvidenceRequired", "RequireDesktopDistributionTrust", "desktopDistributionTrustRequired", "distributionTrustStatus", "authenticode-sha256-timestamped", "developer-id-notarized-stapled", "installerAppJarSha256", "installerRuntimeSha256", "installerBrainFiles", "desktop-native-package-runtime-evidence", "desktop-native-distribution-trust") Test-TextEvidence ` -Id "android-toolchain-check" ` diff --git a/tools/check_desktop_python_runtime_payload.ps1 b/tools/check_desktop_python_runtime_payload.ps1 index 5c5cf090..045b0227 100644 --- a/tools/check_desktop_python_runtime_payload.ps1 +++ b/tools/check_desktop_python_runtime_payload.ps1 @@ -262,3 +262,4 @@ if ($Json) { if ($failed.Count -gt 0) { exit 1 } +exit 0 diff --git a/tools/check_desktop_release_signing_readiness.cmd b/tools/check_desktop_release_signing_readiness.cmd new file mode 100644 index 00000000..ceeb91d6 --- /dev/null +++ b/tools/check_desktop_release_signing_readiness.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_desktop_release_signing_readiness.ps1" %* diff --git a/tools/check_desktop_release_signing_readiness.ps1 b/tools/check_desktop_release_signing_readiness.ps1 new file mode 100644 index 00000000..bc77bcaf --- /dev/null +++ b/tools/check_desktop_release_signing_readiness.ps1 @@ -0,0 +1,454 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet("windows", "macos")] + [string]$Platform, + [switch]$RequireReady, + [switch]$RequireNativeToolchain, + [switch]$ValidateAppleNotaryCredentials, + [switch]$Json +) + +$ErrorActionPreference = "Stop" + +$codeSigningOid = "1.3.6.1.5.5.7.3.3" +$minimumRemainingDays = 30 +$checks = @() +$issues = @() + +function Add-Check { + param( + [string]$Id, + [ValidateSet("pass", "fail", "pending")] + [string]$Status, + [string]$Detail + ) + + $script:checks += [ordered]@{ + id = $Id + status = $Status + detail = $Detail + } + if ($Status -eq "fail") { $script:issues += $Detail } +} + +function Get-HostPlatform { + if ($env:OS -eq "Windows_NT") { return "windows" } + try { + $uname = (& uname -s 2>$null | Out-String).Trim().ToLowerInvariant() + if ($uname -eq "darwin") { return "macos" } + if ($uname -eq "linux") { return "linux" } + } catch { + } + return "unknown" +} + +function Invoke-NativeCommand { + param( + [string]$Command, + [string[]]$Arguments + ) + + $output = "" + $exitCode = -1 + $oldPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = (& $Command @Arguments 2>&1 | Out-String).Trim() + $exitCode = $LASTEXITCODE + } catch { + $output = $_.Exception.Message + } finally { + $ErrorActionPreference = $oldPreference + } + return [pscustomobject]@{ exitCode = $exitCode; output = $output } +} + +function Find-WindowsSignTool { + $command = Get-Command signtool.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $command) { return [string]$command.Source } + + $programFilesX86 = [Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFilesX86) + if ([string]::IsNullOrWhiteSpace($programFilesX86)) { return "" } + $kitsRoot = Join-Path $programFilesX86 "Windows Kits/10/bin" + if (-not (Test-Path -LiteralPath $kitsRoot -PathType Container)) { return "" } + $candidate = Get-ChildItem -LiteralPath $kitsRoot -Recurse -File -Filter "signtool.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '[\\/]x64[\\/]signtool\.exe$' } | + Sort-Object FullName -Descending | + Select-Object -First 1 + if ($null -eq $candidate) { return "" } + return [string]$candidate.FullName +} + +function Test-NativeWindowsCredentialMaterial { + param( + [string]$SignToolPath, + [byte[]]$Pkcs12Bytes, + [string]$Password + ) + + $temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("stackchan-signing-readiness-" + [guid]::NewGuid().ToString("N")) + $certificatePath = Join-Path $temporaryRoot "authenticode.p12" + $probePath = Join-Path $temporaryRoot "stackchan-signing-probe.exe" + try { + New-Item -ItemType Directory -Force -Path $temporaryRoot | Out-Null + [IO.File]::WriteAllBytes($certificatePath, $Pkcs12Bytes) + + $sourceExecutable = (Get-Process -Id $PID -ErrorAction Stop).Path + if ([string]::IsNullOrWhiteSpace($sourceExecutable) -or -not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) { + throw "The current PowerShell executable is unavailable for a native signing probe." + } + Copy-Item -LiteralPath $sourceExecutable -Destination $probePath + + $signed = Invoke-NativeCommand $SignToolPath @( + "sign", "/fd", "SHA256", "/f", $certificatePath, "/p", $Password, $probePath + ) + if ($signed.exitCode -ne 0) { + throw "SignTool could not sign a temporary executable with the configured PKCS#12 credential." + } + $verified = Invoke-NativeCommand $SignToolPath @("verify", "/pa", "/all", "/v", $probePath) + if ($verified.exitCode -ne 0) { + throw "SignTool could not verify the temporary Authenticode signing probe." + } + } finally { + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Get-RequiredEnvironment { + param([string[]]$Names) + + $values = @{} + $missing = @() + foreach ($name in $Names) { + $value = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrWhiteSpace($value)) { + $missing += $name + } else { + $values[$name] = $value + } + } + return [pscustomobject]@{ values = $values; missing = @($missing) } +} + +function Test-CodeSigningEku { + param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) + + $ekuExtensions = @($Certificate.Extensions | Where-Object { + $_.Oid.Value -eq "2.5.29.37" + }) + foreach ($extension in $ekuExtensions) { + $eku = [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]$extension + foreach ($oid in $eku.EnhancedKeyUsages) { + if ($oid.Value -eq $script:codeSigningOid) { return $true } + } + } + return $false +} + +function Get-KeySummary { + param([System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) + + $algorithm = [string]$Certificate.PublicKey.Oid.FriendlyName + $bits = 0 + $key = $null + try { + if ($Certificate.PublicKey.Oid.Value -eq "1.2.840.113549.1.1.1") { + $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($Certificate) + $algorithm = "RSA" + if ($null -ne $key) { + $parameters = $key.ExportParameters($false) + $bits = [int]($parameters.Modulus.Length * 8) + } + } elseif ($Certificate.PublicKey.Oid.Value -eq "1.2.840.10045.2.1") { + $key = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPublicKey($Certificate) + $algorithm = "ECDSA" + if ($null -ne $key) { + $parameters = $key.ExportParameters($false) + $bits = [int]($parameters.Q.X.Length * 8) + } + } + } finally { + if ($null -ne $key) { $key.Dispose() } + } + return [pscustomobject]@{ algorithm = $algorithm; bits = $bits } +} + +function Test-NativeCertificateTrust { + param( + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]$Certificates + ) + + $chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain + try { + $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck + $chain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::NoFlag + foreach ($item in $Certificates) { + if (-not [object]::ReferenceEquals($item, $Certificate)) { + [void]$chain.ChainPolicy.ExtraStore.Add($item) + } + } + if (-not $chain.Build($Certificate)) { + throw "The code-signing certificate does not chain to a root trusted by the native host." + } + } finally { + $chain.Dispose() + } +} + +function Get-SigningCertificate { + param( + [string]$Base64Value, + [string]$Password + ) + + try { + $bytes = [Convert]::FromBase64String($Base64Value) + } catch { + throw "The configured PKCS#12 value is not valid base64." + } + + $collection = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2Collection + $flags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + try { + $collection.Import($bytes, $Password, $flags) + } catch { + throw "The configured PKCS#12 value could not be opened with its password." + } + + $candidates = @($collection | Where-Object { + $_.HasPrivateKey -and (Test-CodeSigningEku $_) + }) + if ($candidates.Count -ne 1) { + foreach ($item in $collection) { $item.Dispose() } + throw "The PKCS#12 bundle must contain exactly one private code-signing certificate." + } + + $selected = $candidates[0] + return [pscustomobject]@{ certificate = $selected; certificates = $collection; bytes = $bytes } +} + +function Test-NativeMacOSCredentialMaterial { + param( + [byte[]]$Pkcs12Bytes, + [string]$Password, + [string]$Identity + ) + + $security = Get-Command security -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $security) { throw "The macOS security tool is unavailable." } + $codesign = Get-Command codesign -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $codesign) { throw "The macOS codesign tool is unavailable." } + + $temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("stackchan-signing-readiness-" + [guid]::NewGuid().ToString("N")) + $certificatePath = Join-Path $temporaryRoot "developer-id.p12" + $keychainPath = Join-Path $temporaryRoot "readiness.keychain-db" + $probePath = Join-Path $temporaryRoot "stackchan-signing-probe" + $keychainPassword = [guid]::NewGuid().ToString("N") + try { + New-Item -ItemType Directory -Force -Path $temporaryRoot | Out-Null + [IO.File]::WriteAllBytes($certificatePath, $Pkcs12Bytes) + $created = Invoke-NativeCommand $security.Source @("create-keychain", "-p", $keychainPassword, $keychainPath) + if ($created.exitCode -ne 0) { throw "A temporary validation keychain could not be created." } + $unlocked = Invoke-NativeCommand $security.Source @("unlock-keychain", "-p", $keychainPassword, $keychainPath) + if ($unlocked.exitCode -ne 0) { throw "The temporary validation keychain could not be unlocked." } + $imported = Invoke-NativeCommand $security.Source @("import", $certificatePath, "-k", $keychainPath, "-P", $Password, "-T", "/usr/bin/codesign", "-T", "/usr/bin/security") + if ($imported.exitCode -ne 0) { throw "The Developer ID certificate could not be imported into a temporary keychain." } + $partitioned = Invoke-NativeCommand $security.Source @("set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", $keychainPassword, $keychainPath) + if ($partitioned.exitCode -ne 0) { throw "The imported Developer ID private key could not be enabled for codesign." } + $identities = Invoke-NativeCommand $security.Source @("find-identity", "-v", "-p", "codesigning", $keychainPath) + if ($identities.exitCode -ne 0 -or $identities.output.IndexOf($Identity, [StringComparison]::OrdinalIgnoreCase) -lt 0) { + throw "The configured Developer ID signing identity was not found after native keychain import." + } + Copy-Item -LiteralPath "/usr/bin/true" -Destination $probePath + $signed = Invoke-NativeCommand $codesign.Source @( + "--force", "--sign", $Identity, "--keychain", $keychainPath, + "--options", "runtime", "--timestamp=none", $probePath + ) + if ($signed.exitCode -ne 0) { + throw "codesign could not sign a temporary hardened-runtime executable with the configured Developer ID credential." + } + $verified = Invoke-NativeCommand $codesign.Source @("--verify", "--strict", "--verbose=2", $probePath) + if ($verified.exitCode -ne 0) { + throw "codesign could not verify the temporary Developer ID signing probe." + } + } finally { + if (Test-Path -LiteralPath $keychainPath) { + [void](Invoke-NativeCommand $security.Source @("delete-keychain", $keychainPath)) + } + Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} + +$requiredNames = if ($Platform -eq "windows") { + @("STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_WINDOWS_PFX_PASSWORD") +} else { + @( + "STACKCHAN_MACOS_CERTIFICATE_B64", + "STACKCHAN_MACOS_CERTIFICATE_PASSWORD", + "STACKCHAN_MACOS_SIGNING_IDENTITY", + "STACKCHAN_MACOS_NOTARIZATION_APPLE_ID", + "STACKCHAN_MACOS_NOTARIZATION_PASSWORD", + "STACKCHAN_MACOS_NOTARIZATION_TEAM_ID" + ) +} + +$configured = Get-RequiredEnvironment $requiredNames +$certificateSummary = [ordered]@{ + subject = "" + simpleName = "" + thumbprint = "" + notBeforeUtc = "" + notAfterUtc = "" + keyAlgorithm = "" + keyBits = 0 + hasPrivateKey = $false + codeSigningEku = $false +} +$nativeToolchainValidated = $false +$appleNotaryCredentialsValidated = $false + +if ($configured.missing.Count -gt 0) { + Add-Check "credential-presence" "pending" ("Missing required environment names: " + ($configured.missing -join ", ") + ".") +} else { + Add-Check "credential-presence" "pass" "All required environment names are configured." + $certificate = $null + $material = $null + try { + $base64Name = if ($Platform -eq "windows") { "STACKCHAN_WINDOWS_PFX_B64" } else { "STACKCHAN_MACOS_CERTIFICATE_B64" } + $passwordName = if ($Platform -eq "windows") { "STACKCHAN_WINDOWS_PFX_PASSWORD" } else { "STACKCHAN_MACOS_CERTIFICATE_PASSWORD" } + $material = Get-SigningCertificate -Base64Value $configured.values[$base64Name] -Password $configured.values[$passwordName] + $certificate = $material.certificate + $key = Get-KeySummary $certificate + $simpleName = $certificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + $certificateSummary.subject = [string]$certificate.Subject + $certificateSummary.simpleName = [string]$simpleName + $certificateSummary.thumbprint = ([string]$certificate.Thumbprint).ToLowerInvariant() + $certificateSummary.notBeforeUtc = $certificate.NotBefore.ToUniversalTime().ToString("o") + $certificateSummary.notAfterUtc = $certificate.NotAfter.ToUniversalTime().ToString("o") + $certificateSummary.keyAlgorithm = [string]$key.algorithm + $certificateSummary.keyBits = [int]$key.bits + $certificateSummary.hasPrivateKey = [bool]$certificate.HasPrivateKey + $certificateSummary.codeSigningEku = Test-CodeSigningEku $certificate + + $now = [DateTime]::UtcNow + if ($certificate.NotBefore.ToUniversalTime() -gt $now) { + throw "The code-signing certificate is not valid yet." + } + if ($certificate.NotAfter.ToUniversalTime() -lt $now.AddDays($minimumRemainingDays)) { + throw "The code-signing certificate must remain valid for at least $minimumRemainingDays days." + } + if (($key.algorithm -eq "RSA" -and $key.bits -lt 2048) -or ($key.algorithm -eq "ECDSA" -and $key.bits -lt 256) -or $key.bits -le 0) { + throw "The code-signing certificate uses an unsupported or undersized public key." + } + + if ($Platform -eq "macos") { + $identity = [string]$configured.values["STACKCHAN_MACOS_SIGNING_IDENTITY"] + $teamId = [string]$configured.values["STACKCHAN_MACOS_NOTARIZATION_TEAM_ID"] + $appleId = [string]$configured.values["STACKCHAN_MACOS_NOTARIZATION_APPLE_ID"] + if ($identity -notmatch '^Developer ID Application: ') { + throw "STACKCHAN_MACOS_SIGNING_IDENTITY must name a Developer ID Application identity." + } + if (-not [string]::Equals($simpleName, $identity, [StringComparison]::OrdinalIgnoreCase)) { + throw "The configured macOS signing identity does not match the PKCS#12 certificate." + } + if ($teamId -notmatch '^[A-Z0-9]{10}$' -or + $certificate.Subject.IndexOf("OU=$teamId", [StringComparison]::OrdinalIgnoreCase) -lt 0 -or + $simpleName.IndexOf("($teamId)", [StringComparison]::OrdinalIgnoreCase) -lt 0) { + throw "The configured Apple team ID does not match the Developer ID certificate." + } + if ($appleId -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { + throw "STACKCHAN_MACOS_NOTARIZATION_APPLE_ID must be an email address." + } + } + + Add-Check "certificate-material" "pass" "A private code-signing certificate with a valid identity, lifetime, and key is present." + + if ($RequireNativeToolchain) { + $hostPlatform = Get-HostPlatform + if ($hostPlatform -ne $Platform) { + throw "Native $Platform signing validation requires a $Platform host." + } + Test-NativeCertificateTrust -Certificate $certificate -Certificates $material.certificates + if ($Platform -eq "windows") { + $signToolPath = Find-WindowsSignTool + if ([string]::IsNullOrWhiteSpace($signToolPath)) { + throw "signtool.exe is unavailable in the native Windows toolchain." + } + Test-NativeWindowsCredentialMaterial ` + -SignToolPath $signToolPath ` + -Pkcs12Bytes $material.bytes ` + -Password $configured.values["STACKCHAN_WINDOWS_PFX_PASSWORD"] + } else { + foreach ($tool in @("codesign", "security", "xcrun")) { + if ($null -eq (Get-Command $tool -ErrorAction SilentlyContinue | Select-Object -First 1)) { + throw "The native macOS signing tool '$tool' is unavailable." + } + } + Test-NativeMacOSCredentialMaterial ` + -Pkcs12Bytes $material.bytes ` + -Password $configured.values["STACKCHAN_MACOS_CERTIFICATE_PASSWORD"] ` + -Identity $configured.values["STACKCHAN_MACOS_SIGNING_IDENTITY"] + } + $nativeToolchainValidated = $true + Add-Check "native-toolchain" "pass" "The native signing toolchain accepted the configured credential material." + } + + if ($ValidateAppleNotaryCredentials) { + if ($Platform -ne "macos") { throw "Apple notarization credential validation is only valid for macOS." } + if ((Get-HostPlatform) -ne "macos") { throw "Apple notarization credential validation requires a native macOS host." } + $xcrun = Get-Command xcrun -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -eq $xcrun) { throw "xcrun is unavailable for Apple notarization credential validation." } + $notaryResult = Invoke-NativeCommand $xcrun.Source @( + "notarytool", "history", + "--apple-id", $configured.values["STACKCHAN_MACOS_NOTARIZATION_APPLE_ID"], + "--password", $configured.values["STACKCHAN_MACOS_NOTARIZATION_PASSWORD"], + "--team-id", $configured.values["STACKCHAN_MACOS_NOTARIZATION_TEAM_ID"], + "--output-format", "json" + ) + if ($notaryResult.exitCode -ne 0) { throw "Apple rejected the configured notarization credentials." } + $appleNotaryCredentialsValidated = $true + Add-Check "apple-notary-credentials" "pass" "Apple accepted the configured notarization credentials." + } + } catch { + Add-Check "credential-validation" "fail" $_.Exception.Message + } finally { + if ($null -ne $material -and $null -ne $material.certificates) { + foreach ($item in $material.certificates) { $item.Dispose() } + } elseif ($null -ne $certificate) { + $certificate.Dispose() + } + if ($null -ne $material -and $null -ne $material.bytes) { + [Array]::Clear($material.bytes, 0, $material.bytes.Length) + } + } +} + +$failed = @($checks | Where-Object { $_.status -eq "fail" }).Count +$pending = @($checks | Where-Object { $_.status -eq "pending" }).Count +$status = if ($failed -gt 0) { "not-ready" } elseif ($pending -gt 0) { "pending-credentials" } else { "ready" } +$report = [ordered]@{ + schema = "stackchan.desktop-signing-readiness.v1" + status = $status + platform = $Platform + hostPlatform = Get-HostPlatform + generatedAtUtc = [DateTime]::UtcNow.ToString("o") + requiredEnvironmentNames = @($requiredNames) + certificate = $certificateSummary + nativeToolchainRequired = [bool]$RequireNativeToolchain + nativeToolchainValidated = $nativeToolchainValidated + appleNotaryCredentialsRequired = [bool]$ValidateAppleNotaryCredentials + appleNotaryCredentialsValidated = $appleNotaryCredentialsValidated + checks = @($checks) + issues = @($issues) +} + +if ($Json) { + $report | ConvertTo-Json -Depth 6 +} else { + Write-Host "Desktop signing readiness: $status ($Platform)" + foreach ($check in $checks) { Write-Host "[$($check.status)] $($check.id): $($check.detail)" } +} + +if ($failed -gt 0) { exit 1 } +if ($RequireReady -and $status -ne "ready") { exit 2 } diff --git a/tools/check_desktop_target_install_evidence.cmd b/tools/check_desktop_target_install_evidence.cmd new file mode 100644 index 00000000..d66a6af7 --- /dev/null +++ b/tools/check_desktop_target_install_evidence.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_desktop_target_install_evidence.ps1" %* diff --git a/tools/check_desktop_target_install_evidence.ps1 b/tools/check_desktop_target_install_evidence.ps1 new file mode 100644 index 00000000..1a6c5716 --- /dev/null +++ b/tools/check_desktop_target_install_evidence.ps1 @@ -0,0 +1,123 @@ +param( + [string]$EvidencePath, + [ValidateSet("", "windows", "linux", "macos")] + [string]$ExpectedPlatform = "", + [string]$ExpectedPackageSha256 = "", + [string]$ExpectedSourceCommit = "", + [switch]$RequireOperatorTarget, + [switch]$Json +) + +$ErrorActionPreference = "Stop" +$checks = @() + +function Add-Check([string]$Id, [string]$Status, [string]$Detail) { + $script:checks += [ordered]@{ id = $Id; status = $Status; detail = $Detail } +} + +function Test-Sha256([string]$Value) { return $Value -match '^[a-fA-F0-9]{64}$' } +function Test-Commit([string]$Value) { return $Value -match '^[a-fA-F0-9]{40}$' } + +$evidence = $null +if ([string]::IsNullOrWhiteSpace($EvidencePath) -or -not (Test-Path -LiteralPath $EvidencePath -PathType Leaf)) { + Add-Check "evidence-json" "fail" "Desktop target-install evidence is missing: $EvidencePath" +} else { + try { + $evidence = Get-Content -LiteralPath $EvidencePath -Raw | ConvertFrom-Json + Add-Check "evidence-json" "pass" "Desktop target-install evidence JSON parses." + } catch { + Add-Check "evidence-json" "fail" "Desktop target-install evidence is invalid JSON: $($_.Exception.Message)" + } +} + +if ($null -ne $evidence) { + if ([string]$evidence.schema -eq "stackchan.desktop-target-install-evidence.v1") { Add-Check "schema" "pass" "Schema matches." } else { Add-Check "schema" "fail" "Unexpected schema: $($evidence.schema)" } + if ([string]$evidence.status -eq "installed-and-ready") { Add-Check "status" "pass" "Installed package is ready." } else { Add-Check "status" "fail" "Expected installed-and-ready, got $($evidence.status)." } + + $platform = [string]$evidence.platform + if ($platform -in @("windows", "linux", "macos") -and [string]$evidence.host.platform -eq $platform) { Add-Check "platform" "pass" "Host and evidence platform match $platform." } else { Add-Check "platform" "fail" "Evidence platform and native host platform must match." } + if ([string]::IsNullOrWhiteSpace($ExpectedPlatform) -or $platform -eq $ExpectedPlatform) { Add-Check "expected-platform" "pass" "Expected platform matches." } else { Add-Check "expected-platform" "fail" "Expected $ExpectedPlatform, got $platform." } + + $sourceCommit = [string]$evidence.sourceCommit + if (Test-Commit $sourceCommit) { Add-Check "source-commit" "pass" "Full source commit is recorded." } else { Add-Check "source-commit" "fail" "sourceCommit must be a full 40-character SHA." } + if ([string]::IsNullOrWhiteSpace($ExpectedSourceCommit) -or $sourceCommit -eq $ExpectedSourceCommit) { Add-Check "expected-source-commit" "pass" "Expected source commit matches." } else { Add-Check "expected-source-commit" "fail" "Evidence source commit does not match the release candidate." } + + $packageSha = ([string]$evidence.package.sha256).ToLowerInvariant() + $expectedExtension = @{ windows = ".msi"; linux = ".deb"; macos = ".dmg" }[$platform] + if ((Test-Sha256 $packageSha) -and [string]$evidence.package.name -like "*$expectedExtension" -and [int64]$evidence.package.bytes -gt 0) { Add-Check "package" "pass" "Exact native package identity is recorded." } else { Add-Check "package" "fail" "Package name, size, extension, or SHA-256 is invalid." } + if ([string]::IsNullOrWhiteSpace($ExpectedPackageSha256) -or $packageSha -eq $ExpectedPackageSha256.ToLowerInvariant()) { Add-Check "expected-package-sha256" "pass" "Expected package SHA-256 matches." } else { Add-Check "expected-package-sha256" "fail" "Installed package SHA-256 does not match the release artifact." } + + $expectedMethod = @{ windows = "msiexec-install"; linux = "dpkg-install"; macos = "dmg-application-copy" }[$platform] + $installExitCode = $evidence.install.exitCode + if ([string]$evidence.install.method -eq $expectedMethod -and $null -ne $installExitCode -and [int]$installExitCode -in @(0, 1641, 3010)) { Add-Check "native-install" "pass" "Native installation method completed successfully." } else { Add-Check "native-install" "fail" "Native installation method or exit code is invalid." } + + if ($platform -eq "windows") { + if ($evidence.host.elevatedAdministrator -eq $true) { + Add-Check "windows-elevation" "pass" "Windows MSI installation ran from an elevated session." + } else { + Add-Check "windows-elevation" "fail" "Windows MSI target-install evidence must prove an elevated installation session." + } + + $windowsInstall = $evidence.install.windows + $preExisting = @($windowsInstall.preExistingRegistrations) + $uninstallAttempts = @($windowsInstall.uninstallAttempts) + $postInstall = @($windowsInstall.postInstallRegistrations) + $validPreExisting = $preExisting.Count -le 1 -and @($preExisting | Where-Object { [string]$_.productCode -notmatch '^\{[a-fA-F0-9-]{36}\}$' }).Count -eq 0 + $validUninstalls = @($uninstallAttempts | Where-Object { [string]$_.productCode -notmatch '^\{[a-fA-F0-9-]{36}\}$' -or $null -eq $_.exitCode -or [int]$_.exitCode -notin @(0, 1641, 3010) }).Count -eq 0 + $validPostInstall = $postInstall.Count -eq 1 -and [string]$postInstall[0].productCode -match '^\{[a-fA-F0-9-]{36}\}$' + if ($validPreExisting -and $validUninstalls -and $validPostInstall) { + Add-Check "windows-install-registration" "pass" "Windows pre-install and post-install product registrations are recorded." + } else { + Add-Check "windows-install-registration" "fail" "Windows product registration or uninstall evidence is malformed or incomplete." + } + + $replacementSafe = if ($preExisting.Count -eq 0) { + $windowsInstall.replacementPerformed -eq $false -and $uninstallAttempts.Count -eq 0 + } else { + $windowsInstall.replacementRequested -eq $true -and + $windowsInstall.replacementPerformed -eq $true -and + $uninstallAttempts.Count -eq $preExisting.Count + } + if ($replacementSafe) { + Add-Check "windows-exact-package-replacement" "pass" "Any existing Windows installation was explicitly removed before installing the exact MSI." + } else { + Add-Check "windows-exact-package-replacement" "fail" "Existing Windows installation replacement was not explicitly requested, completed, and recorded." + } + } + + $launchExitCode = $evidence.launch.exitCode + if (-not [string]::IsNullOrWhiteSpace([string]$evidence.install.installedLauncherPath) -and $null -ne $launchExitCode -and [int]$launchExitCode -eq 0) { Add-Check "installed-launcher" "pass" "Installed launcher exited successfully." } else { Add-Check "installed-launcher" "fail" "Installed launcher path or exit code is invalid." } + + $probe = $evidence.launch.probe + if ([string]$probe.schema -eq "stackchan.desktop-packaged-runtime-smoke.v1" -and [string]$probe.status -eq "ready" -and [string]$probe.platform -eq $platform -and [string]$probe.launchContext -eq "installed-package" -and [string]$probe.scope -eq "installed-native-package-headless-runtime-probe" -and $probe.runtimePresent -eq $true -and $probe.pythonAvailable -eq $true -and $probe.brainScriptAvailable -eq $true -and @($probe.issues).Count -eq 0 -and $probe.substitutesForTargetInstall -eq $false) { + Add-Check "installed-runtime-probe" "pass" "Installed managed runtime, Python, and brain entry point are ready." + } else { + Add-Check "installed-runtime-probe" "fail" "Installed packaged-runtime probe is incomplete or has invalid scope." + } + + if ($evidence.targetInstallVerified -eq $true -and [string]$evidence.scope -eq "exact-native-package-install-and-headless-launch" -and @($evidence.issues).Count -eq 0) { Add-Check "target-install-scope" "pass" "Exact native target install is verified." } else { Add-Check "target-install-scope" "fail" "Evidence does not verify an exact native package install." } + if ($evidence.substitutesForHumanAcceptance -eq $false) { Add-Check "human-acceptance-scope" "pass" "Human acceptance remains a separate gate." } else { Add-Check "human-acceptance-scope" "fail" "Target-install evidence must not claim human acceptance." } + + $environmentKind = [string]$evidence.environmentKind + if ($environmentKind -in @("operator-target-workstation", "ci-native-runner")) { Add-Check "environment-kind" "pass" "Installation environment is classified." } else { Add-Check "environment-kind" "fail" "Unexpected environmentKind: $environmentKind" } + if (-not $RequireOperatorTarget -or $environmentKind -eq "operator-target-workstation") { Add-Check "operator-target" "pass" "Operator target requirement is satisfied." } else { Add-Check "operator-target" "fail" "Final desktop acceptance requires operator-target-workstation evidence, not CI evidence." } +} + +$failed = @($checks | Where-Object { $_.status -eq "fail" }) +$passed = @($checks | Where-Object { $_.status -eq "pass" }) +$report = [ordered]@{ + schema = "stackchan.desktop-target-install-evidence-check.v1" + status = if ($failed.Count -eq 0) { "desktop-target-install-ready" } else { "not-ready" } + evidencePath = $EvidencePath + platform = if ($null -eq $evidence) { "" } else { [string]$evidence.platform } + sourceCommit = if ($null -eq $evidence) { "" } else { [string]$evidence.sourceCommit } + packageSha256 = if ($null -eq $evidence) { "" } else { ([string]$evidence.package.sha256).ToLowerInvariant() } + environmentKind = if ($null -eq $evidence) { "" } else { [string]$evidence.environmentKind } + passed = $passed.Count + failed = $failed.Count + checks = @($checks) +} + +if ($Json) { $report | ConvertTo-Json -Depth 8 } else { Write-Host "Desktop target install evidence: $($report.status)" } +if ($failed.Count -gt 0) { exit 1 } +exit 0 diff --git a/tools/check_desktop_v1_evidence_bundle.ps1 b/tools/check_desktop_v1_evidence_bundle.ps1 index cb6fe8d6..5aaafe03 100644 --- a/tools/check_desktop_v1_evidence_bundle.ps1 +++ b/tools/check_desktop_v1_evidence_bundle.ps1 @@ -315,6 +315,58 @@ function Test-DesktopArtifact { } } +function Test-DesktopTargetInstallReport { + param( + [string]$Id, + [string]$Name, + [object]$Reports, + [string]$ReportField, + [object]$Artifacts, + [string]$ArtifactField, + [string]$ExpectedPlatform, + [string]$ExpectedSourceCommit + ) + + $relativePath = [string](Get-Field $Reports $ReportField) + $path = Resolve-EvidencePath $relativePath + if ([string]::IsNullOrWhiteSpace($relativePath)) { + Add-Check $Id $Name "pending" "" "Record reports.$ReportField after installing the exact package on an operator target workstation." + return + } + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Add-Check $Id $Name "pending" (Convert-ToRelativePath $path) "Missing target-install report." + return + } + + $artifact = Get-Field $Artifacts $ArtifactField + $expectedPackageSha = [string](Get-Field $artifact "sha256") + if (-not (Test-Hash $expectedPackageSha) -or -not (Test-Commit $ExpectedSourceCommit)) { + Add-Check $Id $Name "pending" "DESKTOP_V1_EVIDENCE_BUNDLE.json" "Record the final package SHA-256 and sourceCommit before checking target installation." + return + } + + $checker = Join-Path $PSScriptRoot "check_desktop_target_install_evidence.ps1" + try { + $output = & (Get-Process -Id $PID).Path -NoProfile -ExecutionPolicy Bypass -File $checker ` + -EvidencePath $path ` + -ExpectedPlatform $ExpectedPlatform ` + -ExpectedPackageSha256 $expectedPackageSha ` + -ExpectedSourceCommit $ExpectedSourceCommit ` + -RequireOperatorTarget ` + -Json 2>&1 | Out-String + $exitCode = $LASTEXITCODE + $result = $output | ConvertFrom-Json + if ($exitCode -eq 0 -and [string]$result.status -eq "desktop-target-install-ready") { + Add-Check $Id $Name "pass" (Convert-ToRelativePath $path) "Exact package install and installed runtime launch are verified on an operator $ExpectedPlatform workstation." + } else { + $failedDetails = @($result.checks | Where-Object { $_.status -eq "fail" } | ForEach-Object { "$($_.id): $($_.detail)" }) + Add-Check $Id $Name "fail" (Convert-ToRelativePath $path) ($failedDetails -join " ") + } + } catch { + Add-Check $Id $Name "fail" (Convert-ToRelativePath $path) "Target-install checker failed: $($_.Exception.Message)" + } +} + function Write-DesktopV1EvidenceTemplate { New-Item -ItemType Directory -Force -Path $EvidenceRoot | Out-Null New-Item -ItemType Directory -Force -Path (Join-Path $EvidenceRoot "reports") | Out-Null @@ -340,6 +392,9 @@ function Write-DesktopV1EvidenceTemplate { windowsRuntimePayloadReport = "reports/desktop_runtime_windows.json" macosRuntimePayloadReport = "reports/desktop_runtime_macos.json" linuxRuntimePayloadReport = "reports/desktop_runtime_linux.json" + windowsTargetInstallReport = "reports/desktop_target_install_windows.json" + macosTargetInstallReport = "reports/desktop_target_install_macos.json" + linuxTargetInstallReport = "reports/desktop_target_install_linux.json" pcBrainDeployCheckReport = "reports/pc_brain_deploy_check.json" pcBrainQuietSoakCheckReport = "reports/pc_brain_quiet_soak_check.json" voiceSourceReadinessReport = "reports/voice_source_readiness.json" @@ -362,6 +417,7 @@ Required ready statuses: - ``stackchan.companion.c6-brain-supervisor-smoke.v1``: ``overall_ok=true`` - ``stackchan.companion.c6-gui-rehearsal.v1``: ``overall_ok=true`` - ``stackchan.desktop-python-runtime-payload.v1``: ``ready`` for Windows, macOS, and Linux +- ``stackchan.desktop-target-install-evidence.v1``: ``installed-and-ready`` from an operator target workstation for Windows, macOS, and Linux - ``stackchan.pc-brain-deploy-evidence-check.v1``: ``pc-brain-deploy-ready`` with matching ``sourceCommit`` - ``stackchan.pc-brain-quiet-soak-evidence-check.v1``: ``pc-brain-quiet-soak-ready`` with matching ``sourceCommit`` - ``stackchan.voice-source-readiness.v1``: ``production-voice-source-ready`` with matching ``sourceCommit`` @@ -386,6 +442,7 @@ Complete after desktop package, runtime payload, and physical PC Brain evidence - Overall desktop v1 decision: pending - Desktop package artifact decision: pending - Managed Python runtime decision: pending +- Target installation decision: pending - C6 GUI/supervisor evidence decision: pending - PC Brain deploy audio decision: pending - PC Brain quiet-soak decision: pending @@ -459,6 +516,9 @@ if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) { Test-RuntimePayloadSummary "runtime-windows-summary" "Windows runtime payload summary" $reports "windowsRuntimePayloadReport" "windows" Test-RuntimePayloadSummary "runtime-macos-summary" "macOS runtime payload summary" $reports "macosRuntimePayloadReport" "macos" Test-RuntimePayloadSummary "runtime-linux-summary" "Linux runtime payload summary" $reports "linuxRuntimePayloadReport" "linux" + Test-DesktopTargetInstallReport "target-install-windows" "Windows operator target installation" $reports "windowsTargetInstallReport" $artifacts "windowsMsi" "windows" ([string]$bundle.sourceCommit) + Test-DesktopTargetInstallReport "target-install-macos" "macOS operator target installation" $reports "macosTargetInstallReport" $artifacts "macosDmg" "macos" ([string]$bundle.sourceCommit) + Test-DesktopTargetInstallReport "target-install-linux" "Linux operator target installation" $reports "linuxTargetInstallReport" $artifacts "linuxDeb" "linux" ([string]$bundle.sourceCommit) Test-ReportStatus "pc-brain-deploy" "PC Brain deploy audio evidence report" $reports "pcBrainDeployCheckReport" "stackchan.pc-brain-deploy-evidence-check.v1" "pc-brain-deploy-ready" Test-ReportStatus "pc-brain-quiet-soak" "PC Brain quiet-soak evidence report" $reports "pcBrainQuietSoakCheckReport" "stackchan.pc-brain-quiet-soak-evidence-check.v1" "pc-brain-quiet-soak-ready" Test-ReportStatus "voice-source-ready" "Production voice-source readiness report" $reports "voiceSourceReadinessReport" "stackchan.voice-source-readiness.v1" "production-voice-source-ready" @@ -479,6 +539,7 @@ if (-not (Test-Path -LiteralPath $bundlePath -PathType Leaf)) { "Overall desktop v1 decision: pass", "Desktop package artifact decision: pass", "Managed Python runtime decision: pass", + "Target installation decision: pass", "C6 GUI/supervisor evidence decision: pass", "PC Brain deploy audio decision: pass", "PC Brain quiet-soak decision: pass", diff --git a/tools/check_privacy_policy_deployment.cmd b/tools/check_privacy_policy_deployment.cmd new file mode 100644 index 00000000..a7f009ec --- /dev/null +++ b/tools/check_privacy_policy_deployment.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_privacy_policy_deployment.ps1" %* +exit /b %ERRORLEVEL% diff --git a/tools/check_privacy_policy_deployment.ps1 b/tools/check_privacy_policy_deployment.ps1 new file mode 100644 index 00000000..1a31e6f9 --- /dev/null +++ b/tools/check_privacy_policy_deployment.ps1 @@ -0,0 +1,269 @@ +param( + [string]$Root = "", + [string]$EvidencePath = "docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json", + [string]$FetchedContentPath = "", + [int]$TimeoutSeconds = 30, + [switch]$Json +) + +$ErrorActionPreference = "Stop" +$canonicalUrl = "https://robvanprod.github.io/stackchan_alive/privacy/" + +if ([string]::IsNullOrWhiteSpace($Root)) { + $Root = Resolve-Path (Join-Path $PSScriptRoot "..") +} else { + $Root = Resolve-Path $Root +} +$Root = [System.IO.Path]::GetFullPath([string]$Root) + +if (-not [System.IO.Path]::IsPathRooted($EvidencePath)) { + $EvidencePath = Join-Path $Root $EvidencePath +} + +$checks = @() +$evidence = $null +$sourceSha256 = "" +$servedSha256 = "" +$verificationMode = if ([string]::IsNullOrWhiteSpace($FetchedContentPath)) { "live-https" } else { "fixture" } + +function Add-Check { + param( + [string]$Id, + [string]$Name, + [ValidateSet("pass", "fail")] + [string]$Status, + [string]$Evidence, + [string]$Detail + ) + + $script:checks += [ordered]@{ + id = $Id + name = $Name + status = $Status + evidence = $Evidence + detail = $Detail + } +} + +function Get-BytesSha256 { + param([byte[]]$Bytes) + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return ([System.BitConverter]::ToString($sha256.ComputeHash($Bytes)) -replace "-", "").ToLowerInvariant() + } finally { + $sha256.Dispose() + } +} + +function Test-Commit { + param([string]$Value) + return $Value -match "^[a-f0-9]{40}$" +} + +function Test-UtcTimestamp { + param([string]$Value) + + if ($Value -notmatch "^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") { + return $false + } + $parsed = [DateTimeOffset]::MinValue + return [DateTimeOffset]::TryParse($Value, [ref]$parsed) +} + +function Get-RawJsonStringField { + param( + [string]$Json, + [string]$Name + ) + + $match = [regex]::Match($Json, '"' + [regex]::Escape($Name) + '"\s*:\s*"([^"]*)"') + if ($match.Success) { + return $match.Groups[1].Value + } + return "" +} + +function Resolve-RootFile { + param([string]$RelativePath) + + if ([string]::IsNullOrWhiteSpace($RelativePath) -or [System.IO.Path]::IsPathRooted($RelativePath)) { + return $null + } + $candidate = [System.IO.Path]::GetFullPath((Join-Path $Root $RelativePath)) + $rootPrefix = $Root.TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar + if (-not $candidate.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + return $null + } + return $candidate +} + +try { + $rawEvidence = Get-Content -LiteralPath $EvidencePath -Raw + $evidence = $rawEvidence | ConvertFrom-Json + Add-Check "evidence-file" "Deployment evidence file" "pass" $EvidencePath "Deployment evidence parsed as JSON." +} catch { + Add-Check "evidence-file" "Deployment evidence file" "fail" $EvidencePath $_.Exception.Message +} + +if ($null -ne $evidence) { + if ([string]$evidence.schema -eq "stackchan.privacy-policy-deployment.v1") { + Add-Check "evidence-schema" "Deployment evidence schema" "pass" ([string]$evidence.schema) "Deployment schema is recognized." + } else { + Add-Check "evidence-schema" "Deployment evidence schema" "fail" ([string]$evidence.schema) "Expected stackchan.privacy-policy-deployment.v1." + } + + if ([string]$evidence.status -eq "deployed") { + Add-Check "deployment-status" "Deployment status" "pass" ([string]$evidence.status) "The policy is recorded as deployed." + } else { + Add-Check "deployment-status" "Deployment status" "fail" ([string]$evidence.status) "Status must be deployed." + } + + if ([string]$evidence.canonicalUrl -ceq $canonicalUrl -and [string]$evidence.finalUrl -ceq $canonicalUrl) { + Add-Check "canonical-url" "Canonical HTTPS URL" "pass" $canonicalUrl "Evidence uses the app's canonical HTTPS privacy URL." + } else { + Add-Check "canonical-url" "Canonical HTTPS URL" "fail" ([string]$evidence.canonicalUrl) "Both canonicalUrl and finalUrl must equal $canonicalUrl" + } + + $sourcePath = Resolve-RootFile ([string]$evidence.sourcePath) + if ($null -ne $sourcePath -and (Test-Path -LiteralPath $sourcePath -PathType Leaf) -and [string]$evidence.sourcePath -eq "site/privacy/index.html") { + Add-Check "source-path" "Policy source path" "pass" ([string]$evidence.sourcePath) "The tracked public policy source exists." + } else { + Add-Check "source-path" "Policy source path" "fail" ([string]$evidence.sourcePath) "sourcePath must resolve to site/privacy/index.html inside the repository root." + } + + if ((Test-Commit ([string]$evidence.sourceCommit)) -and (Test-Commit ([string]$evidence.deploymentCommit))) { + Add-Check "deployment-commits" "Source and deployment commits" "pass" ([string]$evidence.deploymentCommit) "Both deployment identities are full Git commit hashes." + } else { + Add-Check "deployment-commits" "Source and deployment commits" "fail" ([string]$evidence.deploymentCommit) "sourceCommit and deploymentCommit must be full lowercase Git commit hashes." + } + + if ($null -ne $sourcePath -and (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { + $sourceBytes = [System.IO.File]::ReadAllBytes($sourcePath) + $sourceSha256 = Get-BytesSha256 $sourceBytes + if ($sourceSha256 -ceq [string]$evidence.sourceSha256 -and $sourceSha256 -ceq [string]$evidence.servedSha256) { + Add-Check "source-hash" "Policy source SHA-256" "pass" $sourceSha256 "Source and recorded served hashes match." + } else { + Add-Check "source-hash" "Policy source SHA-256" "fail" $sourceSha256 "The tracked source differs from sourceSha256 or servedSha256 in the deployment record." + } + } else { + Add-Check "source-hash" "Policy source SHA-256" "fail" "" "The policy source could not be hashed." + } + + if ([string]$evidence.deploymentMethod -eq "github-pages-branch" -and [string]$evidence.deploymentBranch -eq "gh-pages" -and [long]$evidence.pagesBuildId -gt 0 -and [string]$evidence.pagesBuildStatus -eq "built" -and [bool]$evidence.httpsEnforced) { + Add-Check "pages-build" "GitHub Pages build record" "pass" ([string]$evidence.pagesBuildId) "Pages build is recorded as built with HTTPS enforced." + } else { + Add-Check "pages-build" "GitHub Pages build record" "fail" ([string]$evidence.pagesBuildId) "Expected a built gh-pages deployment with HTTPS enforced." + } + + $pagesBuiltAtUtc = Get-RawJsonStringField $rawEvidence "pagesBuiltAtUtc" + $verifiedAtUtc = Get-RawJsonStringField $rawEvidence "verifiedAtUtc" + if ((Test-UtcTimestamp $pagesBuiltAtUtc) -and (Test-UtcTimestamp $verifiedAtUtc)) { + Add-Check "verification-timestamps" "Deployment verification timestamps" "pass" $verifiedAtUtc "Build and verification times use strict UTC timestamps." + } else { + Add-Check "verification-timestamps" "Deployment verification timestamps" "fail" $verifiedAtUtc "pagesBuiltAtUtc and verifiedAtUtc must use YYYY-MM-DDTHH:MM:SSZ." + } + + $servedBytes = $null + $httpStatus = 0 + $finalUrl = "" + try { + if ($verificationMode -eq "fixture") { + $fixturePath = if ([System.IO.Path]::IsPathRooted($FetchedContentPath)) { $FetchedContentPath } else { Join-Path $Root $FetchedContentPath } + $servedBytes = [System.IO.File]::ReadAllBytes($fixturePath) + $httpStatus = 200 + $finalUrl = $canonicalUrl + } else { + Add-Type -AssemblyName System.Net.Http + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $true + $client = [System.Net.Http.HttpClient]::new($handler) + $client.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds) + try { + $response = $client.GetAsync($canonicalUrl).GetAwaiter().GetResult() + try { + $httpStatus = [int]$response.StatusCode + $finalUrl = $response.RequestMessage.RequestUri.AbsoluteUri + $servedBytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() + } finally { + $response.Dispose() + } + } finally { + $client.Dispose() + $handler.Dispose() + } + } + Add-Check "content-fetch" "Published policy content fetch" "pass" $verificationMode "Policy content was fetched for verification." + } catch { + Add-Check "content-fetch" "Published policy content fetch" "fail" $verificationMode $_.Exception.Message + } + + if ($null -ne $servedBytes) { + if ($httpStatus -eq 200 -and $finalUrl -ceq $canonicalUrl) { + Add-Check "live-http" "Published policy HTTPS response" "pass" "$httpStatus $finalUrl" "Canonical URL returned HTTP 200 without leaving the canonical HTTPS URL." + } else { + Add-Check "live-http" "Published policy HTTPS response" "fail" "$httpStatus $finalUrl" "Expected HTTP 200 at the canonical URL." + } + + $servedSha256 = Get-BytesSha256 $servedBytes + if ($servedSha256 -ceq $sourceSha256 -and $servedSha256 -ceq [string]$evidence.servedSha256) { + Add-Check "live-content-hash" "Published policy byte identity" "pass" $servedSha256 "Published bytes exactly match the tracked source and deployment record." + } else { + Add-Check "live-content-hash" "Published policy byte identity" "fail" $servedSha256 "Published bytes do not match the tracked source and deployment record." + } + + $servedText = [System.Text.Encoding]::UTF8.GetString($servedBytes) + $requiredMarkers = @( + "Stackchan Companion Privacy Policy", + "July 14, 2026", + "dev.stackchan.companion", + "local network", + "configured Android speech-recognition service", + "Privacy inquiries" + ) + $missingMarkers = @($requiredMarkers | Where-Object { $servedText.IndexOf($_, [System.StringComparison]::Ordinal) -lt 0 }) + if ($missingMarkers.Count -eq 0) { + Add-Check "live-content-markers" "Published policy disclosures" "pass" ($requiredMarkers -join ", ") "Required identity and privacy disclosures are present." + } else { + Add-Check "live-content-markers" "Published policy disclosures" "fail" ($missingMarkers -join ", ") "Published policy is missing required disclosure text." + } + } else { + Add-Check "live-http" "Published policy HTTPS response" "fail" "" "No fetched response was available." + Add-Check "live-content-hash" "Published policy byte identity" "fail" "" "No fetched content was available." + Add-Check "live-content-markers" "Published policy disclosures" "fail" "" "No fetched content was available." + } +} + +$failedChecks = @($checks | Where-Object { $_.status -eq "fail" }) +$report = [ordered]@{ + schema = "stackchan.privacy-policy-deployment-check.v1" + status = if ($failedChecks.Count -eq 0) { "privacy-policy-deployment-ready" } else { "privacy-policy-deployment-not-ready" } + evidencePath = [string]$EvidencePath + canonicalUrl = $canonicalUrl + sourceCommit = if ($null -ne $evidence) { [string]$evidence.sourceCommit } else { "" } + deploymentCommit = if ($null -ne $evidence) { [string]$evidence.deploymentCommit } else { "" } + verificationMode = $verificationMode + liveVerified = ($verificationMode -eq "live-https" -and $failedChecks.Count -eq 0) + sourceSha256 = $sourceSha256 + servedSha256 = $servedSha256 + checkedAtUtc = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") + passed = @($checks | Where-Object { $_.status -eq "pass" }).Count + failed = $failedChecks.Count + checks = @($checks) +} + +if ($Json) { + $report | ConvertTo-Json -Depth 8 +} else { + Write-Host "Privacy policy deployment: $($report.status)" + Write-Host "Passed: $($report.passed) Failed: $($report.failed) Mode: $verificationMode" + foreach ($check in $checks) { + $prefix = if ($check.status -eq "pass") { "PASS" } else { "FAIL" } + Write-Host "[$prefix] $($check.name) - $($check.detail)" + } +} + +if ($failedChecks.Count -gt 0) { + exit 1 +} +exit 0 diff --git a/tools/check_release_credential_hygiene.cmd b/tools/check_release_credential_hygiene.cmd new file mode 100644 index 00000000..85d30a2a --- /dev/null +++ b/tools/check_release_credential_hygiene.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0check_release_credential_hygiene.ps1" %* diff --git a/tools/check_release_credential_hygiene.ps1 b/tools/check_release_credential_hygiene.ps1 new file mode 100644 index 00000000..92bf5013 --- /dev/null +++ b/tools/check_release_credential_hygiene.ps1 @@ -0,0 +1,171 @@ +param( + [string]$Root = "", + [switch]$Json +) + +$ErrorActionPreference = "Stop" + +if ([string]::IsNullOrWhiteSpace($Root)) { + $Root = Resolve-Path (Join-Path $PSScriptRoot "..") +} else { + $Root = Resolve-Path $Root +} + +$checks = @() +$blockedTrackedFiles = @() +$privateKeyMarkerFiles = @() + +function Add-Check { + param( + [string]$Id, + [ValidateSet("pass", "fail")] + [string]$Status, + [string]$Detail + ) + + $script:checks += [ordered]@{ + id = $Id + status = $Status + detail = $Detail + } +} + +function Invoke-Git { + param( + [string[]]$Arguments, + [int[]]$AllowedExitCodes = @(0) + ) + + $output = @() + $exitCode = -1 + $oldPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& git -C $Root @Arguments 2>&1) + $exitCode = $LASTEXITCODE + } catch { + $output = @($_.Exception.Message) + } finally { + $ErrorActionPreference = $oldPreference + } + + if ($AllowedExitCodes -notcontains $exitCode) { + throw "git -C $Root $($Arguments -join ' ') failed with exit code $exitCode.`n$($output | Out-String)" + } + return [pscustomobject]@{ + exitCode = $exitCode + output = @($output) + } +} + +try { + $inside = Invoke-Git -Arguments @("rev-parse", "--is-inside-work-tree") + $isWorkTree = (($inside.output | Out-String).Trim() -eq "true") + Add-Check "git-worktree" $(if ($isWorkTree) { "pass" } else { "fail" }) "root=$Root" +} catch { + Add-Check "git-worktree" "fail" "A Git source checkout is required: $($_.Exception.Message)" +} + +$requiredIgnoreProbes = [ordered]@{ + "ignore-pattern-jks" = "credential-hygiene-probe/upload.jks" + "ignore-pattern-keystore" = "credential-hygiene-probe/upload.keystore" + "ignore-pattern-p12" = "credential-hygiene-probe/developer-id.p12" + "ignore-pattern-pfx" = "credential-hygiene-probe/authenticode.pfx" + "ignore-pattern-pkcs12" = "credential-hygiene-probe/signing.pkcs12" + "ignore-pattern-key" = "credential-hygiene-probe/private.key" + "ignore-pattern-p8" = "credential-hygiene-probe/apple-api-key.p8" + "ignore-pattern-snk" = "credential-hygiene-probe/strong-name.snk" +} + +foreach ($entry in $requiredIgnoreProbes.GetEnumerator()) { + try { + $ignored = Invoke-Git ` + -Arguments @("check-ignore", "--quiet", "--no-index", "--", [string]$entry.Value) ` + -AllowedExitCodes @(0, 1) + $isIgnored = ($ignored.exitCode -eq 0) + Add-Check ([string]$entry.Key) $(if ($isIgnored) { "pass" } else { "fail" }) $( + if ($isIgnored) { + "$($entry.Value) is excluded from Git." + } else { + "$($entry.Value) is not excluded; add its private-key extension to .gitignore." + } + ) + } catch { + Add-Check ([string]$entry.Key) "fail" $_.Exception.Message + } +} + +try { + $tracked = Invoke-Git -Arguments @("ls-files") + $blockedPattern = '(?i)\.(jks|keystore|p12|pfx|pkcs12|key|p8|snk)$' + $blockedTrackedFiles = @( + $tracked.output | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -match $blockedPattern } | + Sort-Object -Unique + ) + Add-Check "tracked-private-key-bundles" $(if ($blockedTrackedFiles.Count -eq 0) { "pass" } else { "fail" }) $( + if ($blockedTrackedFiles.Count -eq 0) { + "No private signing credential bundle extension is tracked." + } else { + "Tracked private signing credential paths: $($blockedTrackedFiles -join ', ')" + } + ) +} catch { + Add-Check "tracked-private-key-bundles" "fail" $_.Exception.Message +} + +try { + $markerPattern = 'BEGIN (RSA |EC |OPENSSH |DSA |ENCRYPTED )?' + 'PRIVATE KEY' + $markerSearch = Invoke-Git ` + -Arguments @("grep", "-I", "-l", "-E", $markerPattern, "--", ".") ` + -AllowedExitCodes @(0, 1) + if ($markerSearch.exitCode -eq 0) { + $privateKeyMarkerFiles = @( + $markerSearch.output | + ForEach-Object { ([string]$_).Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique + ) + } + Add-Check "tracked-private-key-markers" $(if ($privateKeyMarkerFiles.Count -eq 0) { "pass" } else { "fail" }) $( + if ($privateKeyMarkerFiles.Count -eq 0) { + "No tracked text file contains a PEM or OpenSSH private-key marker." + } else { + "Tracked files containing private-key markers: $($privateKeyMarkerFiles -join ', ')" + } + ) +} catch { + Add-Check "tracked-private-key-markers" "fail" $_.Exception.Message +} + +$failed = @($checks | Where-Object { $_.status -eq "fail" }) +$status = if ($failed.Count -eq 0) { + "release-credential-hygiene-ready" +} else { + "blocked-release-credential-hygiene" +} +$report = [ordered]@{ + schema = "stackchan.release-credential-hygiene.v1" + status = $status + root = [string]$Root + passed = @($checks | Where-Object { $_.status -eq "pass" }).Count + failed = $failed.Count + blockedTrackedFiles = @($blockedTrackedFiles) + privateKeyMarkerFiles = @($privateKeyMarkerFiles) + checks = @($checks) +} + +if ($Json) { + $report | ConvertTo-Json -Depth 6 +} else { + Write-Host "Release credential hygiene: $status" + Write-Host "Pass: $($report.passed) Fail: $($report.failed)" + foreach ($check in $checks) { + Write-Host "[$($check.status)] $($check.id): $($check.detail)" + } +} + +if ($failed.Count -gt 0) { + exit 1 +} diff --git a/tools/download_companion_ci_candidate.cmd b/tools/download_companion_ci_candidate.cmd new file mode 100644 index 00000000..70e53290 --- /dev/null +++ b/tools/download_companion_ci_candidate.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0download_companion_ci_candidate.ps1" %* diff --git a/tools/download_companion_ci_candidate.ps1 b/tools/download_companion_ci_candidate.ps1 new file mode 100644 index 00000000..4bc62474 --- /dev/null +++ b/tools/download_companion_ci_candidate.ps1 @@ -0,0 +1,424 @@ +param( + [Parameter(Mandatory = $true)] + [long]$RunId, + [string]$Repo = "RobVanProd/stackchan_alive", + [string]$Commit = "", + [string]$OutDir = "", + [string]$FixtureRoot = "", + [switch]$Json +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +Set-Location $repoRoot + +function Normalize-Commit { + param([string]$Value) + + $normalized = ([string]$Value).Trim().ToLowerInvariant() + if ($normalized -notmatch '^[0-9a-f]{40}$') { + throw "Expected a full 40-character source commit; got '$Value'." + } + return $normalized +} + +function Split-Repo { + param([string]$Repository) + + $parts = $Repository -split "/", 2 + if ($parts.Count -ne 2 -or + [string]::IsNullOrWhiteSpace($parts[0]) -or + [string]::IsNullOrWhiteSpace($parts[1])) { + throw "Repo must be in owner/name form: $Repository" + } + return [ordered]@{ owner = $parts[0]; name = $parts[1] } +} + +function Get-FixtureJson { + param([string]$Name) + + $path = Join-Path $FixtureRoot $Name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Missing companion CI candidate fixture: $path" + } + return Get-Content -LiteralPath $path -Raw +} + +function Invoke-GhJson { + param([string[]]$Arguments) + + $oldPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& gh @Arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldPreference + } + if ($exitCode -ne 0) { + throw "gh command failed with exit code $exitCode`: gh $($Arguments -join ' ')`n$($output | Out-String)" + } + return ($output | Out-String).Trim() +} + +function Copy-FixtureArtifact { + param( + [string]$Name, + [string]$Destination + ) + + $source = Join-Path $FixtureRoot "downloads/$Name" + if (-not (Test-Path -LiteralPath $source -PathType Container)) { + throw "Missing downloaded-artifact fixture for $Name`: $source" + } + foreach ($file in @(Get-ChildItem -LiteralPath $source -Recurse -File -Force)) { + $relativePath = Get-RelativePathNormalized -BasePath $source -Path $file.FullName + $target = Join-Path $Destination $relativePath + [void][IO.Directory]::CreateDirectory((Get-ExtendedIoPath ([IO.Path]::GetDirectoryName($target)))) + [IO.File]::Copy( + (Get-ExtendedIoPath $file.FullName), + (Get-ExtendedIoPath $target), + $true + ) + } +} + +function Download-Artifact { + param( + [string]$Name, + [string]$Destination + ) + + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + if (-not [string]::IsNullOrWhiteSpace($FixtureRoot)) { + Copy-FixtureArtifact -Name $Name -Destination $Destination + return + } + + $oldPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& gh run download $RunId --repo $Repo --name $Name --dir $Destination 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldPreference + } + if ($exitCode -ne 0) { + throw "Could not download GitHub Actions artifact '$Name' from run $RunId (exit $exitCode).`n$($output | Out-String)" + } +} + +function Get-ExtendedIoPath { + param([string]$Path) + + $fullPath = [IO.Path]::GetFullPath($Path) + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT -and + -not $fullPath.StartsWith("\\?\", [StringComparison]::Ordinal)) { + if ($fullPath.StartsWith("\\", [StringComparison]::Ordinal)) { + return "\\?\UNC\" + $fullPath.Substring(2) + } + return "\\?\" + $fullPath + } + return $fullPath +} + +function Get-RelativePathNormalized { + param( + [string]$BasePath, + [string]$Path + ) + + $baseFull = [IO.Path]::GetFullPath($BasePath).TrimEnd("\", "/") + $pathFull = [IO.Path]::GetFullPath($Path) + $prefix = $baseFull + [IO.Path]::DirectorySeparatorChar + if (-not $pathFull.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Path is outside candidate root: $pathFull" + } + return $pathFull.Substring($prefix.Length).Replace("\", "/") +} + +function Get-Sha256Hex { + param([string]$Path) + + $stream = $null + $sha256 = $null + try { + $stream = [IO.File]::Open( + (Get-ExtendedIoPath $Path), + [IO.FileMode]::Open, + [IO.FileAccess]::Read, + [IO.FileShare]::Read + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + return ([BitConverter]::ToString($sha256.ComputeHash($stream)) -replace "-", "").ToLowerInvariant() + } finally { + if ($null -ne $sha256) { $sha256.Dispose() } + if ($null -ne $stream) { $stream.Dispose() } + } +} + +function Get-FileEntry { + param( + [string]$CandidateRoot, + [IO.FileInfo]$File + ) + + return [pscustomobject][ordered]@{ + path = Get-RelativePathNormalized -BasePath $CandidateRoot -Path $File.FullName + name = $File.Name + bytes = [int64]$File.Length + sha256 = Get-Sha256Hex -Path $File.FullName + } +} + +if ($RunId -le 0) { + throw "RunId must be a positive GitHub Actions run id." +} +if ([string]::IsNullOrWhiteSpace($Commit)) { + $Commit = (& git rev-parse HEAD).Trim() + if ($LASTEXITCODE -ne 0) { + throw "Could not resolve the current source commit." + } +} +$Commit = Normalize-Commit $Commit +$repoParts = Split-Repo $Repo + +$runJson = if (-not [string]::IsNullOrWhiteSpace($FixtureRoot)) { + Get-FixtureJson "run.json" +} else { + Invoke-GhJson @( + "run", "view", [string]$RunId, + "--repo", $Repo, + "--json", "databaseId,name,headSha,headBranch,status,conclusion,url,event,createdAt,updatedAt,jobs" + ) +} +$run = $runJson | ConvertFrom-Json +$runCommit = Normalize-Commit ([string]$run.headSha) + +if ([long]$run.databaseId -ne $RunId) { + throw "GitHub Actions run id mismatch: requested $RunId, received $($run.databaseId)." +} +if ([string]$run.name -ne "Firmware") { + throw "Run $RunId is '$($run.name)', not the Firmware workflow." +} +if ([string]$run.status -ne "completed" -or [string]$run.conclusion -ne "success") { + throw "Firmware run $RunId is not successful and complete: status=$($run.status) conclusion=$($run.conclusion)." +} +if ($runCommit -ne $Commit) { + throw "Firmware run $RunId source mismatch: run=$runCommit expected=$Commit." +} + +$requiredJobs = @( + "changes", + "bridge-tests", + "native-tests", + "build", + "companion-tests", + "companion-platform-builds (android-apk)", + "companion-platform-builds (desktop-windows)", + "companion-platform-builds (desktop-macos)", + "companion-platform-builds (desktop-linux)", + "companion-android-emulator-smoke", + "companion-release-evidence" +) +$jobReports = @() +foreach ($jobName in $requiredJobs) { + $matches = @($run.jobs | Where-Object { [string]$_.name -eq $jobName }) + if ($matches.Count -ne 1) { + throw "Firmware run $RunId must contain exactly one '$jobName' job; found $($matches.Count)." + } + $job = $matches[0] + if ([string]$job.status -ne "completed" -or [string]$job.conclusion -ne "success") { + throw "Firmware run $RunId job '$jobName' is not successful and complete." + } + $jobReports += [ordered]@{ + name = $jobName + status = [string]$job.status + conclusion = [string]$job.conclusion + } +} + +$artifactsJson = if (-not [string]::IsNullOrWhiteSpace($FixtureRoot)) { + Get-FixtureJson "artifacts.json" +} else { + Invoke-GhJson @( + "api", + "repos/$($repoParts.owner)/$($repoParts.name)/actions/runs/$RunId/artifacts?per_page=100" + ) +} +$artifactResponse = $artifactsJson | ConvertFrom-Json +$availableArtifacts = @($artifactResponse.artifacts) +$requiredArtifacts = @( + "companion-release-evidence", + "companion-android-apks", + "companion-android-emulator-smoke", + "companion-desktop-windows", + "companion-desktop-macos", + "companion-desktop-linux" +) +$artifactMetadata = @() +foreach ($artifactName in $requiredArtifacts) { + $matches = @($availableArtifacts | Where-Object { [string]$_.name -eq $artifactName }) + if ($matches.Count -ne 1) { + throw "Firmware run $RunId must contain exactly one '$artifactName' artifact; found $($matches.Count)." + } + $artifact = $matches[0] + if ([bool]$artifact.expired) { + throw "Firmware run $RunId artifact '$artifactName' has expired." + } + if ([int64]$artifact.size_in_bytes -le 0) { + throw "Firmware run $RunId artifact '$artifactName' is empty." + } + $artifactMetadata += $artifact +} + +if ([string]::IsNullOrWhiteSpace($OutDir)) { + $OutDir = Join-Path $repoRoot "output/companion/ci-candidates/$($Commit.Substring(0, 12))-run-$RunId" +} +$candidateRoot = [IO.Path]::GetFullPath($OutDir) +if (Test-Path -LiteralPath $candidateRoot) { + $existing = @(Get-ChildItem -LiteralPath $candidateRoot -Force -ErrorAction SilentlyContinue) + if ($existing.Count -gt 0) { + throw "Candidate output directory is not empty: $candidateRoot" + } +} +New-Item -ItemType Directory -Force -Path $candidateRoot | Out-Null +$artifactRoot = Join-Path $candidateRoot "artifacts" +New-Item -ItemType Directory -Force -Path $artifactRoot | Out-Null + +$artifactReports = @() +foreach ($artifactName in $requiredArtifacts) { + $metadata = @($artifactMetadata | Where-Object { [string]$_.name -eq $artifactName })[0] + $destination = Join-Path $artifactRoot $artifactName + Download-Artifact -Name $artifactName -Destination $destination + $files = @( + Get-ChildItem -LiteralPath $destination -Recurse -File | + Sort-Object FullName | + ForEach-Object { Get-FileEntry -CandidateRoot $candidateRoot -File $_ } + ) + if ($files.Count -lt 1) { + throw "Downloaded artifact '$artifactName' contains no files." + } + + if ($artifactName -eq "companion-release-evidence") { + $probeFiles = @( + Get-ChildItem -LiteralPath $destination -Recurse -File -Filter "COMPANION_RELEASE_EVIDENCE.json" + ) + if ($probeFiles.Count -ne 1) { + throw "Expected exactly one COMPANION_RELEASE_EVIDENCE.json; found $($probeFiles.Count)." + } + $probe = Get-Content -LiteralPath $probeFiles[0].FullName -Raw | ConvertFrom-Json + if ([string]$probe.schema -ne "stackchan.companion-release-evidence.v1") { + throw "Downloaded companion release evidence has an unexpected schema." + } + if ([string]$probe.status -ne "complete" -or @($probe.pending).Count -ne 0) { + throw "Downloaded companion release evidence is incomplete: status=$($probe.status)." + } + $probeCommit = Normalize-Commit ([string]$probe.commit) + if ($probeCommit -ne $Commit) { + throw "Downloaded companion release evidence source mismatch: evidence=$probeCommit expected=$Commit." + } + if ((Normalize-Commit ([string]$probe.version)) -ne $Commit) { + throw "Downloaded companion release evidence version is not the exact source commit." + } + } + + $artifactReports += [ordered]@{ + name = $artifactName + id = [long]$metadata.id + apiSizeBytes = [int64]$metadata.size_in_bytes + expired = [bool]$metadata.expired + localPath = Get-RelativePathNormalized -BasePath $candidateRoot -Path $destination + fileCount = $files.Count + downloadedBytes = [int64](($files | Measure-Object -Property bytes -Sum).Sum) + files = @($files) + } +} + +$releaseEvidenceFiles = @( + Get-ChildItem -LiteralPath (Join-Path $artifactRoot "companion-release-evidence") -Recurse -File -Filter "COMPANION_RELEASE_EVIDENCE.json" +) +if ($releaseEvidenceFiles.Count -ne 1) { + throw "Expected exactly one COMPANION_RELEASE_EVIDENCE.json; found $($releaseEvidenceFiles.Count)." +} +$releaseEvidenceFile = $releaseEvidenceFiles[0] +$releaseEvidence = Get-Content -LiteralPath $releaseEvidenceFile.FullName -Raw | ConvertFrom-Json +if ([string]$releaseEvidence.schema -ne "stackchan.companion-release-evidence.v1") { + throw "Downloaded companion release evidence has an unexpected schema." +} +if ([string]$releaseEvidence.status -ne "complete" -or @($releaseEvidence.pending).Count -ne 0) { + throw "Downloaded companion release evidence is incomplete: status=$($releaseEvidence.status)." +} +$evidenceCommit = Normalize-Commit ([string]$releaseEvidence.commit) +if ($evidenceCommit -ne $Commit) { + throw "Downloaded companion release evidence source mismatch: evidence=$evidenceCommit expected=$Commit." +} +if ((Normalize-Commit ([string]$releaseEvidence.version)) -ne $Commit) { + throw "Downloaded companion release evidence version is not the exact source commit." +} + +$downloadedFiles = @($artifactReports | ForEach-Object { @($_.files) }) +$evidenceArtifactEntries = @($releaseEvidence.artifacts | ForEach-Object { @($_.entries) }) +if ($evidenceArtifactEntries.Count -ne 6) { + throw "Companion release evidence must bind exactly six Android/desktop distribution artifacts; found $($evidenceArtifactEntries.Count)." +} +foreach ($entry in $evidenceArtifactEntries) { + $matches = @($downloadedFiles | Where-Object { $_.name -eq [string]$entry.name }) + if ($matches.Count -ne 1) { + throw "Evidence entry '$($entry.name)' must match exactly one downloaded file; found $($matches.Count)." + } + $downloaded = $matches[0] + if ([int64]$downloaded.bytes -ne [int64]$entry.bytes -or + [string]$downloaded.sha256 -ne ([string]$entry.sha256).ToLowerInvariant()) { + throw "Downloaded file does not match companion release evidence: $($entry.name)" + } +} + +$report = [ordered]@{ + schema = "stackchan.companion-ci-candidate.v1" + status = "companion-ci-candidate-ready" + scope = "exact-source-ci-rehearsal-artifacts" + substitutesForTaggedRelease = $false + substitutesForPhysicalEvidence = $false + publicReleaseReady = $false + repo = $Repo + runId = $RunId + runUrl = [string]$run.url + workflow = [string]$run.name + event = [string]$run.event + branch = [string]$run.headBranch + sourceCommit = $Commit + generatedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + outputRoot = $candidateRoot + jobs = @($jobReports) + artifacts = @($artifactReports) + releaseEvidence = [ordered]@{ + path = Get-RelativePathNormalized -BasePath $candidateRoot -Path $releaseEvidenceFile.FullName + sha256 = Get-Sha256Hex -Path $releaseEvidenceFile.FullName + status = [string]$releaseEvidence.status + sourceCommit = $evidenceCommit + androidSigningProfile = [string]$releaseEvidence.androidSigning.signingProfile + androidBundleSigningProfile = [string]$releaseEvidence.androidBundleSigning.signingProfile + desktopPackageEvidenceStatus = [string]$releaseEvidence.desktopPackageEvidence.status + } + limitations = @( + "CI rehearsal Android release artifacts may use the explicit lab debug-signing fallback.", + "CI rehearsal desktop packages are not substitutes for production Authenticode or Developer ID/notarization evidence.", + "Target-phone, target-workstation, and robot evidence must remain bound to the exact downloaded file hashes." + ) +} + +$manifestPath = Join-Path $candidateRoot "COMPANION_CI_CANDIDATE.json" +$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 + +if ($Json) { + $report | ConvertTo-Json -Depth 12 +} else { + Write-Host "Companion CI candidate: $($report.status)" + Write-Host "Run: $RunId ($($report.runUrl))" + Write-Host "Source commit: $Commit" + Write-Host "Artifacts: $($artifactReports.Count) Files: $($downloadedFiles.Count)" + Write-Host "Manifest: $manifestPath" + Write-Host "Scope: CI rehearsal only; not a tagged or production-signed release." +} diff --git a/tools/export_companion_release_evidence.ps1 b/tools/export_companion_release_evidence.ps1 index db2c1337..e7a6fe4a 100644 --- a/tools/export_companion_release_evidence.ps1 +++ b/tools/export_companion_release_evidence.ps1 @@ -6,10 +6,16 @@ param( [string]$AndroidArtifactRoot = "", [string]$DesktopArtifactRoot = "", [string]$DesktopPythonRuntimeRoot = "", + [string]$DesktopPackageEvidenceRoot = "", + [string]$AndroidEmulatorEvidencePath = "", [string]$ApkSignerPath = "", [string]$OutDir = "", [switch]$RequireArtifacts, + [switch]$RequireUploadSigning, [switch]$RequireDesktopPythonRuntime, + [switch]$RequireDesktopPackageEvidence, + [switch]$RequireDesktopDistributionTrust, + [switch]$RequireAndroidEmulatorEvidence, [switch]$Json ) @@ -520,6 +526,134 @@ function Test-AndroidReleaseBundleSignature { } } +function Get-AndroidEmulatorReleaseEvidence { + $releaseEntry = Get-AndroidApkEntry $androidArtifacts "release" + if ($null -eq $releaseEntry) { + return [ordered]@{ + status = "pending" + checker = "" + evidence = "" + releaseApk = "" + releaseApkSha256 = "" + smoke = $null + issues = @("Release APK artifact not found for emulator evidence binding.") + } + } + + $releaseApkPath = [string]$releaseEntry["path"] + if (-not [System.IO.Path]::IsPathRooted($releaseApkPath)) { + $releaseApkPath = Join-Path $Root $releaseApkPath + } + if (-not (Test-Path -LiteralPath $releaseApkPath -PathType Leaf)) { + return [ordered]@{ + status = "pending" + checker = "" + evidence = "" + releaseApk = [string]$releaseEntry["path"] + releaseApkSha256 = "" + smoke = $null + issues = @("Release APK path does not exist for emulator evidence binding.") + } + } + + $resolvedEvidencePath = "" + foreach ($candidatePath in @( + $AndroidEmulatorEvidencePath, + "output/android-emulator-smoke/latest/android_emulator_launch_smoke.json", + "output/companion/ci-artifacts/companion-android-emulator-smoke/android_emulator_launch_smoke.json", + "output/companion/release-input/android-emulator/android_emulator_launch_smoke.json" + )) { + if ([string]::IsNullOrWhiteSpace($candidatePath)) { + continue + } + $candidate = if ([System.IO.Path]::IsPathRooted($candidatePath)) { $candidatePath } else { Join-Path $Root $candidatePath } + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + $resolvedEvidencePath = [string](Resolve-Path -LiteralPath $candidate) + break + } + } + if ([string]::IsNullOrWhiteSpace($resolvedEvidencePath)) { + return [ordered]@{ + status = "pending" + checker = "" + evidence = $AndroidEmulatorEvidencePath + releaseApk = [string]$releaseEntry["path"] + releaseApkSha256 = Get-Sha256Text $releaseApkPath + smoke = $null + issues = @("Android emulator launch evidence was not found.") + } + } + + $checkerPath = Join-Path $Root "tools/check_android_emulator_release_evidence.ps1" + if (-not (Test-Path -LiteralPath $checkerPath -PathType Leaf)) { + return [ordered]@{ + status = "failed" + checker = "" + evidence = Convert-ToRelativePath $resolvedEvidencePath + releaseApk = [string]$releaseEntry["path"] + releaseApkSha256 = Get-Sha256Text $releaseApkPath + smoke = $null + issues = @("Android emulator release evidence checker is missing.") + } + } + + $powerShellRunner = Find-PowerShellRunner + if ([string]::IsNullOrWhiteSpace($powerShellRunner)) { + return [ordered]@{ + status = "failed" + checker = Convert-ToRelativePath $checkerPath + evidence = Convert-ToRelativePath $resolvedEvidencePath + releaseApk = [string]$releaseEntry["path"] + releaseApkSha256 = Get-Sha256Text $releaseApkPath + smoke = $null + issues = @("Neither pwsh nor powershell was found for the Android emulator evidence checker.") + } + } + + $checkerOutput = @() + $checkerExitCode = -1 + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $checkerOutput = @(& $powerShellRunner -NoProfile -File $checkerPath -EvidencePath $resolvedEvidencePath -ReleaseApkPath $releaseApkPath -Json 2>&1) + $checkerExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + $checkerText = ($checkerOutput | Out-String).Trim() + try { + $checkerReport = $checkerText | ConvertFrom-Json + } catch { + return [ordered]@{ + status = "failed" + checker = Convert-ToRelativePath $checkerPath + evidence = Convert-ToRelativePath $resolvedEvidencePath + releaseApk = [string]$releaseEntry["path"] + releaseApkSha256 = Get-Sha256Text $releaseApkPath + smoke = $null + issues = @("Android emulator evidence checker did not return valid JSON (exit $checkerExitCode).") + } + } + + $checkerStatus = [string]$checkerReport.status + $checkerIssues = @($checkerReport.issues) + if (($checkerExitCode -eq 0) -ne ($checkerStatus -eq "ready")) { + $checkerStatus = "failed" + $checkerIssues += "Android emulator evidence checker status and exit code were inconsistent (status '$($checkerReport.status)', exit $checkerExitCode)." + } + + return [ordered]@{ + status = $checkerStatus + checkerExitCode = $checkerExitCode + checker = Convert-ToRelativePath $checkerPath + evidence = Convert-ToRelativePath $resolvedEvidencePath + releaseApk = [string]$releaseEntry["path"] + releaseApkSha256 = [string]$checkerReport.releaseApkSha256 + smoke = $checkerReport.evidence + issues = $checkerIssues + } +} + function Get-DesktopPythonRuntimeEvidence { $checkerPath = Join-Path $Root "tools/check_desktop_python_runtime_payload.ps1" if (-not (Test-Path -LiteralPath $checkerPath -PathType Leaf)) { @@ -580,6 +714,248 @@ function Get-DesktopPythonRuntimeEvidence { } } +function Get-DesktopPackageEvidence { + $evidenceRoot = Find-FirstExistingDirectory @($DesktopPackageEvidenceRoot, $DesktopArtifactRoot) + if ([string]::IsNullOrWhiteSpace($evidenceRoot)) { + return [ordered]@{ + status = "pending" + root = "" + platforms = @() + issues = @("Desktop package evidence root was not found.") + } + } + + $issues = @() + $summaries = @() + $files = @(Get-ChildItem -LiteralPath $evidenceRoot -Recurse -File -Filter "*-package-evidence.json" -ErrorAction SilentlyContinue) + $parsedReports = @() + foreach ($file in $files) { + try { + $parsedReports += [pscustomobject]@{ path = $file.FullName; report = (Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json) } + } catch { + $issues += "Desktop package evidence is invalid JSON: $($file.FullName)" + } + } + + $expected = [ordered]@{ windows = ".msi"; linux = ".deb"; macos = ".dmg" } + $requiredBrainFiles = @( + "brain/bridge/lan_service.py", + "brain/bridge/reference_bridge.py", + "brain/data/voice_source_provenance.yaml", + "brain/docs/media/voice/stackchan_spark_greeting.wav" + ) + foreach ($platform in $expected.Keys) { + $matches = @($parsedReports | Where-Object { [string]$_.report.platform -eq $platform }) + if ($matches.Count -ne 1) { + $issues += "Expected exactly one desktop package evidence report for $platform; found $($matches.Count)." + continue + } + $path = $matches[0].path + $item = $matches[0].report + if ([string]$item.schema -ne "stackchan.desktop-package-evidence.v1") { $issues += "Desktop package evidence for $platform has unexpected schema." } + if ([string]$item.status -ne "ready") { $issues += "Desktop package evidence for $platform is not ready." } + if ([string]$item.version -ne $versionText) { $issues += "Desktop package evidence version mismatch for $platform." } + if ([string]$item.commit -ne $commitText) { $issues += "Desktop package evidence commit mismatch for $platform." } + if ([string]$item.package.extension -ne [string]$expected[$platform]) { $issues += "Desktop package evidence extension mismatch for $platform." } + $packageSha = ([string]$item.package.sha256).ToLowerInvariant() + if ($packageSha -notmatch '^[a-f0-9]{64}$') { $issues += "Desktop package evidence SHA-256 is invalid for $platform." } + $artifactMatches = @($desktopArtifacts.entries | Where-Object { + ([string]$_['sha256']).ToLowerInvariant() -eq $packageSha -and + ([string]$_['path']).EndsWith([string]$expected[$platform], [System.StringComparison]::OrdinalIgnoreCase) + }) + if ($artifactMatches.Count -ne 1) { $issues += "Desktop package evidence does not match exactly one downloaded $platform artifact." } + $payloadSha = ([string]$item.runtime.payloadSha256).ToLowerInvariant() + $processedSha = ([string]$item.runtime.processedPayloadSha256).ToLowerInvariant() + if ($payloadSha -notmatch '^[a-f0-9]{64}$' -or $processedSha -ne $payloadSha) { $issues += "Processed runtime payload hash mismatch for $platform." } + if ([int64]$item.runtime.processedFileCount -lt 2 -or [int64]$item.runtime.processedBytes -le 0) { $issues += "Processed runtime payload summary is incomplete for $platform." } + foreach ($field in @("source", "pythonVersion", "probedPythonVersion")) { + if ([string]::IsNullOrWhiteSpace([string]$item.runtime.$field)) { $issues += "Desktop runtime $field is missing for $platform." } + } + $installer = $item.installerPayload + $installerAppJarName = "" + $installerAppJarSha = "" + $installerPackageSha = "" + $installerRuntimeSha = "" + $installerFileCount = 0 + $installerBytes = 0 + $installerBrainFiles = @() + $installerExtractionMethod = "" + $installerContentIdentityStatus = "" + $installerSignatureNormalizedFileCount = 0 + $distributionTrustStatus = "" + $distributionTrustPolicy = "" + $distributionTrustSigner = "" + $distributionTrustTimestamped = $false + $distributionTrustNotarizationStapled = $false + $distributionTrustGatekeeperAccepted = $false + if ($null -eq $installer) { + $issues += "Installer-derived runtime evidence is missing for $platform." + } else { + $installerAppJarName = [string]$installer.appJarName + $installerAppJarSha = ([string]$installer.appJarSha256).ToLowerInvariant() + $installerPackageSha = ([string]$installer.packageSha256).ToLowerInvariant() + $installerRuntimeSha = ([string]$installer.runtimePayloadSha256).ToLowerInvariant() + $installerFileCount = [int64]$installer.runtimeFileCount + $installerBytes = [int64]$installer.runtimeBytes + $installerBrainFiles = @($installer.requiredBrainFiles | ForEach-Object { [string]$_ }) + $installerExtractionMethod = [string]$installer.extractionMethod + $installerContentIdentityStatus = [string]$installer.contentIdentityStatus + if ($installer.required -ne $true -or [string]$installer.status -ne "ready") { $issues += "Installer-derived runtime evidence is not required and ready for $platform." } + if ($installerExtractionMethod -ne "native") { $issues += "Installer payload extraction was not performed natively for $platform." } + if ([string]$installer.runtimeLocation -ne "native-app-resources" -or [string]::IsNullOrWhiteSpace([string]$installer.runtimeRootRelative)) { $issues += "Installer runtime is not external native app resources for $platform." } + if ($installerAppJarName -notmatch '^app-desktop-.+\.jar$' -or $installerAppJarSha -notmatch '^[a-f0-9]{64}$') { $issues += "Installer application JAR evidence is invalid for $platform." } + if ($installerPackageSha -ne $packageSha) { $issues += "Installer payload package hash mismatch for $platform." } + $exactContentIdentity = $installerContentIdentityStatus -eq "ready-exact" -and + $installerRuntimeSha -eq $payloadSha -and $installerRuntimeSha -eq $processedSha + $signatureNormalizedContentIdentity = $false + if ($platform -eq "macos" -and $installerContentIdentityStatus -eq "ready-signature-normalized") { + $normalization = $installer.signatureNormalization + $normalizationFiles = @($normalization.files) + $installerSignatureNormalizedFileCount = [int]$normalization.changedFileCount + $normalizationFilesValid = $installerSignatureNormalizedFileCount -gt 0 -and + $normalizationFiles.Count -eq $installerSignatureNormalizedFileCount + $normalizationPaths = New-Object System.Collections.Generic.List[string] + foreach ($proof in $normalizationFiles) { + $proofPath = [string]$proof.path + $normalizationPaths.Add($proofPath) + $architectureProofs = @($proof.architectures) + $architecturesValid = $architectureProofs.Count -gt 0 + $architectureNames = New-Object System.Collections.Generic.List[string] + foreach ($architectureProof in $architectureProofs) { + $architectureNames.Add([string]$architectureProof.architecture) + if ([string]::IsNullOrWhiteSpace([string]$architectureProof.architecture) -or + ([string]$architectureProof.codeContentSha256).ToLowerInvariant() -notmatch '^[a-f0-9]{64}$' -or + [int64]$architectureProof.codeBytes -le 0 -or + [int64]$architectureProof.processedSignatureBytes -le 0 -or + [int64]$architectureProof.installerSignatureBytes -le 0 -or + $architectureProof.installerSignatureVerified -ne $true -or + [int64]$architectureProof.processedLinkEditFileBytes -le 0 -or + [int64]$architectureProof.installerLinkEditFileBytes -le 0 -or + [int64]$architectureProof.processedLinkEditVirtualBytes -le 0 -or + [int64]$architectureProof.installerLinkEditVirtualBytes -le 0) { + $architecturesValid = $false + } + } + if (@($architectureNames | Sort-Object -Unique).Count -ne $architectureNames.Count) { + $architecturesValid = $false + } + if ([string]::IsNullOrWhiteSpace($proofPath) -or + ([string]$proof.processedFileSha256).ToLowerInvariant() -notmatch '^[a-f0-9]{64}$' -or + ([string]$proof.installerFileSha256).ToLowerInvariant() -notmatch '^[a-f0-9]{64}$' -or + ([string]$proof.normalizedFileSha256).ToLowerInvariant() -notmatch '^[a-f0-9]{64}$' -or + ([string]$proof.processedFileSha256).ToLowerInvariant() -eq ([string]$proof.installerFileSha256).ToLowerInvariant() -or + -not $architecturesValid) { + $normalizationFilesValid = $false + } + } + if (@($normalizationPaths | Sort-Object -Unique).Count -ne $normalizationPaths.Count) { + $normalizationFilesValid = $false + } + $signatureNormalizedContentIdentity = $normalization.status -eq "ready" -and + [string]$normalization.tool -eq "codesign" -and + ([string]$normalization.processedPayloadSha256).ToLowerInvariant() -eq $processedSha -and + ([string]$normalization.installerPayloadSha256).ToLowerInvariant() -eq $installerRuntimeSha -and + $installerRuntimeSha -match '^[a-f0-9]{64}$' -and + $normalizationFilesValid + } + if (-not $exactContentIdentity -and -not $signatureNormalizedContentIdentity) { + $issues += "Installer runtime payload identity is invalid for $platform." + } + if ([string]$installer.runtimeManifestSchema -ne "stackchan.desktop-python-runtime.v1" -or + [string]$installer.runtimeManifestPlatform -ne $platform -or + ([string]$installer.runtimeManifestSha256).ToLowerInvariant() -ne $payloadSha) { + $issues += "Installer runtime manifest evidence is invalid for $platform." + } + if ($installerFileCount -ne [int64]$item.runtime.processedFileCount -or + ($exactContentIdentity -and $installerBytes -ne [int64]$item.runtime.processedBytes) -or + $installerFileCount -lt 2 -or $installerBytes -le 0) { + $issues += "Installer runtime payload summary does not match processed resources for $platform." + } + foreach ($brainPath in $requiredBrainFiles) { + if ($installerBrainFiles -notcontains $brainPath) { $issues += "Installer brain resource evidence is missing for $platform`: $brainPath" } + } + } + $launch = $item.launchEvidence + $launchPackageSha = "" + $launchPythonVersion = "" + if ($null -eq $launch) { + $issues += "Exact desktop package launch evidence is missing for $platform." + } else { + $launchPackageSha = ([string]$launch.packageSha256).ToLowerInvariant() + $launchPythonVersion = [string]$launch.pythonVersion + if ($launch.required -ne $true -or [string]$launch.status -ne "ready") { $issues += "Exact desktop package launch evidence is not required and ready for $platform." } + if ($launchPackageSha -ne $packageSha) { $issues += "Exact desktop package launch hash mismatch for $platform." } + if ([string]$launch.extractionMethod -ne "native" -or [int]$launch.processExitCode -ne 0) { $issues += "Exact desktop package was not natively extracted and launched for $platform." } + if ([string]$launch.scope -ne "exact-native-package-extraction-and-headless-launch" -or [string]::IsNullOrWhiteSpace($launchPythonVersion)) { $issues += "Exact desktop package launch probe is incomplete for $platform." } + } + $trust = $item.distributionTrust + if ($null -eq $trust) { + if ($RequireDesktopDistributionTrust -and $platform -ne "linux") { + $issues += "Native desktop distribution trust evidence is missing for $platform." + } + } else { + $distributionTrustStatus = [string]$trust.status + $distributionTrustPolicy = [string]$trust.policy + $distributionTrustSigner = [string]$trust.signerSubject + $distributionTrustTimestamped = -not [string]::IsNullOrWhiteSpace([string]$trust.timestampThumbprint) + $distributionTrustNotarizationStapled = [bool]$trust.notarizationStapled + $distributionTrustGatekeeperAccepted = [bool]$trust.gatekeeperAccepted + if ($RequireDesktopDistributionTrust -and $platform -ne "linux") { + $expectedTrustPolicy = if ($platform -eq "windows") { "authenticode-sha256-timestamped" } else { "developer-id-notarized-stapled" } + if ($trust.required -ne $true -or $distributionTrustStatus -ne "ready" -or + $distributionTrustPolicy -ne $expectedTrustPolicy -or + ([string]$trust.packageSha256).ToLowerInvariant() -ne $packageSha -or + [string]::IsNullOrWhiteSpace($distributionTrustSigner) -or + [string]$trust.signatureStatus -ne "Valid") { + $issues += "Native desktop distribution trust evidence is incomplete for $platform." + } elseif ($platform -eq "windows" -and -not $distributionTrustTimestamped) { + $issues += "Windows desktop distribution trust is missing its timestamp proof." + } elseif ($platform -eq "macos" -and (-not $distributionTrustNotarizationStapled -or -not $distributionTrustGatekeeperAccepted)) { + $issues += "macOS desktop distribution trust is missing notarization or Gatekeeper proof." + } + } + } + $summaries += [ordered]@{ + platform = $platform + path = Convert-ToRelativePath $path + packageName = [string]$item.package.name + packageBytes = [int64]$item.package.bytes + packageSha256 = $packageSha + runtimeSha256 = $payloadSha + runtimeSource = [string]$item.runtime.source + pythonVersion = [string]$item.runtime.pythonVersion + probedPythonVersion = [string]$item.runtime.probedPythonVersion + processedFileCount = [int64]$item.runtime.processedFileCount + processedBytes = [int64]$item.runtime.processedBytes + installerExtractionMethod = $installerExtractionMethod + installerAppJarName = $installerAppJarName + installerAppJarSha256 = $installerAppJarSha + installerPackageSha256 = $installerPackageSha + installerRuntimeSha256 = $installerRuntimeSha + installerRuntimeFileCount = $installerFileCount + installerRuntimeBytes = $installerBytes + installerContentIdentityStatus = $installerContentIdentityStatus + installerSignatureNormalizedFileCount = $installerSignatureNormalizedFileCount + installerBrainFiles = @($installerBrainFiles) + launchPackageSha256 = $launchPackageSha + launchPythonVersion = $launchPythonVersion + distributionTrustStatus = $distributionTrustStatus + distributionTrustPolicy = $distributionTrustPolicy + distributionTrustSigner = $distributionTrustSigner + distributionTrustTimestamped = $distributionTrustTimestamped + distributionTrustNotarizationStapled = $distributionTrustNotarizationStapled + distributionTrustGatekeeperAccepted = $distributionTrustGatekeeperAccepted + } + } + + return [ordered]@{ + status = if ($issues.Count -eq 0 -and $summaries.Count -eq 3) { "ready" } else { "not-ready" } + root = Convert-ToRelativePath $evidenceRoot + platforms = @($summaries) + issues = @($issues) + } +} + $pending = @() if ([string]::IsNullOrWhiteSpace($planPath)) { $pending += "companion-cross-platform-plan" @@ -621,16 +997,71 @@ $androidSigning = Test-AndroidReleaseApkSignature $androidArtifacts if ($androidSigning.status -ne "verified") { $pending += "android-release-apk-signature" } +if ($RequireUploadSigning -and $androidSigning.signingProfile -ne "upload-key") { + $pending += "android-release-apk-upload-signing" +} $androidBundleSigning = Test-AndroidReleaseBundleSignature $androidArtifacts if ($androidBundleSigning.status -ne "verified") { $pending += "android-release-aab-signature" } +if ($RequireUploadSigning -and $androidBundleSigning.signingProfile -ne "upload-key") { + $pending += "android-release-aab-upload-signing" +} +$androidEmulatorEvidence = Get-AndroidEmulatorReleaseEvidence +if ($androidEmulatorEvidence.status -ne "ready" -and ($RequireAndroidEmulatorEvidence -or -not [string]::IsNullOrWhiteSpace($AndroidEmulatorEvidencePath))) { + $pending += "android-emulator-release-apk-evidence" +} $desktopPythonRuntime = Get-DesktopPythonRuntimeEvidence if ($desktopPythonRuntime.status -ne "ready" -and ($RequireDesktopPythonRuntime -or -not [string]::IsNullOrWhiteSpace($desktopPythonRuntime.runtimeRoot))) { $pending += "desktop-managed-python-runtime-payload" } +$desktopPackageEvidence = Get-DesktopPackageEvidence +if (($RequireDesktopPackageEvidence -or $RequireDesktopDistributionTrust) -and $desktopPackageEvidence.status -ne "ready") { + $pending += "desktop-native-package-runtime-evidence" +} +if ($RequireDesktopDistributionTrust -and $desktopPackageEvidence.status -ne "ready") { + $pending += "desktop-native-distribution-trust" +} + +$strictEvidenceRequired = $RequireArtifacts -or $RequireUploadSigning -or $RequireDesktopPythonRuntime -or $RequireDesktopPackageEvidence -or $RequireDesktopDistributionTrust -or $RequireAndroidEmulatorEvidence +$blockingMarkers = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) +function Add-BlockingMarkers { + param([string[]]$Markers) + foreach ($marker in $Markers) { [void]$blockingMarkers.Add($marker) } +} + +if ($strictEvidenceRequired) { + Add-BlockingMarkers @("companion-cross-platform-plan", "companion-v1-readiness-check", "gradle-toolchain-pins") +} +if ($RequireArtifacts) { + Add-BlockingMarkers @( + "android-apk-artifacts", + "android-debug-apk-artifact", + "android-release-apk-artifact", + "android-release-aab-artifact", + "desktop-distribution-artifacts", + "desktop-linux-deb-artifact", + "desktop-macos-dmg-artifact", + "desktop-windows-msi-artifact" + ) +} +if ($RequireUploadSigning) { + Add-BlockingMarkers @( + "android-release-apk-signature", + "android-release-apk-upload-signing", + "android-release-aab-signature", + "android-release-aab-upload-signing" + ) +} +if ($RequireAndroidEmulatorEvidence) { Add-BlockingMarkers @("android-emulator-release-apk-evidence") } +if ($RequireDesktopPythonRuntime) { Add-BlockingMarkers @("desktop-managed-python-runtime-payload") } +if ($RequireDesktopPackageEvidence) { Add-BlockingMarkers @("desktop-native-package-runtime-evidence") } +if ($RequireDesktopDistributionTrust) { + Add-BlockingMarkers @("desktop-native-package-runtime-evidence", "desktop-native-distribution-trust") +} -$status = if ($RequireArtifacts -and $pending.Count -gt 0) { "blocked-missing-artifacts" } elseif ($pending.Count -gt 0) { "evidence-pending-artifacts" } else { "complete" } +$blockingPending = @($pending | Where-Object { $blockingMarkers.Contains($_) }) +$status = if ($strictEvidenceRequired -and $blockingPending.Count -gt 0) { "blocked-release-evidence" } elseif ($pending.Count -gt 0) { "evidence-pending-artifacts" } else { "complete" } $report = [ordered]@{ schema = "stackchan.companion-release-evidence.v1" @@ -648,9 +1079,16 @@ $report = [ordered]@{ artifacts = @($androidArtifacts, $desktopArtifacts) androidSigning = $androidSigning androidBundleSigning = $androidBundleSigning + uploadSigningRequired = [bool]$RequireUploadSigning + androidEmulatorEvidenceRequired = [bool]$RequireAndroidEmulatorEvidence + androidEmulatorEvidence = $androidEmulatorEvidence desktopPythonRuntime = $desktopPythonRuntime + desktopPackageEvidenceRequired = [bool]$RequireDesktopPackageEvidence + desktopDistributionTrustRequired = [bool]$RequireDesktopDistributionTrust + desktopPackageEvidence = $desktopPackageEvidence packageEvidence = Get-PackageEvidence pending = @($pending) + blockingPending = @($blockingPending) } $jsonPath = Join-Path $OutDir "COMPANION_RELEASE_EVIDENCE.json" @@ -686,6 +1124,7 @@ foreach ($artifactGroup in $report.artifacts) { } $lines += "" $lines += "## Android Signing" +$lines += "- Upload signing required: $([bool]$RequireUploadSigning)" $lines += "- Status: $($androidSigning.status)" $lines += "- APK: $($androidSigning.apk)" $lines += "- Scheme: $($androidSigning.scheme)" @@ -702,6 +1141,22 @@ $lines += "- Signing profile: $($androidBundleSigning.signingProfile)" $lines += "- Verifier: $($androidBundleSigning.verifier)" $lines += "- Detail: $($androidBundleSigning.detail)" $lines += "" +$lines += "## Android Emulator Release APK Evidence" +$lines += "- Required: $([bool]$RequireAndroidEmulatorEvidence)" +$lines += "- Status: $($androidEmulatorEvidence.status)" +$lines += "- Evidence: $($androidEmulatorEvidence.evidence)" +$lines += "- Release APK: $($androidEmulatorEvidence.releaseApk)" +$lines += "- Release APK SHA-256: $($androidEmulatorEvidence.releaseApkSha256)" +if ($null -ne $androidEmulatorEvidence.smoke) { + $lines += "- Emulator: $($androidEmulatorEvidence.smoke.model) / API $($androidEmulatorEvidence.smoke.apiLevel)" + $lines += "- Package: $($androidEmulatorEvidence.smoke.packageName) $($androidEmulatorEvidence.smoke.versionName) ($($androidEmulatorEvidence.smoke.versionCode))" + $lines += "- MainActivity resumed: $($androidEmulatorEvidence.smoke.mainActivityResumed)" + $lines += "- CompanionBridgeService present: $($androidEmulatorEvidence.smoke.bridgeServicePresent)" + $lines += "- Fatal process matches: $($androidEmulatorEvidence.smoke.fatalProcessMatches)" + $lines += "- Substitutes for physical evidence: $($androidEmulatorEvidence.smoke.substitutesForPhysicalEvidence)" +} +foreach ($issue in @($androidEmulatorEvidence.issues)) { $lines += "- Issue: $issue" } +$lines += "" $lines += "## Desktop Managed Python Runtime" $lines += "- Status: $($desktopPythonRuntime.status)" $lines += "- Runtime root: $($desktopPythonRuntime.runtimeRoot)" @@ -710,6 +1165,17 @@ foreach ($check in @($desktopPythonRuntime.checks)) { $lines += "- [$($check.status)] $($check.id): $($check.detail)" } $lines += "" +$lines += "## Native Desktop Package Runtime Evidence" +$lines += "- Required: $([bool]$RequireDesktopPackageEvidence)" +$lines += "- Native distribution trust required: $([bool]$RequireDesktopDistributionTrust)" +$lines += "- Status: $($desktopPackageEvidence.status)" +$lines += "- Root: $($desktopPackageEvidence.root)" +foreach ($platform in @($desktopPackageEvidence.platforms)) { + $lines += "- $($platform.platform): package=$($platform.packageName) package_sha256=$($platform.packageSha256) runtime_sha256=$($platform.runtimeSha256) installer_runtime_sha256=$($platform.installerRuntimeSha256) app_jar_sha256=$($platform.installerAppJarSha256) python=$($platform.probedPythonVersion)" + $lines += " - Distribution trust: $($platform.distributionTrustStatus) / $($platform.distributionTrustPolicy) / $($platform.distributionTrustSigner)" +} +foreach ($issue in @($desktopPackageEvidence.issues)) { $lines += "- Issue: $issue" } +$lines += "" $lines += "## Pending" if ($pending.Count -eq 0) { $lines += "- None" @@ -731,6 +1197,6 @@ if ($Json) { } } -if ($RequireArtifacts -and $pending.Count -gt 0) { +if ($strictEvidenceRequired -and $blockingPending.Count -gt 0) { exit 2 } diff --git a/tools/export_desktop_package_evidence.cmd b/tools/export_desktop_package_evidence.cmd new file mode 100644 index 00000000..01e71aaf --- /dev/null +++ b/tools/export_desktop_package_evidence.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0export_desktop_package_evidence.ps1" %* diff --git a/tools/export_desktop_package_evidence.ps1 b/tools/export_desktop_package_evidence.ps1 new file mode 100644 index 00000000..00b8aeca --- /dev/null +++ b/tools/export_desktop_package_evidence.ps1 @@ -0,0 +1,1069 @@ +param( + [ValidateSet("windows", "linux", "macos")] + [string]$Platform, + [string]$PackagePath, + [string]$RuntimePrepareJsonPath, + [string]$ProcessedRuntimeRoot, + [string]$PackageExtractionRoot = "", + [string]$LaunchEvidencePath = "", + [string]$Version = "", + [string]$Commit = "", + [string]$OutPath = "", + [switch]$RequireInstallerPayload, + [switch]$RequireLaunchEvidence, + [switch]$RequireDistributionTrust, + [switch]$UseExistingPackageExtraction, + [switch]$Json +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") + +function Get-Sha256Text { + param([string]$Path) + $fullPath = [System.IO.Path]::GetFullPath($Path) + $ioPath = if ($env:OS -eq "Windows_NT" -and -not $fullPath.StartsWith("\\?\")) { "\\?\$fullPath" } else { $fullPath } + $sha = [System.Security.Cryptography.SHA256]::Create() + $stream = [System.IO.File]::OpenRead($ioPath) + try { + return (($sha.ComputeHash($stream) | ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $stream.Dispose() + $sha.Dispose() + } +} + +function Test-Sha256Text { + param([string]$Value) + return $Value -match '^[a-fA-F0-9]{64}$' +} + +function Get-NormalizedZipEntryName { + param($Entry) + return ([string]$Entry.FullName).Replace("\", "/") +} + +function Get-ZipEntriesByName { + param( + $Archive, + [string]$Name + ) + return @($Archive.Entries | Where-Object { (Get-NormalizedZipEntryName $_) -eq $Name }) +} + +function Get-HostPlatform { + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { return "windows" } + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) { return "linux" } + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) { return "macos" } + return "unknown" +} + +function Expand-DesktopPackage { + param( + [string]$TargetPlatform, + [string]$SourcePackage, + [string]$DestinationRoot + ) + + New-Item -ItemType Directory -Force -Path $DestinationRoot | Out-Null + switch ($TargetPlatform) { + "windows" { + $msiexec = Get-Command msiexec.exe -ErrorAction SilentlyContinue + if ($null -eq $msiexec) { throw "msiexec.exe is required to inspect the Windows installer payload." } + $logPath = Join-Path $DestinationRoot "msiexec-admin.log" + $arguments = @('/a', "`"$SourcePackage`"", '/qn', "TARGETDIR=`"$DestinationRoot`"", '/L*v', "`"$logPath`"") + $process = Start-Process -FilePath $msiexec.Source -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { throw "MSI administrative extraction failed with exit code $($process.ExitCode). See $logPath" } + } + "linux" { + $dpkg = Get-Command dpkg-deb -ErrorAction SilentlyContinue + if ($null -eq $dpkg) { throw "dpkg-deb is required to inspect the Linux installer payload." } + & $dpkg.Source -x $SourcePackage $DestinationRoot + if ($LASTEXITCODE -ne 0) { throw "DEB extraction failed with exit code $LASTEXITCODE." } + } + "macos" { + $hdiutil = Get-Command hdiutil -ErrorAction SilentlyContinue + if ($null -eq $hdiutil) { throw "hdiutil is required to inspect the macOS installer payload." } + $mountPoint = "$DestinationRoot-mount" + New-Item -ItemType Directory -Force -Path $mountPoint | Out-Null + $attached = $false + try { + & $hdiutil.Source attach $SourcePackage -readonly -nobrowse -mountpoint $mountPoint | Out-Null + if ($LASTEXITCODE -ne 0) { throw "DMG mount failed with exit code $LASTEXITCODE." } + $attached = $true + $mountedApps = @(Get-ChildItem -LiteralPath $mountPoint -Directory -Filter "*.app" -ErrorAction Stop) + if ($mountedApps.Count -ne 1) { throw "Expected one application bundle in mounted DMG; found $($mountedApps.Count)." } + & (Get-Command ditto -ErrorAction Stop).Source $mountedApps[0].FullName (Join-Path $DestinationRoot $mountedApps[0].Name) + if ($LASTEXITCODE -ne 0) { throw "Application bundle copy failed with exit code $LASTEXITCODE." } + } finally { + if ($attached) { + & $hdiutil.Source detach $mountPoint -force | Out-Null + } + } + } + } +} + +function Get-RuntimePayloadHash { + param([string]$Root) + + $rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd("\", "/") + $prefix = $rootFull + [System.IO.Path]::DirectorySeparatorChar + $sha = [System.Security.Cryptography.SHA256]::Create() + $utf8 = [System.Text.Encoding]::UTF8 + $files = Get-ChildItem -LiteralPath $Root -File -Recurse -Force | + Where-Object { + $_.Name -ne "stackchan-python-runtime.json" -and + $_.FullName -notmatch "([\\/])__pycache__([\\/])" + } | + Sort-Object FullName + + foreach ($file in $files) { + $full = [System.IO.Path]::GetFullPath($file.FullName) + if (-not $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to hash a processed runtime file outside its root: $full" + } + $relative = $full.Substring($prefix.Length).Replace("\", "/") + $pathBytes = $utf8.GetBytes("$relative`n") + $null = $sha.TransformBlock($pathBytes, 0, $pathBytes.Length, $pathBytes, 0) + $fileHash = Get-Sha256Text $full + $hashBytes = $utf8.GetBytes("$fileHash`n") + $null = $sha.TransformBlock($hashBytes, 0, $hashBytes.Length, $hashBytes, 0) + } + + $empty = [byte[]]@() + $null = $sha.TransformFinalBlock($empty, 0, 0) + return (($sha.Hash | ForEach-Object { $_.ToString("x2") }) -join "") +} + +function Get-RuntimeFileInventory { + param([string]$Root) + + $rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd("\", "/") + $prefix = $rootFull + [System.IO.Path]::DirectorySeparatorChar + $inventory = @{} + Get-ChildItem -LiteralPath $Root -File -Recurse -Force | + Where-Object { + $_.Name -ne "stackchan-python-runtime.json" -and + $_.FullName -notmatch "([\\/])__pycache__([\\/])" + } | + ForEach-Object { + $full = [System.IO.Path]::GetFullPath($_.FullName) + $relative = $full.Substring($prefix.Length).Replace("\", "/") + $inventory[$relative] = [ordered]@{ + fullPath = $full + bytes = [int64]$_.Length + sha256 = Get-Sha256Text $full + } + } + return $inventory +} + +function Compare-RuntimeFileInventories { + param( + [hashtable]$Expected, + [hashtable]$Actual, + [int]$Limit = 12 + ) + + $differences = New-Object System.Collections.Generic.List[string] + $paths = @($Expected.Keys + $Actual.Keys | Sort-Object -Unique) + foreach ($path in $paths) { + $expectedEntry = $Expected[$path] + $actualEntry = $Actual[$path] + if ($null -eq $expectedEntry) { + $differences.Add("added $path ($($actualEntry.bytes) bytes, $(([string]$actualEntry.sha256).Substring(0, 12)))...)") + } elseif ($null -eq $actualEntry) { + $differences.Add("missing $path ($($expectedEntry.bytes) bytes, $(([string]$expectedEntry.sha256).Substring(0, 12)))...)") + } elseif ([int64]$expectedEntry.bytes -ne [int64]$actualEntry.bytes -or [string]$expectedEntry.sha256 -ne [string]$actualEntry.sha256) { + $differences.Add("changed $path (expected $($expectedEntry.bytes)/$(([string]$expectedEntry.sha256).Substring(0, 12))..., actual $($actualEntry.bytes)/$(([string]$actualEntry.sha256).Substring(0, 12))...)") + } + if ($differences.Count -ge $Limit) { break } + } + return @($differences) +} + +function Invoke-NativeCommandCapture { + param( + [string]$Command, + [string[]]$Arguments + ) + + $oldPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & $Command @Arguments 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldPreference + } + return [pscustomobject]@{ exitCode = $exitCode; output = $output.Trim() } +} + +function Find-WindowsSignTool { + $command = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($null -ne $command) { return [string]$command.Source } + + $kitsRoot = Join-Path ${env:ProgramFiles(x86)} "Windows Kits/10/bin" + if (-not (Test-Path -LiteralPath $kitsRoot -PathType Container)) { return "" } + $candidates = @(Get-ChildItem -LiteralPath $kitsRoot -Recurse -File -Filter "signtool.exe" -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '[\\/]x64[\\/]signtool\.exe$' } | + Sort-Object FullName -Descending) + if ($candidates.Count -eq 0) { return "" } + return [string]$candidates[0].FullName +} + +function Test-WindowsDistributionTrust { + param([string]$SourcePackage) + + $result = [ordered]@{ + required = $true + status = "not-ready" + policy = "authenticode-sha256-timestamped" + packageSha256 = Get-Sha256Text $SourcePackage + signerSubject = "" + signerThumbprint = "" + timestampSubject = "" + timestampThumbprint = "" + signatureStatus = "" + notarizationStapled = $false + gatekeeperAccepted = $false + issue = "" + } + + if ((Get-HostPlatform) -ne "windows") { + $result.issue = "Windows Authenticode verification requires a native Windows host." + return $result + } + + try { + $signature = Get-AuthenticodeSignature -LiteralPath $SourcePackage + $result.signatureStatus = [string]$signature.Status + if ($null -ne $signature.SignerCertificate) { + $result.signerSubject = [string]$signature.SignerCertificate.Subject + $result.signerThumbprint = ([string]$signature.SignerCertificate.Thumbprint).ToLowerInvariant() + } + if ($null -ne $signature.TimeStamperCertificate) { + $result.timestampSubject = [string]$signature.TimeStamperCertificate.Subject + $result.timestampThumbprint = ([string]$signature.TimeStamperCertificate.Thumbprint).ToLowerInvariant() + } + if ([string]$signature.Status -ne "Valid" -or $null -eq $signature.SignerCertificate) { + $result.issue = "Windows package does not have a valid Authenticode signature." + return $result + } + if ($null -eq $signature.TimeStamperCertificate) { + $result.issue = "Windows package Authenticode signature is not timestamped." + return $result + } + + $signTool = Find-WindowsSignTool + if ([string]::IsNullOrWhiteSpace($signTool)) { + $result.issue = "signtool.exe is unavailable for Authenticode policy verification." + return $result + } + $verify = Invoke-NativeCommandCapture -Command $signTool -Arguments @("verify", "/pa", "/all", "/tw", "/v", $SourcePackage) + if ($verify.exitCode -ne 0) { + $result.issue = "signtool Authenticode verification failed: $($verify.output)" + return $result + } + + $result.status = "ready" + return $result + } catch { + $result.issue = "Windows Authenticode verification failed: $($_.Exception.Message)" + return $result + } +} + +function Test-MacOSDistributionTrust { + param( + [string]$SourcePackage, + [string]$ExtractionRoot + ) + + $result = [ordered]@{ + required = $true + status = "not-ready" + policy = "developer-id-notarized-stapled" + packageSha256 = Get-Sha256Text $SourcePackage + signerSubject = "" + signerThumbprint = "" + timestampSubject = "" + timestampThumbprint = "" + signatureStatus = "" + notarizationStapled = $false + gatekeeperAccepted = $false + issue = "" + } + + if ((Get-HostPlatform) -ne "macos") { + $result.issue = "Developer ID and notarization verification requires a native macOS host." + return $result + } + + try { + $apps = @(Get-ChildItem -LiteralPath $ExtractionRoot -Recurse -Directory -Filter "*.app" -ErrorAction SilentlyContinue) + if ($apps.Count -ne 1) { + $result.issue = "Expected exactly one extracted macOS application bundle; found $($apps.Count)." + return $result + } + $appPath = $apps[0].FullName + $codesign = (Get-Command codesign -ErrorAction Stop).Source + $spctl = (Get-Command spctl -ErrorAction Stop).Source + $xcrun = (Get-Command xcrun -ErrorAction Stop).Source + + $verify = Invoke-NativeCommandCapture -Command $codesign -Arguments @("--verify", "--deep", "--strict", "--verbose=2", $appPath) + if ($verify.exitCode -ne 0) { + $result.issue = "Strict macOS code-signature verification failed: $($verify.output)" + return $result + } + $details = Invoke-NativeCommandCapture -Command $codesign -Arguments @("-dv", "--verbose=4", $appPath) + if ($details.exitCode -ne 0 -or $details.output -notmatch '(?m)^Authority=(Developer ID Application:.+)$') { + $result.issue = "macOS application is not signed with a Developer ID Application identity." + return $result + } + $result.signerSubject = $Matches[1].Trim() + if ($details.output -match '(?m)^TeamIdentifier=([^\r\n]+)$') { + $result.signerThumbprint = $Matches[1].Trim() + } + $result.signatureStatus = "Valid" + + $gatekeeper = Invoke-NativeCommandCapture -Command $spctl -Arguments @("--assess", "--type", "execute", "--verbose=4", $appPath) + if ($gatekeeper.exitCode -ne 0) { + $result.issue = "Gatekeeper rejected the extracted macOS application: $($gatekeeper.output)" + return $result + } + $result.gatekeeperAccepted = $true + + $stapler = Invoke-NativeCommandCapture -Command $xcrun -Arguments @("stapler", "validate", $SourcePackage) + if ($stapler.exitCode -ne 0) { + $result.issue = "The macOS DMG does not carry a valid stapled notarization ticket: $($stapler.output)" + return $result + } + $result.notarizationStapled = $true + $result.status = "ready" + return $result + } catch { + $result.issue = "macOS distribution trust verification failed: $($_.Exception.Message)" + return $result + } +} + +function Get-Sha256Bytes { + param([byte[]]$Bytes) + + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + return (($sha.ComputeHash($Bytes) | ForEach-Object { $_.ToString("x2") }) -join "") + } finally { + $sha.Dispose() + } +} + +function Get-Sha256Utf8Text { + param([string]$Value) + return Get-Sha256Bytes ([System.Text.Encoding]::UTF8.GetBytes($Value)) +} + +function Get-ByteDifferenceSummary { + param( + [byte[]]$Expected, + [byte[]]$Actual, + [int]$Limit = 8 + ) + + $differences = New-Object System.Collections.Generic.List[string] + $sharedLength = [Math]::Min($Expected.Length, $Actual.Length) + for ($offset = 0; $offset -lt $sharedLength -and $differences.Count -lt $Limit; $offset++) { + if ($Expected[$offset] -ne $Actual[$offset]) { + $differences.Add(("0x{0:x}:0x{1:x2}/0x{2:x2}" -f $offset, $Expected[$offset], $Actual[$offset])) + } + } + if ($differences.Count -lt $Limit -and $Expected.Length -ne $Actual.Length) { + $differences.Add("length:$($Expected.Length)/$($Actual.Length)") + } + if ($differences.Count -eq 0) { return "none" } + return ($differences -join ",") +} + +function Read-MachOUInt32 { + param( + [byte[]]$Bytes, + [int]$Offset, + [bool]$LittleEndian + ) + + if ($Offset -lt 0 -or $Offset + 4 -gt $Bytes.Length) { + throw "Mach-O uint32 offset is outside the file." + } + $valueBytes = [byte[]]::new(4) + [Array]::Copy($Bytes, $Offset, $valueBytes, 0, 4) + if ([BitConverter]::IsLittleEndian -ne $LittleEndian) { + [Array]::Reverse($valueBytes) + } + return [BitConverter]::ToUInt32($valueBytes, 0) +} + +function Read-MachOUInt64 { + param( + [byte[]]$Bytes, + [int]$Offset, + [bool]$LittleEndian + ) + + if ($Offset -lt 0 -or $Offset + 8 -gt $Bytes.Length) { + throw "Mach-O uint64 offset is outside the file." + } + $valueBytes = [byte[]]::new(8) + [Array]::Copy($Bytes, $Offset, $valueBytes, 0, 8) + if ([BitConverter]::IsLittleEndian -ne $LittleEndian) { + [Array]::Reverse($valueBytes) + } + return [BitConverter]::ToUInt64($valueBytes, 0) +} + +function Get-MachOCodeContentIdentity { + param([string]$Path) + + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 32) { throw "Mach-O file is too short." } + $rawMagic = [BitConverter]::ToUInt32($bytes, 0) + switch ($rawMagic) { + 4277009102 { $littleEndian = $true; $headerSize = 28 } + 4277009103 { $littleEndian = $true; $headerSize = 32 } + 3472551422 { $littleEndian = $false; $headerSize = 28 } + 3489328638 { $littleEndian = $false; $headerSize = 32 } + default { throw "Mach-O file has an unsupported magic value." } + } + + $commandCount = [int](Read-MachOUInt32 -Bytes $bytes -Offset 16 -LittleEndian $littleEndian) + $commandBytes = [int](Read-MachOUInt32 -Bytes $bytes -Offset 20 -LittleEndian $littleEndian) + if ($commandCount -lt 1 -or $headerSize + $commandBytes -gt $bytes.Length) { + throw "Mach-O load-command table is invalid." + } + + $commandOffset = $headerSize + $signatureCommandOffset = -1 + $signatureDataOffset = 0 + $signatureDataSize = 0 + $linkEditVirtualSizeOffset = -1 + $linkEditFileSizeOffset = -1 + $linkEditSizeFieldBytes = 0 + $linkEditFileOffset = 0 + $linkEditFileSize = 0 + $linkEditVirtualSize = 0 + for ($index = 0; $index -lt $commandCount; $index++) { + $command = Read-MachOUInt32 -Bytes $bytes -Offset $commandOffset -LittleEndian $littleEndian + $commandSize = [int](Read-MachOUInt32 -Bytes $bytes -Offset ($commandOffset + 4) -LittleEndian $littleEndian) + if ($commandSize -lt 8 -or $commandOffset + $commandSize -gt $headerSize + $commandBytes) { + throw "Mach-O load command is invalid." + } + if ($command -eq 0x1d) { + if ($signatureCommandOffset -ge 0 -or $commandSize -ne 16) { + throw "Mach-O LC_CODE_SIGNATURE command is invalid or duplicated." + } + $signatureCommandOffset = $commandOffset + $signatureDataOffset = [int](Read-MachOUInt32 -Bytes $bytes -Offset ($commandOffset + 8) -LittleEndian $littleEndian) + $signatureDataSize = [int](Read-MachOUInt32 -Bytes $bytes -Offset ($commandOffset + 12) -LittleEndian $littleEndian) + } + if ($command -eq 0x19 -or $command -eq 0x1) { + $segmentName = [System.Text.Encoding]::ASCII.GetString($bytes, $commandOffset + 8, 16).Trim([char]0) + if ($segmentName -eq "__LINKEDIT") { + if ($linkEditFileSizeOffset -ge 0) { throw "Mach-O has duplicate __LINKEDIT segments." } + if ($command -eq 0x19) { + if ($commandSize -lt 72) { throw "Mach-O __LINKEDIT segment command is too short." } + $linkEditVirtualSizeOffset = $commandOffset + 32 + $linkEditFileSizeOffset = $commandOffset + 48 + $linkEditSizeFieldBytes = 8 + $linkEditVirtualSize = [int64](Read-MachOUInt64 -Bytes $bytes -Offset $linkEditVirtualSizeOffset -LittleEndian $littleEndian) + $linkEditFileOffset = [int64](Read-MachOUInt64 -Bytes $bytes -Offset ($commandOffset + 40) -LittleEndian $littleEndian) + $linkEditFileSize = [int64](Read-MachOUInt64 -Bytes $bytes -Offset $linkEditFileSizeOffset -LittleEndian $littleEndian) + } else { + if ($commandSize -lt 56) { throw "Mach-O __LINKEDIT segment command is too short." } + $linkEditVirtualSizeOffset = $commandOffset + 28 + $linkEditFileSizeOffset = $commandOffset + 36 + $linkEditSizeFieldBytes = 4 + $linkEditVirtualSize = [int64](Read-MachOUInt32 -Bytes $bytes -Offset $linkEditVirtualSizeOffset -LittleEndian $littleEndian) + $linkEditFileOffset = [int64](Read-MachOUInt32 -Bytes $bytes -Offset ($commandOffset + 32) -LittleEndian $littleEndian) + $linkEditFileSize = [int64](Read-MachOUInt32 -Bytes $bytes -Offset $linkEditFileSizeOffset -LittleEndian $littleEndian) + } + } + } + $commandOffset += $commandSize + } + if ($signatureCommandOffset -lt 0 -or $signatureDataOffset -lt $headerSize + $commandBytes -or + $signatureDataSize -lt 1 -or $signatureDataOffset + $signatureDataSize -gt $bytes.Length) { + throw "Mach-O embedded code-signature range is invalid." + } + if ($linkEditFileSizeOffset -lt 0 -or $linkEditFileOffset -lt 0 -or $linkEditFileSize -lt 1 -or + $signatureDataOffset -lt $linkEditFileOffset -or + $signatureDataOffset + $signatureDataSize -ne $linkEditFileOffset + $linkEditFileSize -or + $linkEditFileOffset + $linkEditFileSize -gt $bytes.Length) { + throw "Mach-O code signature is not the validated tail of __LINKEDIT." + } + + $canonical = [byte[]]::new($signatureDataOffset) + [Array]::Copy($bytes, 0, $canonical, 0, $signatureDataOffset) + [Array]::Clear($canonical, $signatureCommandOffset, 16) + [Array]::Clear($canonical, $linkEditVirtualSizeOffset, $linkEditSizeFieldBytes) + [Array]::Clear($canonical, $linkEditFileSizeOffset, $linkEditSizeFieldBytes) + return [pscustomobject]@{ + sha256 = Get-Sha256Bytes $canonical + codeBytes = $signatureDataOffset + signatureBytes = $signatureDataSize + linkEditFileBytes = $linkEditFileSize + linkEditVirtualBytes = $linkEditVirtualSize + canonicalBytes = $canonical + } +} + +function Test-MacOSSignatureNormalizedRuntimeIdentity { + param( + [hashtable]$Expected, + [hashtable]$Actual, + [string]$ExpectedPayloadSha256, + [string]$ActualPayloadSha256 + ) + + $result = [ordered]@{ + status = "not-ready" + tool = "codesign" + processedPayloadSha256 = $ExpectedPayloadSha256 + installerPayloadSha256 = $ActualPayloadSha256 + changedFileCount = 0 + files = @() + issue = "" + } + if ((Get-HostPlatform) -ne "macos" -or $Platform -ne "macos") { + $result.issue = "macOS signature normalization requires a native macOS host." + return [pscustomobject]$result + } + + $codesign = Get-Command codesign -ErrorAction SilentlyContinue + $fileCommand = Get-Command file -ErrorAction SilentlyContinue + $lipo = Get-Command lipo -ErrorAction SilentlyContinue + if ($null -eq $codesign -or $null -eq $fileCommand -or $null -eq $lipo) { + $result.issue = "macOS signature normalization requires codesign, file, and lipo." + return [pscustomobject]$result + } + + $expectedPaths = @($Expected.Keys | Sort-Object) + $actualPaths = @($Actual.Keys | Sort-Object) + if ($expectedPaths.Count -ne $actualPaths.Count -or + (Compare-Object -ReferenceObject $expectedPaths -DifferenceObject $actualPaths).Count -ne 0) { + $result.issue = "Processed and installer runtime path sets differ." + return [pscustomobject]$result + } + + $changedPaths = @($expectedPaths | Where-Object { + [int64]$Expected[$_].bytes -ne [int64]$Actual[$_].bytes -or + [string]$Expected[$_].sha256 -ne [string]$Actual[$_].sha256 + }) + if ($changedPaths.Count -lt 1) { + $result.issue = "No changed runtime files require signature normalization." + return [pscustomobject]$result + } + + $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-codesign-normalize-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null + try { + $proofs = New-Object System.Collections.Generic.List[object] + $index = 0 + foreach ($path in $changedPaths) { + $expectedPath = [string]$Expected[$path].fullPath + $actualPath = [string]$Actual[$path].fullPath + $expectedFileType = Invoke-NativeCommandCapture -Command $fileCommand.Source -Arguments @("-b", $expectedPath) + $actualFileType = Invoke-NativeCommandCapture -Command $fileCommand.Source -Arguments @("-b", $actualPath) + if ($expectedFileType.exitCode -ne 0 -or $actualFileType.exitCode -ne 0 -or + $expectedFileType.output -notmatch "Mach-O" -or $actualFileType.output -notmatch "Mach-O") { + $result.issue = "Changed runtime file is not Mach-O on both sides: $path" + return [pscustomobject]$result + } + + $expectedVerify = Invoke-NativeCommandCapture -Command $codesign.Source -Arguments @("--verify", "--strict", "--verbose=2", $expectedPath) + $actualVerify = Invoke-NativeCommandCapture -Command $codesign.Source -Arguments @("--verify", "--strict", "--verbose=2", $actualPath) + if ($actualVerify.exitCode -ne 0) { + $result.issue = "Installer runtime file must have a valid strict code signature: $path" + return [pscustomobject]$result + } + + $expectedArchResult = Invoke-NativeCommandCapture -Command $lipo.Source -Arguments @("-archs", $expectedPath) + $actualArchResult = Invoke-NativeCommandCapture -Command $lipo.Source -Arguments @("-archs", $actualPath) + $expectedArchitectures = @($expectedArchResult.output -split '\s+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) + $actualArchitectures = @($actualArchResult.output -split '\s+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) + if ($expectedArchResult.exitCode -ne 0 -or $actualArchResult.exitCode -ne 0 -or + $expectedArchitectures.Count -lt 1 -or $expectedArchitectures.Count -ne $actualArchitectures.Count -or + (Compare-Object -ReferenceObject $expectedArchitectures -DifferenceObject $actualArchitectures).Count -ne 0) { + $result.issue = "Processed and installer runtime architecture sets differ: $path" + return [pscustomobject]$result + } + + $architectureProofs = New-Object System.Collections.Generic.List[object] + foreach ($architecture in $expectedArchitectures) { + $expectedCopy = Join-Path $tempRoot ("expected-{0:D3}-{1}" -f $index, $architecture) + $actualCopy = Join-Path $tempRoot ("actual-{0:D3}-{1}" -f $index, $architecture) + if ($expectedArchitectures.Count -eq 1) { + Copy-Item -LiteralPath $expectedPath -Destination $expectedCopy + Copy-Item -LiteralPath $actualPath -Destination $actualCopy + } else { + $expectedThin = Invoke-NativeCommandCapture -Command $lipo.Source -Arguments @("-thin", $architecture, $expectedPath, "-output", $expectedCopy) + $actualThin = Invoke-NativeCommandCapture -Command $lipo.Source -Arguments @("-thin", $architecture, $actualPath, "-output", $actualCopy) + if ($expectedThin.exitCode -ne 0 -or $actualThin.exitCode -ne 0) { + $result.issue = "Could not extract matching Mach-O architecture '$architecture': $path" + return [pscustomobject]$result + } + } + + try { + $expectedIdentity = Get-MachOCodeContentIdentity $expectedCopy + $actualIdentity = Get-MachOCodeContentIdentity $actualCopy + } catch { + $result.issue = "Could not parse Mach-O code-signature boundaries for '$architecture': $path ($($_.Exception.Message))" + return [pscustomobject]$result + } + if ($expectedIdentity.sha256 -ne $actualIdentity.sha256 -or $expectedIdentity.codeBytes -ne $actualIdentity.codeBytes) { + $byteDifferences = Get-ByteDifferenceSummary -Expected $expectedIdentity.canonicalBytes -Actual $actualIdentity.canonicalBytes + $result.issue = "Mach-O code content differs outside LC_CODE_SIGNATURE for '$architecture': $path (codeBytes $($expectedIdentity.codeBytes)/$($actualIdentity.codeBytes); signatureBytes $($expectedIdentity.signatureBytes)/$($actualIdentity.signatureBytes); firstDiffs $byteDifferences)" + return [pscustomobject]$result + } + $architectureProofs.Add([ordered]@{ + architecture = $architecture + codeContentSha256 = [string]$expectedIdentity.sha256 + codeBytes = [int64]$expectedIdentity.codeBytes + processedSignatureBytes = [int64]$expectedIdentity.signatureBytes + installerSignatureBytes = [int64]$actualIdentity.signatureBytes + processedSignatureVerified = $expectedVerify.exitCode -eq 0 + installerSignatureVerified = $true + processedLinkEditFileBytes = [int64]$expectedIdentity.linkEditFileBytes + installerLinkEditFileBytes = [int64]$actualIdentity.linkEditFileBytes + processedLinkEditVirtualBytes = [int64]$expectedIdentity.linkEditVirtualBytes + installerLinkEditVirtualBytes = [int64]$actualIdentity.linkEditVirtualBytes + }) + } + $normalizedFileSha = Get-Sha256Utf8Text (($architectureProofs | ForEach-Object { + "$($_.architecture)`n$($_.codeContentSha256)`n$($_.codeBytes)`n" + }) -join "") + $proofs.Add([ordered]@{ + path = $path + processedFileSha256 = [string]$Expected[$path].sha256 + installerFileSha256 = [string]$Actual[$path].sha256 + normalizedFileSha256 = $normalizedFileSha + architectures = $architectureProofs.ToArray() + }) + $index++ + } + + $result.status = "ready" + $result.changedFileCount = $proofs.Count + $result.files = $proofs.ToArray() + return [pscustomobject]$result + } finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } + } +} + +function Add-Issue { + param([string]$Message) + $script:issues += $Message +} + +$issues = @() +$expectedExtension = @{ windows = ".msi"; linux = ".deb"; macos = ".dmg" }[$Platform] +$package = $null +$prepare = $null +$manifest = $null +$processedHash = "" +$processedFiles = @() +$processedInventory = $null +$installerInventory = $null +$installerPayload = [ordered]@{ + required = [bool]$RequireInstallerPayload + status = if ($RequireInstallerPayload) { "not-ready" } else { "not-required" } + extractionMethod = "" + extractionRoot = "" + appJarName = "" + appJarSha256 = "" + packageSha256 = "" + runtimeLocation = "" + runtimeRootRelative = "" + runtimePayloadSha256 = "" + runtimeFileCount = 0 + runtimeBytes = 0 + runtimeManifestSchema = "" + runtimeManifestPlatform = "" + runtimeManifestSha256 = "" + contentIdentityStatus = "not-ready" + signatureNormalization = [ordered]@{ + status = "not-required" + tool = "" + processedPayloadSha256 = "" + installerPayloadSha256 = "" + changedFileCount = 0 + files = @() + } + requiredBrainFiles = @() +} +$launchEvidence = [ordered]@{ + required = [bool]$RequireLaunchEvidence + status = if ($RequireLaunchEvidence) { "not-ready" } else { "not-required" } + path = $LaunchEvidencePath + packageSha256 = "" + extractionMethod = "" + extractionRoot = "" + launcherPath = "" + processExitCode = $null + pythonVersion = "" + scope = "" +} +$distributionTrust = [ordered]@{ + required = [bool]$RequireDistributionTrust + status = if ($RequireDistributionTrust) { "not-ready" } else { "not-required" } + policy = if ($Platform -eq "windows") { "authenticode-sha256-timestamped" } elseif ($Platform -eq "macos") { "developer-id-notarized-stapled" } else { "github-sigstore-attestation-at-publication" } + packageSha256 = "" + signerSubject = "" + signerThumbprint = "" + timestampSubject = "" + timestampThumbprint = "" + signatureStatus = "" + notarizationStapled = $false + gatekeeperAccepted = $false + issue = "" +} + +if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + Add-Issue "Desktop package is missing: $PackagePath" +} else { + $package = Get-Item -LiteralPath $PackagePath + if ($package.Extension.ToLowerInvariant() -ne $expectedExtension) { + Add-Issue "Desktop package for $Platform must use $expectedExtension, got $($package.Extension)." + } +} + +if ([string]::IsNullOrWhiteSpace($RuntimePrepareJsonPath) -or -not (Test-Path -LiteralPath $RuntimePrepareJsonPath -PathType Leaf)) { + Add-Issue "Managed runtime prepare JSON is missing: $RuntimePrepareJsonPath" +} else { + try { + $prepare = Get-Content -LiteralPath $RuntimePrepareJsonPath -Raw | ConvertFrom-Json + } catch { + Add-Issue "Managed runtime prepare JSON is invalid: $($_.Exception.Message)" + } +} + +if ($null -ne $prepare) { + if ([string]$prepare.schema -ne "stackchan.desktop-python-runtime-prepare.v1") { Add-Issue "Unexpected runtime prepare schema: $($prepare.schema)" } + if ([string]$prepare.status -ne "ready") { Add-Issue "Runtime prepare status must be ready, got $($prepare.status)." } + if ([string]$prepare.platform -ne $Platform) { Add-Issue "Runtime prepare platform must be $Platform, got $($prepare.platform)." } + if (-not (Test-Sha256Text ([string]$prepare.payloadSha256))) { Add-Issue "Runtime prepare payloadSha256 is invalid." } + if ($null -eq $prepare.validation) { + Add-Issue "Runtime prepare validation report is missing." + } else { + if ([string]$prepare.validation.schema -ne "stackchan.desktop-python-runtime-payload.v1") { Add-Issue "Unexpected runtime validation schema: $($prepare.validation.schema)" } + if ([string]$prepare.validation.status -ne "ready") { Add-Issue "Runtime validation status must be ready, got $($prepare.validation.status)." } + if ([string]$prepare.validation.platform -ne $Platform) { Add-Issue "Runtime validation platform must be $Platform, got $($prepare.validation.platform)." } + if ([string]$prepare.validation.runtimeSha256 -ne [string]$prepare.payloadSha256) { Add-Issue "Runtime prepare and validation SHA-256 values disagree." } + if ([string]::IsNullOrWhiteSpace([string]$prepare.validation.runtimeSource) -or [string]$prepare.validation.runtimeSource -match '<|>|pending|TBD') { Add-Issue "Runtime source is missing or placeholder text." } + if ([string]::IsNullOrWhiteSpace([string]$prepare.validation.pythonVersion)) { Add-Issue "Runtime pythonVersion is missing." } + if ([string]::IsNullOrWhiteSpace([string]$prepare.validation.probedPythonVersion)) { Add-Issue "Runtime probedPythonVersion is missing." } + } +} + +if ([string]::IsNullOrWhiteSpace($ProcessedRuntimeRoot) -or -not (Test-Path -LiteralPath $ProcessedRuntimeRoot -PathType Container)) { + Add-Issue "Processed desktop runtime resource is missing: $ProcessedRuntimeRoot" +} else { + $manifestPath = Join-Path $ProcessedRuntimeRoot "stackchan-python-runtime.json" + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + Add-Issue "Processed desktop runtime manifest is missing." + } else { + try { + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + } catch { + Add-Issue "Processed desktop runtime manifest is invalid JSON: $($_.Exception.Message)" + } + } + $processedFiles = @(Get-ChildItem -LiteralPath $ProcessedRuntimeRoot -File -Recurse -Force) + if ($processedFiles.Count -lt 2) { Add-Issue "Processed desktop runtime contains too few files." } + try { + $processedHash = Get-RuntimePayloadHash $ProcessedRuntimeRoot + $processedInventory = Get-RuntimeFileInventory $ProcessedRuntimeRoot + } catch { + Add-Issue $_.Exception.Message + } +} + +if ($null -ne $manifest) { + if ([string]$manifest.schema -ne "stackchan.desktop-python-runtime.v1") { Add-Issue "Unexpected processed runtime manifest schema: $($manifest.schema)" } + if ([string]$manifest.platform -ne $Platform) { Add-Issue "Processed runtime platform must be $Platform, got $($manifest.platform)." } + if ($null -ne $prepare -and [string]$manifest.sha256 -ne [string]$prepare.payloadSha256) { Add-Issue "Processed runtime manifest SHA-256 does not match runtime prepare evidence." } +} +if ($null -ne $prepare -and -not [string]::IsNullOrWhiteSpace($processedHash) -and $processedHash -ne [string]$prepare.payloadSha256) { + Add-Issue "Processed runtime payload hash does not match runtime prepare evidence." + if (-not [string]::IsNullOrWhiteSpace([string]$prepare.runtimeRoot) -and + (Test-Path -LiteralPath ([string]$prepare.runtimeRoot) -PathType Container) -and + $null -ne $processedInventory) { + try { + $prepareInventory = Get-RuntimeFileInventory ([string]$prepare.runtimeRoot) + foreach ($difference in Compare-RuntimeFileInventories -Expected $prepareInventory -Actual $processedInventory) { + Add-Issue "Prepare/processed runtime difference: $difference" + } + } catch { + Add-Issue "Prepare/processed runtime inventory comparison failed: $($_.Exception.Message)" + } + } +} + +if ($RequireInstallerPayload) { + $installerIssueStart = $issues.Count + if ($null -eq $package) { + Add-Issue "Installer payload cannot be inspected because the desktop package is missing." + } else { + if ([string]::IsNullOrWhiteSpace($PackageExtractionRoot)) { + $extractionId = [guid]::NewGuid().ToString("N").Substring(0, 12) + $PackageExtractionRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-package-extraction-$Platform-$extractionId" + } + $PackageExtractionRoot = [System.IO.Path]::GetFullPath($PackageExtractionRoot) + $installerPayload.extractionRoot = $PackageExtractionRoot + if ($UseExistingPackageExtraction) { + if (-not (Test-Path -LiteralPath $PackageExtractionRoot -PathType Container)) { + Add-Issue "Existing package extraction root was not found: $PackageExtractionRoot" + } else { + $installerPayload.extractionMethod = "existing" + } + } else { + if ((Get-HostPlatform) -ne $Platform) { + Add-Issue "Installer payload extraction for $Platform must run on a native $Platform host." + } elseif (Test-Path -LiteralPath $PackageExtractionRoot) { + Add-Issue "Package extraction root already exists; refusing to overwrite it: $PackageExtractionRoot" + } else { + try { + Expand-DesktopPackage -TargetPlatform $Platform -SourcePackage $package.FullName -DestinationRoot $PackageExtractionRoot + $installerPayload.extractionMethod = "native" + } catch { + Add-Issue $_.Exception.Message + } + } + } + + if (Test-Path -LiteralPath $PackageExtractionRoot -PathType Container) { + $appJars = @(Get-ChildItem -LiteralPath $PackageExtractionRoot -Recurse -File -Filter "app-desktop-*.jar" -ErrorAction SilentlyContinue) + if ($appJars.Count -ne 1) { + Add-Issue "Expected exactly one packaged application JAR; found $($appJars.Count)." + } else { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $appJar = $appJars[0] + $installerPayload.appJarName = $appJar.Name + $installerPayload.appJarSha256 = Get-Sha256Text $appJar.FullName + $installerPayload.packageSha256 = Get-Sha256Text $package.FullName + $archive = [System.IO.Compression.ZipFile]::OpenRead($appJar.FullName) + try { + $jarRuntimeEntries = @($archive.Entries | Where-Object { (Get-NormalizedZipEntryName $_).StartsWith("python-runtime/") }) + if ($jarRuntimeEntries.Count -ne 0) { Add-Issue "Packaged application JAR must not contain the executable managed runtime." } + + $requiredBrainFiles = @( + "brain/bridge/lan_service.py", + "brain/bridge/reference_bridge.py", + "brain/data/voice_source_provenance.yaml", + "brain/docs/media/voice/stackchan_spark_greeting.wav" + ) + $presentBrainFiles = @() + foreach ($brainPath in $requiredBrainFiles) { + if (@(Get-ZipEntriesByName $archive $brainPath).Count -eq 1) { + $presentBrainFiles += $brainPath + } else { + Add-Issue "Packaged application JAR is missing required brain resource: $brainPath" + } + } + $installerPayload.requiredBrainFiles = @($presentBrainFiles) + } finally { + $archive.Dispose() + } + } + + $runtimeRoots = @(Get-ChildItem -LiteralPath $PackageExtractionRoot -Recurse -Directory -Filter "python-runtime" -ErrorAction SilentlyContinue | Where-Object { + Test-Path -LiteralPath (Join-Path $_.FullName "stackchan-python-runtime.json") -PathType Leaf + }) + if ($runtimeRoots.Count -ne 1) { + Add-Issue "Expected exactly one external managed runtime in native app resources; found $($runtimeRoots.Count)." + } else { + $runtimeRoot = $runtimeRoots[0].FullName + $installerPayload.runtimeLocation = "native-app-resources" + $extractionPrefix = $PackageExtractionRoot.TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar + $installerPayload.runtimeRootRelative = $runtimeRoot.Substring($extractionPrefix.Length).Replace("\", "/") + $runtimeFiles = @(Get-ChildItem -LiteralPath $runtimeRoot -File -Recurse -Force) + $installerPayload.runtimePayloadSha256 = Get-RuntimePayloadHash $runtimeRoot + $installerInventory = Get-RuntimeFileInventory $runtimeRoot + $installerPayload.runtimeFileCount = $runtimeFiles.Count + $installerPayload.runtimeBytes = [int64](($runtimeFiles | Measure-Object -Property Length -Sum).Sum) + $installerManifestPath = Join-Path $runtimeRoot "stackchan-python-runtime.json" + try { + $installerManifest = Get-Content -LiteralPath $installerManifestPath -Raw | ConvertFrom-Json + $installerPayload.runtimeManifestSchema = [string]$installerManifest.schema + $installerPayload.runtimeManifestPlatform = [string]$installerManifest.platform + $installerPayload.runtimeManifestSha256 = [string]$installerManifest.sha256 + if ([string]$installerManifest.schema -ne "stackchan.desktop-python-runtime.v1") { Add-Issue "Packaged runtime manifest schema is invalid." } + if ([string]$installerManifest.platform -ne $Platform) { Add-Issue "Packaged runtime manifest platform must be $Platform." } + if ($null -ne $prepare -and [string]$installerManifest.sha256 -ne [string]$prepare.payloadSha256) { Add-Issue "Packaged runtime manifest SHA-256 does not match runtime prepare evidence." } + } catch { + Add-Issue "Packaged runtime manifest is invalid JSON: $($_.Exception.Message)" + } + $runtimeExecutables = switch ($Platform) { + "windows" { @("python.exe", "python/python.exe") } + default { @("bin/python3", "bin/python", "python3", "python") } + } + if (@($runtimeExecutables | Where-Object { Test-Path -LiteralPath (Join-Path $runtimeRoot $_) -PathType Leaf }).Count -lt 1) { + Add-Issue "External managed runtime does not contain the expected $Platform Python executable." + } + } + } + } + + $installerRuntimeHash = [string]$installerPayload.runtimePayloadSha256 + $runtimeContentIdentityReady = $false + if (-not [string]::IsNullOrWhiteSpace($processedHash) -and -not [string]::IsNullOrWhiteSpace($installerRuntimeHash) -and + $installerRuntimeHash -eq $processedHash) { + $installerPayload.contentIdentityStatus = "ready-exact" + $runtimeContentIdentityReady = $true + } elseif ($Platform -eq "macos" -and $null -ne $processedInventory -and $null -ne $installerInventory) { + $signatureIdentity = Test-MacOSSignatureNormalizedRuntimeIdentity ` + -Expected $processedInventory ` + -Actual $installerInventory ` + -ExpectedPayloadSha256 $processedHash ` + -ActualPayloadSha256 $installerRuntimeHash + $installerPayload.signatureNormalization = [ordered]@{ + status = [string]$signatureIdentity.status + tool = [string]$signatureIdentity.tool + processedPayloadSha256 = [string]$signatureIdentity.processedPayloadSha256 + installerPayloadSha256 = [string]$signatureIdentity.installerPayloadSha256 + changedFileCount = [int]$signatureIdentity.changedFileCount + files = @($signatureIdentity.files) + } + if ($signatureIdentity.status -eq "ready") { + $installerPayload.contentIdentityStatus = "ready-signature-normalized" + $runtimeContentIdentityReady = $true + } else { + Add-Issue "Installer runtime signature-normalized identity was not proven: $($signatureIdentity.issue)" + } + } + if (-not $runtimeContentIdentityReady -and + -not [string]::IsNullOrWhiteSpace($processedHash) -and + -not [string]::IsNullOrWhiteSpace($installerRuntimeHash)) { + Add-Issue "Installer runtime payload hash does not match processed Gradle resources." + if ($null -ne $processedInventory -and $null -ne $installerInventory) { + foreach ($difference in Compare-RuntimeFileInventories -Expected $processedInventory -Actual $installerInventory) { + Add-Issue "Processed/installer runtime difference: $difference" + } + } + } + if ($null -ne $prepare -and -not $runtimeContentIdentityReady) { + Add-Issue "Installer runtime payload identity does not match runtime prepare evidence." + } + if ($processedFiles.Count -gt 0 -and [int]$installerPayload.runtimeFileCount -ne $processedFiles.Count) { + Add-Issue "Installer runtime file count does not match processed Gradle resources." + } + $processedRuntimeBytes = [int64](($processedFiles | Measure-Object -Property Length -Sum).Sum) + if ($installerPayload.contentIdentityStatus -ne "ready-signature-normalized" -and + $processedRuntimeBytes -gt 0 -and [int64]$installerPayload.runtimeBytes -ne $processedRuntimeBytes) { + Add-Issue "Installer runtime byte count does not match processed Gradle resources." + } + if ($issues.Count -eq $installerIssueStart) { $installerPayload.status = "ready" } +} + +if ($RequireLaunchEvidence -or -not [string]::IsNullOrWhiteSpace($LaunchEvidencePath)) { + $launchIssueStart = $issues.Count + if ([string]::IsNullOrWhiteSpace($LaunchEvidencePath) -or -not (Test-Path -LiteralPath $LaunchEvidencePath -PathType Leaf)) { + Add-Issue "Exact desktop package launch evidence is missing: $LaunchEvidencePath" + } else { + try { + $launch = Get-Content -LiteralPath $LaunchEvidencePath -Raw | ConvertFrom-Json + $launchEvidence.path = [System.IO.Path]::GetFullPath($LaunchEvidencePath) + $launchEvidence.packageSha256 = ([string]$launch.package.sha256).ToLowerInvariant() + $launchEvidence.extractionMethod = [string]$launch.extractionMethod + $launchEvidence.extractionRoot = [string]$launch.extractionRoot + $launchEvidence.launcherPath = [string]$launch.launcherPath + $launchEvidence.processExitCode = $launch.processExitCode + $launchEvidence.pythonVersion = [string]$launch.probe.pythonVersion + $launchEvidence.scope = [string]$launch.scope + if ([string]$launch.schema -ne "stackchan.desktop-package-launch-evidence.v1" -or [string]$launch.status -ne "ready") { Add-Issue "Exact desktop package launch evidence is not ready." } + if ([string]$launch.platform -ne $Platform) { Add-Issue "Exact desktop package launch platform mismatch." } + if ($null -eq $package -or $launchEvidence.packageSha256 -ne (Get-Sha256Text $package.FullName)) { Add-Issue "Exact desktop package launch hash does not match the package." } + if ($launch.extractionMethod -ne "native" -or [int]$launch.processExitCode -ne 0) { Add-Issue "Exact desktop package was not natively extracted and launched successfully." } + if ($UseExistingPackageExtraction) { + $launchExtractionRoot = [System.IO.Path]::GetFullPath([string]$launch.extractionRoot).TrimEnd("\", "/") + if ($launchExtractionRoot -ne $PackageExtractionRoot.TrimEnd("\", "/")) { + Add-Issue "Exact desktop package launch used a different extraction root." + } else { + $installerPayload.extractionMethod = "native" + } + } + if ([string]$launch.probe.schema -ne "stackchan.desktop-packaged-runtime-smoke.v1" -or [string]$launch.probe.status -ne "ready") { Add-Issue "Packaged runtime smoke probe is not ready." } + if ($launch.probe.runtimePresent -ne $true -or $launch.probe.pythonAvailable -ne $true -or $launch.probe.brainScriptAvailable -ne $true) { Add-Issue "Packaged runtime smoke did not prove all runtime components." } + if ([string]$launch.probe.launchContext -ne "package-extraction" -or [string]$launch.probe.scope -ne "extracted-native-package-headless-runtime-probe" -or $launch.probe.substitutesForTargetInstall -ne $false) { Add-Issue "Packaged runtime smoke probe context is invalid." } + if (@($launch.probe.issues).Count -ne 0 -or $launch.substitutesForTargetInstall -ne $false) { Add-Issue "Exact desktop package launch evidence has invalid scope or issues." } + if ([string]$launch.scope -ne "exact-native-package-extraction-and-headless-launch") { Add-Issue "Exact desktop package launch scope is invalid." } + } catch { + Add-Issue "Exact desktop package launch evidence is invalid JSON: $($_.Exception.Message)" + } + } + if ($issues.Count -eq $launchIssueStart) { $launchEvidence.status = "ready" } +} + +if ($RequireDistributionTrust) { + if ($null -eq $package) { + Add-Issue "Desktop distribution trust cannot be verified because the package is missing." + } elseif ($Platform -eq "windows") { + $distributionTrust = Test-WindowsDistributionTrust -SourcePackage $package.FullName + } elseif ($Platform -eq "macos") { + $distributionTrust = Test-MacOSDistributionTrust -SourcePackage $package.FullName -ExtractionRoot $PackageExtractionRoot + } else { + $distributionTrust.issue = "Linux package provenance is attested in the final release job, not by the native DEB package exporter." + } + if ($distributionTrust.status -ne "ready") { + Add-Issue "Desktop distribution trust is not ready for $Platform`: $($distributionTrust.issue)" + } +} + +if ([string]::IsNullOrWhiteSpace($Commit)) { + $Commit = ((& git -C $repoRoot rev-parse HEAD 2>$null) | Out-String).Trim() +} +if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = ((& git -C $repoRoot describe --tags --always --dirty 2>$null) | Out-String).Trim() +} + +$packageEvidence = if ($null -eq $package) { + [ordered]@{ name = ""; extension = $expectedExtension; bytes = 0; sha256 = "" } +} else { + [ordered]@{ name = $package.Name; extension = $package.Extension.ToLowerInvariant(); bytes = [int64]$package.Length; sha256 = Get-Sha256Text $package.FullName } +} +$processedBytes = [int64](($processedFiles | Measure-Object -Property Length -Sum).Sum) +$report = [ordered]@{ + schema = "stackchan.desktop-package-evidence.v1" + status = if ($issues.Count -eq 0) { "ready" } else { "not-ready" } + platform = $Platform + version = $Version + commit = $Commit + generatedAtUtc = (Get-Date).ToUniversalTime().ToString("o") + package = $packageEvidence + runtime = [ordered]@{ + payloadSha256 = if ($null -eq $prepare) { "" } else { [string]$prepare.payloadSha256 } + processedPayloadSha256 = $processedHash + source = if ($null -eq $prepare -or $null -eq $prepare.validation) { "" } else { [string]$prepare.validation.runtimeSource } + pythonVersion = if ($null -eq $prepare -or $null -eq $prepare.validation) { "" } else { [string]$prepare.validation.pythonVersion } + probedPythonVersion = if ($null -eq $prepare -or $null -eq $prepare.validation) { "" } else { [string]$prepare.validation.probedPythonVersion } + processedFileCount = $processedFiles.Count + processedBytes = $processedBytes + } + installerPayload = $installerPayload + launchEvidence = $launchEvidence + distributionTrust = $distributionTrust + issues = @($issues) +} + +if ([string]::IsNullOrWhiteSpace($OutPath)) { + $OutPath = Join-Path $repoRoot "output/companion/desktop-package-evidence/$Platform-package-evidence.json" +} +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutPath) | Out-Null +$report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $OutPath -Encoding UTF8 + +if ($Json) { $report | ConvertTo-Json -Depth 8 } else { Write-Host "Desktop package evidence: $($report.status) ($Platform)" } +if ($issues.Count -gt 0) { exit 1 } diff --git a/tools/export_rollout_status.ps1 b/tools/export_rollout_status.ps1 index d7fad034..495dff93 100644 --- a/tools/export_rollout_status.ps1 +++ b/tools/export_rollout_status.ps1 @@ -416,8 +416,8 @@ function Get-RolloutNextAction { return [ordered]@{ owner = "release" action = "Run the consumer promotion verifier." - command = ".\tools\verify_consumer_promotion.cmd -Version $ReleaseVersion" - reason = "All rollout status gates are passing." + command = ".\tools\verify_consumer_promotion.cmd -Version $ReleaseVersion -PackageZip -EvidenceRoot -CompanionV1EvidenceRoot output\companion-v1-evidence\latest" + reason = "All rollout component gates are passing; the aggregate Companion v1 packet remains mandatory for terminal promotion." } } diff --git a/tools/install_android_companion_apk.ps1 b/tools/install_android_companion_apk.ps1 index 33d751a6..f485660f 100644 --- a/tools/install_android_companion_apk.ps1 +++ b/tools/install_android_companion_apk.ps1 @@ -62,7 +62,7 @@ if (-not (Get-Command $AdbPath -ErrorAction SilentlyContinue)) { } if (-not (Test-Path -LiteralPath $ApkPath)) { - throw "Missing Android APK: $ApkPath. From the source checkout, build one with cd companion; .\gradlew.bat :app-android:assembleRelease, then pass the generated APK path with -ApkPath." + throw "Missing Android APK: $ApkPath. From the source checkout, for a lab-only build run cd companion; .\gradlew.bat `"-Pstackchan.allowLabDebugReleaseSigning=true`" :app-android:assembleRelease, then pass the generated APK path with -ApkPath. The unqualified .\gradlew.bat :app-android:assembleRelease command is intentionally rejected without upload signing." } $apkItem = Get-Item -LiteralPath $ApkPath diff --git a/tools/install_desktop_companion_package.cmd b/tools/install_desktop_companion_package.cmd new file mode 100644 index 00000000..bea64786 --- /dev/null +++ b/tools/install_desktop_companion_package.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install_desktop_companion_package.ps1" %* diff --git a/tools/install_desktop_companion_package.ps1 b/tools/install_desktop_companion_package.ps1 new file mode 100644 index 00000000..fb6756cb --- /dev/null +++ b/tools/install_desktop_companion_package.ps1 @@ -0,0 +1,323 @@ +param( + [ValidateSet("windows", "linux", "macos")] + [string]$Platform, + [string]$PackagePath, + [string]$OutputDir = "output/desktop-target-install/latest", + [string]$SourceCommit = "", + [ValidateSet("operator-target-workstation", "ci-native-runner")] + [string]$EnvironmentKind = "operator-target-workstation", + [string]$InstallRoot = "", + [int]$TimeoutSeconds = 120, + [switch]$AllowReplace, + [switch]$Json +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$issues = @() + +function Add-Issue([string]$Message) { $script:issues += $Message } +function Get-Sha256Text([string]$Path) { return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } +function Get-HostPlatform { + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { return "windows" } + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) { return "linux" } + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) { return "macos" } + return "unknown" +} +function Get-GitCommit { + try { return ((& git -C $repoRoot rev-parse HEAD 2>$null) | Out-String).Trim() } catch { return "" } +} +function Test-WindowsAdministrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} +function Find-WindowsLauncher { + $roots = New-Object System.Collections.Generic.List[string] + if (-not [string]::IsNullOrWhiteSpace($InstallRoot)) { $roots.Add([System.IO.Path]::GetFullPath($InstallRoot)) } + foreach ($candidate in @( + (Join-Path $env:ProgramFiles "Stackchan Companion"), + $(if (${env:ProgramFiles(x86)}) { Join-Path ${env:ProgramFiles(x86)} "Stackchan Companion" }), + $(if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Programs\Stackchan Companion" }) + )) { + if (-not [string]::IsNullOrWhiteSpace($candidate)) { $roots.Add($candidate) } + } + $registryRoots = @( + "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + foreach ($entry in @(Get-ItemProperty $registryRoots -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq "Stackchan Companion" })) { + if (-not [string]::IsNullOrWhiteSpace([string]$entry.InstallLocation)) { $roots.Add([string]$entry.InstallLocation) } + } + $matches = @() + foreach ($root in @($roots | Select-Object -Unique)) { + if (Test-Path -LiteralPath $root -PathType Container) { + $matches += @(Get-ChildItem -LiteralPath $root -Recurse -File -Filter "Stackchan Companion.exe" -ErrorAction SilentlyContinue) + } + } + return @($matches | Sort-Object FullName -Unique) +} +function Get-WindowsInstallRegistrations { + $registryRoots = @( + "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" + ) + $registrations = @() + foreach ($entry in @(Get-ItemProperty $registryRoots -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -eq "Stackchan Companion" })) { + $productCode = [string]$entry.PSChildName + if ($productCode -notmatch '^\{[a-fA-F0-9-]{36}\}$') { + $uninstallString = [string]$entry.UninstallString + if ($uninstallString -match '\{[a-fA-F0-9-]{36}\}') { $productCode = $Matches[0] } else { $productCode = "" } + } + $registrations += [ordered]@{ + productCode = $productCode.ToUpperInvariant() + displayName = [string]$entry.DisplayName + displayVersion = [string]$entry.DisplayVersion + publisher = [string]$entry.Publisher + installLocation = [string]$entry.InstallLocation + registryPath = [string]$entry.PSPath + } + } + return @($registrations | Sort-Object productCode, registryPath -Unique) +} + +if ([string]::IsNullOrWhiteSpace($OutputDir)) { $OutputDir = "output/desktop-target-install/latest" } +if (-not [System.IO.Path]::IsPathRooted($OutputDir)) { $OutputDir = Join-Path $repoRoot $OutputDir } +$OutputDir = [System.IO.Path]::GetFullPath($OutputDir) +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +$recordedCommit = $SourceCommit.Trim() +if ([string]::IsNullOrWhiteSpace($recordedCommit)) { $recordedCommit = Get-GitCommit } +if ($recordedCommit -notmatch '^[a-fA-F0-9]{40}$') { Add-Issue "SourceCommit must be a full 40-character git SHA." } + +$hostPlatform = Get-HostPlatform +if ($hostPlatform -ne $Platform) { Add-Issue "Desktop package for $Platform must be installed on a native $Platform host; current host is $hostPlatform." } + +$package = $null +$packageSha = "" +$expectedExtension = @{ windows = ".msi"; linux = ".deb"; macos = ".dmg" }[$Platform] +if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + Add-Issue "Desktop package is missing: $PackagePath" +} else { + $package = Get-Item -LiteralPath $PackagePath + $packageSha = Get-Sha256Text $package.FullName + if ($package.Extension.ToLowerInvariant() -ne $expectedExtension) { Add-Issue "Desktop package for $Platform must use $expectedExtension." } +} + +$installMethod = @{ windows = "msiexec-install"; linux = "dpkg-install"; macos = "dmg-application-copy" }[$Platform] +$installExitCode = $null +$installLogPath = Join-Path $OutputDir "$Platform-native-install.log" +$installedLauncherPath = "" +$probe = $null +$launchExitCode = $null +$mountPoint = "" +$windowsPreExistingRegistrations = @() +$windowsPostInstallRegistrations = @() +$windowsReplacementPerformed = $false +$windowsUninstallAttempts = @() +$windowsElevatedAdministrator = $false + +if ($Platform -eq "windows" -and $hostPlatform -eq "windows") { + $windowsPreExistingRegistrations = @(Get-WindowsInstallRegistrations) + $windowsElevatedAdministrator = Test-WindowsAdministrator + if (-not $windowsElevatedAdministrator) { + Add-Issue "Windows MSI installation requires an elevated PowerShell session; no existing installation was changed." + } +} + +if ($null -ne $package -and $hostPlatform -eq $Platform -and $issues.Count -eq 0) { + try { + switch ($Platform) { + "windows" { + if ($windowsPreExistingRegistrations.Count -gt 1) { + Add-Issue "Expected at most one existing Stackchan Companion product registration; found $($windowsPreExistingRegistrations.Count). Resolve duplicate registrations before replacement." + } + if ($windowsPreExistingRegistrations.Count -gt 0 -and -not $AllowReplace) { + Add-Issue "Stackchan Companion is already installed. Preserve any required evidence, then re-run with -AllowReplace to uninstall only the registered Stackchan Companion product before installing this exact MSI." + } + + if ($windowsPreExistingRegistrations.Count -gt 0 -and $issues.Count -eq 0) { + foreach ($registration in $windowsPreExistingRegistrations) { + $productCode = [string]$registration.productCode + if ($productCode -notmatch '^\{[a-fA-F0-9-]{36}\}$') { + Add-Issue "Existing Stackchan Companion registration does not expose a valid MSI product code: $($registration.registryPath)" + } + } + } + + if ($windowsPreExistingRegistrations.Count -gt 0 -and $issues.Count -eq 0) { + $msiexec = (Get-Command msiexec.exe -ErrorAction Stop).Source + $uninstallIndex = 0 + foreach ($registration in $windowsPreExistingRegistrations) { + $uninstallIndex += 1 + $uninstallLogPath = Join-Path $OutputDir "windows-native-uninstall-$uninstallIndex.log" + $uninstallProcess = Start-Process -FilePath $msiexec -ArgumentList @('/x', [string]$registration.productCode, '/qn', '/norestart', '/L*v', "`"$uninstallLogPath`"") -Wait -PassThru -WindowStyle Hidden + $windowsUninstallAttempts += [ordered]@{ + productCode = [string]$registration.productCode + exitCode = $uninstallProcess.ExitCode + logPath = $uninstallLogPath + } + if ($uninstallProcess.ExitCode -notin @(0, 1641, 3010)) { + Add-Issue "Existing Stackchan Companion uninstall failed with exit code $($uninstallProcess.ExitCode). Inspect $uninstallLogPath." + } + } + if ($issues.Count -eq 0) { + $remainingRegistrations = @(Get-WindowsInstallRegistrations) + if ($remainingRegistrations.Count -ne 0) { + Add-Issue "Stackchan Companion remains registered after replacement uninstall." + } else { + $windowsReplacementPerformed = $true + } + } + } + + if ($issues.Count -eq 0) { + $arguments = @('/i', "`"$($package.FullName)`"", '/qn', '/norestart') + if (-not [string]::IsNullOrWhiteSpace($InstallRoot)) { + $InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) + $arguments += "INSTALLDIR=`"$InstallRoot`"" + } + $arguments += @('/L*v', "`"$installLogPath`"") + $process = Start-Process -FilePath (Get-Command msiexec.exe -ErrorAction Stop).Source -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden + $installExitCode = $process.ExitCode + if ($installExitCode -notin @(0, 1641, 3010)) { Add-Issue "MSI installation failed with exit code $installExitCode. Run this helper from an elevated PowerShell session and inspect $installLogPath." } + if ($issues.Count -eq 0) { + $windowsPostInstallRegistrations = @(Get-WindowsInstallRegistrations) + if ($windowsPostInstallRegistrations.Count -ne 1) { Add-Issue "Expected exactly one installed Windows product registration; found $($windowsPostInstallRegistrations.Count)." } + } + if ($issues.Count -eq 0) { + $launchers = @(Find-WindowsLauncher) + if ($launchers.Count -ne 1) { Add-Issue "Expected exactly one installed Windows launcher; found $($launchers.Count)." } else { $installedLauncherPath = $launchers[0].FullName } + } + } + } + "linux" { + $dpkgDeb = (Get-Command dpkg-deb -ErrorAction Stop).Source + $packageName = ((& $dpkgDeb -f $package.FullName Package 2>&1) | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($packageName)) { throw "Could not read the DEB package name." } + $isRoot = ((& id -u) | Out-String).Trim() -eq "0" + $command = if ($isRoot) { (Get-Command dpkg -ErrorAction Stop).Source } else { (Get-Command sudo -ErrorAction Stop).Source } + $arguments = if ($isRoot) { @('-i', $package.FullName) } else { @('-n', 'dpkg', '-i', $package.FullName) } + $installOutput = @(& $command @arguments 2>&1) + $installExitCode = $LASTEXITCODE + $installOutput | Set-Content -LiteralPath $installLogPath -Encoding UTF8 + if ($installExitCode -ne 0) { Add-Issue "DEB installation failed with exit code $installExitCode. Inspect $installLogPath." } + if ($issues.Count -eq 0) { + $installedFiles = @(& (Get-Command dpkg -ErrorAction Stop).Source -L $packageName 2>&1) + if ($LASTEXITCODE -ne 0) { Add-Issue "Could not enumerate files installed by $packageName." } + $launchers = @($installedFiles | Where-Object { (Split-Path -Leaf ([string]$_)) -eq "Stackchan Companion" -and (Test-Path -LiteralPath ([string]$_) -PathType Leaf) }) + if ($launchers.Count -ne 1) { Add-Issue "Expected exactly one installed Linux launcher; found $($launchers.Count)." } else { $installedLauncherPath = [string]$launchers[0] } + } + } + "macos" { + if ([string]::IsNullOrWhiteSpace($InstallRoot)) { $InstallRoot = Join-Path $HOME "Applications" } + $InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot) + New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null + $mountPoint = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-dmg-install-" + [guid]::NewGuid().ToString("N").Substring(0, 12)) + New-Item -ItemType Directory -Path $mountPoint | Out-Null + $hdiutil = (Get-Command hdiutil -ErrorAction Stop).Source + $attachOutput = @(& $hdiutil attach $package.FullName -readonly -nobrowse -mountpoint $mountPoint 2>&1) + if ($LASTEXITCODE -ne 0) { throw "DMG mount failed." } + try { + $apps = @(Get-ChildItem -LiteralPath $mountPoint -Directory -Filter "*.app") + if ($apps.Count -ne 1) { throw "Expected one application bundle in the DMG; found $($apps.Count)." } + $installRootPrefix = $InstallRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + $installedApp = [System.IO.Path]::GetFullPath((Join-Path $InstallRoot $apps[0].Name)) + if (-not $installedApp.StartsWith($installRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Resolved application path is outside the requested installation root: $installedApp" + } + if (Test-Path -LiteralPath $installedApp) { + if (-not $AllowReplace) { throw "Installed application already exists: $installedApp. Re-run with -AllowReplace after preserving any required evidence." } + Remove-Item -LiteralPath $installedApp -Recurse -Force + } + $copyOutput = @(& (Get-Command ditto -ErrorAction Stop).Source $apps[0].FullName $installedApp 2>&1) + $installExitCode = $LASTEXITCODE + @($attachOutput; $copyOutput) | Set-Content -LiteralPath $installLogPath -Encoding UTF8 + if ($installExitCode -ne 0) { Add-Issue "Application bundle copy failed with exit code $installExitCode." } + $launchers = @(Get-ChildItem -LiteralPath $installedApp -Recurse -File -Filter "Stackchan Companion" | Where-Object { $_.FullName -match '\.app[\\/]Contents[\\/]MacOS[\\/]' }) + if ($launchers.Count -ne 1) { Add-Issue "Expected exactly one installed macOS launcher; found $($launchers.Count)." } else { $installedLauncherPath = $launchers[0].FullName } + } finally { + & $hdiutil detach $mountPoint -force | Out-Null + } + } + } + } catch { + Add-Issue "Native package installation failed: $($_.Exception.Message)" + } +} + +if (-not [string]::IsNullOrWhiteSpace($installedLauncherPath) -and $issues.Count -eq 0) { + $probePath = Join-Path $OutputDir "$Platform-installed-runtime-smoke.json" + try { + $arguments = @( + "--package-smoke-output=`"$probePath`"", + "--package-smoke-context=installed-package" + ) + $process = Start-Process -FilePath $installedLauncherPath -ArgumentList $arguments -PassThru + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + $process.Kill() + Add-Issue "Installed launcher smoke timed out after $TimeoutSeconds seconds." + } else { + $launchExitCode = $process.ExitCode + if ($launchExitCode -ne 0) { Add-Issue "Installed launcher smoke exited with code $launchExitCode." } + } + } catch { + Add-Issue "Installed launcher smoke could not start: $($_.Exception.Message)" + } + if (-not (Test-Path -LiteralPath $probePath -PathType Leaf)) { + Add-Issue "Installed launcher did not write its runtime smoke report." + } else { + try { $probe = Get-Content -LiteralPath $probePath -Raw | ConvertFrom-Json } catch { Add-Issue "Installed runtime smoke report is invalid JSON: $($_.Exception.Message)" } + } +} + +if ($null -ne $probe) { + if ([string]$probe.schema -ne "stackchan.desktop-packaged-runtime-smoke.v1" -or [string]$probe.status -ne "ready") { Add-Issue "Installed runtime smoke is not ready." } + if ([string]$probe.platform -ne $Platform -or [string]$probe.launchContext -ne "installed-package" -or [string]$probe.scope -ne "installed-native-package-headless-runtime-probe") { Add-Issue "Installed runtime smoke platform or scope is invalid." } + if ($probe.runtimePresent -ne $true -or $probe.pythonAvailable -ne $true -or $probe.brainScriptAvailable -ne $true -or @($probe.issues).Count -ne 0) { Add-Issue "Installed runtime smoke did not prove runtime, Python, and brain readiness." } +} + +$report = [ordered]@{ + schema = "stackchan.desktop-target-install-evidence.v1" + status = if ($issues.Count -eq 0) { "installed-and-ready" } else { "not-ready" } + capturedUtc = (Get-Date).ToUniversalTime().ToString("o") + sourceCommit = $recordedCommit + platform = $Platform + environmentKind = $EnvironmentKind + host = [ordered]@{ + platform = $hostPlatform + osDescription = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription + architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + elevatedAdministrator = $windowsElevatedAdministrator + } + package = if ($null -eq $package) { [ordered]@{ name = ""; bytes = 0; sha256 = "" } } else { [ordered]@{ name = $package.Name; bytes = [int64]$package.Length; sha256 = $packageSha } } + install = [ordered]@{ + method = $installMethod + exitCode = $installExitCode + installRoot = $InstallRoot + installedLauncherPath = $installedLauncherPath + logPath = $installLogPath + windows = [ordered]@{ + preExistingRegistrations = @($windowsPreExistingRegistrations) + replacementRequested = [bool]$AllowReplace + replacementPerformed = $windowsReplacementPerformed + uninstallAttempts = @($windowsUninstallAttempts) + postInstallRegistrations = @($windowsPostInstallRegistrations) + } + } + launch = [ordered]@{ + exitCode = $launchExitCode + probe = $probe + } + scope = "exact-native-package-install-and-headless-launch" + targetInstallVerified = ($issues.Count -eq 0) + substitutesForHumanAcceptance = $false + issues = @($issues) +} + +$jsonPath = Join-Path $OutputDir "$Platform-target-install.json" +$report | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 +if ($Json) { $report | ConvertTo-Json -Depth 10 } else { Write-Host "Desktop target install evidence: $($report.status) ($Platform)"; Write-Host $jsonPath } +if ($issues.Count -gt 0) { exit 1 } diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 911eb8d8..8ebae9f9 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -50,6 +50,13 @@ Set-Location $repoRoot . (Join-Path $PSScriptRoot "preview_python_resolver.ps1") . (Join-Path $PSScriptRoot "release_asset_contract.ps1") +$credentialHygieneJson = (& (Join-Path $PSScriptRoot "check_release_credential_hygiene.ps1") -Root $repoRoot -Json | Out-String) +$credentialHygieneReport = $credentialHygieneJson | ConvertFrom-Json +if ($credentialHygieneReport.status -ne "release-credential-hygiene-ready" -or [int]$credentialHygieneReport.failed -ne 0) { + throw "Release credential hygiene failed; refusing to build or package from this checkout." +} +Write-Host $credentialHygieneJson.Trim() + $releaseLegacyPlatformioCore = Get-StackchanPlatformioCoreDir $releasePlatformioCoreRoot = if ($env:OS -eq "Windows_NT") { # The repository may be running through a temporary subst drive. Keep the @@ -176,6 +183,8 @@ $fullOnlineFirmwareDir = Join-Path $firmwareDir "full_online" $mediaDir = Join-Path $outDir "media" $faceArtifactDir = Join-Path $outDir "artifacts/face" $docsDir = Join-Path $outDir "docs" +$siteDir = Join-Path $outDir "site" +$privacySiteDir = Join-Path $siteDir "privacy" $dataDir = Join-Path $outDir "data" $bridgeDir = Join-Path $outDir "bridge" $bridgeModelsDir = Join-Path $bridgeDir "models" @@ -183,7 +192,7 @@ $companionEvidenceDir = Join-Path $outDir "companion/evidence" $provenanceDir = Join-Path $outDir "provenance" $thirdPartyLicensesDir = Join-Path $outDir "third_party_licenses" $toolsDir = Join-Path $outDir "tools" -New-Item -ItemType Directory -Force -Path $displayFirmwareDir, $servoFirmwareDir, $fullOnlineFirmwareDir, $mediaDir, $faceArtifactDir, $docsDir, $dataDir, $bridgeDir, $bridgeModelsDir, $companionEvidenceDir, $provenanceDir, $thirdPartyLicensesDir, $toolsDir | Out-Null +New-Item -ItemType Directory -Force -Path $displayFirmwareDir, $servoFirmwareDir, $fullOnlineFirmwareDir, $mediaDir, $faceArtifactDir, $docsDir, $siteDir, $privacySiteDir, $dataDir, $bridgeDir, $bridgeModelsDir, $companionEvidenceDir, $provenanceDir, $thirdPartyLicensesDir, $toolsDir | Out-Null $releaseRootPrefix = [System.IO.Path]::GetFullPath($outDir).TrimEnd("\", "/") + [System.IO.Path]::DirectorySeparatorChar @@ -425,6 +434,9 @@ Copy-Item -LiteralPath "docs/ANDROID_COMPANION_TEST_PLAN.md" -Destination $docsD Copy-Item -LiteralPath "docs/ANDROID_PLAY_RELEASE.md" -Destination $docsDir Copy-Item -LiteralPath "docs/ANDROID_PLAY_POLICY_DECLARATIONS.md" -Destination $docsDir Copy-Item -LiteralPath "docs/ANDROID_PLAY_PRIVACY_POLICY.md" -Destination $docsDir +Copy-Item -LiteralPath "site/.nojekyll" -Destination $siteDir +Copy-Item -LiteralPath "site/index.html" -Destination $siteDir +Copy-Item -LiteralPath "site/privacy/index.html" -Destination $privacySiteDir Copy-Item -LiteralPath "docs/BRAIN_MODEL.md" -Destination $docsDir Copy-Item -LiteralPath "docs/COMPANION_CROSS_PLATFORM_PLAN.md" -Destination $docsDir Copy-Item -LiteralPath "docs/CONVERSATION_V2_ROADMAP.md" -Destination $docsDir @@ -659,6 +671,29 @@ $releaseTools = @( "tools/check_desktop_python_runtime_payload.ps1", "tools/test_desktop_python_runtime_payload_contract.cmd", "tools/test_desktop_python_runtime_payload_contract.ps1", + "tools/export_desktop_package_evidence.cmd", + "tools/export_desktop_package_evidence.ps1", + "tools/test_desktop_package_launch.cmd", + "tools/test_desktop_package_launch.ps1", + "tools/test_desktop_package_evidence_contract.cmd", + "tools/test_desktop_package_evidence_contract.ps1", + "tools/check_desktop_release_signing_readiness.cmd", + "tools/check_desktop_release_signing_readiness.ps1", + "tools/test_desktop_release_signing_readiness_contract.ps1", + "tools/check_release_credential_hygiene.cmd", + "tools/check_release_credential_hygiene.ps1", + "tools/test_release_credential_hygiene_contract.cmd", + "tools/test_release_credential_hygiene_contract.ps1", + "tools/download_companion_ci_candidate.cmd", + "tools/download_companion_ci_candidate.ps1", + "tools/test_companion_ci_candidate_contract.cmd", + "tools/test_companion_ci_candidate_contract.ps1", + "tools/install_desktop_companion_package.cmd", + "tools/install_desktop_companion_package.ps1", + "tools/check_desktop_target_install_evidence.cmd", + "tools/check_desktop_target_install_evidence.ps1", + "tools/test_desktop_target_install_evidence_contract.cmd", + "tools/test_desktop_target_install_evidence_contract.ps1", "tools/check_desktop_v1_evidence_bundle.cmd", "tools/check_desktop_v1_evidence_bundle.ps1", "tools/test_desktop_v1_evidence_bundle_contract.cmd", @@ -674,6 +709,18 @@ $releaseTools = @( "tools/check_android_toolchain.ps1", "tools/check_android_play_release_readiness.cmd", "tools/check_android_play_release_readiness.ps1", + "tools/check_privacy_policy_deployment.cmd", + "tools/check_privacy_policy_deployment.ps1", + "tools/test_privacy_policy_deployment_contract.cmd", + "tools/test_privacy_policy_deployment_contract.ps1", + "tools/test_android_upload_signing_contract.cmd", + "tools/test_android_upload_signing_contract.ps1", + "tools/test_android_emulator_launch.cmd", + "tools/test_android_emulator_launch.ps1", + "tools/check_android_emulator_release_evidence.cmd", + "tools/check_android_emulator_release_evidence.ps1", + "tools/test_android_emulator_release_evidence_contract.cmd", + "tools/test_android_emulator_release_evidence_contract.ps1", "tools/check_android_play_store_evidence.cmd", "tools/check_android_play_store_evidence.ps1", "tools/check_android_v1_evidence_bundle.cmd", @@ -682,6 +729,10 @@ $releaseTools = @( "tools/check_android_diagnostics_export_evidence.ps1", "tools/check_companion_v1_readiness.cmd", "tools/check_companion_v1_readiness.ps1", + "tools/check_companion_release_version.cmd", + "tools/check_companion_release_version.ps1", + "tools/test_companion_release_version_contract.cmd", + "tools/test_companion_release_version_contract.ps1", "tools/export_companion_release_evidence.cmd", "tools/export_companion_release_evidence.ps1", "tools/preview_python_resolver.ps1", @@ -904,6 +955,8 @@ Copy-Item -LiteralPath "partitions_esp_sr_16.csv" -Destination $provenanceDir Copy-Item -LiteralPath "requirements-preview.txt" -Destination $provenanceDir Copy-Item -LiteralPath ".github/workflows/firmware.yml" -Destination $provenanceDir Copy-Item -LiteralPath ".github/workflows/release.yml" -Destination $provenanceDir +Copy-Item -LiteralPath ".github/workflows/pages.yml" -Destination $provenanceDir +Copy-Item -LiteralPath ".github/workflows/companion-signing-readiness.yml" -Destination $provenanceDir Copy-Item -LiteralPath "src" -Destination (Join-Path $provenanceDir "src") -Recurse Copy-Item -LiteralPath "bridge" -Destination (Join-Path $provenanceDir "bridge") -Recurse Copy-Item -LiteralPath "protocol-fixtures" -Destination (Join-Path $provenanceDir "protocol-fixtures") -Recurse @@ -1354,6 +1407,9 @@ $manifest = [ordered]@{ androidPlayRelease = "docs/ANDROID_PLAY_RELEASE.md" androidPlayPolicyDeclarations = "docs/ANDROID_PLAY_POLICY_DECLARATIONS.md" androidPlayPrivacyPolicy = "docs/ANDROID_PLAY_PRIVACY_POLICY.md" + androidPlayPrivacyPolicySite = "site/privacy/index.html" + androidPlayPrivacyPolicyUrl = "https://robvanprod.github.io/stackchan_alive/privacy/" + pagesWorkflow = "provenance/pages.yml" androidPlayIcon = "docs/store-assets/play/icon-512.png" androidPlayFeatureGraphic = "docs/store-assets/play/feature-graphic-1024x500.png" companionCrossPlatformPlan = "docs/COMPANION_CROSS_PLATFORM_PLAN.md" @@ -1482,6 +1538,21 @@ $manifest = [ordered]@{ "tools/check_desktop_python_runtime_payload.ps1", "tools/test_desktop_python_runtime_payload_contract.cmd", "tools/test_desktop_python_runtime_payload_contract.ps1", + "tools/export_desktop_package_evidence.cmd", + "tools/export_desktop_package_evidence.ps1", + "tools/test_desktop_package_launch.cmd", + "tools/test_desktop_package_launch.ps1", + "tools/test_desktop_package_evidence_contract.cmd", + "tools/test_desktop_package_evidence_contract.ps1", + "tools/check_desktop_release_signing_readiness.cmd", + "tools/check_desktop_release_signing_readiness.ps1", + "tools/test_desktop_release_signing_readiness_contract.ps1", + "tools/install_desktop_companion_package.cmd", + "tools/install_desktop_companion_package.ps1", + "tools/check_desktop_target_install_evidence.cmd", + "tools/check_desktop_target_install_evidence.ps1", + "tools/test_desktop_target_install_evidence_contract.cmd", + "tools/test_desktop_target_install_evidence_contract.ps1", "tools/check_desktop_v1_evidence_bundle.cmd", "tools/check_desktop_v1_evidence_bundle.ps1", "tools/test_desktop_v1_evidence_bundle_contract.cmd", @@ -1497,6 +1568,18 @@ $manifest = [ordered]@{ "tools/check_android_toolchain.ps1", "tools/check_android_play_release_readiness.cmd", "tools/check_android_play_release_readiness.ps1", + "tools/check_privacy_policy_deployment.cmd", + "tools/check_privacy_policy_deployment.ps1", + "tools/test_privacy_policy_deployment_contract.cmd", + "tools/test_privacy_policy_deployment_contract.ps1", + "tools/test_android_upload_signing_contract.cmd", + "tools/test_android_upload_signing_contract.ps1", + "tools/test_android_emulator_launch.cmd", + "tools/test_android_emulator_launch.ps1", + "tools/check_android_emulator_release_evidence.cmd", + "tools/check_android_emulator_release_evidence.ps1", + "tools/test_android_emulator_release_evidence_contract.cmd", + "tools/test_android_emulator_release_evidence_contract.ps1", "tools/check_android_play_store_evidence.cmd", "tools/check_android_play_store_evidence.ps1", "tools/check_android_v1_evidence_bundle.cmd", @@ -1505,6 +1588,10 @@ $manifest = [ordered]@{ "tools/check_android_diagnostics_export_evidence.ps1", "tools/check_companion_v1_readiness.cmd", "tools/check_companion_v1_readiness.ps1", + "tools/check_companion_release_version.cmd", + "tools/check_companion_release_version.ps1", + "tools/test_companion_release_version_contract.cmd", + "tools/test_companion_release_version_contract.ps1", "tools/export_companion_release_evidence.cmd", "tools/export_companion_release_evidence.ps1", "tools/preview_python_resolver.ps1", @@ -1769,6 +1856,7 @@ $manifest = [ordered]@{ "provenance/protocol-fixtures/invalid/wrong_protocol.json", "provenance/firmware.yml", "provenance/release.yml", + "provenance/companion-signing-readiness.yml", "provenance/data/commands.yaml", "provenance/personas/spark/pack.yaml", "provenance/personas/spark/character.yaml", diff --git a/tools/prepare_desktop_python_runtime.ps1 b/tools/prepare_desktop_python_runtime.ps1 index adf048e9..19cad5ac 100644 --- a/tools/prepare_desktop_python_runtime.ps1 +++ b/tools/prepare_desktop_python_runtime.ps1 @@ -8,6 +8,7 @@ param( ) $ErrorActionPreference = "Stop" +$materializedSymlinkCount = 0 $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $platform = if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { @@ -160,7 +161,17 @@ function Copy-RuntimeDirectory { New-Item -ItemType Directory -Force -Path $CurrentTarget | Out-Null Get-ChildItem -LiteralPath $CurrentSource -File -Force | ForEach-Object { - Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $CurrentTarget $_.Name) + if ($_.Name -eq ".gitignore") { return } + $destination = Join-Path $CurrentTarget $_.Name + $isUnixFileLink = $platform -ne "windows" -and -not [string]::IsNullOrWhiteSpace([string]$_.LinkType) + if ($isUnixFileLink) { + [System.IO.File]::Copy($_.FullName, $destination, $true) + $sourceMode = [System.IO.File]::GetUnixFileMode($_.FullName) + [System.IO.File]::SetUnixFileMode($destination, $sourceMode) + $script:materializedSymlinkCount++ + } else { + Copy-Item -LiteralPath $_.FullName -Destination $destination + } } Get-ChildItem -LiteralPath $CurrentSource -Directory -Force | ForEach-Object { if ($excludedDirectoryNames.ContainsKey($_.Name)) { return } @@ -232,10 +243,12 @@ $result = [ordered]@{ pythonVersion = $versionCheck.version source = $SourceName dryRun = [bool]$DryRun + materializedSymlinkCount = $materializedSymlinkCount } if (-not $DryRun) { Copy-RuntimeDirectory -SourceRoot $sourceRoot -TargetRoot $RuntimeRoot + $result.materializedSymlinkCount = $materializedSymlinkCount $payloadHash = Get-RuntimePayloadHash $RuntimeRoot $manifest = [ordered]@{ schema = "stackchan.desktop-python-runtime.v1" diff --git a/tools/release_asset_contract.ps1 b/tools/release_asset_contract.ps1 index 8f08cc9f..9e3b25bd 100644 --- a/tools/release_asset_contract.ps1 +++ b/tools/release_asset_contract.ps1 @@ -100,3 +100,24 @@ function Get-ReleaseAllowedAuditAssetEntries { New-ReleaseAssetEntry -Name "RELEASE_AUDIT.json" -Path (Join-Path $AuditRoot "RELEASE_AUDIT.json") -Phase "audit" ) } + +function Get-ReleaseCompanionAssetEntries { + param( + [string]$Version, + [string]$CompanionAssetRoot + ) + + if ([string]::IsNullOrWhiteSpace($CompanionAssetRoot)) { + throw "CompanionAssetRoot is required for companion release assets." + } + + return @( + New-ReleaseAssetEntry -Name "stackchan-companion-android-$Version.apk" -Path (Join-Path $CompanionAssetRoot "stackchan-companion-android-$Version.apk") -Phase "final" + New-ReleaseAssetEntry -Name "stackchan-companion-android-$Version.aab" -Path (Join-Path $CompanionAssetRoot "stackchan-companion-android-$Version.aab") -Phase "final" + New-ReleaseAssetEntry -Name "stackchan-companion-windows-$Version.msi" -Path (Join-Path $CompanionAssetRoot "stackchan-companion-windows-$Version.msi") -Phase "final" + New-ReleaseAssetEntry -Name "stackchan-companion-linux-$Version.deb" -Path (Join-Path $CompanionAssetRoot "stackchan-companion-linux-$Version.deb") -Phase "final" + New-ReleaseAssetEntry -Name "stackchan-companion-macos-$Version.dmg" -Path (Join-Path $CompanionAssetRoot "stackchan-companion-macos-$Version.dmg") -Phase "final" + New-ReleaseAssetEntry -Name "COMPANION_RELEASE_EVIDENCE.json" -Path (Join-Path $CompanionAssetRoot "COMPANION_RELEASE_EVIDENCE.json") -Phase "final" + New-ReleaseAssetEntry -Name "COMPANION_RELEASE_EVIDENCE.md" -Path (Join-Path $CompanionAssetRoot "COMPANION_RELEASE_EVIDENCE.md") -Phase "final" + ) +} diff --git a/tools/start_hardware_evidence.ps1 b/tools/start_hardware_evidence.ps1 index d6a5b5ca..7a8de480 100644 --- a/tools/start_hardware_evidence.ps1 +++ b/tools/start_hardware_evidence.ps1 @@ -6,6 +6,7 @@ param( [string]$Operator = "", [string]$DeviceId = "", [string]$ShareRoot = "", + [string]$CompanionV1EvidenceRoot = "output/companion-v1-evidence/latest", [switch]$AllowIncompleteMetadata, [switch]$AllowDirtyPackage ) @@ -777,14 +778,16 @@ if ($AllowDirtyPackage) { $progressCommand = "& '.\tools\check_hardware_evidence_progress.ps1' -EvidenceRoot $(Quote-PowerShellArgument $outDir)" $addMediaCommand = "& '.\tools\add_hardware_evidence_media.ps1' -EvidenceRoot $(Quote-PowerShellArgument $outDir)" $evidenceVerifyCommand = "& '.\tools\verify_hardware_evidence.ps1' -EvidenceRoot $(Quote-PowerShellArgument $outDir)" -$promotionPackageArg = "-PackageZip $(Quote-PowerShellArgument '')" +$rolloutPackageArg = "-PackageZip $(Quote-PowerShellArgument '')" +$consumerPromotionPackageArg = "-PackageZip $(Quote-PowerShellArgument '')" if ($packageInfo -and $packageInfo.Contains("copiedFile")) { - $promotionPackageArg = "-PackageZip $(Quote-PowerShellArgument $packageFlashZip)" + $rolloutPackageArg = "-PackageZip $(Quote-PowerShellArgument $packageFlashZip)" + $consumerPromotionPackageArg = "-PackageZip $(Quote-PowerShellArgument $packageFlashZip)" } elseif (-not [string]::IsNullOrWhiteSpace($PackageRoot)) { - $promotionPackageArg = "-PackageRoot $(Quote-PowerShellArgument $PackageRoot)" + $rolloutPackageArg = "-PackageRoot $(Quote-PowerShellArgument $PackageRoot)" } -$rolloutStatusCommand = "& '.\tools\export_rollout_status.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $promotionPackageArg -EvidenceRoot $(Quote-PowerShellArgument $outDir) -ExpectedCommit $(Quote-PowerShellArgument $commit) -OutDir $(Quote-PowerShellArgument $outDir)" -$consumerPromotionCommand = "& '.\tools\verify_consumer_promotion.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $promotionPackageArg -EvidenceRoot $(Quote-PowerShellArgument $outDir) -ExpectedCommit $(Quote-PowerShellArgument $commit)" +$rolloutStatusCommand = "& '.\tools\export_rollout_status.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $rolloutPackageArg -EvidenceRoot $(Quote-PowerShellArgument $outDir) -ExpectedCommit $(Quote-PowerShellArgument $commit) -OutDir $(Quote-PowerShellArgument $outDir)" +$consumerPromotionCommand = "& '.\tools\verify_consumer_promotion.ps1' -Version $(Quote-PowerShellArgument $ReleaseTag) $consumerPromotionPackageArg -EvidenceRoot $(Quote-PowerShellArgument $outDir) -CompanionV1EvidenceRoot $(Quote-PowerShellArgument $CompanionV1EvidenceRoot) -ExpectedCommit $(Quote-PowerShellArgument $commit)" $platformioResolver = Quote-PowerShellArgument (Join-Path $PSScriptRoot "platformio_resolver.ps1") $soakCommand = ". $platformioResolver; Invoke-StackchanPlatformio device monitor --baud 115200$monitorPortArg 2>&1 | Tee-Object -FilePath $soakLog" $playLeadCommand = "Write-Host 'No RVC lead audition reference was copied into this packet.'" @@ -893,7 +896,8 @@ $nextSteps = @( "15. Run ``RUN_PROGRESS_CHECK.cmd`` to refresh ``BENCH_STATUS.md/json`` and fix every missing field, marker, media file, and unchecked checklist item it reports.", "16. Run ``RUN_ROLLOUT_STATUS.cmd`` to write ``ROLLOUT_STATUS.md`` and ``ROLLOUT_STATUS.json`` for handoff review.", "17. Run ``RUN_EVIDENCE_VERIFY.cmd`` for the strict hardware evidence gate.", - "18. Run ``RUN_CONSUMER_PROMOTION_CHECK.cmd`` only after strict evidence verification passes.", + "18. Complete the aggregate Companion v1 packet at ``$CompanionV1EvidenceRoot`` and confirm its checker reports ``companion-v1-evidence-ready``.", + "19. Run ``RUN_CONSUMER_PROMOTION_CHECK.cmd`` only after strict hardware and aggregate Companion v1 evidence verification pass.", "", "## Gates Still Expected", "", @@ -903,6 +907,7 @@ $nextSteps = @( "- RVC voice-base evidence remains review-only until consumer and distribution approvals are explicitly recorded.", "- GitHub Actions may still be externally blocked; use ``RUN_ROLLOUT_STATUS.cmd`` for the current CI/account state.", "- Hosted media or synthetic diagnostic packets are review aids only. They do not replace real-device evidence.", + "- Consumer promotion requires the exact release ZIP and a ready aggregate Companion v1 packet that binds Android, desktop, Play, hardware, CI, voice, and release evidence to the same commit.", "", "## Hard Stops", "", @@ -1069,7 +1074,7 @@ $readme = @( "", " .\RUN_EVIDENCE_VERIFY.cmd", "", - "After the strict evidence check passes, run the full consumer promotion gate. This also requires successful GitHub Actions status and completed production voice-source provenance:", + "After the strict evidence check passes, complete the aggregate Companion v1 packet at ``$CompanionV1EvidenceRoot``. Its checker must report ``companion-v1-evidence-ready`` before the full consumer promotion gate can pass. The final gate also requires the exact release ZIP, successful GitHub Actions status, and completed production voice-source provenance:", "", " $consumerPromotionCommand", "", @@ -1214,6 +1219,7 @@ $metadata = [ordered]@{ refreshCommand = "RUN_PROGRESS_CHECK.cmd" } promotionVerifier = "tools/verify_consumer_promotion.ps1" + companionV1EvidenceRoot = $CompanionV1EvidenceRoot hardwareEvidenceVerifier = "tools/verify_hardware_evidence.ps1" } diff --git a/tools/test_android_emulator_launch.cmd b/tools/test_android_emulator_launch.cmd new file mode 100644 index 00000000..9a0476d9 --- /dev/null +++ b/tools/test_android_emulator_launch.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_android_emulator_launch.ps1" %* diff --git a/tools/test_android_emulator_launch.ps1 b/tools/test_android_emulator_launch.ps1 new file mode 100644 index 00000000..660dc1d2 --- /dev/null +++ b/tools/test_android_emulator_launch.ps1 @@ -0,0 +1,250 @@ +param( + [string]$ApkPath = "companion/app-android/build/outputs/apk/release/app-android-release.apk", + [string]$OutputDir = "output/android-emulator-smoke/latest", + [string]$PackageName = "dev.stackchan.companion", + [string]$ActivityName = "dev.stackchan.companion.android.MainActivity", + [string]$ServiceName = "dev.stackchan.companion.android.CompanionBridgeService", + [string]$AdbPath = "adb", + [string]$Serial = "", + [int]$SettleSeconds = 10, + [switch]$Json +) + +$ErrorActionPreference = "Stop" + +function Invoke-Adb { + param([string[]]$Arguments) + + $adbArguments = @() + if (-not [string]::IsNullOrWhiteSpace($Serial)) { + $adbArguments += @("-s", $Serial) + } + $adbArguments += $Arguments + + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& $AdbPath @adbArguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + + return [pscustomobject]@{ + exitCode = $exitCode + output = @($output | ForEach-Object { [string]$_ }) + } +} + +function Assert-AdbSuccess { + param( + [object]$Result, + [string]$Operation + ) + + if ($Result.exitCode -ne 0) { + throw "$Operation failed with adb exit code $($Result.exitCode): $($Result.output -join ' ')" + } + return @($Result.output) +} + +function Get-RegexValue { + param( + [string]$Text, + [string]$Pattern + ) + + $match = [regex]::Match($Text, $Pattern) + if ($match.Success) { + return $match.Groups[1].Value.Trim() + } + return "" +} + +if ($SettleSeconds -lt 1 -or $SettleSeconds -gt 120) { + throw "SettleSeconds must be between 1 and 120." +} +if (-not (Get-Command $AdbPath -ErrorAction SilentlyContinue)) { + throw "adb was not found. Install Android platform-tools or pass -AdbPath." +} +if (-not (Test-Path -LiteralPath $ApkPath -PathType Leaf)) { + throw "Missing Android APK: $ApkPath" +} + +$apk = Get-Item -LiteralPath $ApkPath +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +$devicesResult = Invoke-Adb -Arguments @("devices") +$devicesOutput = Assert-AdbSuccess -Result $devicesResult -Operation "adb devices" +$connectedDevices = @( + $devicesOutput | + Select-Object -Skip 1 | + Where-Object { $_ -match "\sdevice$" } | + ForEach-Object { ($_ -split "\s+")[0] } +) +if ($connectedDevices.Count -eq 0) { + throw "No adb emulator is connected and authorized." +} +if ([string]::IsNullOrWhiteSpace($Serial)) { + if ($connectedDevices.Count -ne 1) { + throw "Expected exactly one adb device; found $($connectedDevices.Count). Pass -Serial to select the emulator." + } + $Serial = $connectedDevices[0] +} elseif ($connectedDevices -notcontains $Serial) { + throw "Requested adb serial '$Serial' is not connected and authorized." +} + +$qemuResult = Invoke-Adb -Arguments @("shell", "getprop", "ro.kernel.qemu") +$qemuValue = (Assert-AdbSuccess -Result $qemuResult -Operation "read emulator identity" | Out-String).Trim() +if ($qemuValue -ne "1") { + throw "Android launch smoke requires an emulator; serial '$Serial' did not report ro.kernel.qemu=1. Physical-device evidence must use the dedicated Android evidence workflow." +} + +$installLogPath = Join-Path $OutputDir "adb_install.log" +$startLogPath = Join-Path $OutputDir "am_start.log" +$activityLogPath = Join-Path $OutputDir "dumpsys_activity.txt" +$serviceLogPath = Join-Path $OutputDir "dumpsys_services.txt" +$packageLogPath = Join-Path $OutputDir "dumpsys_package.txt" +$logcatPath = Join-Path $OutputDir "logcat.txt" +$jsonPath = Join-Path $OutputDir "android_emulator_launch_smoke.json" +$markdownPath = Join-Path $OutputDir "ANDROID_EMULATOR_LAUNCH_SMOKE.md" + +$installResult = Invoke-Adb -Arguments @("install", "-r", $apk.FullName) +$installResult.output | Set-Content -LiteralPath $installLogPath -Encoding UTF8 +Assert-AdbSuccess -Result $installResult -Operation "install emulator APK" | Out-Null + +$grantResult = Invoke-Adb -Arguments @("shell", "pm", "grant", $PackageName, "android.permission.POST_NOTIFICATIONS") +Assert-AdbSuccess -Result $grantResult -Operation "grant emulator notification permission" | Out-Null +Assert-AdbSuccess -Result (Invoke-Adb -Arguments @("shell", "am", "force-stop", $PackageName)) -Operation "force-stop app before cold launch" | Out-Null +Assert-AdbSuccess -Result (Invoke-Adb -Arguments @("logcat", "-c")) -Operation "clear emulator logcat" | Out-Null + +$component = "$PackageName/$ActivityName" +$activityStateName = if ($ActivityName.StartsWith("$PackageName.")) { $ActivityName.Substring($PackageName.Length) } else { $ActivityName } +$serviceStateName = if ($ServiceName.StartsWith("$PackageName.")) { $ServiceName.Substring($PackageName.Length) } else { $ServiceName } +$startResult = Invoke-Adb -Arguments @("shell", "am", "start", "-W", "-n", $component) +$startResult.output | Set-Content -LiteralPath $startLogPath -Encoding UTF8 +Assert-AdbSuccess -Result $startResult -Operation "cold-launch MainActivity" | Out-Null +Start-Sleep -Seconds $SettleSeconds + +$processResult = Invoke-Adb -Arguments @("shell", "pidof", $PackageName) +$processId = if ($processResult.exitCode -eq 0) { ($processResult.output -join "").Trim() } else { "" } +$activityResult = Invoke-Adb -Arguments @("shell", "dumpsys", "activity", "activities") +$activityOutput = Assert-AdbSuccess -Result $activityResult -Operation "capture activity state" +$activityOutput | Set-Content -LiteralPath $activityLogPath -Encoding UTF8 +$activityText = $activityOutput -join "`n" + +$serviceResult = Invoke-Adb -Arguments @("shell", "dumpsys", "activity", "services", $PackageName) +$serviceOutput = Assert-AdbSuccess -Result $serviceResult -Operation "capture service state" +$serviceOutput | Set-Content -LiteralPath $serviceLogPath -Encoding UTF8 +$serviceText = $serviceOutput -join "`n" + +$packageResult = Invoke-Adb -Arguments @("shell", "dumpsys", "package", $PackageName) +$packageOutput = Assert-AdbSuccess -Result $packageResult -Operation "capture package state" +$packageOutput | Set-Content -LiteralPath $packageLogPath -Encoding UTF8 +$packageText = $packageOutput -join "`n" + +$logcatResult = Invoke-Adb -Arguments @("logcat", "-d", "-v", "threadtime") +$logcatOutput = Assert-AdbSuccess -Result $logcatResult -Operation "capture post-launch logcat" +$logcatOutput | Set-Content -LiteralPath $logcatPath -Encoding UTF8 +$logcatText = $logcatOutput -join "`n" + +$model = (Assert-AdbSuccess -Result (Invoke-Adb -Arguments @("shell", "getprop", "ro.product.model")) -Operation "read emulator model" | Out-String).Trim() +$apiLevel = (Assert-AdbSuccess -Result (Invoke-Adb -Arguments @("shell", "getprop", "ro.build.version.sdk")) -Operation "read emulator API" | Out-String).Trim() +$versionName = Get-RegexValue -Text $packageText -Pattern "(?m)^\s*versionName=([^\r\n]+)" +$versionCode = Get-RegexValue -Text $packageText -Pattern "(?m)^\s*versionCode=(\d+)" +$launchState = Get-RegexValue -Text ($startResult.output -join "`n") -Pattern "(?m)^LaunchState:\s*([^\r\n]+)" +$totalTimeMs = Get-RegexValue -Text ($startResult.output -join "`n") -Pattern "(?m)^TotalTime:\s*(\d+)" +$mainActivityResumed = $activityText -match ("(?m)(topResumedActivity|ResumedActivity:).*" + [regex]::Escape("$PackageName/$activityStateName")) +$bridgeServicePresent = $serviceText -match [regex]::Escape("$PackageName/$serviceStateName") +$fatalProcessMatches = @( + [regex]::Matches($logcatText, "(?m)Process:\s*" + [regex]::Escape($PackageName) + "(?:,|\s|$)") +).Count + @( + [regex]::Matches($logcatText, "(?m)Fatal signal.*" + [regex]::Escape($PackageName)) +).Count + +$issues = New-Object System.Collections.Generic.List[string] +if ([string]::IsNullOrWhiteSpace($processId)) { + $issues.Add("app process is not alive after launch") +} +if (-not $mainActivityResumed) { + $issues.Add("MainActivity is not the top resumed activity") +} +if (-not $bridgeServicePresent) { + $issues.Add("CompanionBridgeService is absent after launch") +} +if ($fatalProcessMatches -gt 0) { + $issues.Add("post-launch logcat contains $fatalProcessMatches app fatal-process match(es)") +} +if ([string]::IsNullOrWhiteSpace($versionName) -or [string]::IsNullOrWhiteSpace($versionCode)) { + $issues.Add("installed package version identity is incomplete") +} + +$status = if ($issues.Count -eq 0) { "pass" } else { "fail" } +$report = [ordered]@{ + schema = "stackchan.android-emulator-launch-smoke.v1" + status = $status + capturedUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + serial = $Serial + model = $model + apiLevel = $apiLevel + packageName = $PackageName + versionName = $versionName + versionCode = $versionCode + apkFileName = $apk.Name + apkSizeBytes = $apk.Length + apkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $apk.FullName).Hash.ToLowerInvariant() + processId = $processId + launchState = $launchState + totalTimeMs = if ($totalTimeMs -match "^\d+$") { [int]$totalTimeMs } else { $null } + mainActivityResumed = [bool]$mainActivityResumed + bridgeServicePresent = [bool]$bridgeServicePresent + fatalProcessMatches = $fatalProcessMatches + notificationPermissionPregranted = $true + scope = "emulator-install-launch-service-smoke-only" + substitutesForPhysicalEvidence = $false + issues = @($issues) + files = [ordered]@{ + installLog = "adb_install.log" + startLog = "am_start.log" + activityState = "dumpsys_activity.txt" + serviceState = "dumpsys_services.txt" + packageState = "dumpsys_package.txt" + logcat = "logcat.txt" + } +} +$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 + +$markdown = @( + "# Android Emulator Launch Smoke", + "", + "- Status: $status", + "- Captured UTC: $($report.capturedUtc)", + "- Emulator: $model / API $apiLevel / $Serial", + "- Package: $PackageName $versionName ($versionCode)", + "- APK SHA-256: $($report.apkSha256)", + "- Launch: $launchState / $($report.totalTimeMs) ms", + "- Process alive: $(-not [string]::IsNullOrWhiteSpace($processId))", + "- MainActivity resumed: $mainActivityResumed", + "- CompanionBridgeService present: $bridgeServicePresent", + "- Fatal process matches: $fatalProcessMatches", + "", + "This is an emulator-only install, cold-launch, foreground-service, and crash smoke. It does not replace target-phone, physical robot, screen-off, microphone, Gemma accelerator, or Play internal-testing evidence." +) +if ($issues.Count -gt 0) { + $markdown += @("", "Issues:", "") + $markdown += @($issues | ForEach-Object { "- $_" }) +} +$markdown | Set-Content -LiteralPath $markdownPath -Encoding UTF8 + +if ($Json) { + $report | ConvertTo-Json -Depth 6 +} else { + Write-Host "Android emulator launch smoke: $status" + Write-Host "Package: $PackageName $versionName ($versionCode)" + Write-Host "Launch: $launchState / $($report.totalTimeMs) ms" + Write-Host "Evidence: $jsonPath" +} + +if ($status -ne "pass") { + exit 1 +} diff --git a/tools/test_android_emulator_release_evidence_contract.cmd b/tools/test_android_emulator_release_evidence_contract.cmd new file mode 100644 index 00000000..0716f46c --- /dev/null +++ b/tools/test_android_emulator_release_evidence_contract.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_android_emulator_release_evidence_contract.ps1" %* diff --git a/tools/test_android_emulator_release_evidence_contract.ps1 b/tools/test_android_emulator_release_evidence_contract.ps1 new file mode 100644 index 00000000..e84ecb6b --- /dev/null +++ b/tools/test_android_emulator_release_evidence_contract.ps1 @@ -0,0 +1,138 @@ +param() + +$ErrorActionPreference = "Stop" +$checkerPath = Join-Path $PSScriptRoot "check_android_emulator_release_evidence.ps1" +$powerShellExe = (Get-Process -Id $PID).Path +$temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-emulator-evidence-contract-" + [guid]::NewGuid().ToString("N")) +$apkPath = Join-Path $temporaryDirectory "app-android-release.apk" +$evidencePath = Join-Path $temporaryDirectory "android_emulator_launch_smoke.json" +$passCount = 0 + +function Write-ContractEvidence { + param( + [string]$Status = "pass", + [string]$PackageName = "dev.stackchan.companion", + [string]$ApiLevel = "35", + [bool]$MainActivityResumed = $true, + [bool]$BridgeServicePresent = $true, + [int]$FatalProcessMatches = 0, + [bool]$SubstitutesForPhysicalEvidence = $false, + [string]$ApkSha256 = "" + ) + + if ([string]::IsNullOrWhiteSpace($ApkSha256)) { + $ApkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $apkPath).Hash.ToLowerInvariant() + } + [ordered]@{ + schema = "stackchan.android-emulator-launch-smoke.v1" + status = $Status + capturedUtc = "2026-07-13T12:00:00Z" + serial = "emulator-5554" + model = "Android ATD built for x86_64" + apiLevel = $ApiLevel + packageName = $PackageName + versionName = "1.0.0" + versionCode = "1" + apkFileName = "app-android-release.apk" + apkSizeBytes = (Get-Item -LiteralPath $apkPath).Length + apkSha256 = $ApkSha256 + processId = "1234" + launchState = "COLD" + totalTimeMs = 400 + mainActivityResumed = $MainActivityResumed + bridgeServicePresent = $BridgeServicePresent + fatalProcessMatches = $FatalProcessMatches + notificationPermissionPregranted = $true + scope = "emulator-install-launch-service-smoke-only" + substitutesForPhysicalEvidence = $SubstitutesForPhysicalEvidence + issues = @() + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $evidencePath -Encoding UTF8 +} + +function Invoke-Checker { + $arguments = @( + "-NoProfile", + "-File", $checkerPath, + "-EvidencePath", $evidencePath, + "-ReleaseApkPath", $apkPath, + "-Json" + ) + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& $powerShellExe @arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + $text = ($output | Out-String).Trim() + try { + $report = $text | ConvertFrom-Json + } catch { + throw "Emulator evidence checker output was not valid JSON: $text" + } + return [pscustomobject]@{ exitCode = $exitCode; report = $report } +} + +function Assert-Case { + param( + [string]$Name, + [int]$ExpectedExitCode, + [string]$ExpectedStatus, + [string]$IssuePattern = "" + ) + + $result = Invoke-Checker + if ($result.exitCode -ne $ExpectedExitCode) { + throw "$Name returned exit code $($result.exitCode); expected $ExpectedExitCode." + } + if ($result.report.status -ne $ExpectedStatus) { + throw "$Name returned status '$($result.report.status)'; expected '$ExpectedStatus'." + } + if (-not [string]::IsNullOrWhiteSpace($IssuePattern) -and (@($result.report.issues) -join "`n") -notmatch [regex]::Escape($IssuePattern)) { + throw "$Name did not report expected issue '$IssuePattern'." + } + $script:passCount++ + Write-Host "[PASS] $Name" +} + +try { + New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null + [System.IO.File]::WriteAllBytes($apkPath, [byte[]](1..64)) + + Write-ContractEvidence + Assert-Case -Name "matching release APK evidence" -ExpectedExitCode 0 -ExpectedStatus "ready" + + Write-ContractEvidence -ApkSha256 ("0" * 64) + Assert-Case -Name "stale APK hash is rejected" -ExpectedExitCode 2 -ExpectedStatus "not-ready" -IssuePattern "does not match the release APK" + + Write-ContractEvidence -ApiLevel "34" + Assert-Case -Name "old emulator API is rejected" -ExpectedExitCode 2 -ExpectedStatus "not-ready" -IssuePattern "below required API 35" + + Write-ContractEvidence -Status "fail" + Assert-Case -Name "failed launch smoke is rejected" -ExpectedExitCode 2 -ExpectedStatus "not-ready" -IssuePattern "expected 'pass'" + + Write-ContractEvidence -MainActivityResumed $false + Assert-Case -Name "non-resumed activity is rejected" -ExpectedExitCode 2 -ExpectedStatus "not-ready" -IssuePattern "MainActivity was not resumed" + + Write-ContractEvidence -BridgeServicePresent $false + Assert-Case -Name "missing bridge service is rejected" -ExpectedExitCode 2 -ExpectedStatus "not-ready" -IssuePattern "CompanionBridgeService was not present" + + Write-ContractEvidence -FatalProcessMatches 1 + Assert-Case -Name "fatal process match is rejected" -ExpectedExitCode 2 -ExpectedStatus "not-ready" -IssuePattern "must be zero" + + Write-ContractEvidence -SubstitutesForPhysicalEvidence $true + Assert-Case -Name "physical-evidence substitution is rejected" -ExpectedExitCode 2 -ExpectedStatus "not-ready" -IssuePattern "substitutesForPhysicalEvidence=false" + + Write-ContractEvidence -PackageName "dev.example.wrong" + Assert-Case -Name "wrong package identity is rejected" -ExpectedExitCode 2 -ExpectedStatus "not-ready" -IssuePattern "does not match" + +} finally { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue +} + +if ($passCount -ne 9) { + throw "Android emulator release evidence contract did not execute all cases." +} +Write-Host "Android emulator release evidence contract: 9/9 passed" +exit 0 diff --git a/tools/test_android_evidence_packet_contract.ps1 b/tools/test_android_evidence_packet_contract.ps1 index c6d66692..9c767335 100644 --- a/tools/test_android_evidence_packet_contract.ps1 +++ b/tools/test_android_evidence_packet_contract.ps1 @@ -99,6 +99,11 @@ try { "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "capture_android_companion_logcat.ps1", '-OutputDir `"$androidLogcatDir`" %*', + "CompanionV1EvidenceRoot", + "consumerPromotionPackageArg", + "companion-v1-evidence-ready", + "companionV1EvidenceRoot", + "Consumer promotion requires the exact release ZIP", "exit /b %ERRORLEVEL%", "androidCompanionProbes = [ordered]@{", "apkInstallReport = `"android/apk-install/android_apk_install.json`"", diff --git a/tools/test_android_play_store_evidence_contract.ps1 b/tools/test_android_play_store_evidence_contract.ps1 index a5b746ae..cc969d63 100644 --- a/tools/test_android_play_store_evidence_contract.ps1 +++ b/tools/test_android_play_store_evidence_contract.ps1 @@ -141,9 +141,14 @@ try { if ($templateResult.report.status -ne "pending-play-store-evidence") { throw "Expected placeholder template status pending-play-store-evidence, got $($templateResult.report.status)." } - foreach ($id in @("evidence-status", "source-commit", "release-aab-sha", "play-signing", "privacy-policy-url", "upload-status", "internal-install", "play-console-release", "tester-group", "uploaded-at-utc", "screenshots")) { + foreach ($id in @("evidence-status", "source-commit", "release-aab-sha", "play-signing", "upload-status", "internal-install", "play-console-release", "tester-group", "uploaded-at-utc", "screenshots")) { Assert-CheckStatus -Report $templateResult.report -Id $id -Status "fail" } + Assert-CheckStatus -Report $templateResult.report -Id "privacy-policy-url" -Status "pass" + $templateEvidence = Get-Content -LiteralPath (Join-Path $templateRoot "PLAY_STORE_EVIDENCE.json") -Raw | ConvertFrom-Json + if ($templateEvidence.privacyPolicyUrl -ne "https://robvanprod.github.io/stackchan_alive/privacy/") { + throw "Expected template to use the canonical Stackchan privacy-policy URL." + } Write-Host "[ok] placeholder Play Store template is rejected" $completeRoot = New-TempEvidenceRoot @@ -195,6 +200,18 @@ pairing evidence tied to the final internal testing build. } Write-Host "[ok] complete Play Store internal testing packet is accepted" + $invalidPrivacyRoot = New-TempEvidenceRoot + Copy-Item -Path (Join-Path $completeRoot "*") -Destination $invalidPrivacyRoot -Recurse + $invalidPrivacyEvidence = New-CompletePlayEvidence + $invalidPrivacyEvidence.privacyPolicyUrl = "http://stackchan.example/privacy" + Write-JsonFile -Path (Join-Path $invalidPrivacyRoot "PLAY_STORE_EVIDENCE.json") -Value $invalidPrivacyEvidence + $invalidPrivacyResult = Invoke-PlayStoreEvidenceCheck -EvidenceRoot $invalidPrivacyRoot + if ([int]$invalidPrivacyResult.exitCode -eq 0) { + throw "Expected a non-HTTPS privacy-policy URL to fail." + } + Assert-CheckStatus -Report $invalidPrivacyResult.report -Id "privacy-policy-url" -Status "fail" + Write-Host "[ok] non-HTTPS privacy-policy URL is rejected" + $pendingStatusRoot = New-TempEvidenceRoot New-Item -ItemType Directory -Force -Path (Join-Path $pendingStatusRoot "screenshots") | Out-Null foreach ($name in @("phone-pairing-setup", "phone-live-dashboard", "phone-brain-model", "phone-personas-diagnostics")) { diff --git a/tools/test_android_upload_signing_contract.cmd b/tools/test_android_upload_signing_contract.cmd new file mode 100644 index 00000000..297aa377 --- /dev/null +++ b/tools/test_android_upload_signing_contract.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_android_upload_signing_contract.ps1" %* diff --git a/tools/test_android_upload_signing_contract.ps1 b/tools/test_android_upload_signing_contract.ps1 new file mode 100644 index 00000000..1686307e --- /dev/null +++ b/tools/test_android_upload_signing_contract.ps1 @@ -0,0 +1,335 @@ +param() + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$checkScript = Join-Path $PSScriptRoot "check_android_play_release_readiness.ps1" +$keytool = Get-Command keytool -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($null -eq $keytool) { + throw "keytool is required for the Android upload signing contract." +} + +$temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-upload-signing-contract-" + [guid]::NewGuid().ToString("N")) +$keystorePath = Join-Path $temporaryDirectory "contract-upload-keys.jks" +$storePassword = ([guid]::NewGuid().ToString("N") + "StoreAa1!") +$validKeyPassword = ([guid]::NewGuid().ToString("N") + "ValidAa1!") +$weakKeyPassword = ([guid]::NewGuid().ToString("N") + "WeakAa1!") +$debugKeyPassword = ([guid]::NewGuid().ToString("N") + "DebugAa1!") +$shortKeyPassword = ([guid]::NewGuid().ToString("N") + "ShortAa1!") +$wrongPassword = ([guid]::NewGuid().ToString("N") + "WrongAa1!") +$storePasswordEnvironmentName = "STACKCHAN_CONTRACT_STORE_PASSWORD" +$keyPasswordEnvironmentName = "STACKCHAN_CONTRACT_KEY_PASSWORD" +$signingEnvironmentNames = @( + "STACKCHAN_ANDROID_KEYSTORE", + "STACKCHAN_ANDROID_KEYSTORE_PASSWORD", + "STACKCHAN_ANDROID_KEY_ALIAS", + "STACKCHAN_ANDROID_KEY_PASSWORD" +) +$environmentNames = @($signingEnvironmentNames + $storePasswordEnvironmentName + $keyPasswordEnvironmentName) +$previousEnvironment = @{} +$passCount = 0 + +$signingReadinessWorkflowPath = Join-Path $repoRoot ".github/workflows/companion-signing-readiness.yml" +$signingReadinessWorkflow = Get-Content -LiteralPath $signingReadinessWorkflowPath -Raw +foreach ($pattern in @( + "android-upload-key", + "actions/setup-java@v5", + "STACKCHAN_ANDROID_KEYSTORE_B64", + "STACKCHAN_ANDROID_KEYSTORE_PASSWORD", + "STACKCHAN_ANDROID_KEY_ALIAS", + "STACKCHAN_ANDROID_KEY_PASSWORD", + "check_android_play_release_readiness.ps1", + "RequireUploadSigning" +)) { + if ($signingReadinessWorkflow -notmatch [regex]::Escape($pattern)) { + throw "Manual signing readiness workflow is missing Android upload-key validation: $pattern" + } +} +foreach ($forbidden in @("gh release", "upload-artifact", "contents: write")) { + if ($signingReadinessWorkflow -match [regex]::Escape($forbidden)) { + throw "Manual signing readiness workflow must not publish artifacts or releases: $forbidden" + } +} +$passCount++ +Write-Host "[PASS] manual signing readiness workflow validates Android upload key without publishing" + +$releaseWorkflowPath = Join-Path $repoRoot ".github/workflows/release.yml" +$releaseWorkflow = Get-Content -LiteralPath $releaseWorkflowPath -Raw +foreach ($pattern in @("Validate Android upload key", "check_android_play_release_readiness.ps1 -RequireUploadSigning -Json")) { + if ($releaseWorkflow -notmatch [regex]::Escape($pattern)) { + throw "Tagged release workflow is missing strict Android upload signing validation: $pattern" + } +} +$passCount++ +Write-Host "[PASS] tagged Android release requires upload signing readiness" + +function Invoke-ContractKeytool { + param([string[]]$Arguments) + + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& $keytool.Source @Arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + if ($exitCode -ne 0) { + throw "Contract keytool command failed with exit code $exitCode." + } +} + +function New-ContractKey { + param( + [string]$Alias, + [string]$KeyPassword, + [int]$KeySize, + [int]$ValidityDays, + [string]$DistinguishedName + ) + + [Environment]::SetEnvironmentVariable($keyPasswordEnvironmentName, $KeyPassword) + Invoke-ContractKeytool -Arguments @( + "-genkeypair", + "-keystore", $keystorePath, + "-storetype", "JKS", + "-alias", $Alias, + "-keyalg", "RSA", + "-keysize", [string]$KeySize, + "-validity", [string]$ValidityDays, + "-dname", $DistinguishedName, + "-storepass:env", $storePasswordEnvironmentName, + "-keypass:env", $keyPasswordEnvironmentName, + "-noprompt" + ) +} + +function Set-SigningEnvironment { + param( + [string]$StorePassword, + [string]$Alias, + [string]$KeyPassword + ) + + [Environment]::SetEnvironmentVariable("STACKCHAN_ANDROID_KEYSTORE", $keystorePath) + [Environment]::SetEnvironmentVariable("STACKCHAN_ANDROID_KEYSTORE_PASSWORD", $StorePassword) + [Environment]::SetEnvironmentVariable("STACKCHAN_ANDROID_KEY_ALIAS", $Alias) + [Environment]::SetEnvironmentVariable("STACKCHAN_ANDROID_KEY_PASSWORD", $KeyPassword) +} + +function Clear-SigningEnvironment { + foreach ($name in $signingEnvironmentNames) { + [Environment]::SetEnvironmentVariable($name, $null) + } +} + +function Invoke-ReadinessCheck { + param([bool]$RequireUploadSigning = $false) + + $powerShellExe = (Get-Process -Id $PID).Path + $arguments = @( + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + $checkScript, + "-Root", + [string]$repoRoot, + "-Json" + ) + if ($RequireUploadSigning) { + $arguments += "-RequireUploadSigning" + } + + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& $powerShellExe @arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldErrorActionPreference + } + + $text = ($output | Out-String).Trim() + foreach ($secret in @($storePassword, $validKeyPassword, $weakKeyPassword, $debugKeyPassword, $shortKeyPassword, $wrongPassword)) { + if ($text.Contains($secret)) { + throw "Android signing readiness output exposed a contract credential." + } + } + + try { + $report = $text | ConvertFrom-Json + } catch { + throw "Android signing readiness output was not valid JSON." + } + + return [pscustomobject]@{ + exitCode = $exitCode + report = $report + } +} + +function Get-SigningCheck { + param([object]$Report) + + $checks = @($Report.checks | Where-Object { $_.id -eq "play-upload-signing-environment" }) + if ($checks.Count -ne 1) { + throw "Expected exactly one play-upload-signing-environment check." + } + return $checks[0] +} + +function Assert-Readiness { + param( + [object]$Result, + [int]$ExpectedExitCode, + [string]$ExpectedReportStatus, + [string]$ExpectedCheckStatus, + [string]$DetailPattern, + [string]$Name + ) + + $check = Get-SigningCheck -Report $Result.report + if ($Result.exitCode -ne $ExpectedExitCode) { + throw "$Name returned exit code $($Result.exitCode); expected $ExpectedExitCode. Report status: $($Result.report.status). Signing check: $($check.status). Detail: $($check.detail)" + } + if ($Result.report.status -ne $ExpectedReportStatus) { + throw "$Name returned status '$($Result.report.status)'; expected '$ExpectedReportStatus'." + } + if ($check.status -ne $ExpectedCheckStatus) { + throw "$Name signing check was '$($check.status)'; expected '$ExpectedCheckStatus'." + } + if (-not [string]::IsNullOrWhiteSpace($DetailPattern) -and $check.detail -notmatch [regex]::Escape($DetailPattern)) { + throw "$Name detail did not include '$DetailPattern': $($check.detail)" + } + + $script:passCount++ + Write-Host "[PASS] $Name" +} + +try { + foreach ($name in $environmentNames) { + $previousEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + } + New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null + [Environment]::SetEnvironmentVariable($storePasswordEnvironmentName, $storePassword) + + New-ContractKey ` + -Alias "valid-upload" ` + -KeyPassword $validKeyPassword ` + -KeySize 4096 ` + -ValidityDays 10000 ` + -DistinguishedName "CN=Stackchan Upload Contract, OU=Release, O=Stackchan, L=Test, ST=Test, C=US" + New-ContractKey ` + -Alias "weak-upload" ` + -KeyPassword $weakKeyPassword ` + -KeySize 2048 ` + -ValidityDays 10000 ` + -DistinguishedName "CN=Stackchan Weak Contract, OU=Release, O=Stackchan, L=Test, ST=Test, C=US" + New-ContractKey ` + -Alias "debug-upload" ` + -KeyPassword $debugKeyPassword ` + -KeySize 4096 ` + -ValidityDays 10000 ` + -DistinguishedName "CN=Android Debug, OU=Android, O=Android, C=US" + New-ContractKey ` + -Alias "short-upload" ` + -KeyPassword $shortKeyPassword ` + -KeySize 4096 ` + -ValidityDays 365 ` + -DistinguishedName "CN=Stackchan Short Contract, OU=Release, O=Stackchan, L=Test, ST=Test, C=US" + + Clear-SigningEnvironment + Assert-Readiness ` + -Result (Invoke-ReadinessCheck) ` + -ExpectedExitCode 0 ` + -ExpectedReportStatus "source-ready-pending-upload-signing" ` + -ExpectedCheckStatus "pending" ` + -DetailPattern "Release tasks fail closed" ` + -Name "missing upload signing credentials remain pending and fail closed" + + Assert-Readiness ` + -Result (Invoke-ReadinessCheck -RequireUploadSigning $true) ` + -ExpectedExitCode 2 ` + -ExpectedReportStatus "source-ready-pending-upload-signing" ` + -ExpectedCheckStatus "pending" ` + -DetailPattern "Release tasks fail closed" ` + -Name "required upload signing credentials fail closed when missing" + + Set-SigningEnvironment -StorePassword $storePassword -Alias "valid-upload" -KeyPassword $validKeyPassword + Assert-Readiness ` + -Result (Invoke-ReadinessCheck -RequireUploadSigning $true) ` + -ExpectedExitCode 0 ` + -ExpectedReportStatus "source-ready" ` + -ExpectedCheckStatus "pass" ` + -DetailPattern "RSA 4096 bits" ` + -Name "valid 4096-bit private upload key is accepted" + + Set-SigningEnvironment -StorePassword $storePassword -Alias "missing-upload" -KeyPassword $validKeyPassword + Assert-Readiness ` + -Result (Invoke-ReadinessCheck) ` + -ExpectedExitCode 1 ` + -ExpectedReportStatus "not-ready" ` + -ExpectedCheckStatus "fail" ` + -DetailPattern "store password and alias" ` + -Name "missing upload-key alias is rejected" + + Set-SigningEnvironment -StorePassword $wrongPassword -Alias "valid-upload" -KeyPassword $validKeyPassword + Assert-Readiness ` + -Result (Invoke-ReadinessCheck) ` + -ExpectedExitCode 1 ` + -ExpectedReportStatus "not-ready" ` + -ExpectedCheckStatus "fail" ` + -DetailPattern "store password and alias" ` + -Name "wrong keystore password is rejected" + + Set-SigningEnvironment -StorePassword $storePassword -Alias "valid-upload" -KeyPassword $wrongPassword + Assert-Readiness ` + -Result (Invoke-ReadinessCheck) ` + -ExpectedExitCode 1 ` + -ExpectedReportStatus "not-ready" ` + -ExpectedCheckStatus "fail" ` + -DetailPattern "private-key entry" ` + -Name "wrong private-key password is rejected" + + Set-SigningEnvironment -StorePassword $storePassword -Alias "weak-upload" -KeyPassword $weakKeyPassword + Assert-Readiness ` + -Result (Invoke-ReadinessCheck) ` + -ExpectedExitCode 1 ` + -ExpectedReportStatus "not-ready" ` + -ExpectedCheckStatus "fail" ` + -DetailPattern "requires at least 4096 bits" ` + -Name "weak 2048-bit upload key is rejected" + + Set-SigningEnvironment -StorePassword $storePassword -Alias "debug-upload" -KeyPassword $debugKeyPassword + Assert-Readiness ` + -Result (Invoke-ReadinessCheck) ` + -ExpectedExitCode 1 ` + -ExpectedReportStatus "not-ready" ` + -ExpectedCheckStatus "fail" ` + -DetailPattern "Android debug key" ` + -Name "Android debug certificate subject is rejected" + + Set-SigningEnvironment -StorePassword $storePassword -Alias "short-upload" -KeyPassword $shortKeyPassword + Assert-Readiness ` + -Result (Invoke-ReadinessCheck) ` + -ExpectedExitCode 1 ` + -ExpectedReportStatus "not-ready" ` + -ExpectedCheckStatus "fail" ` + -DetailPattern "2033-10-23 UTC" ` + -Name "upload certificate expiring before the Play minimum is rejected" +} finally { + foreach ($name in $environmentNames) { + if ($null -eq $previousEnvironment[$name]) { + [Environment]::SetEnvironmentVariable($name, $null) + } else { + [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name]) + } + } + if (Test-Path -LiteralPath $temporaryDirectory) { + Remove-Item -LiteralPath $temporaryDirectory -Recurse -Force + } +} + +Write-Host "Android upload signing contract passed: $passCount checks." +exit 0 diff --git a/tools/test_companion_ci_candidate_contract.cmd b/tools/test_companion_ci_candidate_contract.cmd new file mode 100644 index 00000000..5143c8ce --- /dev/null +++ b/tools/test_companion_ci_candidate_contract.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_companion_ci_candidate_contract.ps1" %* diff --git a/tools/test_companion_ci_candidate_contract.ps1 b/tools/test_companion_ci_candidate_contract.ps1 new file mode 100644 index 00000000..b2f47dca --- /dev/null +++ b/tools/test_companion_ci_candidate_contract.ps1 @@ -0,0 +1,276 @@ +param() + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$downloader = Join-Path $PSScriptRoot "download_companion_ci_candidate.ps1" +$powerShellCommand = Get-Command pwsh -ErrorAction SilentlyContinue +$powerShellHost = if ($null -ne $powerShellCommand) { $powerShellCommand.Source } else { (Get-Process -Id $PID).Path } +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("stackchan-companion-ci-candidate-" + [guid]::NewGuid().ToString("N")) +$sourceCommit = "1111111111111111111111111111111111111111" +$runId = 424242 +$requiredJobs = @( + "changes", + "bridge-tests", + "native-tests", + "build", + "companion-tests", + "companion-platform-builds (android-apk)", + "companion-platform-builds (desktop-windows)", + "companion-platform-builds (desktop-macos)", + "companion-platform-builds (desktop-linux)", + "companion-android-emulator-smoke", + "companion-release-evidence" +) +$requiredArtifacts = @( + "companion-android-apks", + "companion-android-emulator-smoke", + "companion-desktop-windows", + "companion-desktop-macos", + "companion-desktop-linux", + "companion-release-evidence" +) +$passCount = 0 + +function Get-Sha256 { + param([string]$Path) + return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Add-FixtureFile { + param( + [string]$Root, + [string]$RelativePath, + [string]$Content + ) + + $path = Join-Path $Root $RelativePath + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null + Set-Content -LiteralPath $path -Value $Content -Encoding UTF8 + return $path +} + +function New-Fixture { + param([string]$Name) + + $root = Join-Path $tempRoot $Name + New-Item -ItemType Directory -Force -Path $root | Out-Null + + $run = [ordered]@{ + databaseId = $runId + name = "Firmware" + headSha = $sourceCommit + headBranch = "codex/fixture" + status = "completed" + conclusion = "success" + url = "https://example.invalid/actions/runs/$runId" + event = "pull_request" + createdAt = "2026-07-14T00:00:00Z" + updatedAt = "2026-07-14T00:10:00Z" + jobs = @($requiredJobs | ForEach-Object { + [ordered]@{ name = $_; status = "completed"; conclusion = "success" } + }) + } + $run | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $root "run.json") -Encoding UTF8 + + $artifacts = [ordered]@{ + total_count = $requiredArtifacts.Count + artifacts = @($requiredArtifacts | ForEach-Object { + [ordered]@{ + id = 1000 + [array]::IndexOf($requiredArtifacts, $_) + name = $_ + size_in_bytes = 1024 + expired = $false + } + }) + } + $artifacts | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $root "artifacts.json") -Encoding UTF8 + + $downloadRoot = Join-Path $root "downloads" + $androidDebug = Add-FixtureFile $downloadRoot "companion-android-apks/apk/debug/app-android-debug.apk" "fixture debug apk" + $androidRelease = Add-FixtureFile $downloadRoot "companion-android-apks/apk/release/app-android-release.apk" "fixture release apk" + $androidBundle = Add-FixtureFile $downloadRoot "companion-android-apks/bundle/release/app-android-release.aab" "fixture release aab" + $windowsPackage = Add-FixtureFile $downloadRoot "companion-desktop-windows/companion/app-desktop/build/compose/binaries/main/msi/Stackchan Companion-1.0.0.msi" "fixture windows package" + $macosPackage = Add-FixtureFile $downloadRoot "companion-desktop-macos/package/Stackchan Companion-1.0.0.dmg" "fixture macos package" + $linuxPackage = Add-FixtureFile $downloadRoot "companion-desktop-linux/package/stackchan-companion_1.0.0_amd64.deb" "fixture linux package" + Add-FixtureFile $downloadRoot "companion-android-emulator-smoke/android_emulator_launch_smoke.json" '{"schema":"stackchan.android-emulator-launch-smoke.v1","status":"pass"}' | Out-Null + + $evidenceEntries = @( + [ordered]@{ path = "ci/apk/debug/app-android-debug.apk"; name = "app-android-debug.apk"; bytes = (Get-Item $androidDebug).Length; sha256 = Get-Sha256 $androidDebug }, + [ordered]@{ path = "ci/apk/release/app-android-release.apk"; name = "app-android-release.apk"; bytes = (Get-Item $androidRelease).Length; sha256 = Get-Sha256 $androidRelease }, + [ordered]@{ path = "ci/bundle/release/app-android-release.aab"; name = "app-android-release.aab"; bytes = (Get-Item $androidBundle).Length; sha256 = Get-Sha256 $androidBundle }, + [ordered]@{ path = "ci/windows/Stackchan Companion-1.0.0.msi"; name = "Stackchan Companion-1.0.0.msi"; bytes = (Get-Item $windowsPackage).Length; sha256 = Get-Sha256 $windowsPackage }, + [ordered]@{ path = "ci/macos/Stackchan Companion-1.0.0.dmg"; name = "Stackchan Companion-1.0.0.dmg"; bytes = (Get-Item $macosPackage).Length; sha256 = Get-Sha256 $macosPackage }, + [ordered]@{ path = "ci/linux/stackchan-companion_1.0.0_amd64.deb"; name = "stackchan-companion_1.0.0_amd64.deb"; bytes = (Get-Item $linuxPackage).Length; sha256 = Get-Sha256 $linuxPackage } + ) + $evidence = [ordered]@{ + schema = "stackchan.companion-release-evidence.v1" + status = "complete" + version = $sourceCommit + commit = $sourceCommit + pending = @() + artifacts = @( + [ordered]@{ kind = "android-apk"; status = "present"; entries = @($evidenceEntries[0..2]) }, + [ordered]@{ kind = "desktop-package"; status = "present"; entries = @($evidenceEntries[3..5]) } + ) + androidSigning = [ordered]@{ signingProfile = "lab-debug-fallback" } + androidBundleSigning = [ordered]@{ signingProfile = "lab-debug-fallback" } + desktopPackageEvidence = [ordered]@{ status = "ready" } + } + $evidencePath = Add-FixtureFile $downloadRoot "companion-release-evidence/COMPANION_RELEASE_EVIDENCE.json" "placeholder" + $evidence | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $evidencePath -Encoding UTF8 + Add-FixtureFile $downloadRoot "companion-release-evidence/COMPANION_RELEASE_EVIDENCE.md" "# Fixture evidence" | Out-Null + + return $root +} + +function Invoke-Downloader { + param( + [string]$FixtureRoot, + [string]$Name, + [string]$Commit = $sourceCommit, + [string]$OutDir = "" + ) + + if ([string]::IsNullOrWhiteSpace($OutDir)) { + $OutDir = Join-Path $tempRoot "out-$Name" + } + $oldPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& $powerShellHost -NoProfile -File $downloader ` + -RunId $runId ` + -Repo "fixture/stackchan" ` + -Commit $Commit ` + -OutDir $OutDir ` + -FixtureRoot $FixtureRoot ` + -Json 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldPreference + } + return [pscustomobject]@{ + exitCode = $exitCode + output = ($output | Out-String).Trim() + outDir = $OutDir + } +} + +function Assert-Failed { + param( + [object]$Result, + [string]$Pattern, + [string]$Name + ) + + $matched = [regex]::IsMatch( + [string]$Result.output, + $Pattern, + [System.Text.RegularExpressions.RegexOptions]::Singleline + ) + if ($Result.exitCode -eq 0 -or -not $matched) { + throw "$Name was not rejected as expected. Exit=$($Result.exitCode) Output=$($Result.output)" + } + $script:passCount++ + Write-Host "[PASS] $Name" +} + +try { + $workflowText = Get-Content -LiteralPath (Join-Path $repoRoot ".github/workflows/firmware.yml") -Raw + if ($workflowText -notmatch [regex]::Escape('STACKCHAN_CI_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}')) { + throw "Firmware workflow does not derive the exact PR branch-head source SHA." + } + $checkoutCount = [regex]::Matches($workflowText, 'uses:\s+actions/checkout@v7').Count + $pinnedCheckoutCount = [regex]::Matches($workflowText, 'ref:\s+\$\{\{ env\.STACKCHAN_CI_SOURCE_SHA \}\}').Count + if ($checkoutCount -lt 1 -or $pinnedCheckoutCount -ne $checkoutCount) { + throw "Every Firmware workflow checkout must use STACKCHAN_CI_SOURCE_SHA: checkout=$checkoutCount pinned=$pinnedCheckoutCount." + } + if ([regex]::Matches($workflowText, '\$\{\{ github\.sha \}\}').Count -ne 0) { + throw "Firmware workflow still records the PR merge SHA directly." + } + foreach ($pattern in @( + '-Version "${{ env.STACKCHAN_CI_SOURCE_SHA }}"', + '-Commit "${{ env.STACKCHAN_CI_SOURCE_SHA }}"', + "test_companion_ci_candidate_contract.ps1" + )) { + if ($workflowText -notmatch [regex]::Escape($pattern)) { + throw "Firmware workflow exact-source contract is missing: $pattern" + } + } + $passCount++ + Write-Host "[PASS] Firmware workflow checks out and records the exact PR branch head" + + $readyFixture = New-Fixture "ready" + $ready = Invoke-Downloader $readyFixture "ready" + if ($ready.exitCode -ne 0) { + throw "Complete exact-source candidate fixture failed: $($ready.output)" + } + $report = $ready.output | ConvertFrom-Json + $manifest = Get-Content -LiteralPath (Join-Path $ready.outDir "COMPANION_CI_CANDIDATE.json") -Raw | ConvertFrom-Json + if ([string]$report.status -ne "companion-ci-candidate-ready" -or + [string]$manifest.sourceCommit -ne $sourceCommit -or + @($manifest.jobs).Count -ne $requiredJobs.Count -or + @($manifest.artifacts).Count -ne $requiredArtifacts.Count -or + [bool]$manifest.publicReleaseReady -or + [bool]$manifest.substitutesForTaggedRelease -or + [bool]$manifest.substitutesForPhysicalEvidence) { + throw "Ready candidate manifest did not preserve exact-source scope and limitations." + } + $passCount++ + Write-Host "[PASS] complete exact-source CI candidate is accepted and inventoried" + + $longOutDir = Join-Path $tempRoot ("out-long-" + ("x" * 36)) + $longPathReady = Invoke-Downloader $readyFixture "long-path" $sourceCommit $longOutDir + if ($longPathReady.exitCode -ne 0) { + throw "Long-path exact-source candidate fixture failed: $($longPathReady.output)" + } + $longPathPackage = Get-ChildItem -LiteralPath $longPathReady.outDir -Recurse -File -Filter "*.msi" | Select-Object -First 1 + if ($null -eq $longPathPackage) { + throw "Long-path candidate did not contain its Windows package fixture." + } + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT -and $longPathPackage.FullName.Length -le 260) { + throw "Long-path candidate fixture did not cross the Windows legacy path boundary." + } + $passCount++ + Write-Host "[PASS] long downloaded artifact paths are hashed portably" + + $mergeMismatch = Invoke-Downloader $readyFixture "merge-mismatch" "2222222222222222222222222222222222222222" + Assert-Failed $mergeMismatch "source mismatch" "PR merge/head source mismatch is rejected" + + $failedJobFixture = New-Fixture "failed-job" + $failedRunPath = Join-Path $failedJobFixture "run.json" + $failedRun = Get-Content -LiteralPath $failedRunPath -Raw | ConvertFrom-Json + $failedRun.jobs[4].conclusion = "failure" + $failedRun | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $failedRunPath -Encoding UTF8 + Assert-Failed (Invoke-Downloader $failedJobFixture "failed-job") "not successful" "failed companion job is rejected" + + $missingArtifactFixture = New-Fixture "missing-artifact" + $missingArtifactsPath = Join-Path $missingArtifactFixture "artifacts.json" + $missingArtifacts = Get-Content -LiteralPath $missingArtifactsPath -Raw | ConvertFrom-Json + $missingArtifacts.artifacts = @($missingArtifacts.artifacts | Where-Object name -ne "companion-desktop-macos") + $missingArtifacts | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $missingArtifactsPath -Encoding UTF8 + Assert-Failed (Invoke-Downloader $missingArtifactFixture "missing-artifact") "companion-desktop-macos.*found 0" "missing platform artifact is rejected" + + $expiredArtifactFixture = New-Fixture "expired-artifact" + $expiredArtifactsPath = Join-Path $expiredArtifactFixture "artifacts.json" + $expiredArtifacts = Get-Content -LiteralPath $expiredArtifactsPath -Raw | ConvertFrom-Json + @($expiredArtifacts.artifacts | Where-Object name -eq "companion-android-apks")[0].expired = $true + $expiredArtifacts | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $expiredArtifactsPath -Encoding UTF8 + Assert-Failed (Invoke-Downloader $expiredArtifactFixture "expired-artifact") "has expired" "expired candidate artifact is rejected" + + $staleEvidenceFixture = New-Fixture "stale-evidence" + $staleEvidencePath = Join-Path $staleEvidenceFixture "downloads/companion-release-evidence/COMPANION_RELEASE_EVIDENCE.json" + $staleEvidence = Get-Content -LiteralPath $staleEvidencePath -Raw | ConvertFrom-Json + $staleEvidence.commit = "3333333333333333333333333333333333333333" + $staleEvidence | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $staleEvidencePath -Encoding UTF8 + Assert-Failed (Invoke-Downloader $staleEvidenceFixture "stale-evidence") "release evidence source mismatch" "stale embedded release evidence is rejected" + + $tamperedFixture = New-Fixture "tampered-download" + Set-Content -LiteralPath (Join-Path $tamperedFixture "downloads/companion-android-apks/apk/release/app-android-release.apk") -Value "tampered fixture release apk" -Encoding UTF8 + Assert-Failed (Invoke-Downloader $tamperedFixture "tampered-download") "does not match companion release evidence" "downloaded artifact hash tampering is rejected" +} finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Companion CI candidate contract tests passed: $passCount" +exit 0 diff --git a/tools/test_companion_release_version_contract.cmd b/tools/test_companion_release_version_contract.cmd new file mode 100644 index 00000000..3a670abd --- /dev/null +++ b/tools/test_companion_release_version_contract.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_companion_release_version_contract.ps1" %* diff --git a/tools/test_companion_release_version_contract.ps1 b/tools/test_companion_release_version_contract.ps1 new file mode 100644 index 00000000..1e07b857 --- /dev/null +++ b/tools/test_companion_release_version_contract.ps1 @@ -0,0 +1,56 @@ +param() + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$checker = Join-Path $PSScriptRoot "check_companion_release_version.ps1" +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-companion-version-" + [guid]::NewGuid().ToString("N")) +$powerShellCommand = Get-Command pwsh -ErrorAction SilentlyContinue +$powerShellHost = if ($null -ne $powerShellCommand) { $powerShellCommand.Source } else { "" } +if ([string]::IsNullOrWhiteSpace($powerShellHost)) { + $powerShellHost = (Get-Process -Id $PID).Path +} + +function Invoke-Checker { + param([string]$Root, [string]$ExpectedVersion) + $output = & $powerShellHost -NoProfile -File $checker -Root $Root -ExpectedVersion $ExpectedVersion -Json 2>&1 | Out-String + return [ordered]@{ exitCode = $LASTEXITCODE; output = $output } +} + +try { + $current = Invoke-Checker $repoRoot "v1.0.0" + if ($current.exitCode -ne 0) { throw "Current companion version contract failed: $($current.output)" } + Write-Host "[ok] current companion declarations match v1.0.0" + + $wrongTag = Invoke-Checker $repoRoot "v9.9.9" + if ($wrongTag.exitCode -eq 0 -or $wrongTag.output -notmatch "Tag/version mismatch") { + throw "Expected a mismatched release tag to fail." + } + Write-Host "[ok] mismatched release tag is rejected" + + $relativeFiles = @( + "companion/build.gradle.kts", + "companion/app-android/build.gradle.kts", + "companion/app-desktop/build.gradle.kts", + "companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt" + ) + foreach ($relativeFile in $relativeFiles) { + $destination = Join-Path $tempRoot $relativeFile + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot $relativeFile) -Destination $destination + } + $desktopPath = Join-Path $tempRoot "companion/app-desktop/build.gradle.kts" + (Get-Content -LiteralPath $desktopPath -Raw).Replace('packageVersion = "1.0.0"', 'packageVersion = "1.0.1"') | + Set-Content -LiteralPath $desktopPath -Encoding UTF8 + $drift = Invoke-Checker $tempRoot "v1.0.0" + if ($drift.exitCode -eq 0 -or $drift.output -notmatch "declarations disagree") { + throw "Expected cross-platform version drift to fail." + } + Write-Host "[ok] cross-platform version drift is rejected" +} finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } +} + +Write-Host "Companion release version contract tests passed" +exit 0 diff --git a/tools/test_companion_v1_evidence_bundle_contract.ps1 b/tools/test_companion_v1_evidence_bundle_contract.ps1 index c4d3e203..d62e0118 100644 --- a/tools/test_companion_v1_evidence_bundle_contract.ps1 +++ b/tools/test_companion_v1_evidence_bundle_contract.ps1 @@ -81,6 +81,7 @@ function Write-StatusReport { [string]$SourceCommit = "", [string]$Version = "", [string]$EvidenceRoot = "", + [string]$EvidenceCommit = "", [string]$ReleaseApkSha256 = "", [string]$ReleaseAabSha256 = "", [string]$WindowsMsiSha256 = "", @@ -106,10 +107,11 @@ function Write-StatusReport { $report.version = $Version } if (-not [string]::IsNullOrWhiteSpace($EvidenceRoot)) { + $metadataCommit = if ([string]::IsNullOrWhiteSpace($EvidenceCommit)) { $Commit } else { $EvidenceCommit } $report.evidenceRoot = $EvidenceRoot $report.evidence = [ordered]@{ metadata = [ordered]@{ - commit = $Commit + commit = $metadataCommit } } } @@ -153,6 +155,36 @@ function Write-StatusReport { $report.artifacts = @($artifactGroups) } if ($Schema -eq "stackchan.companion-release-evidence.v1") { + $report.desktopDistributionTrustRequired = $true + $report.desktopPackageEvidence = [ordered]@{ + status = "ready" + platforms = @( + [ordered]@{ + platform = "windows" + distributionTrustStatus = "ready" + distributionTrustPolicy = "authenticode-sha256-timestamped" + distributionTrustTimestamped = $true + distributionTrustNotarizationStapled = $false + distributionTrustGatekeeperAccepted = $false + }, + [ordered]@{ + platform = "linux" + distributionTrustStatus = "not-required" + distributionTrustPolicy = "github-sigstore-attestation-at-publication" + distributionTrustTimestamped = $false + distributionTrustNotarizationStapled = $false + distributionTrustGatekeeperAccepted = $false + }, + [ordered]@{ + platform = "macos" + distributionTrustStatus = "ready" + distributionTrustPolicy = "developer-id-notarized-stapled" + distributionTrustTimestamped = $false + distributionTrustNotarizationStapled = $true + distributionTrustGatekeeperAccepted = $true + } + ) + } $report.packageEvidence = [ordered]@{ status = "present" root = "output/release/v1.0.0" @@ -175,13 +207,14 @@ try { if ($templateResult.report.status -ne "pending-companion-v1-evidence-bundle") { throw "Expected placeholder bundle to be pending, got $($templateResult.report.status)." } - foreach ($id in @("source-commit", "release-package", "hardware-evidence", "android-v1-status", "desktop-v1-status", "companion-v1-review")) { + foreach ($id in @("source-commit", "firmware-source-commit", "release-package", "hardware-evidence", "android-v1-status", "desktop-v1-status", "companion-v1-review")) { Assert-CheckStatus -Report $templateResult.report -Id $id -Status "pending" } Write-Host "[ok] placeholder Companion v1 evidence bundle is pending" $readyRoot = New-TempEvidenceRoot $sourceCommit = "d" * 40 + $firmwareSourceCommit = "f" * 40 $releaseVersion = "v1.0.0" $releaseApkSha = "9" * 64 $releaseAabSha = "a" * 64 @@ -207,6 +240,7 @@ try { schema = "stackchan.companion-v1-evidence-bundle.v1" status = "ready" sourceCommit = $sourceCommit + firmwareSourceCommit = $firmwareSourceCommit releaseVersion = $releaseVersion releasePackage = [ordered]@{ path = "artifacts/stackchan_alive_v1.0.0.zip" @@ -222,7 +256,7 @@ try { Write-StatusReport -Path (Join-Path $readyRoot $reports.companionReadinessReport) -Schema "stackchan.companion-v1-readiness.v1" -Status "source-ready-pending-hardware" -SourceCommit $sourceCommit Write-StatusReport -Path (Join-Path $readyRoot $reports.companionReleaseEvidenceReport) -Schema "stackchan.companion-release-evidence.v1" -Status "complete" -Commit $sourceCommit -Version $releaseVersion -ReleaseApkSha256 $releaseApkSha -ReleaseAabSha256 $releaseAabSha -WindowsMsiSha256 $windowsMsiSha -MacosDmgSha256 $macosDmgSha -LinuxDebSha256 $linuxDebSha Write-StatusReport -Path (Join-Path $readyRoot $reports.githubActionsStatusReport) -Schema "stackchan.github-actions-status.v1" -Status "success" -Commit $sourceCommit -Version $releaseVersion - Write-StatusReport -Path (Join-Path $readyRoot $reports.rolloutStatusReport) -Schema "stackchan.rollout-status.v1" -Status "consumer-promotion-ready" -Commit $sourceCommit -Version $releaseVersion -EvidenceRoot $hardwareEvidenceRoot + Write-StatusReport -Path (Join-Path $readyRoot $reports.rolloutStatusReport) -Schema "stackchan.rollout-status.v1" -Status "consumer-promotion-ready" -Commit $sourceCommit -Version $releaseVersion -EvidenceRoot $hardwareEvidenceRoot -EvidenceCommit $firmwareSourceCommit Write-JsonFile -Path (Join-Path $readyRoot $reports.androidV1BundleReport) -Value ([ordered]@{ schema = "stackchan.android-v1-evidence-bundle-check.v1" status = "android-v1-evidence-ready" @@ -287,16 +321,43 @@ try { if ($readyResult.report.status -ne "companion-v1-evidence-ready") { throw "Expected companion-v1-evidence-ready, got $($readyResult.report.status)." } - if ($readyResult.report.sourceCommit -ne $sourceCommit -or $readyResult.report.releaseVersion -ne $releaseVersion) { - throw "Expected Companion v1 bundle check report to emit sourceCommit and releaseVersion." + if ($readyResult.report.sourceCommit -ne $sourceCommit -or $readyResult.report.firmwareSourceCommit -ne $firmwareSourceCommit -or $readyResult.report.releaseVersion -ne $releaseVersion) { + throw "Expected Companion v1 bundle check report to emit sourceCommit, firmwareSourceCommit, and releaseVersion." } if ($readyResult.report.androidGemmaBenchmarkProfile -ne "gemma4-e2b-litert-lm" -or [double]$readyResult.report.androidGemmaBenchmarkMedianMs -ne 1200.0 -or [double]$readyResult.report.androidGemmaBenchmarkMedianTokensPerSec -ne 8.5 -or "phone-live-dashboard" -notin @($readyResult.report.androidDashboardMediaIds)) { throw "Expected Companion v1 bundle check report to emit Android Gemma benchmark and dashboard media summaries." } - foreach ($id in @("release-package", "hardware-evidence", "android-v1-status", "desktop-v1-status", "companion-readiness", "companion-release-evidence", "github-actions", "rollout-status", "android-v1-bundle", "desktop-v1-bundle", "voice-source-ready", "companion-readiness-commit-match", "release-evidence-commit-match", "github-actions-commit-match", "rollout-status-commit-match", "android-v1-commit-match", "desktop-v1-commit-match", "release-evidence-version-match", "github-actions-version-match", "rollout-status-version-match", "voice-source-commit-match", "android-v1-application-id-match", "android-v1-version-name-match", "android-v1-version-code-match", "android-v1-gemma-benchmark-summary", "android-v1-dashboard-media-summary", "android-v1-release-apk-hash-match", "android-v1-release-aab-hash-match", "desktop-v1-artifact-hashes-match", "release-package-evidence-present", "rollout-hardware-root-match", "rollout-hardware-commit-match", "companion-v1-review")) { + foreach ($id in @("firmware-source-commit", "release-package", "hardware-evidence", "android-v1-status", "desktop-v1-status", "companion-readiness", "companion-release-evidence", "github-actions", "rollout-status", "android-v1-bundle", "desktop-v1-bundle", "voice-source-ready", "companion-readiness-commit-match", "release-evidence-commit-match", "github-actions-commit-match", "rollout-status-commit-match", "android-v1-commit-match", "desktop-v1-commit-match", "release-evidence-version-match", "github-actions-version-match", "rollout-status-version-match", "voice-source-commit-match", "android-v1-application-id-match", "android-v1-version-name-match", "android-v1-version-code-match", "android-v1-gemma-benchmark-summary", "android-v1-dashboard-media-summary", "android-v1-release-apk-hash-match", "android-v1-release-aab-hash-match", "desktop-v1-artifact-hashes-match", "release-package-evidence-present", "rollout-hardware-root-match", "rollout-hardware-commit-match", "companion-v1-review")) { Assert-CheckStatus -Report $readyResult.report -Id $id -Status "pass" } - Write-Host "[ok] complete Companion v1 evidence bundle is accepted" + Write-Host "[ok] complete Companion v1 evidence bundle with distinct release and firmware commits is accepted" + + $desktopTrustMissingRoot = New-TempEvidenceRoot + Copy-Item -Path (Join-Path $readyRoot "*") -Destination $desktopTrustMissingRoot -Recurse -Force + $desktopTrustMissingPath = Join-Path $desktopTrustMissingRoot $reports.companionReleaseEvidenceReport + $desktopTrustMissingReport = Get-Content -LiteralPath $desktopTrustMissingPath -Raw | ConvertFrom-Json + $desktopTrustMissingReport.desktopDistributionTrustRequired = $false + $desktopTrustMissingReport.desktopPackageEvidence.platforms[0].distributionTrustStatus = "not-ready" + Write-JsonFile -Path $desktopTrustMissingPath -Value $desktopTrustMissingReport + $desktopTrustMissingResult = Invoke-CompanionV1BundleCheck -EvidenceRoot $desktopTrustMissingRoot + if ([int]$desktopTrustMissingResult.exitCode -eq 0) { + throw "Expected missing desktop distribution trust to fail Companion v1 evidence." + } + Assert-CheckStatus -Report $desktopTrustMissingResult.report -Id "desktop-v1-artifact-hashes-match" -Status "fail" + Write-Host "[ok] missing desktop distribution trust is rejected by final Companion v1 evidence" + + $legacyCommitRoot = New-TempEvidenceRoot + Copy-Item -Path (Join-Path $readyRoot "*") -Destination $legacyCommitRoot -Recurse -Force + $legacyCommitBundlePath = Join-Path $legacyCommitRoot "COMPANION_V1_EVIDENCE_BUNDLE.json" + $legacyCommitBundle = Get-Content -LiteralPath $legacyCommitBundlePath -Raw | ConvertFrom-Json + $legacyCommitBundle.PSObject.Properties.Remove("firmwareSourceCommit") + Write-JsonFile -Path $legacyCommitBundlePath -Value $legacyCommitBundle + Write-StatusReport -Path (Join-Path $legacyCommitRoot $reports.rolloutStatusReport) -Schema "stackchan.rollout-status.v1" -Status "consumer-promotion-ready" -Commit $sourceCommit -Version $releaseVersion -EvidenceRoot $hardwareEvidenceRoot -EvidenceCommit $sourceCommit + $legacyCommitResult = Invoke-CompanionV1BundleCheck -EvidenceRoot $legacyCommitRoot -RequireReady + if ([int]$legacyCommitResult.exitCode -ne 0 -or $legacyCommitResult.report.firmwareSourceCommit -ne $sourceCommit) { + throw "Expected a legacy same-commit Companion v1 packet to fall back firmwareSourceCommit to sourceCommit." + } + Write-Host "[ok] legacy same-commit Companion v1 evidence bundle remains accepted" $androidSummaryMissingRoot = New-TempEvidenceRoot Copy-Item -Path (Join-Path $readyRoot "*") -Destination $androidSummaryMissingRoot -Recurse -Force @@ -369,7 +430,7 @@ try { $hardwareRootMismatchRoot = New-TempEvidenceRoot Copy-Item -Path (Join-Path $readyRoot "*") -Destination $hardwareRootMismatchRoot -Recurse -Force - Write-StatusReport -Path (Join-Path $hardwareRootMismatchRoot $reports.rolloutStatusReport) -Schema "stackchan.rollout-status.v1" -Status "consumer-promotion-ready" -Commit $sourceCommit -Version $releaseVersion -EvidenceRoot (Join-Path $hardwareRootMismatchRoot "hardware-evidence/other") + Write-StatusReport -Path (Join-Path $hardwareRootMismatchRoot $reports.rolloutStatusReport) -Schema "stackchan.rollout-status.v1" -Status "consumer-promotion-ready" -Commit $sourceCommit -Version $releaseVersion -EvidenceRoot (Join-Path $hardwareRootMismatchRoot "hardware-evidence/other") -EvidenceCommit $firmwareSourceCommit $hardwareRootMismatchResult = Invoke-CompanionV1BundleCheck -EvidenceRoot $hardwareRootMismatchRoot if ([int]$hardwareRootMismatchResult.exitCode -eq 0) { throw "Expected mismatched Companion v1 hardware evidence root to fail." @@ -379,7 +440,7 @@ try { $hardwareCommitMismatchRoot = New-TempEvidenceRoot Copy-Item -Path (Join-Path $readyRoot "*") -Destination $hardwareCommitMismatchRoot -Recurse -Force - Write-StatusReport -Path (Join-Path $hardwareCommitMismatchRoot $reports.rolloutStatusReport) -Schema "stackchan.rollout-status.v1" -Status "consumer-promotion-ready" -Commit ("e" * 40) -Version $releaseVersion -EvidenceRoot $hardwareEvidenceRoot + Write-StatusReport -Path (Join-Path $hardwareCommitMismatchRoot $reports.rolloutStatusReport) -Schema "stackchan.rollout-status.v1" -Status "consumer-promotion-ready" -Commit $sourceCommit -Version $releaseVersion -EvidenceRoot $hardwareEvidenceRoot -EvidenceCommit ("e" * 40) $hardwareCommitMismatchResult = Invoke-CompanionV1BundleCheck -EvidenceRoot $hardwareCommitMismatchRoot if ([int]$hardwareCommitMismatchResult.exitCode -eq 0) { throw "Expected mismatched Companion v1 hardware evidence commit to fail." diff --git a/tools/test_consumer_promotion_contract.ps1 b/tools/test_consumer_promotion_contract.ps1 index 19b8d0f1..598c8270 100644 --- a/tools/test_consumer_promotion_contract.ps1 +++ b/tools/test_consumer_promotion_contract.ps1 @@ -11,7 +11,15 @@ $required = @( "dirty source worktree", "same installed firmware SHA-256", "ExpectedFirmwareSourceCommit", "Release commit:", "Firmware source commit:", "ActionsStatusPath", "export_github_actions_status.ps1", "successful live GitHub Actions status", - "stackchan.github-actions-status.v1", "GitHub Actions evidence:" + "stackchan.github-actions-status.v1", "GitHub Actions evidence:", + "CompanionV1EvidenceRoot", "Assert-CompanionV1PromotionReady", + "check_companion_v1_evidence_bundle.ps1", "-RequireReady", + "stackchan.companion-v1-evidence-bundle-check.v1", "companion-v1-evidence-ready", + "Companion v1 aggregate source commit mismatch", "Companion v1 aggregate release version mismatch", + "Companion v1 aggregate firmware source commit mismatch", + "Companion v1 hardware evidence root does not match the packet being promoted", + "Companion v1 release ZIP SHA-256 does not match the package being promoted", + "Consumer promotion requires -PackageZip", "Companion v1 evidence:", "Release ZIP SHA256:" ) foreach ($fragment in $required) { if (-not $source.Contains($fragment)) { @@ -23,7 +31,11 @@ $identityBindings = @( '& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $verifyPackage -Version $Version -PackageRoot $packageRootPath -ExpectedCommit $ExpectedCommit', '$cameraEvidence = Assert-CameraFollowReady $CameraFollowSummaryPath $ExpectedFirmwareSourceCommit', '$bodyEvidence = Assert-BodySensorReady $BodySensorReportPath $ExpectedFirmwareSourceCommit', - '$soakEvidence = Assert-FinalSoakReady $FullSystemSoakSummaryPath $ExpectedFirmwareSourceCommit $MinFinalSoakDurationSeconds' + '$soakEvidence = Assert-FinalSoakReady $FullSystemSoakSummaryPath $ExpectedFirmwareSourceCommit $MinFinalSoakDurationSeconds', + '-ExpectedSourceCommit $ExpectedCommit', + '-ExpectedFirmwareSourceCommit $ExpectedFirmwareSourceCommit', + '-ExpectedHardwareEvidenceRoot $EvidenceRoot', + '-ExpectedPackageZipSha256 $promotionPackageZipSha256' ) foreach ($binding in $identityBindings) { if (-not $source.Contains($binding)) { diff --git a/tools/test_desktop_package_evidence_contract.cmd b/tools/test_desktop_package_evidence_contract.cmd new file mode 100644 index 00000000..af169497 --- /dev/null +++ b/tools/test_desktop_package_evidence_contract.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_desktop_package_evidence_contract.ps1" %* diff --git a/tools/test_desktop_package_evidence_contract.ps1 b/tools/test_desktop_package_evidence_contract.ps1 new file mode 100644 index 00000000..3e95d0b9 --- /dev/null +++ b/tools/test_desktop_package_evidence_contract.ps1 @@ -0,0 +1,492 @@ +param() + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$checker = Join-Path $PSScriptRoot "export_desktop_package_evidence.ps1" +$releaseExporter = Join-Path $PSScriptRoot "export_companion_release_evidence.ps1" +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-desktop-package-evidence-" + [guid]::NewGuid().ToString("N")) +$powerShellHost = (Get-Process -Id $PID).Path + +$checkerText = Get-Content -LiteralPath $checker -Raw +foreach ($pattern in @("Test-MacOSSignatureNormalizedRuntimeIdentity", '"--verify", "--strict"', "LC_CODE_SIGNATURE", "Get-MachOCodeContentIdentity", 'contentIdentityStatus = "ready-signature-normalized"', "architectureProofs.ToArray()", "proofs.ToArray()", "Test-WindowsDistributionTrust", "Get-AuthenticodeSignature", '"/pa", "/all", "/tw"', "Test-MacOSDistributionTrust", '"stapler", "validate"', "developer-id-notarized-stapled")) { + if ($checkerText -notmatch [regex]::Escape($pattern)) { + throw "Desktop package evidence must prove macOS installer rewrites by strict code-signature normalization: missing $pattern" + } +} +Write-Host "[ok] macOS installer runtime identity requires strict code-signature normalization" + +$releaseWorkflowText = Get-Content -LiteralPath (Join-Path $repoRoot ".github/workflows/release.yml") -Raw +foreach ($pattern in @("STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_MACOS_CERTIFICATE_B64", ":app-desktop:notarizeDmg", "RequireDistributionTrust", "actions/attest@v4", "RequireDesktopDistributionTrust", "media/voice/*", "release_assets.json")) { + if ($releaseWorkflowText -notmatch [regex]::Escape($pattern)) { + throw "Tagged release workflow must fail closed on native desktop trust and provenance: missing $pattern" + } +} +Write-Host "[ok] tagged release requires native signing, notarization, and provenance attestation" + +function Get-Sha256Text { + param([string]$Path) + return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function Get-PayloadHash { + param([string]$Root) + $rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd("\", "/") + $prefix = $rootFull + [System.IO.Path]::DirectorySeparatorChar + $sha = [System.Security.Cryptography.SHA256]::Create() + $utf8 = [System.Text.Encoding]::UTF8 + $files = Get-ChildItem -LiteralPath $Root -File -Recurse | + Where-Object { $_.Name -ne "stackchan-python-runtime.json" } | + Sort-Object FullName + foreach ($file in $files) { + $relative = $file.FullName.Substring($prefix.Length).Replace("\", "/") + $pathBytes = $utf8.GetBytes("$relative`n") + $null = $sha.TransformBlock($pathBytes, 0, $pathBytes.Length, $pathBytes, 0) + $hashBytes = $utf8.GetBytes("$(Get-Sha256Text $file.FullName)`n") + $null = $sha.TransformBlock($hashBytes, 0, $hashBytes.Length, $hashBytes, 0) + } + $empty = [byte[]]@() + $null = $sha.TransformFinalBlock($empty, 0, 0) + return (($sha.Hash | ForEach-Object { $_.ToString("x2") }) -join "") +} + +function Invoke-Evidence { + param( + [string]$PackagePath, + [string]$PreparePath, + [string]$ProcessedRoot, + [string]$ExtractionRoot, + [string]$LaunchPath, + [string]$OutPath + ) + $output = & $powerShellHost -NoProfile -File $checker ` + -Platform windows ` + -PackagePath $PackagePath ` + -RuntimePrepareJsonPath $PreparePath ` + -ProcessedRuntimeRoot $ProcessedRoot ` + -PackageExtractionRoot $ExtractionRoot ` + -LaunchEvidencePath $LaunchPath ` + -Version v1.0.0 ` + -Commit 1111111111111111111111111111111111111111 ` + -OutPath $OutPath ` + -RequireInstallerPayload ` + -RequireLaunchEvidence ` + -UseExistingPackageExtraction ` + -Json 2>&1 | Out-String + return [ordered]@{ exitCode = $LASTEXITCODE; output = $output } +} + +function New-FixtureApplicationJar { + param( + [string]$JarRoot, + [string]$JarPath + ) + Add-Type -AssemblyName System.IO.Compression.FileSystem + if (Test-Path -LiteralPath $JarPath) { Remove-Item -LiteralPath $JarPath -Force } + [System.IO.Compression.ZipFile]::CreateFromDirectory($JarRoot, $JarPath, [System.IO.Compression.CompressionLevel]::Optimal, $false) +} + +try { + $processedRoot = Join-Path $tempRoot "processed/python-runtime" + New-Item -ItemType Directory -Force -Path (Join-Path $processedRoot "Lib") | Out-Null + Set-Content -LiteralPath (Join-Path $processedRoot "python.exe") -Value "synthetic executable" -Encoding ASCII + Set-Content -LiteralPath (Join-Path $processedRoot "Lib/runtime.txt") -Value "synthetic runtime" -Encoding ASCII + $payloadHash = Get-PayloadHash $processedRoot + $manifest = [ordered]@{ + schema = "stackchan.desktop-python-runtime.v1" + pythonVersion = "Python 3.12.4" + platform = "windows" + source = "contract-fixture" + sha256 = $payloadHash + license = "Python Software Foundation License Version 2" + builtAt = "2026-07-13T00:00:00Z" + } + $manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $processedRoot "stackchan-python-runtime.json") -Encoding UTF8 + + $preparePath = Join-Path $tempRoot "windows-prepare.json" + $prepare = [ordered]@{ + schema = "stackchan.desktop-python-runtime-prepare.v1" + status = "ready" + platform = "windows" + payloadSha256 = $payloadHash + validation = [ordered]@{ + schema = "stackchan.desktop-python-runtime-payload.v1" + status = "ready" + platform = "windows" + runtimeSha256 = $payloadHash + runtimeSource = "contract-fixture" + pythonVersion = "Python 3.12.4" + probedPythonVersion = "Python 3.12.4" + } + } + $prepare | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $preparePath -Encoding UTF8 + $packagePath = Join-Path $tempRoot "Stackchan Companion-1.0.0.msi" + Set-Content -LiteralPath $packagePath -Value "synthetic msi" -Encoding ASCII + + $requiredBrainFiles = @( + "brain/bridge/lan_service.py", + "brain/bridge/reference_bridge.py", + "brain/data/voice_source_provenance.yaml", + "brain/docs/media/voice/stackchan_spark_greeting.wav" + ) + $jarRoot = Join-Path $tempRoot "jar-root" + foreach ($brainPath in $requiredBrainFiles) { + $targetPath = Join-Path $jarRoot $brainPath + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $targetPath) | Out-Null + Set-Content -LiteralPath $targetPath -Value "fixture resource: $brainPath" -Encoding ASCII + } + $extractionRoot = Join-Path $tempRoot "package-extraction" + New-Item -ItemType Directory -Force -Path $extractionRoot | Out-Null + $installerRuntimeRoot = Join-Path $extractionRoot "Stackchan Companion/python-runtime" + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $installerRuntimeRoot) | Out-Null + Copy-Item -LiteralPath $processedRoot -Destination $installerRuntimeRoot -Recurse + $fixtureJar = Join-Path $extractionRoot "app-desktop-1.0.0-fixture.jar" + New-FixtureApplicationJar -JarRoot $jarRoot -JarPath $fixtureJar + + $launchPath = Join-Path $tempRoot "windows-package-launch.json" + $launch = [ordered]@{ + schema = "stackchan.desktop-package-launch-evidence.v1" + status = "ready" + platform = "windows" + package = [ordered]@{ name = (Split-Path -Leaf $packagePath); bytes = (Get-Item $packagePath).Length; sha256 = Get-Sha256Text $packagePath } + extractionMethod = "native" + extractionRoot = $extractionRoot + launcherPath = "fixture/Stackchan Companion.exe" + processExitCode = 0 + probe = [ordered]@{ + schema = "stackchan.desktop-packaged-runtime-smoke.v1" + status = "ready" + platform = "windows" + appVersion = "1.0.0" + protocol = "stackchan.bridge.v1" + runtimePresent = $true + pythonAvailable = $true + pythonVersion = "Python 3.12.4" + brainScriptAvailable = $true + launchContext = "package-extraction" + scope = "extracted-native-package-headless-runtime-probe" + substitutesForTargetInstall = $false + issues = @() + } + scope = "exact-native-package-extraction-and-headless-launch" + substitutesForTargetInstall = $false + issues = @() + } + $launch | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $launchPath -Encoding UTF8 + + $readyPath = Join-Path $tempRoot "ready.json" + $ready = Invoke-Evidence $packagePath $preparePath $processedRoot $extractionRoot $launchPath $readyPath + if ($ready.exitCode -ne 0) { throw "Complete desktop package evidence was rejected: $($ready.output)" } + $readyReport = Get-Content -LiteralPath $readyPath -Raw | ConvertFrom-Json + if ($readyReport.status -ne "ready" -or + $readyReport.runtime.processedPayloadSha256 -ne $payloadHash -or + $readyReport.installerPayload.status -ne "ready" -or + $readyReport.installerPayload.runtimeLocation -ne "native-app-resources" -or + $readyReport.installerPayload.runtimePayloadSha256 -ne $payloadHash -or + $readyReport.installerPayload.contentIdentityStatus -ne "ready-exact" -or + $readyReport.launchEvidence.status -ne "ready" -or + @($readyReport.installerPayload.requiredBrainFiles).Count -ne $requiredBrainFiles.Count) { + throw "Complete desktop package evidence did not preserve the processed and installer runtime proof." + } + Write-Host "[ok] complete installer-derived desktop package evidence is accepted" + + $launch.probe.launchContext = "installed-package" + $launch.probe.scope = "installed-native-package-headless-runtime-probe" + $launch | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $launchPath -Encoding UTF8 + $wrongLaunchContext = Invoke-Evidence $packagePath $preparePath $processedRoot $extractionRoot $launchPath (Join-Path $tempRoot "wrong-launch-context.json") + if ($wrongLaunchContext.exitCode -eq 0 -or $wrongLaunchContext.output -notmatch "probe context is invalid") { + throw "Installed-package probe context was accepted as extraction evidence." + } + Write-Host "[ok] installed launch context cannot replace package-extraction evidence" + $launch.probe.launchContext = "package-extraction" + $launch.probe.scope = "extracted-native-package-headless-runtime-probe" + $launch | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $launchPath -Encoding UTF8 + + Set-Content -LiteralPath (Join-Path $installerRuntimeRoot "Lib/runtime.txt") -Value "tampered installer runtime" -Encoding ASCII + $tamperedInstaller = Invoke-Evidence $packagePath $preparePath $processedRoot $extractionRoot $launchPath (Join-Path $tempRoot "tampered-installer.json") + if ($tamperedInstaller.exitCode -eq 0 -or + $tamperedInstaller.output -notmatch "Installer runtime payload hash does not match" -or + $tamperedInstaller.output -notmatch "Processed/installer runtime difference: changed Lib/runtime.txt") { + throw "Tampered installer runtime was not rejected." + } + Write-Host "[ok] installer runtime tampering is rejected" + Set-Content -LiteralPath (Join-Path $installerRuntimeRoot "Lib/runtime.txt") -Value "synthetic runtime" -Encoding ASCII + + $jarRuntimeRoot = Join-Path $jarRoot "python-runtime" + Copy-Item -LiteralPath $processedRoot -Destination $jarRuntimeRoot -Recurse + New-FixtureApplicationJar -JarRoot $jarRoot -JarPath $fixtureJar + $embeddedRuntime = Invoke-Evidence $packagePath $preparePath $processedRoot $extractionRoot $launchPath (Join-Path $tempRoot "embedded-runtime.json") + if ($embeddedRuntime.exitCode -eq 0 -or $embeddedRuntime.output -notmatch "must not contain the executable managed runtime") { + throw "JAR-embedded executable runtime was not rejected." + } + Write-Host "[ok] JAR-embedded executable runtime is rejected" + Remove-Item -LiteralPath $jarRuntimeRoot -Recurse -Force + New-FixtureApplicationJar -JarRoot $jarRoot -JarPath $fixtureJar + + $aggregateRoot = Join-Path $tempRoot "aggregate" + New-Item -ItemType Directory -Force -Path $aggregateRoot | Out-Null + foreach ($target in @( + [ordered]@{ platform = "windows"; extension = ".msi" }, + [ordered]@{ platform = "linux"; extension = ".deb" }, + [ordered]@{ platform = "macos"; extension = ".dmg" } + )) { + $targetPackage = Join-Path $aggregateRoot ("stackchan-companion-{0}-v1.0.0{1}" -f $target.platform, $target.extension) + Set-Content -LiteralPath $targetPackage -Value ("synthetic {0} package" -f $target.platform) -Encoding ASCII + $targetItem = Get-Item -LiteralPath $targetPackage + $targetPackageSha = Get-Sha256Text $targetItem.FullName + $processedFiles = @(Get-ChildItem -LiteralPath $processedRoot -File -Recurse) + $processedBytes = [int64](($processedFiles | Measure-Object -Property Length -Sum).Sum) + $distributionTrust = [ordered]@{ + required = $false + status = "not-required" + policy = "github-sigstore-attestation-at-publication" + packageSha256 = $targetPackageSha + signerSubject = "" + signerThumbprint = "" + timestampSubject = "" + timestampThumbprint = "" + signatureStatus = "" + notarizationStapled = $false + gatekeeperAccepted = $false + issue = "" + } + if ($target.platform -eq "windows") { + $distributionTrust.required = $true + $distributionTrust.status = "ready" + $distributionTrust.policy = "authenticode-sha256-timestamped" + $distributionTrust.signerSubject = "CN=Stackchan Release Fixture" + $distributionTrust.signerThumbprint = "1" * 40 + $distributionTrust.timestampSubject = "CN=Timestamp Fixture" + $distributionTrust.timestampThumbprint = "2" * 40 + $distributionTrust.signatureStatus = "Valid" + } elseif ($target.platform -eq "macos") { + $distributionTrust.required = $true + $distributionTrust.status = "ready" + $distributionTrust.policy = "developer-id-notarized-stapled" + $distributionTrust.signerSubject = "Developer ID Application: Stackchan Release Fixture" + $distributionTrust.signerThumbprint = "TEAMFIXTURE" + $distributionTrust.signatureStatus = "Valid" + $distributionTrust.notarizationStapled = $true + $distributionTrust.gatekeeperAccepted = $true + } + $targetReport = [ordered]@{ + schema = "stackchan.desktop-package-evidence.v1" + status = "ready" + platform = $target.platform + version = "v1.0.0" + commit = "1111111111111111111111111111111111111111" + package = [ordered]@{ + name = $targetItem.Name + extension = $target.extension + bytes = [int64]$targetItem.Length + sha256 = $targetPackageSha + } + runtime = [ordered]@{ + payloadSha256 = $payloadHash + processedPayloadSha256 = $payloadHash + source = "contract-fixture" + pythonVersion = "Python 3.12.4" + probedPythonVersion = "Python 3.12.4" + processedFileCount = $processedFiles.Count + processedBytes = $processedBytes + } + installerPayload = [ordered]@{ + required = $true + status = "ready" + extractionMethod = "native" + appJarName = "app-desktop-1.0.0-fixture.jar" + appJarSha256 = $payloadHash + packageSha256 = $targetPackageSha + runtimeLocation = "native-app-resources" + runtimeRootRelative = "Stackchan Companion/python-runtime" + runtimePayloadSha256 = $payloadHash + runtimeFileCount = $processedFiles.Count + runtimeBytes = $processedBytes + runtimeManifestSchema = "stackchan.desktop-python-runtime.v1" + runtimeManifestPlatform = $target.platform + runtimeManifestSha256 = $payloadHash + contentIdentityStatus = "ready-exact" + signatureNormalization = [ordered]@{ + status = "not-required" + tool = "" + processedPayloadSha256 = "" + installerPayloadSha256 = "" + changedFileCount = 0 + files = @() + } + requiredBrainFiles = @($requiredBrainFiles) + } + launchEvidence = [ordered]@{ + required = $true + status = "ready" + packageSha256 = $targetPackageSha + extractionMethod = "native" + launcherPath = "fixture/Stackchan Companion" + processExitCode = 0 + pythonVersion = "Python 3.12.4" + scope = "exact-native-package-extraction-and-headless-launch" + } + distributionTrust = $distributionTrust + issues = @() + } + if ($target.platform -eq "macos") { + $macosInstallerPayloadSha = "b" * 64 + $targetReport.installerPayload.runtimePayloadSha256 = $macosInstallerPayloadSha + $targetReport.installerPayload.runtimeBytes = $processedBytes + 128 + $targetReport.installerPayload.contentIdentityStatus = "ready-signature-normalized" + $targetReport.installerPayload.signatureNormalization = [ordered]@{ + status = "ready" + tool = "codesign" + processedPayloadSha256 = $payloadHash + installerPayloadSha256 = $macosInstallerPayloadSha + changedFileCount = 1 + files = @([ordered]@{ + path = "lib/libpython3.12.dylib" + processedFileSha256 = "c" * 64 + installerFileSha256 = "d" * 64 + normalizedFileSha256 = "e" * 64 + architectures = @([ordered]@{ + architecture = "arm64" + codeContentSha256 = "f" * 64 + codeBytes = 1024 + processedSignatureBytes = 256 + installerSignatureBytes = 192 + processedSignatureVerified = $false + installerSignatureVerified = $true + processedLinkEditFileBytes = 4096 + installerLinkEditFileBytes = 4032 + processedLinkEditVirtualBytes = 16384 + installerLinkEditVirtualBytes = 8192 + }) + }) + } + } + $targetReport | ConvertTo-Json -Depth 7 | Set-Content -LiteralPath (Join-Path $aggregateRoot ("{0}-package-evidence.json" -f $target.platform)) -Encoding UTF8 + } + + $emptyAndroidRoot = Join-Path $tempRoot "empty-android" + New-Item -ItemType Directory -Path $emptyAndroidRoot | Out-Null + $aggregateOut = Join-Path $tempRoot "aggregate-out" + $aggregateOutput = & $powerShellHost -NoProfile -File $releaseExporter ` + -Version v1.0.0 ` + -Commit 1111111111111111111111111111111111111111 ` + -AndroidArtifactRoot $emptyAndroidRoot ` + -DesktopArtifactRoot $aggregateRoot ` + -DesktopPackageEvidenceRoot $aggregateRoot ` + -OutDir $aggregateOut ` + -RequireDesktopDistributionTrust ` + -Json 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { throw "Complete aggregate desktop package evidence unexpectedly failed: $aggregateOutput" } + $aggregateReport = $aggregateOutput | ConvertFrom-Json + if ($aggregateReport.status -eq "blocked-release-evidence" -or @($aggregateReport.blockingPending).Count -ne 0) { + throw "Ready desktop trust must not be blocked by unrelated optional Android evidence." + } + if ($aggregateReport.desktopPackageEvidence.status -ne "ready" -or @($aggregateReport.desktopPackageEvidence.platforms).Count -ne 3) { + throw "Complete aggregate desktop package evidence did not report three ready platforms." + } + Write-Host "[ok] aggregate companion evidence accepts all three native package reports" + + $macosReportPath = Join-Path $aggregateRoot "macos-package-evidence.json" + $macosReport = Get-Content -LiteralPath $macosReportPath -Raw | ConvertFrom-Json + $macosReport.installerPayload.runtimePayloadSha256 = "0" * 64 + $macosReport | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $macosReportPath -Encoding UTF8 + $aggregateTamperedOutput = & $powerShellHost -NoProfile -File $releaseExporter ` + -Version v1.0.0 ` + -Commit 1111111111111111111111111111111111111111 ` + -AndroidArtifactRoot $emptyAndroidRoot ` + -DesktopArtifactRoot $aggregateRoot ` + -DesktopPackageEvidenceRoot $aggregateRoot ` + -OutDir (Join-Path $tempRoot "aggregate-tampered-out") ` + -RequireDesktopPackageEvidence ` + -RequireDesktopDistributionTrust ` + -Json 2>&1 | Out-String + if ($LASTEXITCODE -ne 2) { throw "Strict aggregate evidence did not reject a tampered installer runtime: $aggregateTamperedOutput" } + $aggregateTamperedReport = $aggregateTamperedOutput | ConvertFrom-Json + if (@($aggregateTamperedReport.blockingPending) -notcontains "desktop-native-package-runtime-evidence") { + throw "Strict aggregate evidence did not preserve the installer runtime mismatch marker." + } + Write-Host "[ok] aggregate companion evidence rejects installer-derived runtime mismatch" + $macosReport.installerPayload.runtimePayloadSha256 = $macosReport.installerPayload.signatureNormalization.installerPayloadSha256 + $macosReport | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $macosReportPath -Encoding UTF8 + + $macosReport.launchEvidence.packageSha256 = "0" * 64 + $macosReport | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $macosReportPath -Encoding UTF8 + $staleLaunchOutput = & $powerShellHost -NoProfile -File $releaseExporter ` + -Version v1.0.0 ` + -Commit 1111111111111111111111111111111111111111 ` + -AndroidArtifactRoot $emptyAndroidRoot ` + -DesktopArtifactRoot $aggregateRoot ` + -DesktopPackageEvidenceRoot $aggregateRoot ` + -OutDir (Join-Path $tempRoot "aggregate-stale-launch-out") ` + -RequireDesktopPackageEvidence ` + -RequireDesktopDistributionTrust ` + -Json 2>&1 | Out-String + if ($LASTEXITCODE -ne 2 -or $staleLaunchOutput -notmatch "desktop-native-package-runtime-evidence") { throw "Strict aggregate evidence did not reject stale package launch evidence." } + Write-Host "[ok] aggregate companion evidence rejects stale exact-package launch evidence" + $macosReport.launchEvidence.packageSha256 = (Get-Sha256Text (Join-Path $aggregateRoot "stackchan-companion-macos-v1.0.0.dmg")) + $macosReport | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $macosReportPath -Encoding UTF8 + + $windowsReportPath = Join-Path $aggregateRoot "windows-package-evidence.json" + $windowsReport = Get-Content -LiteralPath $windowsReportPath -Raw | ConvertFrom-Json + $windowsReport.distributionTrust.status = "not-ready" + $windowsReport | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $windowsReportPath -Encoding UTF8 + $trustTamperedOutput = & $powerShellHost -NoProfile -File $releaseExporter ` + -Version v1.0.0 ` + -Commit 1111111111111111111111111111111111111111 ` + -AndroidArtifactRoot $emptyAndroidRoot ` + -DesktopArtifactRoot $aggregateRoot ` + -DesktopPackageEvidenceRoot $aggregateRoot ` + -OutDir (Join-Path $tempRoot "aggregate-trust-tampered-out") ` + -RequireDesktopPackageEvidence ` + -RequireDesktopDistributionTrust ` + -Json 2>&1 | Out-String + if ($LASTEXITCODE -ne 2) { throw "Strict aggregate evidence did not reject missing Windows distribution trust: $trustTamperedOutput" } + $trustTamperedReport = $trustTamperedOutput | ConvertFrom-Json + if (@($trustTamperedReport.blockingPending) -notcontains "desktop-native-distribution-trust") { + throw "Strict aggregate evidence did not preserve the desktop distribution trust marker." + } + Write-Host "[ok] aggregate companion evidence rejects missing native distribution trust" + $windowsReport.distributionTrust.status = "ready" + $windowsReport | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $windowsReportPath -Encoding UTF8 + + Remove-Item -LiteralPath $macosReportPath -Force + $missingOut = Join-Path $tempRoot "aggregate-missing-out" + $missingOutput = & $powerShellHost -NoProfile -File $releaseExporter ` + -Version v1.0.0 ` + -Commit 1111111111111111111111111111111111111111 ` + -AndroidArtifactRoot $emptyAndroidRoot ` + -DesktopArtifactRoot $aggregateRoot ` + -DesktopPackageEvidenceRoot $aggregateRoot ` + -OutDir $missingOut ` + -RequireDesktopPackageEvidence ` + -RequireDesktopDistributionTrust ` + -Json 2>&1 | Out-String + if ($LASTEXITCODE -ne 2) { throw "Strict aggregate evidence did not fail when a native report was missing: $missingOutput" } + $missingReport = $missingOutput | ConvertFrom-Json + if (@($missingReport.blockingPending) -notcontains "desktop-native-package-runtime-evidence") { + throw "Strict aggregate evidence did not preserve the missing native package marker." + } + Write-Host "[ok] strict aggregate evidence rejects a missing native package report" + + $wrongExtension = Join-Path $tempRoot "Stackchan Companion-1.0.0.deb" + Copy-Item -LiteralPath $packagePath -Destination $wrongExtension + $wrong = Invoke-Evidence $wrongExtension $preparePath $processedRoot $extractionRoot $launchPath (Join-Path $tempRoot "wrong-extension.json") + if ($wrong.exitCode -eq 0 -or $wrong.output -notmatch "must use .msi") { throw "Wrong desktop package extension was not rejected." } + Write-Host "[ok] wrong platform package extension is rejected" + + Set-Content -LiteralPath (Join-Path $processedRoot "Lib/runtime.txt") -Value "tampered runtime" -Encoding ASCII + $tampered = Invoke-Evidence $packagePath $preparePath $processedRoot $extractionRoot $launchPath (Join-Path $tempRoot "tampered.json") + if ($tampered.exitCode -eq 0 -or $tampered.output -notmatch "payload hash does not match") { throw "Tampered processed runtime was not rejected." } + Write-Host "[ok] processed runtime tampering is rejected" + + $prepare.platform = "linux" + $prepare.validation.platform = "linux" + $prepare | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $preparePath -Encoding UTF8 + $wrongPlatform = Invoke-Evidence $packagePath $preparePath $processedRoot $extractionRoot $launchPath (Join-Path $tempRoot "wrong-platform.json") + if ($wrongPlatform.exitCode -eq 0 -or $wrongPlatform.output -notmatch "must be windows") { throw "Wrong runtime evidence platform was not rejected." } + Write-Host "[ok] runtime prepare platform mismatch is rejected" +} finally { + if (Test-Path -LiteralPath $tempRoot) { Remove-Item -LiteralPath $tempRoot -Recurse -Force } +} + +Write-Host "Desktop package evidence contract tests passed" +exit 0 diff --git a/tools/test_desktop_package_launch.cmd b/tools/test_desktop_package_launch.cmd new file mode 100644 index 00000000..916a8857 --- /dev/null +++ b/tools/test_desktop_package_launch.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_desktop_package_launch.ps1" %* diff --git a/tools/test_desktop_package_launch.ps1 b/tools/test_desktop_package_launch.ps1 new file mode 100644 index 00000000..ffd684fe --- /dev/null +++ b/tools/test_desktop_package_launch.ps1 @@ -0,0 +1,170 @@ +param( + [ValidateSet("windows", "linux", "macos")] + [string]$Platform, + [string]$PackagePath, + [string]$ExtractionRoot = "", + [string]$OutPath = "", + [int]$TimeoutSeconds = 120, + [switch]$UseExistingPackageExtraction, + [switch]$Json +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$issues = @() + +function Add-Issue([string]$Message) { $script:issues += $Message } +function Get-Sha256Text([string]$Path) { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } +function Get-HostPlatform { + if ($IsWindows -or $env:OS -eq "Windows_NT") { return "windows" } + if ($IsMacOS) { return "macos" } + if ($IsLinux) { return "linux" } + return "unknown" +} + +function Expand-NativePackage([string]$TargetPlatform, [string]$SourcePackage, [string]$DestinationRoot) { + New-Item -ItemType Directory -Path $DestinationRoot | Out-Null + switch ($TargetPlatform) { + "windows" { + $msiexec = Get-Command msiexec.exe -ErrorAction Stop + $logPath = Join-Path $DestinationRoot "msiexec-admin.log" + $arguments = @('/a', "`"$SourcePackage`"", '/qn', "TARGETDIR=`"$DestinationRoot`"", '/L*v', "`"$logPath`"") + $process = Start-Process -FilePath $msiexec.Source -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden + if ($process.ExitCode -ne 0) { throw "MSI administrative extraction failed with exit code $($process.ExitCode). See $logPath" } + } + "linux" { + & (Get-Command dpkg-deb -ErrorAction Stop).Source -x $SourcePackage $DestinationRoot + if ($LASTEXITCODE -ne 0) { throw "DEB extraction failed with exit code $LASTEXITCODE." } + } + "macos" { + $hdiutil = (Get-Command hdiutil -ErrorAction Stop).Source + $ditto = (Get-Command ditto -ErrorAction Stop).Source + $mountPoint = "$DestinationRoot-mount" + New-Item -ItemType Directory -Path $mountPoint | Out-Null + $attached = $false + try { + & $hdiutil attach $SourcePackage -readonly -nobrowse -mountpoint $mountPoint | Out-Null + if ($LASTEXITCODE -ne 0) { throw "DMG mount failed with exit code $LASTEXITCODE." } + $attached = $true + $apps = @(Get-ChildItem -LiteralPath $mountPoint -Directory -Filter "*.app") + if ($apps.Count -ne 1) { throw "Expected one application bundle in DMG; found $($apps.Count)." } + & $ditto $apps[0].FullName (Join-Path $DestinationRoot $apps[0].Name) + if ($LASTEXITCODE -ne 0) { throw "Application bundle copy failed with exit code $LASTEXITCODE." } + } finally { + if ($attached) { & $hdiutil detach $mountPoint -force | Out-Null } + } + } + } +} + +function Find-PackagedLauncher([string]$TargetPlatform, [string]$Root) { + $matches = switch ($TargetPlatform) { + "windows" { @(Get-ChildItem -LiteralPath $Root -Recurse -File -Filter "Stackchan Companion.exe" -ErrorAction SilentlyContinue) } + "linux" { @(Get-ChildItem -LiteralPath $Root -Recurse -File -Filter "Stackchan Companion" -ErrorAction SilentlyContinue) } + "macos" { @(Get-ChildItem -LiteralPath $Root -Recurse -File -Filter "Stackchan Companion" -ErrorAction SilentlyContinue | Where-Object { $_.FullName -match '\.app[\\/]Contents[\\/]MacOS[\\/]' }) } + } + return @($matches) +} + +$package = $null +$packageSha = "" +$launcherPath = "" +$processExitCode = $null +$probe = $null +$extractionMethod = if ($UseExistingPackageExtraction) { "existing" } else { "native" } +$expectedExtension = @{ windows = ".msi"; linux = ".deb"; macos = ".dmg" }[$Platform] + +if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + Add-Issue "Desktop package is missing: $PackagePath" +} else { + $package = Get-Item -LiteralPath $PackagePath + $packageSha = Get-Sha256Text $package.FullName + if ($package.Extension.ToLowerInvariant() -ne $expectedExtension) { Add-Issue "Desktop package for $Platform must use $expectedExtension." } +} + +if ([string]::IsNullOrWhiteSpace($ExtractionRoot)) { + $ExtractionRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-package-launch-{0}-{1}" -f $Platform, [guid]::NewGuid().ToString("N").Substring(0, 12)) +} +$ExtractionRoot = [System.IO.Path]::GetFullPath($ExtractionRoot) +if ([string]::IsNullOrWhiteSpace($OutPath)) { + $OutPath = Join-Path $repoRoot "output/companion/desktop-package-launch/$Platform-package-launch.json" +} +$OutPath = [System.IO.Path]::GetFullPath($OutPath) + +if ($null -ne $package -and $issues.Count -eq 0) { + if ((Get-HostPlatform) -ne $Platform) { + Add-Issue "Exact package launch for $Platform must run on a native $Platform host." + } elseif ($UseExistingPackageExtraction) { + if (-not (Test-Path -LiteralPath $ExtractionRoot -PathType Container)) { Add-Issue "Existing package extraction root was not found: $ExtractionRoot" } + } elseif (Test-Path -LiteralPath $ExtractionRoot) { + Add-Issue "Package extraction root already exists; refusing to overwrite it: $ExtractionRoot" + } else { + try { Expand-NativePackage $Platform $package.FullName $ExtractionRoot } catch { Add-Issue $_.Exception.Message } + } +} + +if (Test-Path -LiteralPath $ExtractionRoot -PathType Container) { + $launchers = @(Find-PackagedLauncher $Platform $ExtractionRoot) + if ($launchers.Count -ne 1) { + Add-Issue "Expected exactly one packaged launcher for $Platform; found $($launchers.Count)." + } else { + $launcherPath = $launchers[0].FullName + $probePath = Join-Path (Split-Path -Parent $OutPath) "$Platform-packaged-runtime-smoke.json" + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $probePath) | Out-Null + try { + $arguments = @( + "--package-smoke-output=`"$probePath`"", + "--package-smoke-context=package-extraction" + ) + $process = Start-Process -FilePath $launcherPath -ArgumentList $arguments -PassThru + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + $process.Kill() + Add-Issue "Packaged launcher smoke timed out after $TimeoutSeconds seconds." + } else { + $processExitCode = $process.ExitCode + if ($processExitCode -ne 0) { Add-Issue "Packaged launcher smoke exited with code $processExitCode." } + } + } catch { + Add-Issue "Packaged launcher smoke could not start: $($_.Exception.Message)" + } + if (-not (Test-Path -LiteralPath $probePath -PathType Leaf)) { + Add-Issue "Packaged launcher did not write its runtime smoke report." + } else { + try { $probe = Get-Content -LiteralPath $probePath -Raw | ConvertFrom-Json } catch { Add-Issue "Packaged runtime smoke report is invalid JSON: $($_.Exception.Message)" } + } + } +} + +if ($null -ne $probe) { + if ([string]$probe.schema -ne "stackchan.desktop-packaged-runtime-smoke.v1") { Add-Issue "Packaged runtime smoke schema is invalid." } + if ([string]$probe.status -ne "ready") { Add-Issue "Packaged runtime smoke status is not ready." } + if ([string]$probe.platform -ne $Platform) { Add-Issue "Packaged runtime smoke platform mismatch." } + if ([string]$probe.appVersion -ne "1.0.0" -or [string]$probe.protocol -ne "stackchan.bridge.v1") { Add-Issue "Packaged runtime identity is invalid." } + if ($probe.runtimePresent -ne $true -or $probe.pythonAvailable -ne $true -or $probe.brainScriptAvailable -ne $true) { Add-Issue "Packaged runtime smoke did not prove runtime, Python, and brain readiness." } + if (@($probe.issues).Count -ne 0) { Add-Issue "Packaged runtime smoke reported issues." } + if ([string]$probe.launchContext -ne "package-extraction" -or [string]$probe.scope -ne "extracted-native-package-headless-runtime-probe" -or $probe.substitutesForTargetInstall -ne $false) { Add-Issue "Packaged runtime smoke scope is invalid." } + foreach ($pathField in @("runtimeRoot", "runtimeManifest", "runtimeExecutable", "brainScript")) { + $value = [string]$probe.$pathField + if ([string]::IsNullOrWhiteSpace($value) -or -not (Test-Path -LiteralPath $value)) { Add-Issue "Packaged runtime smoke path is missing: $pathField" } + } +} + +$report = [ordered]@{ + schema = "stackchan.desktop-package-launch-evidence.v1" + status = if ($issues.Count -eq 0) { "ready" } else { "not-ready" } + platform = $Platform + generatedAtUtc = (Get-Date).ToUniversalTime().ToString("o") + package = if ($null -eq $package) { [ordered]@{ name = ""; bytes = 0; sha256 = "" } } else { [ordered]@{ name = $package.Name; bytes = [int64]$package.Length; sha256 = $packageSha } } + extractionMethod = $extractionMethod + extractionRoot = $ExtractionRoot + launcherPath = $launcherPath + processExitCode = $processExitCode + probe = $probe + scope = "exact-native-package-extraction-and-headless-launch" + substitutesForTargetInstall = $false + issues = @($issues) +} +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutPath) | Out-Null +$report | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $OutPath -Encoding UTF8 +if ($Json) { $report | ConvertTo-Json -Depth 10 } else { Write-Host "Desktop package launch evidence: $($report.status) ($Platform)" } +if ($issues.Count -gt 0) { exit 1 } diff --git a/tools/test_desktop_python_runtime_payload_contract.ps1 b/tools/test_desktop_python_runtime_payload_contract.ps1 index cf3426bc..7685c3ef 100644 --- a/tools/test_desktop_python_runtime_payload_contract.ps1 +++ b/tools/test_desktop_python_runtime_payload_contract.ps1 @@ -2,6 +2,25 @@ param() $ErrorActionPreference = "Stop" +$desktopBuildText = Get-Content -LiteralPath (Join-Path $PSScriptRoot "../companion/app-desktop/build.gradle.kts") -Raw +$runtimePrepareText = Get-Content -LiteralPath (Join-Path $PSScriptRoot "prepare_desktop_python_runtime.ps1") -Raw +if ($desktopBuildText -notmatch [regex]::Escape("process.waitFor(120, TimeUnit.SECONDS)")) { + throw "Desktop packaging must allow the managed-runtime validation subprocess a bounded 120-second first-launch window." +} +foreach ($pattern in @("desktop-runtime-validator-output", "outputReader.join(5_000)", "output.append(reader.readText())")) { + if ($desktopBuildText -notmatch [regex]::Escape($pattern)) { + throw "Desktop packaging must drain managed-runtime checker output while the subprocess runs: missing $pattern" + } +} +Write-Host "[ok] desktop packaging runtime validation uses the bounded first-launch window" + +foreach ($pattern in @("[System.IO.File]::Copy", "GetUnixFileMode", "SetUnixFileMode", "materializedSymlinkCount", '$_.Name -eq ".gitignore"')) { + if ($runtimePrepareText -notmatch [regex]::Escape($pattern)) { + throw "Desktop runtime preparation must materialize Unix file symlinks deterministically: missing $pattern" + } +} +Write-Host "[ok] desktop runtime preparation materializes Unix file symlinks and excludes source-control-only files" + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $checkScript = Join-Path $PSScriptRoot "check_desktop_python_runtime_payload.ps1" $createdRoots = New-Object System.Collections.Generic.List[string] @@ -191,3 +210,4 @@ try { } } } +exit 0 diff --git a/tools/test_desktop_release_signing_readiness_contract.ps1 b/tools/test_desktop_release_signing_readiness_contract.ps1 new file mode 100644 index 00000000..c55cc10a --- /dev/null +++ b/tools/test_desktop_release_signing_readiness_contract.ps1 @@ -0,0 +1,375 @@ +$ErrorActionPreference = "Stop" + +$checker = Join-Path $PSScriptRoot "check_desktop_release_signing_readiness.ps1" +$powerShellHost = Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($null -eq $powerShellHost) { + $powerShellHost = Get-Command powershell.exe -ErrorAction Stop | Select-Object -First 1 +} + +$environmentNames = @( + "STACKCHAN_WINDOWS_PFX_B64", + "STACKCHAN_WINDOWS_PFX_PASSWORD", + "STACKCHAN_MACOS_CERTIFICATE_B64", + "STACKCHAN_MACOS_CERTIFICATE_PASSWORD", + "STACKCHAN_MACOS_SIGNING_IDENTITY", + "STACKCHAN_MACOS_NOTARIZATION_APPLE_ID", + "STACKCHAN_MACOS_NOTARIZATION_PASSWORD", + "STACKCHAN_MACOS_NOTARIZATION_TEAM_ID" +) +$previousEnvironment = @{} +$secretValues = @( + "windows-contract-password-Aa1!", + "macos-contract-password-Bb2!", + "wrong-contract-password-Cc3!", + "notary-contract-password-Dd4!" +) +$passCount = 0 + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$readinessWorkflowPath = Join-Path $repoRoot ".github/workflows/companion-signing-readiness.yml" +$releaseWorkflowPath = Join-Path $repoRoot ".github/workflows/release.yml" +if (-not (Test-Path -LiteralPath $readinessWorkflowPath -PathType Leaf)) { + throw "Missing manual companion signing readiness workflow." +} +$readinessWorkflow = Get-Content -LiteralPath $readinessWorkflowPath -Raw +foreach ($pattern in @( + "workflow_dispatch", + "contents: read", + "check_desktop_release_signing_readiness.ps1", + "STACKCHAN_WINDOWS_PFX_B64", + "STACKCHAN_WINDOWS_PFX_PASSWORD", + "STACKCHAN_MACOS_CERTIFICATE_B64", + "STACKCHAN_MACOS_CERTIFICATE_PASSWORD", + "STACKCHAN_MACOS_SIGNING_IDENTITY", + "STACKCHAN_MACOS_NOTARIZATION_APPLE_ID", + "STACKCHAN_MACOS_NOTARIZATION_PASSWORD", + "STACKCHAN_MACOS_NOTARIZATION_TEAM_ID", + "RequireNativeToolchain", + "ValidateAppleNotaryCredentials" +)) { + if ($readinessWorkflow -notmatch [regex]::Escape($pattern)) { + throw "Manual signing readiness workflow is missing: $pattern" + } +} +foreach ($forbidden in @("gh release", "upload-artifact", "contents: write")) { + if ($readinessWorkflow -match [regex]::Escape($forbidden)) { + throw "Manual signing readiness workflow must not publish artifacts or releases: $forbidden" + } +} +$passCount++ +Write-Host "[PASS] manual signing readiness workflow validates without publishing" + +$checkerText = Get-Content -LiteralPath $checker -Raw +foreach ($pattern in @( + "SignTool could not verify the temporary Authenticode signing probe", + "codesign could not verify the temporary Developer ID signing probe", + "does not chain to a root trusted by the native host", + "ExportParameters", + '"--options", "runtime"' +)) { + if ($checkerText -notmatch [regex]::Escape($pattern)) { + throw "Desktop signing readiness checker is missing a native sign-and-verify probe: $pattern" + } +} +$passCount++ +Write-Host "[PASS] native desktop credential checks sign and verify temporary executables" + +$releaseWorkflow = Get-Content -LiteralPath $releaseWorkflowPath -Raw +foreach ($pattern in @("Validate production desktop signing credentials", "check_desktop_release_signing_readiness.ps1", "RequireNativeToolchain", "ValidateAppleNotaryCredentials")) { + if ($releaseWorkflow -notmatch [regex]::Escape($pattern)) { + throw "Tagged release workflow is missing desktop signing preflight: $pattern" + } +} +$passCount++ +Write-Host "[PASS] tagged release runs native desktop signing preflight" + +$firmwareWorkflowPath = Join-Path $repoRoot ".github/workflows/firmware.yml" +$firmwareWorkflow = Get-Content -LiteralPath $firmwareWorkflowPath -Raw +foreach ($pattern in @("check_desktop_release_signing_readiness.*", "test_desktop_release_signing_readiness_contract.ps1", "Run desktop release signing readiness contract")) { + if ($firmwareWorkflow -notmatch [regex]::Escape($pattern)) { + throw "Firmware CI is missing desktop signing readiness contract coverage: $pattern" + } +} +$passCount++ +Write-Host "[PASS] companion CI runs the desktop signing readiness contract" + +function New-ContractPkcs12Base64 { + param( + [string]$Subject, + [string]$Password, + [bool]$IncludeCodeSigningEku = $true, + [int]$ValidDays = 365, + [int]$KeyBits = 3072 + ) + + $rsa = [System.Security.Cryptography.RSA]::Create($KeyBits) + $certificate = $null + try { + $distinguishedName = New-Object System.Security.Cryptography.X509Certificates.X500DistinguishedName($Subject) + $request = New-Object System.Security.Cryptography.X509Certificates.CertificateRequest( + $distinguishedName, + $rsa, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + $request.CertificateExtensions.Add( + (New-Object System.Security.Cryptography.X509Certificates.X509KeyUsageExtension( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, + $true + )) + ) + if ($IncludeCodeSigningEku) { + $oids = New-Object System.Security.Cryptography.OidCollection + [void]$oids.Add((New-Object System.Security.Cryptography.Oid("1.3.6.1.5.5.7.3.3"))) + $request.CertificateExtensions.Add( + (New-Object System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension($oids, $false)) + ) + } + $certificate = $request.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddDays(-1), + [DateTimeOffset]::UtcNow.AddDays($ValidDays) + ) + $bytes = $certificate.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, + $Password + ) + return [Convert]::ToBase64String($bytes) + } finally { + if ($null -ne $certificate) { $certificate.Dispose() } + $rsa.Dispose() + } +} + +function Clear-ContractEnvironment { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $null) + } +} + +function Set-WindowsContractEnvironment { + param( + [string]$Base64, + [string]$Password + ) + Clear-ContractEnvironment + [Environment]::SetEnvironmentVariable("STACKCHAN_WINDOWS_PFX_B64", $Base64) + [Environment]::SetEnvironmentVariable("STACKCHAN_WINDOWS_PFX_PASSWORD", $Password) +} + +function Set-MacOSContractEnvironment { + param( + [string]$Base64, + [string]$Password, + [string]$Identity = "Developer ID Application: Stackchan Contract (AB12CD34EF)", + [string]$AppleId = "release-contract@example.com", + [string]$NotaryPassword = "notary-contract-password-Dd4!", + [string]$TeamId = "AB12CD34EF" + ) + Clear-ContractEnvironment + [Environment]::SetEnvironmentVariable("STACKCHAN_MACOS_CERTIFICATE_B64", $Base64) + [Environment]::SetEnvironmentVariable("STACKCHAN_MACOS_CERTIFICATE_PASSWORD", $Password) + [Environment]::SetEnvironmentVariable("STACKCHAN_MACOS_SIGNING_IDENTITY", $Identity) + [Environment]::SetEnvironmentVariable("STACKCHAN_MACOS_NOTARIZATION_APPLE_ID", $AppleId) + [Environment]::SetEnvironmentVariable("STACKCHAN_MACOS_NOTARIZATION_PASSWORD", $NotaryPassword) + [Environment]::SetEnvironmentVariable("STACKCHAN_MACOS_NOTARIZATION_TEAM_ID", $TeamId) +} + +function Invoke-ContractCheck { + param( + [ValidateSet("windows", "macos")] + [string]$Platform, + [bool]$RequireReady = $true + ) + + $arguments = @("-NoProfile", "-File", $checker, "-Platform", $Platform, "-Json") + if ($RequireReady) { $arguments += "-RequireReady" } + $oldPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = @(& $powerShellHost.Source @arguments 2>&1) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldPreference + } + $text = ($output | Out-String).Trim() + foreach ($secret in $secretValues) { + if ($text.Contains($secret)) { throw "Desktop signing readiness output exposed a contract credential." } + } + try { + $report = $text | ConvertFrom-Json + } catch { + throw "Desktop signing readiness output was not valid JSON: $text" + } + return [pscustomobject]@{ exitCode = $exitCode; report = $report } +} + +function Assert-Result { + param( + [object]$Result, + [int]$ExitCode, + [string]$Status, + [string]$DetailPattern, + [string]$Name + ) + + if ($Result.exitCode -ne $ExitCode) { + throw "$Name returned exit code $($Result.exitCode); expected $ExitCode. Status: $($Result.report.status). Issues: $(@($Result.report.issues) -join '; ')" + } + if ([string]$Result.report.status -ne $Status) { + throw "$Name returned status '$($Result.report.status)'; expected '$Status'." + } + $details = @($Result.report.checks | ForEach-Object { [string]$_.detail }) -join "`n" + if (-not [string]::IsNullOrWhiteSpace($DetailPattern) -and $details -notmatch [regex]::Escape($DetailPattern)) { + throw "$Name did not report '$DetailPattern': $details" + } + $script:passCount++ + Write-Host "[PASS] $Name" +} + +try { + foreach ($name in $environmentNames) { + $previousEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + } + + $windowsPassword = $secretValues[0] + $macosPassword = $secretValues[1] + $wrongPassword = $secretValues[2] + $windowsPfx = New-ContractPkcs12Base64 ` + -Subject "CN=Stackchan Windows Contract, OU=Release, O=Stackchan Contract, C=US" ` + -Password $windowsPassword + $windowsNoEkuPfx = New-ContractPkcs12Base64 ` + -Subject "CN=Stackchan Windows No EKU, OU=Release, O=Stackchan Contract, C=US" ` + -Password $windowsPassword ` + -IncludeCodeSigningEku $false + $windowsExpiringPfx = New-ContractPkcs12Base64 ` + -Subject "CN=Stackchan Windows Expiring, OU=Release, O=Stackchan Contract, C=US" ` + -Password $windowsPassword ` + -ValidDays 5 + $windowsWeakKeyPfx = New-ContractPkcs12Base64 ` + -Subject "CN=Stackchan Windows Weak Key, OU=Release, O=Stackchan Contract, C=US" ` + -Password $windowsPassword ` + -KeyBits 1024 + $macosIdentity = "Developer ID Application: Stackchan Contract (AB12CD34EF)" + $macosPfx = New-ContractPkcs12Base64 ` + -Subject "CN=$macosIdentity, OU=AB12CD34EF, O=Stackchan Contract, C=US" ` + -Password $macosPassword + + Clear-ContractEnvironment + Assert-Result ` + -Result (Invoke-ContractCheck -Platform windows -RequireReady $false) ` + -ExitCode 0 ` + -Status "pending-credentials" ` + -DetailPattern "STACKCHAN_WINDOWS_PFX_B64" ` + -Name "missing Windows credentials remain pending" + + Assert-Result ` + -Result (Invoke-ContractCheck -Platform windows) ` + -ExitCode 2 ` + -Status "pending-credentials" ` + -DetailPattern "STACKCHAN_WINDOWS_PFX_PASSWORD" ` + -Name "required Windows credentials fail closed when missing" + + Set-WindowsContractEnvironment -Base64 "not-base64" -Password $windowsPassword + Assert-Result ` + -Result (Invoke-ContractCheck -Platform windows) ` + -ExitCode 1 ` + -Status "not-ready" ` + -DetailPattern "not valid base64" ` + -Name "invalid Windows PKCS12 base64 is rejected" + + Set-WindowsContractEnvironment -Base64 $windowsPfx -Password $wrongPassword + Assert-Result ` + -Result (Invoke-ContractCheck -Platform windows) ` + -ExitCode 1 ` + -Status "not-ready" ` + -DetailPattern "could not be opened" ` + -Name "wrong Windows PKCS12 password is rejected" + + Set-WindowsContractEnvironment -Base64 $windowsNoEkuPfx -Password $windowsPassword + Assert-Result ` + -Result (Invoke-ContractCheck -Platform windows) ` + -ExitCode 1 ` + -Status "not-ready" ` + -DetailPattern "private code-signing certificate" ` + -Name "Windows certificate without code-signing EKU is rejected" + + Set-WindowsContractEnvironment -Base64 $windowsExpiringPfx -Password $windowsPassword + Assert-Result ` + -Result (Invoke-ContractCheck -Platform windows) ` + -ExitCode 1 ` + -Status "not-ready" ` + -DetailPattern "remain valid for at least 30 days" ` + -Name "near-expiry Windows certificate is rejected" + + Set-WindowsContractEnvironment -Base64 $windowsWeakKeyPfx -Password $windowsPassword + Assert-Result ` + -Result (Invoke-ContractCheck -Platform windows) ` + -ExitCode 1 ` + -Status "not-ready" ` + -DetailPattern "unsupported or undersized public key" ` + -Name "undersized Windows signing key is rejected" + + Set-WindowsContractEnvironment -Base64 $windowsPfx -Password $windowsPassword + $validWindows = Invoke-ContractCheck -Platform windows + Assert-Result ` + -Result $validWindows ` + -ExitCode 0 ` + -Status "ready" ` + -DetailPattern "private code-signing certificate" ` + -Name "valid Windows signing credential material is accepted" + if ($validWindows.report.certificate.keyAlgorithm -ne "RSA" -or [int]$validWindows.report.certificate.keyBits -ne 3072) { + throw "Valid Windows signing material did not preserve its RSA key identity." + } + + Set-MacOSContractEnvironment -Base64 $macosPfx -Password $macosPassword + $validMacOS = Invoke-ContractCheck -Platform macos + Assert-Result ` + -Result $validMacOS ` + -ExitCode 0 ` + -Status "ready" ` + -DetailPattern "valid identity" ` + -Name "valid macOS Developer ID credential material is accepted" + if ([string]$validMacOS.report.certificate.simpleName -ne $macosIdentity) { + throw "Valid macOS signing material did not preserve its Developer ID identity." + } + + Set-MacOSContractEnvironment ` + -Base64 $macosPfx ` + -Password $macosPassword ` + -Identity "Developer ID Application: Wrong Contract (AB12CD34EF)" + Assert-Result ` + -Result (Invoke-ContractCheck -Platform macos) ` + -ExitCode 1 ` + -Status "not-ready" ` + -DetailPattern "does not match the PKCS#12 certificate" ` + -Name "mismatched macOS signing identity is rejected" + + Set-MacOSContractEnvironment ` + -Base64 $macosPfx ` + -Password $macosPassword ` + -TeamId "ZZ98YY76XX" + Assert-Result ` + -Result (Invoke-ContractCheck -Platform macos) ` + -ExitCode 1 ` + -Status "not-ready" ` + -DetailPattern "team ID does not match" ` + -Name "mismatched Apple team ID is rejected" + + Set-MacOSContractEnvironment ` + -Base64 $macosPfx ` + -Password $macosPassword ` + -AppleId "not-an-email" + Assert-Result ` + -Result (Invoke-ContractCheck -Platform macos) ` + -ExitCode 1 ` + -Status "not-ready" ` + -DetailPattern "must be an email address" ` + -Name "invalid Apple notarization account shape is rejected" +} finally { + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name]) + } +} + +Write-Host "Desktop release signing readiness contract passed: $passCount checks." +exit 0 diff --git a/tools/test_desktop_target_install_evidence_contract.cmd b/tools/test_desktop_target_install_evidence_contract.cmd new file mode 100644 index 00000000..b66c8361 --- /dev/null +++ b/tools/test_desktop_target_install_evidence_contract.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_desktop_target_install_evidence_contract.ps1" %* diff --git a/tools/test_desktop_target_install_evidence_contract.ps1 b/tools/test_desktop_target_install_evidence_contract.ps1 new file mode 100644 index 00000000..1558d004 --- /dev/null +++ b/tools/test_desktop_target_install_evidence_contract.ps1 @@ -0,0 +1,185 @@ +param() + +$ErrorActionPreference = "Stop" +$checker = Join-Path $PSScriptRoot "check_desktop_target_install_evidence.ps1" +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-desktop-target-install-contract-" + [guid]::NewGuid().ToString("N")) +$sourceCommit = "a" * 40 +$packageHashes = [ordered]@{ windows = ("b" * 64); linux = ("c" * 64); macos = ("d" * 64) } + +function New-TargetInstallFixture { + param( + [ValidateSet("windows", "linux", "macos")] + [string]$Platform, + [string]$EnvironmentKind = "operator-target-workstation" + ) + $extension = @{ windows = ".msi"; linux = ".deb"; macos = ".dmg" }[$Platform] + $method = @{ windows = "msiexec-install"; linux = "dpkg-install"; macos = "dmg-application-copy" }[$Platform] + return [ordered]@{ + schema = "stackchan.desktop-target-install-evidence.v1" + status = "installed-and-ready" + capturedUtc = "2026-07-13T00:00:00Z" + sourceCommit = $sourceCommit + platform = $Platform + environmentKind = $EnvironmentKind + host = [ordered]@{ platform = $Platform; osDescription = "contract fixture"; architecture = "x64"; elevatedAdministrator = ($Platform -eq "windows") } + package = [ordered]@{ name = "stackchan-companion$extension"; bytes = 12345; sha256 = $packageHashes[$Platform] } + install = [ordered]@{ + method = $method + exitCode = 0 + installRoot = "/fixture" + installedLauncherPath = "/fixture/Stackchan Companion" + logPath = "fixture.log" + windows = if ($Platform -eq "windows") { + [ordered]@{ + preExistingRegistrations = @() + replacementRequested = $false + replacementPerformed = $false + uninstallAttempts = @() + postInstallRegistrations = @([ordered]@{ + productCode = "{11111111-2222-3333-4444-555555555555}" + displayName = "Stackchan Companion" + displayVersion = "1.0.0" + publisher = "fixture" + installLocation = "/fixture" + registryPath = "fixture-registry" + }) + } + } else { $null } + } + launch = [ordered]@{ + exitCode = 0 + probe = [ordered]@{ + schema = "stackchan.desktop-packaged-runtime-smoke.v1" + status = "ready" + platform = $Platform + appVersion = "1.0.0" + protocol = "stackchan.bridge.v1" + runtimePresent = $true + pythonAvailable = $true + pythonVersion = "Python 3.12.4" + brainScriptAvailable = $true + launchContext = "installed-package" + scope = "installed-native-package-headless-runtime-probe" + substitutesForTargetInstall = $false + issues = @() + } + } + scope = "exact-native-package-install-and-headless-launch" + targetInstallVerified = $true + substitutesForHumanAcceptance = $false + issues = @() + } +} + +function Write-Fixture([string]$Name, [object]$Value) { + $path = Join-Path $tempRoot "$Name.json" + $Value | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $path -Encoding UTF8 + return $path +} + +function Invoke-Check { + param( + [string]$Path, + [string]$Platform, + [string]$PackageSha256, + [switch]$RequireOperatorTarget + ) + $arguments = @( + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $checker, + '-EvidencePath', $Path, + '-ExpectedPlatform', $Platform, + '-ExpectedPackageSha256', $PackageSha256, + '-ExpectedSourceCommit', $sourceCommit, + '-Json' + ) + if ($RequireOperatorTarget) { $arguments += '-RequireOperatorTarget' } + $output = & (Get-Process -Id $PID).Path @arguments 2>&1 | Out-String + return [ordered]@{ exitCode = $LASTEXITCODE; output = $output; report = ($output | ConvertFrom-Json) } +} + +try { + New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null + foreach ($platform in @("windows", "linux", "macos")) { + $path = Write-Fixture "$platform-ready" (New-TargetInstallFixture $platform) + $result = Invoke-Check $path $platform $packageHashes[$platform] -RequireOperatorTarget + if ($result.exitCode -ne 0 -or $result.report.status -ne "desktop-target-install-ready") { throw "Ready $platform target-install evidence was rejected: $($result.output)" } + } + Write-Host "[ok] operator target-install evidence is accepted for Windows, Linux, and macOS" + + $windowsReplacement = New-TargetInstallFixture "windows" + $windowsReplacement.install.windows.preExistingRegistrations = @([ordered]@{ + productCode = "{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}" + displayName = "Stackchan Companion" + displayVersion = "1.0.0" + publisher = "fixture" + installLocation = "/fixture-old" + registryPath = "fixture-registry-old" + }) + $windowsReplacement.install.windows.replacementRequested = $true + $windowsReplacement.install.windows.replacementPerformed = $true + $windowsReplacement.install.windows.uninstallAttempts = @([ordered]@{ + productCode = "{AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE}" + exitCode = 0 + logPath = "fixture-uninstall.log" + }) + $windowsReplacementPath = Write-Fixture "windows-replacement" $windowsReplacement + $windowsReplacementResult = Invoke-Check $windowsReplacementPath "windows" $packageHashes.windows -RequireOperatorTarget + if ($windowsReplacementResult.exitCode -ne 0 -or $windowsReplacementResult.report.status -ne "desktop-target-install-ready") { throw "Safe Windows replacement evidence was rejected: $($windowsReplacementResult.output)" } + Write-Host "[ok] explicit Windows replacement evidence is accepted" + + $unsafeWindowsReplacement = New-TargetInstallFixture "windows" + $unsafeWindowsReplacement.install.windows.preExistingRegistrations = $windowsReplacement.install.windows.preExistingRegistrations + $unsafeWindowsReplacementPath = Write-Fixture "unsafe-windows-replacement" $unsafeWindowsReplacement + $unsafeWindowsReplacementResult = Invoke-Check $unsafeWindowsReplacementPath "windows" $packageHashes.windows -RequireOperatorTarget + if ($unsafeWindowsReplacementResult.exitCode -eq 0 -or @($unsafeWindowsReplacementResult.report.checks | Where-Object { $_.id -eq "windows-exact-package-replacement" -and $_.status -eq "fail" }).Count -ne 1) { throw "Unsafe Windows replacement evidence was accepted." } + Write-Host "[ok] implicit Windows maintenance-mode replacement is rejected" + + $nonElevatedWindows = New-TargetInstallFixture "windows" + $nonElevatedWindows.host.elevatedAdministrator = $false + $nonElevatedWindowsPath = Write-Fixture "non-elevated-windows" $nonElevatedWindows + $nonElevatedWindowsResult = Invoke-Check $nonElevatedWindowsPath "windows" $packageHashes.windows -RequireOperatorTarget + if ($nonElevatedWindowsResult.exitCode -eq 0 -or @($nonElevatedWindowsResult.report.checks | Where-Object { $_.id -eq "windows-elevation" -and $_.status -eq "fail" }).Count -ne 1) { throw "Non-elevated Windows install evidence was accepted." } + Write-Host "[ok] non-elevated Windows install evidence is rejected" + + $wrongHashPath = Write-Fixture "wrong-hash" (New-TargetInstallFixture "windows") + $wrongHash = Invoke-Check $wrongHashPath "windows" ("0" * 64) -RequireOperatorTarget + if ($wrongHash.exitCode -eq 0 -or @($wrongHash.report.checks | Where-Object { $_.id -eq "expected-package-sha256" -and $_.status -eq "fail" }).Count -ne 1) { throw "Stale package hash was not rejected." } + Write-Host "[ok] stale target-install package hash is rejected" + + $ciPath = Write-Fixture "ci-runner" (New-TargetInstallFixture "linux" "ci-native-runner") + $ciResult = Invoke-Check $ciPath "linux" $packageHashes.linux -RequireOperatorTarget + if ($ciResult.exitCode -eq 0 -or @($ciResult.report.checks | Where-Object { $_.id -eq "operator-target" -and $_.status -eq "fail" }).Count -ne 1) { throw "CI evidence was accepted as operator target evidence." } + Write-Host "[ok] CI native-runner evidence cannot replace operator target evidence" + + $extracted = New-TargetInstallFixture "macos" + $extracted.launch.probe.launchContext = "package-extraction" + $extracted.launch.probe.scope = "extracted-native-package-headless-runtime-probe" + $extractedPath = Write-Fixture "extracted-launch" $extracted + $extractedResult = Invoke-Check $extractedPath "macos" $packageHashes.macos -RequireOperatorTarget + if ($extractedResult.exitCode -eq 0 -or @($extractedResult.report.checks | Where-Object { $_.id -eq "installed-runtime-probe" -and $_.status -eq "fail" }).Count -ne 1) { throw "Extracted package launch was accepted as installed launch evidence." } + Write-Host "[ok] package extraction cannot replace installed launcher evidence" + + $missingExitCodes = New-TargetInstallFixture "windows" + $missingExitCodes.install.Remove("exitCode") + $missingExitCodes.launch.Remove("exitCode") + $missingExitCodesPath = Write-Fixture "missing-exit-codes" $missingExitCodes + $missingExitCodesResult = Invoke-Check $missingExitCodesPath "windows" $packageHashes.windows -RequireOperatorTarget + if ($missingExitCodesResult.exitCode -eq 0 -or @($missingExitCodesResult.report.checks | Where-Object { $_.id -in @("native-install", "installed-launcher") -and $_.status -eq "fail" }).Count -ne 2) { throw "Missing install or launch exit codes were accepted." } + Write-Host "[ok] missing install and launch exit codes are rejected" + + $wrongCommit = New-TargetInstallFixture "windows" + $wrongCommit.sourceCommit = "e" * 40 + $wrongCommitPath = Write-Fixture "wrong-commit" $wrongCommit + $wrongCommitResult = Invoke-Check $wrongCommitPath "windows" $packageHashes.windows -RequireOperatorTarget + if ($wrongCommitResult.exitCode -eq 0 -or @($wrongCommitResult.report.checks | Where-Object { $_.id -eq "expected-source-commit" -and $_.status -eq "fail" }).Count -ne 1) { throw "Mismatched source commit was not rejected." } + Write-Host "[ok] mismatched target-install source commit is rejected" +} finally { + if (Test-Path -LiteralPath $tempRoot) { + $resolved = (Resolve-Path -LiteralPath $tempRoot).Path + if (-not $resolved.StartsWith([System.IO.Path]::GetTempPath(), [System.StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to remove non-temporary contract root: $resolved" } + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} + +Write-Host "Desktop target install evidence contract tests passed" +exit 0 diff --git a/tools/test_desktop_v1_evidence_bundle_contract.ps1 b/tools/test_desktop_v1_evidence_bundle_contract.ps1 index af1367ec..adf56aa3 100644 --- a/tools/test_desktop_v1_evidence_bundle_contract.ps1 +++ b/tools/test_desktop_v1_evidence_bundle_contract.ps1 @@ -132,6 +132,72 @@ function Write-C6Report { }) } +function Write-TargetInstallReport { + param( + [string]$Path, + [ValidateSet("windows", "linux", "macos")] + [string]$Platform, + [string]$SourceCommit, + [string]$PackageSha256, + [string]$EnvironmentKind = "operator-target-workstation" + ) + + $extension = @{ windows = ".msi"; linux = ".deb"; macos = ".dmg" }[$Platform] + $method = @{ windows = "msiexec-install"; linux = "dpkg-install"; macos = "dmg-application-copy" }[$Platform] + Write-JsonFile -Path $Path -Value ([ordered]@{ + schema = "stackchan.desktop-target-install-evidence.v1" + status = "installed-and-ready" + capturedUtc = "2026-07-13T00:00:00Z" + sourceCommit = $SourceCommit + platform = $Platform + environmentKind = $EnvironmentKind + host = [ordered]@{ platform = $Platform; osDescription = "contract fixture"; architecture = "x64"; elevatedAdministrator = ($Platform -eq "windows") } + package = [ordered]@{ name = "stackchan-companion$extension"; bytes = 12345; sha256 = $PackageSha256 } + install = [ordered]@{ + method = $method + exitCode = 0 + installRoot = "/fixture" + installedLauncherPath = "/fixture/Stackchan Companion" + logPath = "fixture.log" + windows = if ($Platform -eq "windows") { + [ordered]@{ + preExistingRegistrations = @() + replacementRequested = $false + replacementPerformed = $false + uninstallAttempts = @() + postInstallRegistrations = @([ordered]@{ + productCode = "{11111111-2222-3333-4444-555555555555}" + displayName = "Stackchan Companion" + displayVersion = "1.0.0" + publisher = "fixture" + installLocation = "/fixture" + registryPath = "fixture-registry" + }) + } + } else { $null } + } + launch = [ordered]@{ + exitCode = 0 + probe = [ordered]@{ + schema = "stackchan.desktop-packaged-runtime-smoke.v1" + status = "ready" + platform = $Platform + runtimePresent = $true + pythonAvailable = $true + brainScriptAvailable = $true + launchContext = "installed-package" + scope = "installed-native-package-headless-runtime-probe" + substitutesForTargetInstall = $false + issues = @() + } + } + scope = "exact-native-package-install-and-headless-launch" + targetInstallVerified = $true + substitutesForHumanAcceptance = $false + issues = @() + }) +} + try { Set-Location $repoRoot @@ -140,7 +206,7 @@ try { if ($templateResult.report.status -ne "pending-desktop-v1-evidence-bundle") { throw "Expected placeholder bundle to be pending, got $($templateResult.report.status)." } - foreach ($id in @("source-commit", "hardware-evidence", "runtime-payload-status", "pc-brain-lab-status", "artifact-windows", "runtime-windows", "desktop-v1-review")) { + foreach ($id in @("source-commit", "hardware-evidence", "runtime-payload-status", "pc-brain-lab-status", "artifact-windows", "runtime-windows", "target-install-windows", "desktop-v1-review")) { Assert-CheckStatus -Report $templateResult.report -Id $id -Status "pending" } Write-Host "[ok] placeholder Desktop v1 evidence bundle is pending" @@ -154,6 +220,9 @@ try { windowsRuntimePayloadReport = "reports/desktop_runtime_windows.json" macosRuntimePayloadReport = "reports/desktop_runtime_macos.json" linuxRuntimePayloadReport = "reports/desktop_runtime_linux.json" + windowsTargetInstallReport = "reports/desktop_target_install_windows.json" + macosTargetInstallReport = "reports/desktop_target_install_macos.json" + linuxTargetInstallReport = "reports/desktop_target_install_linux.json" pcBrainDeployCheckReport = "reports/pc_brain_deploy_check.json" pcBrainQuietSoakCheckReport = "reports/pc_brain_quiet_soak_check.json" voiceSourceReadinessReport = "reports/voice_source_readiness.json" @@ -181,6 +250,9 @@ try { Write-StatusReport -Path (Join-Path $readyRoot $reports.windowsRuntimePayloadReport) -Schema "stackchan.desktop-python-runtime-payload.v1" -Status "ready" -Platform "windows" -PythonVersion "Python 3.12.0" -RuntimeSha256 ("d" * 64) -RuntimeSource "python.org-embed-3.12.0-windows" -ProbedPythonVersion "Python 3.12.0" Write-StatusReport -Path (Join-Path $readyRoot $reports.macosRuntimePayloadReport) -Schema "stackchan.desktop-python-runtime-payload.v1" -Status "ready" -Platform "macos" -PythonVersion "Python 3.12.0" -RuntimeSha256 ("e" * 64) -RuntimeSource "python.org-embed-3.12.0-macos" -ProbedPythonVersion "Python 3.12.0" Write-StatusReport -Path (Join-Path $readyRoot $reports.linuxRuntimePayloadReport) -Schema "stackchan.desktop-python-runtime-payload.v1" -Status "ready" -Platform "linux" -PythonVersion "Python 3.12.0" -RuntimeSha256 ("f" * 64) -RuntimeSource "python.org-embed-3.12.0-linux" -ProbedPythonVersion "Python 3.12.0" + Write-TargetInstallReport -Path (Join-Path $readyRoot $reports.windowsTargetInstallReport) -Platform "windows" -SourceCommit $sourceCommit -PackageSha256 ("a" * 64) + Write-TargetInstallReport -Path (Join-Path $readyRoot $reports.macosTargetInstallReport) -Platform "macos" -SourceCommit $sourceCommit -PackageSha256 ("b" * 64) + Write-TargetInstallReport -Path (Join-Path $readyRoot $reports.linuxTargetInstallReport) -Platform "linux" -SourceCommit $sourceCommit -PackageSha256 ("c" * 64) Write-StatusReport -Path (Join-Path $readyRoot $reports.pcBrainDeployCheckReport) -Schema "stackchan.pc-brain-deploy-evidence-check.v1" -Status "pc-brain-deploy-ready" -SourceCommit $sourceCommit Write-StatusReport -Path (Join-Path $readyRoot $reports.pcBrainQuietSoakCheckReport) -Schema "stackchan.pc-brain-quiet-soak-evidence-check.v1" -Status "pc-brain-quiet-soak-ready" -SourceCommit $sourceCommit Write-JsonFile -Path (Join-Path $readyRoot $reports.voiceSourceReadinessReport) -Value ([ordered]@{ @@ -200,7 +272,8 @@ try { - Source commit: $sourceCommit - Overall desktop v1 decision: pass - Desktop package artifact decision: pass -- Managed Python runtime decision: pass + - Managed Python runtime decision: pass + - Target installation decision: pass - C6 GUI/supervisor evidence decision: pass - PC Brain deploy audio decision: pass - PC Brain quiet-soak decision: pass @@ -221,7 +294,7 @@ try { if ($readyResult.report.windowsMsiSha256 -ne ("a" * 64) -or $readyResult.report.macosDmgSha256 -ne ("b" * 64) -or $readyResult.report.linuxDebSha256 -ne ("c" * 64)) { throw "Expected Desktop v1 bundle check report to emit desktop package artifact hashes." } - foreach ($id in @("artifact-windows", "artifact-macos", "artifact-linux", "companion-readiness", "c6-brain-supervisor", "c6-gui-rehearsal", "runtime-windows", "runtime-macos", "runtime-linux", "runtime-windows-summary", "runtime-macos-summary", "runtime-linux-summary", "pc-brain-deploy", "pc-brain-quiet-soak", "companion-readiness-source-commit-match", "pc-brain-deploy-commit-match", "pc-brain-quiet-soak-commit-match", "voice-source-ready", "voice-source-commit-match", "desktop-v1-review")) { + foreach ($id in @("artifact-windows", "artifact-macos", "artifact-linux", "companion-readiness", "c6-brain-supervisor", "c6-gui-rehearsal", "runtime-windows", "runtime-macos", "runtime-linux", "runtime-windows-summary", "runtime-macos-summary", "runtime-linux-summary", "target-install-windows", "target-install-macos", "target-install-linux", "pc-brain-deploy", "pc-brain-quiet-soak", "companion-readiness-source-commit-match", "pc-brain-deploy-commit-match", "pc-brain-quiet-soak-commit-match", "voice-source-ready", "voice-source-commit-match", "desktop-v1-review")) { Assert-CheckStatus -Report $readyResult.report -Id $id -Status "pass" } Write-Host "[ok] complete Desktop v1 evidence bundle is accepted" @@ -256,6 +329,26 @@ try { Assert-CheckStatus -Report $runtimePlatformMismatchResult.report -Id "runtime-macos-summary" -Status "fail" Write-Host "[ok] mismatched Desktop v1 runtime payload platform is rejected" + $targetInstallHashMismatchRoot = New-TempEvidenceRoot + Copy-Item -Path (Join-Path $readyRoot "*") -Destination $targetInstallHashMismatchRoot -Recurse -Force + Write-TargetInstallReport -Path (Join-Path $targetInstallHashMismatchRoot $reports.windowsTargetInstallReport) -Platform "windows" -SourceCommit $sourceCommit -PackageSha256 ("0" * 64) + $targetInstallHashMismatchResult = Invoke-DesktopV1BundleCheck -EvidenceRoot $targetInstallHashMismatchRoot + if ([int]$targetInstallHashMismatchResult.exitCode -eq 0) { + throw "Expected stale Windows target-install package hash to fail." + } + Assert-CheckStatus -Report $targetInstallHashMismatchResult.report -Id "target-install-windows" -Status "fail" + Write-Host "[ok] stale Desktop v1 target-install package hash is rejected" + + $targetInstallCiRoot = New-TempEvidenceRoot + Copy-Item -Path (Join-Path $readyRoot "*") -Destination $targetInstallCiRoot -Recurse -Force + Write-TargetInstallReport -Path (Join-Path $targetInstallCiRoot $reports.linuxTargetInstallReport) -Platform "linux" -SourceCommit $sourceCommit -PackageSha256 ("c" * 64) -EnvironmentKind "ci-native-runner" + $targetInstallCiResult = Invoke-DesktopV1BundleCheck -EvidenceRoot $targetInstallCiRoot + if ([int]$targetInstallCiResult.exitCode -eq 0) { + throw "Expected CI native-runner target-install evidence to fail final Desktop v1 acceptance." + } + Assert-CheckStatus -Report $targetInstallCiResult.report -Id "target-install-linux" -Status "fail" + Write-Host "[ok] CI install rehearsal cannot replace Desktop v1 operator target evidence" + $readinessMismatchRoot = New-TempEvidenceRoot Copy-Item -Path (Join-Path $readyRoot "*") -Destination $readinessMismatchRoot -Recurse -Force Write-StatusReport -Path (Join-Path $readinessMismatchRoot $reports.companionReadinessReport) -Schema "stackchan.companion-v1-readiness.v1" -Status "source-ready-pending-hardware" -SourceCommit ("d" * 40) @@ -276,7 +369,8 @@ try { - Source commit: $("d" * 40) - Overall desktop v1 decision: pass - Desktop package artifact decision: pass -- Managed Python runtime decision: pass + - Managed Python runtime decision: pass + - Target installation decision: pass - C6 GUI/supervisor evidence decision: pass - PC Brain deploy audio decision: pass - PC Brain quiet-soak decision: pass diff --git a/tools/test_privacy_policy_deployment_contract.cmd b/tools/test_privacy_policy_deployment_contract.cmd new file mode 100644 index 00000000..b17380e8 --- /dev/null +++ b/tools/test_privacy_policy_deployment_contract.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_privacy_policy_deployment_contract.ps1" %* +exit /b %ERRORLEVEL% diff --git a/tools/test_privacy_policy_deployment_contract.ps1 b/tools/test_privacy_policy_deployment_contract.ps1 new file mode 100644 index 00000000..41edb157 --- /dev/null +++ b/tools/test_privacy_policy_deployment_contract.ps1 @@ -0,0 +1,134 @@ +param() + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$checkScript = Join-Path $PSScriptRoot "check_privacy_policy_deployment.ps1" +$temporaryRoots = New-Object System.Collections.Generic.List[string] + +function New-TestRoot { + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("stackchan-privacy-deployment-contract-" + [guid]::NewGuid().ToString("N")) + $temporaryRoots.Add($root) | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $root "site/privacy") | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $root "docs/store-assets/play") | Out-Null + Copy-Item -LiteralPath (Join-Path $repoRoot "site/privacy/index.html") -Destination (Join-Path $root "site/privacy/index.html") + Copy-Item -LiteralPath (Join-Path $repoRoot "site/privacy/index.html") -Destination (Join-Path $root "fetched.html") + return $root +} + +function Get-Sha256 { + param([string]$Path) + return (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() +} + +function New-Evidence { + param([string]$Root) + + $sha = Get-Sha256 (Join-Path $Root "site/privacy/index.html") + return [ordered]@{ + schema = "stackchan.privacy-policy-deployment.v1" + status = "deployed" + canonicalUrl = "https://robvanprod.github.io/stackchan_alive/privacy/" + sourcePath = "site/privacy/index.html" + sourceCommit = ("a" * 40) + sourceSha256 = $sha + sourceGitBlob = ("b" * 40) + deploymentMethod = "github-pages-branch" + deploymentBranch = "gh-pages" + deploymentCommit = ("c" * 40) + pagesBuildId = 1 + pagesBuildStatus = "built" + pagesBuiltAtUtc = "2026-07-14T05:05:42Z" + verifiedAtUtc = "2026-07-14T05:06:53Z" + httpStatus = 200 + finalUrl = "https://robvanprod.github.io/stackchan_alive/privacy/" + servedSha256 = $sha + httpsEnforced = $true + } +} + +function Write-Evidence { + param([string]$Root, [object]$Evidence) + $Evidence | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $Root "docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json") -Encoding UTF8 +} + +function Invoke-Check { + param([string]$Root) + + $powerShellExe = (Get-Process -Id $PID).Path + $oldPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = & $powerShellExe -NoProfile -ExecutionPolicy Bypass -File $checkScript -Root $Root -FetchedContentPath "fetched.html" -Json 2>&1 + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $oldPreference + } + $text = ($output | Out-String).Trim() + return [pscustomobject]@{ exitCode = $exitCode; text = $text; report = ($text | ConvertFrom-Json) } +} + +function Assert-Check { + param([object]$Report, [string]$Id, [string]$Status) + + $check = @($Report.checks | Where-Object { $_.id -eq $Id }) + if ($check.Count -ne 1 -or $check[0].status -ne $Status) { + throw "Expected check '$Id' to be '$Status'." + } +} + +try { + $validRoot = New-TestRoot + Write-Evidence $validRoot (New-Evidence $validRoot) + $valid = Invoke-Check $validRoot + if ($valid.exitCode -ne 0 -or $valid.report.status -ne "privacy-policy-deployment-ready") { + throw "Expected exact policy deployment evidence to pass. Output:`n$($valid.text)" + } + Assert-Check $valid.report "live-content-hash" "pass" + Write-Host "[ok] exact published privacy policy bytes are accepted" + + $tamperedRoot = New-TestRoot + Write-Evidence $tamperedRoot (New-Evidence $tamperedRoot) + Add-Content -LiteralPath (Join-Path $tamperedRoot "fetched.html") -Value "tampered" + $tampered = Invoke-Check $tamperedRoot + if ($tampered.exitCode -eq 0) { throw "Expected tampered published content to fail." } + Assert-Check $tampered.report "live-content-hash" "fail" + Write-Host "[ok] tampered published privacy policy bytes are rejected" + + $urlRoot = New-TestRoot + $urlEvidence = New-Evidence $urlRoot + $urlEvidence.canonicalUrl = "http://robvanprod.github.io/stackchan_alive/privacy/" + Write-Evidence $urlRoot $urlEvidence + $urlResult = Invoke-Check $urlRoot + if ($urlResult.exitCode -eq 0) { throw "Expected noncanonical privacy URL to fail." } + Assert-Check $urlResult.report "canonical-url" "fail" + Write-Host "[ok] noncanonical privacy policy URL is rejected" + + $staleRoot = New-TestRoot + $staleEvidence = New-Evidence $staleRoot + $staleEvidence.sourceSha256 = ("d" * 64) + Write-Evidence $staleRoot $staleEvidence + $stale = Invoke-Check $staleRoot + if ($stale.exitCode -eq 0) { throw "Expected stale policy source hash to fail." } + Assert-Check $stale.report "source-hash" "fail" + Write-Host "[ok] stale privacy policy source hash is rejected" + + $pendingRoot = New-TestRoot + $pendingEvidence = New-Evidence $pendingRoot + $pendingEvidence.status = "pending" + Write-Evidence $pendingRoot $pendingEvidence + $pending = Invoke-Check $pendingRoot + if ($pending.exitCode -eq 0) { throw "Expected pending deployment status to fail." } + Assert-Check $pending.report "deployment-status" "fail" + Write-Host "[ok] pending privacy policy deployment status is rejected" + + Write-Host "Privacy policy deployment contract tests passed: 5/5." +} finally { + foreach ($root in $temporaryRoots) { + if (Test-Path -LiteralPath $root) { + Remove-Item -LiteralPath $root -Recurse -Force + } + } +} + +exit 0 diff --git a/tools/test_release_credential_hygiene_contract.cmd b/tools/test_release_credential_hygiene_contract.cmd new file mode 100644 index 00000000..c2d5c3d3 --- /dev/null +++ b/tools/test_release_credential_hygiene_contract.cmd @@ -0,0 +1,2 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0test_release_credential_hygiene_contract.ps1" %* diff --git a/tools/test_release_credential_hygiene_contract.ps1 b/tools/test_release_credential_hygiene_contract.ps1 new file mode 100644 index 00000000..f23aaa26 --- /dev/null +++ b/tools/test_release_credential_hygiene_contract.ps1 @@ -0,0 +1,118 @@ +param() + +$ErrorActionPreference = "Stop" + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$checker = Join-Path $PSScriptRoot "check_release_credential_hygiene.ps1" +$tempRoot = Join-Path ([IO.Path]::GetTempPath()) ("stackchan-release-credential-hygiene-" + [guid]::NewGuid().ToString("N")) +$powerShellCommand = Get-Command pwsh -ErrorAction SilentlyContinue +$powerShellHost = if ($null -ne $powerShellCommand) { $powerShellCommand.Source } else { (Get-Process -Id $PID).Path } + +$packageScript = Get-Content -LiteralPath (Join-Path $repoRoot "tools/package_release.ps1") -Raw +foreach ($pattern in @("check_release_credential_hygiene.ps1", "refusing to build or package from this checkout")) { + if ($packageScript -notmatch [regex]::Escape($pattern)) { + throw "package_release.ps1 does not fail closed on release credential hygiene: $pattern" + } +} +Write-Host "[ok] every release package runs credential hygiene before building" + +$firmwareWorkflow = Get-Content -LiteralPath (Join-Path $repoRoot ".github/workflows/firmware.yml") -Raw +foreach ($pattern in @("Run release credential hygiene contract", "test_release_credential_hygiene_contract.ps1")) { + if ($firmwareWorkflow -notmatch [regex]::Escape($pattern)) { + throw "firmware.yml does not run the release credential hygiene contract: $pattern" + } +} +Write-Host "[ok] companion CI runs the release credential hygiene contract" + +$ignoreLines = @( + "*.jks", + "*.keystore", + "*.p12", + "*.pfx", + "*.pkcs12", + "*.key", + "*.p8", + "*.snk" +) + +function Invoke-Checker { + param([string]$Root) + + $output = & $powerShellHost -NoProfile -File $checker -Root $Root -Json 2>&1 | Out-String + return [pscustomobject]@{ + exitCode = $LASTEXITCODE + output = $output + } +} + +function New-FixtureRepository { + param( + [string]$Name, + [string[]]$IgnorePatterns = $ignoreLines + ) + + $root = Join-Path $tempRoot $Name + New-Item -ItemType Directory -Force -Path $root | Out-Null + & git -C $root init --quiet + if ($LASTEXITCODE -ne 0) { throw "Could not initialize fixture repository $Name." } + $IgnorePatterns | Set-Content -LiteralPath (Join-Path $root ".gitignore") -Encoding UTF8 + "safe fixture" | Set-Content -LiteralPath (Join-Path $root "README.md") -Encoding UTF8 + & git -C $root add .gitignore README.md + if ($LASTEXITCODE -ne 0) { throw "Could not stage baseline fixture files for $Name." } + return $root +} + +try { + $current = Invoke-Checker $repoRoot + if ($current.exitCode -ne 0) { + throw "Current source tree credential hygiene failed: $($current.output)" + } + Write-Host "[ok] current source tree has no tracked private signing credentials" + + $baselineRoot = New-FixtureRepository "baseline" + $baseline = Invoke-Checker $baselineRoot + if ($baseline.exitCode -ne 0 -or $baseline.output -notmatch 'release-credential-hygiene-ready') { + throw "Expected a clean fixture to pass: $($baseline.output)" + } + Write-Host "[ok] all private-key bundle extensions are ignored" + + foreach ($extension in @("jks", "keystore", "p12", "pfx", "pkcs12", "key", "p8", "snk")) { + $fixtureRoot = New-FixtureRepository "tracked-$extension" + $credentialPath = Join-Path $fixtureRoot "credential.$extension" + "not a real credential" | Set-Content -LiteralPath $credentialPath -Encoding UTF8 + & git -C $fixtureRoot add --force "credential.$extension" + if ($LASTEXITCODE -ne 0) { throw "Could not force-stage .$extension fixture." } + $result = Invoke-Checker $fixtureRoot + if ($result.exitCode -eq 0 -or $result.output -notmatch "credential\.$extension") { + throw "Expected a tracked .$extension bundle to fail: $($result.output)" + } + } + Write-Host "[ok] tracked private-key bundle extensions are rejected" + + $markerRoot = New-FixtureRepository "private-key-marker" + $beginMarker = "-----BEGIN " + "PRIVATE KEY-----" + $endMarker = "-----END " + "PRIVATE KEY-----" + @($beginMarker, "not-a-real-key", $endMarker) | + Set-Content -LiteralPath (Join-Path $markerRoot "notes.txt") -Encoding UTF8 + & git -C $markerRoot add notes.txt + if ($LASTEXITCODE -ne 0) { throw "Could not stage private-key marker fixture." } + $markerResult = Invoke-Checker $markerRoot + if ($markerResult.exitCode -eq 0 -or $markerResult.output -notmatch 'notes.txt') { + throw "Expected a tracked private-key marker to fail: $($markerResult.output)" + } + Write-Host "[ok] tracked PEM private key markers are rejected" + + $missingIgnoreRoot = New-FixtureRepository ` + -Name "missing-pfx-ignore" ` + -IgnorePatterns @($ignoreLines | Where-Object { $_ -ne "*.pfx" }) + $missingIgnore = Invoke-Checker $missingIgnoreRoot + if ($missingIgnore.exitCode -eq 0 -or $missingIgnore.output -notmatch 'ignore-pattern-pfx') { + throw "Expected a missing private-key ignore pattern to fail: $($missingIgnore.output)" + } + Write-Host "[ok] missing private-key ignore patterns are rejected" +} finally { + Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host "Release credential hygiene contract tests passed" +exit 0 diff --git a/tools/verify_architecture.ps1 b/tools/verify_architecture.ps1 index bf4263f3..12db5c57 100644 --- a/tools/verify_architecture.ps1 +++ b/tools/verify_architecture.ps1 @@ -143,6 +143,21 @@ function Assert-PlatformioFlag { } } +function Assert-FilePatternCount { + param( + [string]$Path, + [string]$Pattern, + [int]$ExpectedCount, + [string]$Message + ) + + $text = Get-Content -LiteralPath $Path -Raw + $actualCount = [regex]::Matches($text, $Pattern).Count + if ($actualCount -ne $ExpectedCount) { + throw "$Message Expected $ExpectedCount matches, found $actualCount." + } +} + $sdProvisionerPath = Expand-SourcePath "src/sd_provisioner.cpp" if (Test-Path -LiteralPath $sdProvisionerPath) { Assert-FileContains ` @@ -231,6 +246,10 @@ Assert-FileContains (Expand-SourcePath "src/main.cpp") "xQueuePeek\s*\(\s*gFrame Assert-FileContains (Expand-SourcePath "src/main.cpp") "xTaskCreatePinnedToCore\s*\(\s*MotionTask\s*,[^;]*&gMotionTaskHandle\s*,\s*1\s*\)" "MotionTask must be pinned to Core 1 and expose a telemetry handle." Assert-FileContains (Expand-SourcePath "src/main.cpp") "xTaskCreatePinnedToCore\s*\(\s*FaceTask\s*,[^;]*&gFaceTaskHandle\s*,\s*1\s*\)" "FaceTask must be pinned to Core 1 and expose a telemetry handle." Assert-FileContains (Expand-SourcePath "src/main.cpp") "xTaskCreatePinnedToCore\s*\(\s*IntentTask\s*,[^;]*&gIntentTaskHandle\s*,\s*1\s*\)" "IntentTask must be pinned to Core 1 and expose a telemetry handle." +Assert-FilePatternCount (Expand-SourcePath "src/main.cpp") "updateBridgeNetwork\s*\(" 2 "IntentTask must remain the single runtime caller of updateBridgeNetwork." +Assert-FilePatternCount (Expand-SourcePath "src/main.cpp") "pollBridgeOutputs\s*\(" 4 "Bridge outputs must only be polled from IntentTask paths." +Assert-FilePatternCount (Expand-SourcePath "src/main.cpp") "pollBridgeDebugServer\s*\(" 2 "IntentTask must remain the single runtime caller of pollBridgeDebugServer." +Assert-FilePatternCount (Expand-SourcePath "src/main.cpp") "ensureWakeSrStarted\s*\(" 2 "IntentTask must remain the single runtime caller of ensureWakeSrStarted." Assert-FileContains (Expand-SourcePath "src/main.cpp") "\[system\]" "Main loop must emit runtime health telemetry." Assert-FileContains (Expand-SourcePath "src/main.cpp") "heap_free" "Runtime health telemetry must report free heap." Assert-FileContains (Expand-SourcePath "src/main.cpp") "heap_min" "Runtime health telemetry must report minimum free heap." diff --git a/tools/verify_consumer_promotion.ps1 b/tools/verify_consumer_promotion.ps1 index d6d73ce9..16ef1317 100644 --- a/tools/verify_consumer_promotion.ps1 +++ b/tools/verify_consumer_promotion.ps1 @@ -3,6 +3,7 @@ param( [string]$PackageRoot, [string]$PackageZip, [string]$EvidenceRoot, + [string]$CompanionV1EvidenceRoot, [string]$VoiceSourceProvenancePath, [string]$VoiceSourceTemplatePath, [string]$ProjectLicensePath, @@ -42,6 +43,8 @@ if ([string]::IsNullOrWhiteSpace($ExpectedFirmwareSourceCommit)) { $cleanupDir = $null $actionsStatusTempDir = $null +$promotionPackageZipPath = "" +$promotionPackageZipSha256 = "" function Join-ResolvedPath { param( @@ -74,6 +77,91 @@ function Read-JsonFile { return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } +function Resolve-PromotionPath { + param([string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return "" + } + $fullPath = if ([System.IO.Path]::IsPathRooted($Path)) { + [System.IO.Path]::GetFullPath($Path) + } else { + [System.IO.Path]::GetFullPath((Join-Path $repoRoot $Path)) + } + if (Test-Path -LiteralPath $fullPath) { + return (Resolve-Path -LiteralPath $fullPath).Path + } + return $fullPath +} + +function Assert-CompanionV1PromotionReady { + param( + [string]$EvidenceRootPath, + [string]$ExpectedVersion, + [string]$ExpectedSourceCommit, + [string]$ExpectedFirmwareSourceCommit, + [string]$ExpectedHardwareEvidenceRoot, + [string]$ExpectedPackageZipPath, + [string]$ExpectedPackageZipSha256 + ) + + $resolvedRoot = Resolve-PromotionPath $EvidenceRootPath + if ([string]::IsNullOrWhiteSpace($resolvedRoot) -or -not (Test-Path -LiteralPath $resolvedRoot -PathType Container)) { + throw "Consumer promotion requires a completed Companion v1 aggregate evidence directory: $EvidenceRootPath" + } + + $checker = Join-Path $PSScriptRoot "check_companion_v1_evidence_bundle.ps1" + $checkerOutput = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $checker ` + -Root $repoRoot ` + -EvidenceRoot $resolvedRoot ` + -RequireReady ` + -Json 2>&1 + $checkerExitCode = $LASTEXITCODE + $checkerText = ($checkerOutput | Out-String).Trim() + if ($checkerExitCode -ne 0) { + throw "Companion v1 aggregate evidence is not promotion-ready: $checkerText" + } + try { + $checkerReport = $checkerText | ConvertFrom-Json + } catch { + throw "Companion v1 aggregate checker did not return valid JSON: $($_.Exception.Message)" + } + if ($checkerReport.schema -ne "stackchan.companion-v1-evidence-bundle-check.v1" -or + $checkerReport.status -ne "companion-v1-evidence-ready" -or + [int]$checkerReport.failed -ne 0 -or [int]$checkerReport.pending -ne 0) { + throw "Companion v1 aggregate evidence did not return companion-v1-evidence-ready." + } + if ([string]$checkerReport.sourceCommit -ne $ExpectedSourceCommit) { + throw "Companion v1 aggregate source commit mismatch: expected $ExpectedSourceCommit, got $($checkerReport.sourceCommit)" + } + if ([string]$checkerReport.firmwareSourceCommit -ne $ExpectedFirmwareSourceCommit) { + throw "Companion v1 aggregate firmware source commit mismatch: expected $ExpectedFirmwareSourceCommit, got $($checkerReport.firmwareSourceCommit)" + } + if ([string]$checkerReport.releaseVersion -ne $ExpectedVersion) { + throw "Companion v1 aggregate release version mismatch: expected $ExpectedVersion, got $($checkerReport.releaseVersion)" + } + + $bundlePath = Join-Path $resolvedRoot "COMPANION_V1_EVIDENCE_BUNDLE.json" + $bundle = Read-JsonFile $bundlePath + $bundleHardwareRoot = Resolve-PromotionPath ([string]$bundle.hardwareEvidenceRoot) + $promotionHardwareRoot = Resolve-PromotionPath $ExpectedHardwareEvidenceRoot + if ([string]::IsNullOrWhiteSpace($bundleHardwareRoot) -or + -not $bundleHardwareRoot.Equals($promotionHardwareRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Companion v1 hardware evidence root does not match the packet being promoted: expected $promotionHardwareRoot, got $bundleHardwareRoot" + } + + $bundlePackageSha256 = ([string]$bundle.releasePackage.sha256).ToLowerInvariant() + if ($bundlePackageSha256 -ne $ExpectedPackageZipSha256.ToLowerInvariant()) { + throw "Companion v1 release ZIP SHA-256 does not match the package being promoted: $ExpectedPackageZipPath" + } + + return [pscustomobject]@{ + root = $resolvedRoot + report = $checkerReport + bundle = $bundle + } +} + function Assert-ObjectTextComplete { param( [object]$Value, @@ -305,12 +393,18 @@ function Assert-VoiceSourceReady { } } +if ([string]::IsNullOrWhiteSpace($PackageZip)) { + throw "Consumer promotion requires -PackageZip so the exact release archive can be bound to Companion v1 evidence." +} + if (-not [string]::IsNullOrWhiteSpace($PackageZip)) { Assert-FilePath $PackageZip 100000 + $promotionPackageZipPath = (Resolve-Path -LiteralPath $PackageZip).Path + $promotionPackageZipSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $promotionPackageZipPath).Hash.ToLowerInvariant() $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "stackchan-consumer-promotion" $cleanupDir = Join-Path $tempRoot ([System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $cleanupDir | Out-Null - Expand-Archive -LiteralPath $PackageZip -DestinationPath $cleanupDir + Expand-Archive -LiteralPath $promotionPackageZipPath -DestinationPath $cleanupDir $PackageRoot = $cleanupDir } @@ -341,6 +435,18 @@ try { throw "Hardware evidence verification failed." } + if ([string]::IsNullOrWhiteSpace($CompanionV1EvidenceRoot)) { + throw "Consumer promotion requires -CompanionV1EvidenceRoot with a completed aggregate Companion v1 evidence packet." + } + $companionV1Evidence = Assert-CompanionV1PromotionReady ` + -EvidenceRootPath $CompanionV1EvidenceRoot ` + -ExpectedVersion $Version ` + -ExpectedSourceCommit $ExpectedCommit ` + -ExpectedFirmwareSourceCommit $ExpectedFirmwareSourceCommit ` + -ExpectedHardwareEvidenceRoot $EvidenceRoot ` + -ExpectedPackageZipPath $promotionPackageZipPath ` + -ExpectedPackageZipSha256 $promotionPackageZipSha256 + if ([string]::IsNullOrWhiteSpace($ProjectLicensePath)) { $ProjectLicensePath = Join-Path $repoRoot "LICENSE" } @@ -425,6 +531,8 @@ try { Write-Host "Firmware source commit: $ExpectedFirmwareSourceCommit" Write-Host "Package: $packageRootPath" Write-Host "Evidence: $EvidenceRoot" + Write-Host "Companion v1 evidence: $($companionV1Evidence.root)" + Write-Host "Release ZIP SHA256: $promotionPackageZipSha256" Write-Host "Installed firmware SHA256: $($firmwareHashes[0])" if ($null -ne $ciException) { Write-Host "CI exception: $ExternalAccountCiExceptionPath" diff --git a/tools/verify_published_release.ps1 b/tools/verify_published_release.ps1 index 141414a9..5dfeb46d 100644 --- a/tools/verify_published_release.ps1 +++ b/tools/verify_published_release.ps1 @@ -89,6 +89,40 @@ function Assert-Asset { } } +function Assert-CompanionEvidenceHash { + param( + [object]$Evidence, + [string]$PublishedPath, + [string]$Extension, + [string]$RequiredEvidencePathPattern = "" + ) + + $entries = @($Evidence.artifacts | ForEach-Object { @($_.entries) } | Where-Object { + ([string]$_.path).EndsWith($Extension, [System.StringComparison]::OrdinalIgnoreCase) -and + ([string]::IsNullOrWhiteSpace($RequiredEvidencePathPattern) -or ([string]$_.path -match $RequiredEvidencePathPattern)) + }) + if ($entries.Count -ne 1) { + throw "Expected exactly one companion evidence entry for $Extension / $RequiredEvidencePathPattern; found $($entries.Count)." + } + $actualHash = Get-Sha256 $PublishedPath + if ($actualHash -ne ([string]$entries[0].sha256).ToLowerInvariant()) { + throw "Published companion artifact hash does not match release evidence: $(Split-Path -Leaf $PublishedPath)" + } +} + +function Assert-GitHubProvenanceAttestation { + param( + [string]$Path, + [string]$Repository + ) + + $signerWorkflow = "$Repository/.github/workflows/release.yml" + $output = gh attestation verify $Path --repo $Repository --signer-workflow $signerWorkflow 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "GitHub provenance attestation verification failed for $(Split-Path -Leaf $Path): $($output.Trim())" + } +} + if ([string]::IsNullOrWhiteSpace($Version)) { $Version = (git describe --tags --always --dirty).Trim() } @@ -147,6 +181,8 @@ if (-not $release.isPrerelease -and -not $AllowNonPrerelease) { $assets = @($release.assets) $expectedAssetEntries = Get-ReleaseFinalAssetEntries -Version $Version -PackageRoot $PackageRoot -ZipPath $ZipPath -ZipSidecarPath $ZipSidecarPath +$remoteDir = Join-Path $repoRoot "output/release/remote-$Version" +$companionAssetEntries = Get-ReleaseCompanionAssetEntries -Version $Version -CompanionAssetRoot $remoteDir $releaseAssetManifestPath = Join-Path $PackageRoot "release_assets.json" Assert-File $releaseAssetManifestPath @@ -214,15 +250,120 @@ foreach ($assetName in $manifestAuditAssetNames) { $allowedAssetNames[$assetName] = $true } +foreach ($assetEntry in $companionAssetEntries) { + $matchingAssets = @($assets | Where-Object { $_.name -eq $assetEntry.Name }) + if ($matchingAssets.Count -ne 1) { + throw "Expected exactly one companion release asset named $($assetEntry.Name); found $($matchingAssets.Count)" + } + $allowedAssetNames[$assetEntry.Name] = $true +} + foreach ($asset in $assets) { if (-not $allowedAssetNames.ContainsKey([string]$asset.name)) { throw "Unexpected release asset: $($asset.name)" } } -$remoteDir = Join-Path $repoRoot "output/release/remote-$Version" New-Item -ItemType Directory -Force -Path $remoteDir | Out-Null +foreach ($assetEntry in $companionAssetEntries) { + gh release download $Version --repo $Repo --pattern $assetEntry.Name --dir $remoteDir --clobber + if ($LASTEXITCODE -ne 0) { + throw "Failed to download published companion asset $($assetEntry.Name) for $Version" + } + Assert-Asset -Assets $assets -Name $assetEntry.Name -ExpectedPath $assetEntry.Path +} + +$companionEvidencePath = Join-Path $remoteDir "COMPANION_RELEASE_EVIDENCE.json" +$companionEvidence = Get-Content -LiteralPath $companionEvidencePath -Raw | ConvertFrom-Json +if ($companionEvidence.schema -ne "stackchan.companion-release-evidence.v1") { + throw "Companion release evidence schema mismatch: $($companionEvidence.schema)" +} +if ($companionEvidence.status -ne "complete") { + throw "Companion release evidence is not complete: $($companionEvidence.status)" +} +if ($companionEvidence.version -ne $Version) { + throw "Companion release evidence version mismatch: expected $Version, got $($companionEvidence.version)" +} +if ([string]$companionEvidence.commit -ne $ExpectedCommit) { + throw "Companion release evidence commit mismatch: expected $ExpectedCommit, got $($companionEvidence.commit)" +} +if ($companionEvidence.uploadSigningRequired -ne $true -or + $companionEvidence.androidSigning.signingProfile -ne "upload-key" -or + $companionEvidence.androidBundleSigning.signingProfile -ne "upload-key") { + throw "Companion Android release evidence is not pinned to APK and AAB upload-key signing." +} +if ($companionEvidence.packageEvidence.status -ne "present") { + throw "Companion release evidence does not include the release package provenance." +} + +if ($companionEvidence.desktopPackageEvidenceRequired -ne $true -or + $companionEvidence.desktopDistributionTrustRequired -ne $true -or + $companionEvidence.desktopPackageEvidence.status -ne "ready") { + throw "Companion release evidence does not require ready native desktop package/runtime and distribution-trust evidence." +} +$desktopEvidencePlatforms = @($companionEvidence.desktopPackageEvidence.platforms) +if ($desktopEvidencePlatforms.Count -ne 3 -or @($desktopEvidencePlatforms.platform | Select-Object -Unique).Count -ne 3) { + throw "Companion release evidence must contain one native package/runtime summary for Windows, Linux, and macOS." +} +$publishedDesktopPaths = [ordered]@{ + windows = Join-Path $remoteDir "stackchan-companion-windows-$Version.msi" + linux = Join-Path $remoteDir "stackchan-companion-linux-$Version.deb" + macos = Join-Path $remoteDir "stackchan-companion-macos-$Version.dmg" +} +$requiredInstallerBrainFiles = @( + "brain/bridge/lan_service.py", + "brain/bridge/reference_bridge.py", + "brain/data/voice_source_provenance.yaml", + "brain/docs/media/voice/stackchan_spark_greeting.wav" +) +foreach ($platform in $publishedDesktopPaths.Keys) { + $summary = @($desktopEvidencePlatforms | Where-Object { [string]$_.platform -eq $platform }) + if ($summary.Count -ne 1) { throw "Missing unique native desktop package/runtime evidence for $platform." } + if ((Get-Sha256 $publishedDesktopPaths[$platform]) -ne ([string]$summary[0].packageSha256).ToLowerInvariant()) { + throw "Published $platform desktop package does not match its native package evidence." + } + if ([string]$summary[0].runtimeSha256 -notmatch '^[a-fA-F0-9]{64}$' -or + [int64]$summary[0].processedFileCount -lt 2 -or + [int64]$summary[0].processedBytes -le 0) { + throw "Native desktop runtime evidence is incomplete for $platform." + } + if ([string]$summary[0].installerExtractionMethod -ne "native" -or + [string]$summary[0].installerAppJarName -notmatch '^app-desktop-.+\.jar$' -or + [string]$summary[0].installerAppJarSha256 -notmatch '^[a-fA-F0-9]{64}$' -or + ([string]$summary[0].installerPackageSha256).ToLowerInvariant() -ne ([string]$summary[0].packageSha256).ToLowerInvariant() -or + ([string]$summary[0].installerRuntimeSha256).ToLowerInvariant() -ne ([string]$summary[0].runtimeSha256).ToLowerInvariant() -or + [int64]$summary[0].installerRuntimeFileCount -ne [int64]$summary[0].processedFileCount -or + [int64]$summary[0].installerRuntimeBytes -ne [int64]$summary[0].processedBytes) { + throw "Installer-derived desktop runtime evidence is incomplete or inconsistent for $platform." + } + $installerBrainFiles = @($summary[0].installerBrainFiles | ForEach-Object { [string]$_ }) + foreach ($brainPath in $requiredInstallerBrainFiles) { + if ($installerBrainFiles -notcontains $brainPath) { throw "Installer-derived desktop evidence is missing $brainPath for $platform." } + } + if ($platform -eq "windows" -and + ([string]$summary[0].distributionTrustStatus -ne "ready" -or + [string]$summary[0].distributionTrustPolicy -ne "authenticode-sha256-timestamped" -or + [string]::IsNullOrWhiteSpace([string]$summary[0].distributionTrustSigner) -or + $summary[0].distributionTrustTimestamped -ne $true)) { + throw "Published Windows package evidence does not prove timestamped Authenticode trust." + } + if ($platform -eq "macos" -and + ([string]$summary[0].distributionTrustStatus -ne "ready" -or + [string]$summary[0].distributionTrustPolicy -ne "developer-id-notarized-stapled" -or + [string]::IsNullOrWhiteSpace([string]$summary[0].distributionTrustSigner) -or + $summary[0].distributionTrustNotarizationStapled -ne $true -or + $summary[0].distributionTrustGatekeeperAccepted -ne $true)) { + throw "Published macOS package evidence does not prove Developer ID, notarization, and Gatekeeper trust." + } +} + +Assert-CompanionEvidenceHash $companionEvidence (Join-Path $remoteDir "stackchan-companion-android-$Version.apk") ".apk" '(^|[\\/])release[\\/]' +Assert-CompanionEvidenceHash $companionEvidence (Join-Path $remoteDir "stackchan-companion-android-$Version.aab") ".aab" +Assert-CompanionEvidenceHash $companionEvidence (Join-Path $remoteDir "stackchan-companion-windows-$Version.msi") ".msi" +Assert-CompanionEvidenceHash $companionEvidence (Join-Path $remoteDir "stackchan-companion-linux-$Version.deb") ".deb" +Assert-CompanionEvidenceHash $companionEvidence (Join-Path $remoteDir "stackchan-companion-macos-$Version.dmg") ".dmg" + gh release download $Version --repo $Repo --pattern "stackchan_alive_$Version.zip" --dir $remoteDir --clobber if ($LASTEXITCODE -ne 0) { throw "Failed to download published ZIP for $Version" @@ -235,6 +376,12 @@ if ($LASTEXITCODE -ne 0) { $remoteZip = Join-Path $remoteDir "stackchan_alive_$Version.zip" $remoteZipSidecar = Join-Path $remoteDir "stackchan_alive_$Version.zip.sha256" +$attestedPaths = @($expectedAssetEntries | ForEach-Object { $_.Path }) + @( + $companionAssetEntries | ForEach-Object { Join-Path $remoteDir $_.Name } +) +foreach ($attestedPath in $attestedPaths) { + Assert-GitHubProvenanceAttestation -Path $attestedPath -Repository $Repo +} $remoteZipHashText = (Get-Content -LiteralPath $remoteZipSidecar -Raw).Trim() if ($remoteZipHashText -notmatch "^([a-f0-9]{64}) stackchan_alive_$([regex]::Escape($Version))\.zip$") { throw "Invalid published ZIP SHA256 sidecar format: $remoteZipHashText" diff --git a/tools/verify_release_package.ps1 b/tools/verify_release_package.ps1 index 5797e7be..400a0ff5 100644 --- a/tools/verify_release_package.ps1 +++ b/tools/verify_release_package.ps1 @@ -64,7 +64,14 @@ if ($restrictedVoicePayloads.Count -gt 0) { function Join-PackagePath { param([string]$RelativePath) - return Join-Path $packageRootPath ($RelativePath -replace "/", "\") + $path = Join-Path $packageRootPath ($RelativePath -replace "/", "\") + if ($env:OS -eq "Windows_NT" -and $path.Length -ge 260 -and -not $path.StartsWith("\\?\")) { + if ($path.StartsWith("\\")) { + return "\\?\UNC\$($path.TrimStart('\'))" + } + return "\\?\$path" + } + return $path } function Assert-File { @@ -174,10 +181,14 @@ $requiredFiles = @( "docs/ANDROID_PLAY_RELEASE.md", "docs/ANDROID_PLAY_POLICY_DECLARATIONS.md", "docs/ANDROID_PLAY_PRIVACY_POLICY.md", + "site/.nojekyll", + "site/index.html", + "site/privacy/index.html", "docs/store-assets/play/icon-512.png", "docs/store-assets/play/icon-512.svg", "docs/store-assets/play/feature-graphic-1024x500.png", "docs/store-assets/play/README.md", + "provenance/pages.yml", "docs/BRAIN_MODEL.md", "docs/COMPANION_CROSS_PLATFORM_PLAN.md", "docs/CONVERSATION_V2_ROADMAP.md", @@ -425,6 +436,29 @@ $requiredFiles = @( "tools/check_desktop_python_runtime_payload.ps1", "tools/test_desktop_python_runtime_payload_contract.cmd", "tools/test_desktop_python_runtime_payload_contract.ps1", + "tools/export_desktop_package_evidence.cmd", + "tools/export_desktop_package_evidence.ps1", + "tools/test_desktop_package_launch.cmd", + "tools/test_desktop_package_launch.ps1", + "tools/test_desktop_package_evidence_contract.cmd", + "tools/test_desktop_package_evidence_contract.ps1", + "tools/check_desktop_release_signing_readiness.cmd", + "tools/check_desktop_release_signing_readiness.ps1", + "tools/test_desktop_release_signing_readiness_contract.ps1", + "tools/check_release_credential_hygiene.cmd", + "tools/check_release_credential_hygiene.ps1", + "tools/test_release_credential_hygiene_contract.cmd", + "tools/test_release_credential_hygiene_contract.ps1", + "tools/download_companion_ci_candidate.cmd", + "tools/download_companion_ci_candidate.ps1", + "tools/test_companion_ci_candidate_contract.cmd", + "tools/test_companion_ci_candidate_contract.ps1", + "tools/install_desktop_companion_package.cmd", + "tools/install_desktop_companion_package.ps1", + "tools/check_desktop_target_install_evidence.cmd", + "tools/check_desktop_target_install_evidence.ps1", + "tools/test_desktop_target_install_evidence_contract.cmd", + "tools/test_desktop_target_install_evidence_contract.ps1", "tools/check_desktop_v1_evidence_bundle.cmd", "tools/check_desktop_v1_evidence_bundle.ps1", "tools/test_desktop_v1_evidence_bundle_contract.cmd", @@ -440,6 +474,18 @@ $requiredFiles = @( "tools/check_android_toolchain.ps1", "tools/check_android_play_release_readiness.cmd", "tools/check_android_play_release_readiness.ps1", + "tools/check_privacy_policy_deployment.cmd", + "tools/check_privacy_policy_deployment.ps1", + "tools/test_privacy_policy_deployment_contract.cmd", + "tools/test_privacy_policy_deployment_contract.ps1", + "tools/test_android_upload_signing_contract.cmd", + "tools/test_android_upload_signing_contract.ps1", + "tools/test_android_emulator_launch.cmd", + "tools/test_android_emulator_launch.ps1", + "tools/check_android_emulator_release_evidence.cmd", + "tools/check_android_emulator_release_evidence.ps1", + "tools/test_android_emulator_release_evidence_contract.cmd", + "tools/test_android_emulator_release_evidence_contract.ps1", "tools/check_android_play_store_evidence.cmd", "tools/check_android_play_store_evidence.ps1", "tools/check_android_v1_evidence_bundle.cmd", @@ -878,12 +924,19 @@ foreach ($pattern in @("-All", "output/share", "Test-ShareOwnedProcess", "skippe } $hardwareStarterText = Get-Content -LiteralPath (Join-PackagePath "tools/start_hardware_evidence.ps1") -Raw -foreach ($pattern in @("NEXT_STEPS.md", "Stackchan Evidence Next Steps", "Run Order", "Gates Still Expected", "Hard Stops", "BENCH_STATUS.md", "BENCH_STATUS.json", "stackchan.bench-status.v1", "benchStatus", "RELEASE_ACCEPTANCE.md", "release_acceptance.json", "AUDIO_REVIEW.md", "Stackchan Audio Review", "Speaker recording file", "Intelligible through device speaker", "CI_ACCOUNT_BLOCK_EXCEPTION_TEMPLATE.json", "stackchan.ci-account-block-exception.v1", "starts unapproved", "false proof booleans", "TBD - accountable approver required", "TBD - CI account owner", "Copy-AcceptanceArtifactsFromZip", "Copy-AcceptanceArtifactsFromRoot", "Copy-VoiceLeadArtifactsFromZip", "Copy-ShareVerificationArtifactsFromRoot", "Write-EvidenceChecklist", "Set-ChecklistItemState", "Pre-marked no-hardware gates were proven", "GitHub Actions, production voice-source, media, audio, and promotion gates still require explicit evidence", "shareVerification", "HOSTED_MEDIA_REFERENCE.md", "share/share_verification_report.json", "share/VERIFIED_URL.txt", "verifiedUrl", "verifiedUrlFile", "urlKind", "voiceLeadAudition", "RVC_LEAD_AUDITION.md", "reference_audio", "RUN_PLAY_LEAD_VOICE.cmd", "RUN_HARDWARE_SIM_BASELINE.cmd", "hardware_simulation_baseline.log", "simulation/hardware-sim/latest", "comparison baseline only", "run_hardware_simulation.ps1", "RUN_SIM_HARDWARE_COMPARE.cmd", "compare_hardware_sim_baseline.ps1", "SIM_HARDWARE_COMPARE.md", "SIM_HARDWARE_COMPARE.json", "advisory sim-vs-real", "compareCommand", "compareReport", "RUN_ANDROID_APK_INSTALL.cmd", "install_android_companion_apk.ps1", "android/apk-install", "apkInstallCommand", "android_apk_install.json", "RUN_ANDROID_COMPANION_PROBE.cmd", "run_android_companion_probe.ps1", "android/companion-probe", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd", "run_android_companion_soak.ps1", "android/screen-off-soak", "screenOffSoakCommand", "android_companion_soak.json", "RUN_ANDROID_UDP_BEACON_PROBE.cmd", "run_android_udp_beacon_probe.ps1", "android/udp-beacon-probe", "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "capture_android_companion_logcat.ps1", "android/logcat", "logcatCommand", "android_companion_logcat.json", "Android dashboard connected state", "robot identity", "firmware/version signal", "last bridge frame", "active brain owner", "foreground service state", "androidCompanionProbes", "RUN_SPEECH_MOUTH_DEMO.cmd", "RUN_SPEAK_ALL_INTENTS.cmd", "speak_all_intents_serial.log", "send_speak_all_intents_demo.ps1", "speech_mouth_demo_serial.log", "speechDir", "lead_voice.speech_envelope.json", "generate_speech_envelope_sidecar.ps1", "verify_speech_envelope_sidecar.ps1", "leadAudition", "leadSourcePath", "RUN_ADD_MEDIA.cmd", "add_hardware_evidence_media.ps1", "media_manifest.json", "RUN_PROGRESS_CHECK.cmd", "check_hardware_evidence_progress.ps1", "RUN_ROLLOUT_STATUS.cmd", "export_rollout_status.ps1", "ROLLOUT_STATUS.md", "RUN_CONSUMER_PROMOTION_CHECK.cmd", "verify_consumer_promotion.ps1", "New-PowerShellCommandFile", "`$global:LASTEXITCODE", "exit /b %ERRORLEVEL%")) { +foreach ($pattern in @("NEXT_STEPS.md", "Stackchan Evidence Next Steps", "Run Order", "Gates Still Expected", "Hard Stops", "BENCH_STATUS.md", "BENCH_STATUS.json", "stackchan.bench-status.v1", "benchStatus", "RELEASE_ACCEPTANCE.md", "release_acceptance.json", "AUDIO_REVIEW.md", "Stackchan Audio Review", "Speaker recording file", "Intelligible through device speaker", "CI_ACCOUNT_BLOCK_EXCEPTION_TEMPLATE.json", "stackchan.ci-account-block-exception.v1", "starts unapproved", "false proof booleans", "TBD - accountable approver required", "TBD - CI account owner", "Copy-AcceptanceArtifactsFromZip", "Copy-AcceptanceArtifactsFromRoot", "Copy-VoiceLeadArtifactsFromZip", "Copy-ShareVerificationArtifactsFromRoot", "Write-EvidenceChecklist", "Set-ChecklistItemState", "Pre-marked no-hardware gates were proven", "GitHub Actions, production voice-source, media, audio, and promotion gates still require explicit evidence", "shareVerification", "HOSTED_MEDIA_REFERENCE.md", "share/share_verification_report.json", "share/VERIFIED_URL.txt", "verifiedUrl", "verifiedUrlFile", "urlKind", "voiceLeadAudition", "RVC_LEAD_AUDITION.md", "reference_audio", "RUN_PLAY_LEAD_VOICE.cmd", "RUN_HARDWARE_SIM_BASELINE.cmd", "hardware_simulation_baseline.log", "simulation/hardware-sim/latest", "comparison baseline only", "run_hardware_simulation.ps1", "RUN_SIM_HARDWARE_COMPARE.cmd", "compare_hardware_sim_baseline.ps1", "SIM_HARDWARE_COMPARE.md", "SIM_HARDWARE_COMPARE.json", "advisory sim-vs-real", "compareCommand", "compareReport", "RUN_ANDROID_APK_INSTALL.cmd", "install_android_companion_apk.ps1", "android/apk-install", "apkInstallCommand", "android_apk_install.json", "RUN_ANDROID_COMPANION_PROBE.cmd", "run_android_companion_probe.ps1", "android/companion-probe", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd", "run_android_companion_soak.ps1", "android/screen-off-soak", "screenOffSoakCommand", "android_companion_soak.json", "RUN_ANDROID_UDP_BEACON_PROBE.cmd", "run_android_udp_beacon_probe.ps1", "android/udp-beacon-probe", "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "capture_android_companion_logcat.ps1", "android/logcat", "logcatCommand", "android_companion_logcat.json", "Android dashboard connected state", "robot identity", "firmware/version signal", "last bridge frame", "active brain owner", "foreground service state", "androidCompanionProbes", "RUN_SPEECH_MOUTH_DEMO.cmd", "RUN_SPEAK_ALL_INTENTS.cmd", "speak_all_intents_serial.log", "send_speak_all_intents_demo.ps1", "speech_mouth_demo_serial.log", "speechDir", "lead_voice.speech_envelope.json", "generate_speech_envelope_sidecar.ps1", "verify_speech_envelope_sidecar.ps1", "leadAudition", "leadSourcePath", "RUN_ADD_MEDIA.cmd", "add_hardware_evidence_media.ps1", "media_manifest.json", "RUN_PROGRESS_CHECK.cmd", "check_hardware_evidence_progress.ps1", "RUN_ROLLOUT_STATUS.cmd", "export_rollout_status.ps1", "ROLLOUT_STATUS.md", "RUN_CONSUMER_PROMOTION_CHECK.cmd", "verify_consumer_promotion.ps1", "CompanionV1EvidenceRoot", "companion-v1-evidence-ready", "companionV1EvidenceRoot", "New-PowerShellCommandFile", "`$global:LASTEXITCODE", "exit /b %ERRORLEVEL%")) { if ($hardwareStarterText -notmatch [regex]::Escape($pattern)) { throw "tools/start_hardware_evidence.ps1 missing acceptance artifact capture logic: $pattern" } } +$androidEvidencePacketContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_android_evidence_packet_contract.ps1") -Raw +foreach ($pattern in @("CompanionV1EvidenceRoot", "consumerPromotionPackageArg", "companion-v1-evidence-ready", "companionV1EvidenceRoot", "Consumer promotion requires the exact release ZIP", "Android evidence packet contract tests passed")) { + if ($androidEvidencePacketContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_android_evidence_packet_contract.ps1 missing promotion command generator coverage: $pattern" + } +} + $hardwareMediaImporterText = Get-Content -LiteralPath (Join-PackagePath "tools/add_hardware_evidence_media.ps1") -Raw foreach ($pattern in @("stackchan.hardware-media-manifest.v1", "Test-PhotoEvidenceFile", "Test-AudioEvidenceFile", "media_manifest.json", "SHA256", "photos", "audio")) { if ($hardwareMediaImporterText -notmatch [regex]::Escape($pattern)) { @@ -1053,7 +1106,7 @@ foreach ($pattern in @("stackchan.ci-account-block-exception.v1", "stackchan.git } $consumerPromotionVerifierText = Get-Content -LiteralPath (Join-PackagePath "tools/verify_consumer_promotion.ps1") -Raw -foreach ($pattern in @("verify_release_package.ps1", "verify_hardware_evidence.ps1", "github_actions_status.json", "ActionsStatusPath", "export_github_actions_status.ps1", "successful live GitHub Actions status", "missingRequiredWorkflows", "required workflow evidence", "external-account-billing-or-spending-limit", "ExternalAccountCiExceptionPath", "Assert-CiExceptionRecord", "stackchan.ci-account-block-exception.v1", "riskAccepted", "localReleaseVerificationPassed", "strictHardwareEvidencePassed", "productionVoiceSourceReady", "voice_source_provenance.yaml", "pending-production-source", "Assert-VoiceStatusReportsReady", "voice_source_status.json is not production-source-ready", "rvc_voice_base_status.json is not consumer approved", "rvc_voice_base_status.json is not distribution approved", "ProjectLicensePath", "Assert-ProjectLicenseReady", "CameraFollowSummaryPath", "Assert-CameraFollowReady", "BodySensorReportPath", "Assert-BodySensorReady", "FullSystemSoakSummaryPath", "Assert-FinalSoakReady", "ExpectedFirmwareSourceCommit", "same installed firmware SHA-256", "Consumer promotion gate verified", "AllowMissingMedia cannot be used for consumer promotion", "strict media evidence")) { +foreach ($pattern in @("verify_release_package.ps1", "verify_hardware_evidence.ps1", "github_actions_status.json", "ActionsStatusPath", "export_github_actions_status.ps1", "successful live GitHub Actions status", "missingRequiredWorkflows", "required workflow evidence", "external-account-billing-or-spending-limit", "ExternalAccountCiExceptionPath", "Assert-CiExceptionRecord", "stackchan.ci-account-block-exception.v1", "riskAccepted", "localReleaseVerificationPassed", "strictHardwareEvidencePassed", "productionVoiceSourceReady", "voice_source_provenance.yaml", "pending-production-source", "Assert-VoiceStatusReportsReady", "voice_source_status.json is not production-source-ready", "rvc_voice_base_status.json is not consumer approved", "rvc_voice_base_status.json is not distribution approved", "ProjectLicensePath", "Assert-ProjectLicenseReady", "CameraFollowSummaryPath", "Assert-CameraFollowReady", "BodySensorReportPath", "Assert-BodySensorReady", "FullSystemSoakSummaryPath", "Assert-FinalSoakReady", "ExpectedFirmwareSourceCommit", "same installed firmware SHA-256", "Consumer promotion gate verified", "AllowMissingMedia cannot be used for consumer promotion", "strict media evidence", "CompanionV1EvidenceRoot", "Assert-CompanionV1PromotionReady", "check_companion_v1_evidence_bundle.ps1", "stackchan.companion-v1-evidence-bundle-check.v1", "companion-v1-evidence-ready", "Companion v1 aggregate source commit mismatch", "Companion v1 aggregate firmware source commit mismatch", "Companion v1 aggregate release version mismatch", "Companion v1 hardware evidence root does not match the packet being promoted", "Companion v1 release ZIP SHA-256 does not match the package being promoted", "Consumer promotion requires -PackageZip")) { if ($consumerPromotionVerifierText -notmatch [regex]::Escape($pattern)) { throw "tools/verify_consumer_promotion.ps1 missing promotion gate logic: $pattern" } @@ -1062,8 +1115,15 @@ if ($consumerPromotionVerifierText -match "evidenceArgs\s*\+=\s*['`"]-AllowMissi throw "tools/verify_consumer_promotion.ps1 must not forward AllowMissingMedia to hardware evidence verification" } +$consumerPromotionContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_consumer_promotion_contract.ps1") -Raw +foreach ($pattern in @("CompanionV1EvidenceRoot", "Assert-CompanionV1PromotionReady", "-ExpectedSourceCommit `$ExpectedCommit", "-ExpectedFirmwareSourceCommit `$ExpectedFirmwareSourceCommit", "-ExpectedHardwareEvidenceRoot `$EvidenceRoot", "-ExpectedPackageZipSha256 `$promotionPackageZipSha256", "Consumer promotion contract verified")) { + if ($consumerPromotionContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_consumer_promotion_contract.ps1 missing aggregate Companion v1 promotion coverage: $pattern" + } +} + $releaseAssetContractText = Get-Content -LiteralPath (Join-PackagePath "tools/release_asset_contract.ps1") -Raw -foreach ($pattern in @("Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntries", "Get-ReleaseAllowedAuditAssetEntries", "firmware-display-only.bin", "firmware-servo-calibration.bin", "stackchan_spark_audition_bright_robot_greeting.mp3", "stackchan_spark_thinking.mp3")) { +foreach ($pattern in @("Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntries", "Get-ReleaseAllowedAuditAssetEntries", "Get-ReleaseCompanionAssetEntries", "firmware-display-only.bin", "firmware-servo-calibration.bin", "stackchan_spark_audition_bright_robot_greeting.mp3", "stackchan_spark_thinking.mp3", "stackchan-companion-android-`$Version.apk", "stackchan-companion-android-`$Version.aab", "stackchan-companion-windows-`$Version.msi", "stackchan-companion-linux-`$Version.deb", "stackchan-companion-macos-`$Version.dmg", "COMPANION_RELEASE_EVIDENCE.json")) { if ($releaseAssetContractText -notmatch [regex]::Escape($pattern)) { throw "tools/release_asset_contract.ps1 missing required release asset contract logic: $pattern" } @@ -1077,7 +1137,7 @@ foreach ($pattern in @("Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntr } $publishedVerifierText = Get-Content -LiteralPath (Join-PackagePath "tools/verify_published_release.ps1") -Raw -foreach ($pattern in @("release_asset_contract.ps1", "release_assets.json", "stackchan.release-assets.v1", "Get-ReleaseFinalAssetEntries", "Get-ReleaseAllowedAuditAssetEntries", "ZipSidecarPath", ".zip.sha256", "Published ZIP SHA256 sidecar", "allowedAssetNames", "Unexpected release asset")) { +foreach ($pattern in @("release_asset_contract.ps1", "release_assets.json", "stackchan.release-assets.v1", "Get-ReleaseFinalAssetEntries", "Get-ReleaseAllowedAuditAssetEntries", "Get-ReleaseCompanionAssetEntries", "ZipSidecarPath", ".zip.sha256", "Published ZIP SHA256 sidecar", "allowedAssetNames", "Unexpected release asset", "stackchan.companion-release-evidence.v1", "uploadSigningRequired", "upload-key", "desktopPackageEvidenceRequired", "desktopDistributionTrustRequired", "authenticode-sha256-timestamped", "developer-id-notarized-stapled", "Assert-GitHubProvenanceAttestation", "gh attestation verify", "attestedPaths", "expectedAssetEntries", "packageSha256", "runtimeSha256", "processedFileCount", "installerAppJarSha256", "installerRuntimeSha256", "installerBrainFiles", "Assert-CompanionEvidenceHash")) { if ($publishedVerifierText -notmatch [regex]::Escape($pattern)) { throw "tools/verify_published_release.ps1 missing required published ZIP sidecar verification logic: $pattern" } @@ -1091,7 +1151,7 @@ foreach ($pattern in @("stackchan.release-audit.v1", "verify_published_release.p } $releaseWorkflowText = Get-Content -LiteralPath (Join-PackagePath "provenance/release.yml") -Raw -foreach ($pattern in @("release_asset_contract.ps1", "verify_release_asset_contract.ps1", "Get-ReleaseFinalAssetEntries", "FirmwareAssetRoot `$stageDir", "FirmwareAssetPathMode Stage", "workflow-assets-", '$releaseAssetPaths', '@releaseAssetPaths')) { +foreach ($pattern in @("release_asset_contract.ps1", "verify_release_asset_contract.ps1", "Get-ReleaseFinalAssetEntries", "Get-ReleaseCompanionAssetEntries", "FirmwareAssetRoot `$stageDir", "FirmwareAssetPathMode Stage", "workflow-assets-", "companion-android-release", "companion-android-emulator-smoke", "companion-desktop-release", "gradle/actions/setup-gradle@v6", "check_companion_release_version.ps1", "check_android_play_release_readiness.ps1 -RequireUploadSigning -Json", "check_desktop_release_signing_readiness.ps1", "Validate production desktop signing credentials", "RequireNativeToolchain", "ValidateAppleNotaryCredentials", "STACKCHAN_ANDROID_KEYSTORE_B64", "STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_MACOS_CERTIFICATE_B64", ":app-desktop:notarizeDmg", "actions/attest@v4", "media/voice/*", "release_assets.json", "test_android_emulator_launch.ps1", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "prepare_desktop_python_runtime.ps1", "test_desktop_package_launch.ps1", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "RequireDistributionTrust", "RequireUploadSigning", "RequireDesktopPackageEvidence", "RequireDesktopDistributionTrust", '$releaseAssetPaths', '@releaseAssetPaths')) { if ($releaseWorkflowText -notmatch [regex]::Escape($pattern)) { throw "provenance/release.yml missing release asset contract upload logic: $pattern" } @@ -1101,12 +1161,57 @@ if ($releaseWorkflowText -notmatch 'package_release\.ps1 -Version' -or throw "provenance/release.yml must let package_release.ps1 build all required firmware profiles on a clean tag runner." } +$signingReadinessWorkflowText = Get-Content -LiteralPath (Join-PackagePath "provenance/companion-signing-readiness.yml") -Raw +foreach ($pattern in @("workflow_dispatch", "contents: read", "android-upload-key", "actions/setup-java@v5", "STACKCHAN_ANDROID_KEYSTORE_B64", "check_android_play_release_readiness.ps1", "RequireUploadSigning", "check_desktop_release_signing_readiness.ps1", "STACKCHAN_WINDOWS_PFX_B64", "STACKCHAN_MACOS_CERTIFICATE_B64", "RequireNativeToolchain", "ValidateAppleNotaryCredentials")) { + if ($signingReadinessWorkflowText -notmatch [regex]::Escape($pattern)) { + throw "provenance/companion-signing-readiness.yml missing safe credential validation logic: $pattern" + } +} +foreach ($forbidden in @("gh release", "upload-artifact", "contents: write")) { + if ($signingReadinessWorkflowText -match [regex]::Escape($forbidden)) { + throw "provenance/companion-signing-readiness.yml must not publish release assets: $forbidden" + } +} + +$releaseCredentialHygieneText = Get-Content -LiteralPath (Join-PackagePath "tools/check_release_credential_hygiene.ps1") -Raw +foreach ($pattern in @("stackchan.release-credential-hygiene.v1", "release-credential-hygiene-ready", "blocked-release-credential-hygiene", "check-ignore", "ls-files", "tracked-private-key-bundles", "tracked-private-key-markers", "ignore-pattern-pfx", "ignore-pattern-pkcs12")) { + if ($releaseCredentialHygieneText -notmatch [regex]::Escape($pattern)) { + throw "tools/check_release_credential_hygiene.ps1 missing private signing credential protection: $pattern" + } +} + +$releaseCredentialHygieneContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_release_credential_hygiene_contract.ps1") -Raw +foreach ($pattern in @("current source tree has no tracked private signing credentials", "all private-key bundle extensions are ignored", "tracked private-key bundle extensions are rejected", "tracked PEM private key markers are rejected", "missing private-key ignore patterns are rejected", "every release package runs credential hygiene before building", "companion CI runs the release credential hygiene contract", "Release credential hygiene contract tests passed")) { + if ($releaseCredentialHygieneContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_release_credential_hygiene_contract.ps1 missing credential hygiene coverage: $pattern" + } +} + +$companionCiCandidateText = Get-Content -LiteralPath (Join-PackagePath "tools/download_companion_ci_candidate.ps1") -Raw +foreach ($pattern in @("stackchan.companion-ci-candidate.v1", "companion-ci-candidate-ready", "exact-source-ci-rehearsal-artifacts", "substitutesForTaggedRelease", "substitutesForPhysicalEvidence", "publicReleaseReady", "COMPANION_RELEASE_EVIDENCE.json", "release evidence source mismatch", "does not match companion release evidence")) { + if ($companionCiCandidateText -notmatch [regex]::Escape($pattern)) { + throw "tools/download_companion_ci_candidate.ps1 missing exact-source artifact handoff protection: $pattern" + } +} + +$companionCiCandidateContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_companion_ci_candidate_contract.ps1") -Raw +foreach ($pattern in @("Firmware workflow checks out and records the exact PR branch head", "complete exact-source CI candidate is accepted and inventoried", "PR merge/head source mismatch is rejected", "failed companion job is rejected", "missing platform artifact is rejected", "expired candidate artifact is rejected", "stale embedded release evidence is rejected", "downloaded artifact hash tampering is rejected", "Companion CI candidate contract tests passed")) { + if ($companionCiCandidateContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_companion_ci_candidate_contract.ps1 missing exact-source candidate coverage: $pattern" + } +} + $firmwareWorkflowText = Get-Content -LiteralPath (Join-PackagePath "provenance/firmware.yml") -Raw foreach ($pattern in @("Install bridge test dependencies", "sudo apt-get install -y ffmpeg", "python -m pip install -r bridge/requirements-vision.txt", "Verify bundled persona packs", "verify_persona_pack.py glow", "Compile native logic with Glow persona", "STACKCHAN_PERSONA: glow", "Run LiteRT-LM contract smoke", "litert_lm_contract_smoke.py", "litert-lm-contract-smoke", "LITERT_LM_SMOKE.md")) { if ($firmwareWorkflowText -notmatch [regex]::Escape($pattern)) { throw "provenance/firmware.yml missing LiteRT-LM contract smoke workflow support: $pattern" } } +foreach ($pattern in @("workflow_dispatch", "github.event_name != 'workflow_dispatch'", "github.event_name == 'workflow_dispatch'", "STACKCHAN_CI_SOURCE_SHA", "github.event.pull_request.head.sha", "companion-platform-builds", "companion-android-emulator-smoke", "python-version: `"3.12`"", "gradle/actions/setup-gradle@v6", "test_android_emulator_release_evidence_contract.ps1", "test_desktop_package_evidence_contract.ps1", "test_desktop_release_signing_readiness_contract.ps1", "test_release_credential_hygiene_contract.ps1", "Run release credential hygiene contract", "test_companion_ci_candidate_contract.ps1", "Run exact-source companion CI candidate contract", "test_desktop_target_install_evidence_contract.ps1", "test_desktop_package_launch.ps1", "prepare_desktop_python_runtime.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT", "export_desktop_package_evidence.ps1", "RequireInstallerPayload", "RequireLaunchEvidence", "linux-package-evidence.json", "macos-package-evidence.json", "windows-package-evidence.json", "AndroidEmulatorEvidencePath", "RequireAndroidEmulatorEvidence", "DesktopPackageEvidenceRoot", "RequireDesktopPackageEvidence")) { + if ($firmwareWorkflowText -notmatch [regex]::Escape($pattern)) { + throw "provenance/firmware.yml missing native desktop package/runtime PR evidence support: $pattern" + } +} $publisherText = Get-Content -LiteralPath (Join-PackagePath "tools/publish_release.ps1") -Raw foreach ($pattern in @("release_asset_contract.ps1", "verify_release_asset_contract.ps1", "Get-ReleaseBaseAssetEntries", "Get-ReleaseFinalAssetEntries", "Export-ActionsStatusWithRetry", "Update-ReleaseArchive", "Clear-TransientPackageOutput", "output/voice_auditions/VOICE_AUDITION_INDEX.html", '$baseReleaseAssets', '$finalReleaseAssets', '@baseReleaseAssets', '@finalReleaseAssets', "Verify finalized release asset contract before upload", "FirmwareAssetRoot `$stageDir", "FirmwareAssetPathMode Stage", "SHA256SUMS.txt", "--clobber", "PushCurrentBranch", "Assert-CurrentBranchPublishedAtCommit", "git ls-remote", "Firmware workflow can be observed", "Push the branch first or pass -PushCurrentBranch", "audit_published_release.ps1", "-UploadToRelease")) { @@ -1125,7 +1230,7 @@ foreach ($docPath in @("README.md", "docs/RELEASE_PROCESS.md")) { } $releaseProcessText = Get-Content -LiteralPath (Join-PackagePath "docs/RELEASE_PROCESS.md") -Raw -foreach ($pattern in @("export_companion_release_evidence.cmd", "COMPANION_RELEASE_EVIDENCE.json/md", "artifact SHA256s", "libs.versions.toml", "-RequireArtifacts")) { +foreach ($pattern in @("export_companion_release_evidence.cmd", "COMPANION_RELEASE_EVIDENCE.json/md", "artifact SHA256s", "libs.versions.toml", "-RequireArtifacts", "-RequireUploadSigning", "-RequireAndroidEmulatorEvidence", "-RequireDesktopPackageEvidence", "-RequireDesktopDistributionTrust", "Companion Signing Readiness", "check_desktop_release_signing_readiness.cmd", "check_release_credential_hygiene.cmd", "download_companion_ci_candidate.cmd", "COMPANION_CI_CANDIDATE.json", "STACKCHAN_CI_SOURCE_SHA", "RequireNativeToolchain", "timestamped Authenticode", "Developer ID", "stapled", "provenance attestations", "API 35 emulator launch evidence", "APK SHA-256 matches the release", "package SHA-256", "installer application JAR", "installer-derived runtime SHA-256")) { if ($releaseProcessText -notmatch [regex]::Escape($pattern)) { throw "docs/RELEASE_PROCESS.md missing companion C8 release evidence guidance: $pattern" } @@ -1160,12 +1265,33 @@ foreach ($pattern in @("On-device wake phrase", "microphone capture", "wake-gate throw "README.md missing mic capture status guidance: $pattern" } } -foreach ($pattern in @("public v0.2 release candidate", "formal one-hour actuator acceptance", "more than five hours", "exact paired candidate", "public build", "FIRST_DEPLOY_STATUS.md", "CONVERSATION_V2_ROADMAP.md")) { +foreach ($pattern in @("public v0.2 release candidate", "Status as of July 13, 2026", "corrected exact paired candidate", "28807 s", "5643/5643", "77/77", "bounded final stop", "public build", "FIRST_DEPLOY_STATUS.md", "CONVERSATION_V2_ROADMAP.md")) { if ($repoReadmeText -notmatch [regex]::Escape($pattern)) { throw "README.md missing current release-candidate status or navigation: $pattern" } } +$productionReadinessText = Get-Content -LiteralPath (Join-PackagePath "docs/PRODUCTION_READINESS.md") -Raw +foreach ($pattern in @("Current status (2026-07-13)", "corrected single-owner exact-image", "28807 s", "5643/5643", "77/77", "bounded final motion stop evidence")) { + if ($productionReadinessText -notmatch [regex]::Escape($pattern)) { + throw "docs/PRODUCTION_READINESS.md missing corrected exact-image soak evidence: $pattern" + } +} + +$hardwareFeatureRoadmapText = Get-Content -LiteralPath (Join-PackagePath "docs/HARDWARE_FEATURE_ROADMAP.md") -Raw +foreach ($pattern in @("Current status (2026-07-13)", "corrected exact release image", "28807 s", "5643/5643", "77/77", "verified off after completion")) { + if ($hardwareFeatureRoadmapText -notmatch [regex]::Escape($pattern)) { + throw "docs/HARDWARE_FEATURE_ROADMAP.md missing corrected exact-image soak evidence: $pattern" + } +} + +$powerBlackoutForensicsText = Get-Content -LiteralPath (Join-PackagePath "docs/POWER_BLACKOUT_FORENSICS.md") -Raw +foreach ($pattern in @('corrected `v0.2.0` exact-image candidate', "28807 s", "5643/5643", "77/77", "bounded final stop evidence", "Historical full-off root cause remains unidentified")) { + if ($powerBlackoutForensicsText -notmatch [regex]::Escape($pattern)) { + throw "docs/POWER_BLACKOUT_FORENSICS.md missing corrected exact-image soak evidence: $pattern" + } +} + $agentGuideText = Get-Content -LiteralPath (Join-PackagePath "AGENTS.md") -Raw foreach ($pattern in @("FIRST_DEPLOY_STATUS.md", "POWER_BLACKOUT_FORENSICS.md", "CUSTOMIZING_THE_FACE.md", "CONVERSATION_V2_ROADMAP.md", "exact installed binary", "motion-stop")) { if ($agentGuideText -notmatch [regex]::Escape($pattern)) { @@ -1416,6 +1542,18 @@ foreach ($pattern in @("gFaceControlQueue", "gMotionControlQueue", "FaceControlI throw "provenance/src/main.cpp missing bench control support: $pattern" } } +$singleOwnerCalls = [ordered]@{ + 'updateBridgeNetwork\s*\(' = 2 + 'pollBridgeOutputs\s*\(' = 4 + 'pollBridgeDebugServer\s*\(' = 2 + 'ensureWakeSrStarted\s*\(' = 2 +} +foreach ($entry in $singleOwnerCalls.GetEnumerator()) { + $actualCount = [regex]::Matches($mainText, $entry.Key).Count + if ($actualCount -ne $entry.Value) { + throw "provenance/src/main.cpp violates single-owner bridge runtime contract for $($entry.Key): expected $($entry.Value), found $actualCount" + } +} foreach ($pattern in @("submitCapturedAudioWindowToBridgeUplink", "lastPcmWindow", "lastPcmSampleCount", "submitPcmChunk", "audioWindowsBefore")) { if ($mainText -notmatch [regex]::Escape($pattern)) { throw "provenance/src/main.cpp missing mic capture to uplink handoff support: $pattern" @@ -1452,7 +1590,7 @@ foreach ($pattern in @("stackchan.android-toolchain-check.v1", "JAVA_HOME", "jav } $androidPlayReadinessCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_android_play_release_readiness.ps1") -Raw -foreach ($pattern in @("stackchan.android-play-release-readiness.v1", "Play high-resolution icon", "Gradle Play upload signing inputs", "CI builds Android release bundle", "Release evidence covers AAB signing", "play-store-evidence-checker", "applicationId", "play-listing-full-description", "Gemma-4-E2B", "raw microphone audio is not stored")) { +foreach ($pattern in @("stackchan.android-play-release-readiness.v1", "RequireUploadSigning", "uploadSigningRequired", "ExportParameters", "Play high-resolution icon", "Gradle Play upload signing inputs", "stackchan.allowLabDebugReleaseSigning", "Release tasks fail closed", "keytool private-key validation", "RSACertificateExtensions", "project policy requires at least 4096 bits", "2033-10-23 UTC", "certificate SHA-256", "CI builds Android release bundle", "CI runs Android emulator launch smoke", "Tag release validates upload key and exact release APK launch", "system-images;android-35;aosp_atd;x86_64", "test_android_emulator_launch.ps1", "RequireAndroidEmulatorEvidence", "Release evidence covers AAB signing", "play-store-evidence-checker", "applicationId", "play-listing-full-description", "Gemma-4-E2B", "raw microphone audio is not stored")) { if ($androidPlayReadinessCheckerText -notmatch [regex]::Escape($pattern)) { throw "tools/check_android_play_release_readiness.ps1 missing Android Play release readiness logic: $pattern" } @@ -1465,6 +1603,20 @@ foreach ($pattern in @("stackchan.android-play-store-evidence.v1", "play-interna } } +$privacyPolicyDeploymentCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_privacy_policy_deployment.ps1") -Raw +foreach ($pattern in @("stackchan.privacy-policy-deployment-check.v1", "privacy-policy-deployment-ready", "live-https", "Published policy byte identity", "Published policy disclosures", "sourceSha256", "servedSha256")) { + if ($privacyPolicyDeploymentCheckerText -notmatch [regex]::Escape($pattern)) { + throw "tools/check_privacy_policy_deployment.ps1 missing privacy-policy deployment verification logic: $pattern" + } +} + +$privacyPolicyDeploymentContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_privacy_policy_deployment_contract.ps1") -Raw +foreach ($pattern in @("exact published privacy policy bytes are accepted", "tampered published privacy policy bytes are rejected", "noncanonical privacy policy URL is rejected", "stale privacy policy source hash is rejected", "pending privacy policy deployment status is rejected", "5/5")) { + if ($privacyPolicyDeploymentContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_privacy_policy_deployment_contract.ps1 missing privacy-policy deployment contract coverage: $pattern" + } +} + $androidV1BundleCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_android_v1_evidence_bundle.ps1") -Raw foreach ($pattern in @("stackchan.android-v1-evidence-bundle.v1", "android-v1-evidence-ready", "pending-android-v1-evidence-bundle", "stackchan.android-apk-install.v1", "android-speech-ready", "android-controls-ready", "android-pairing-ready", "android-wifi-ready", "android-gemma-real-device-ready", "android-screen-off-soak-ready", "play-internal-testing-ready", "sourceCommit", "expectedSourceCommit", "applicationId", "packageName", "apkSha256", "versionName", "versionCode", "releaseAabSha256", "gemmaBenchmarkProfile", "gemma-benchmark-profile", "gemma-benchmark-speed", "benchmarkMedianMs", "gemma4-e2b-litert-lm", "androidDashboardEvidenceRoot", "androidDashboardMedia", "androidDashboardMediaIds", "dashboard-evidence", "phone-live-dashboard", "Connected dashboard media decision", "companion-readiness-source-commit-match", "apk-install-source-commit-match", "diagnostics-expected-source-commit-match", "speech-expected-source-commit-match", "controls-expected-source-commit-match", "pairing-expected-source-commit-match", "wifi-expected-source-commit-match", "gemma-expected-source-commit-match", "screen-off-soak-expected-source-commit-match", "apk-install-application-id-match", "play-store-application-id-match", "speech-source-commit-match", "screen-off-soak-source-commit-match", "play-store-source-commit-match", "play-store-version-name-match", "play-store-version-code-match", "Get-AndroidSourceApplicationId", "Get-ReviewSourceCommit", "Source commit:", "ANDROID_V1_REVIEW.md", "RequireReady")) { if ($androidV1BundleCheckerText -notmatch [regex]::Escape($pattern)) { @@ -1578,14 +1730,14 @@ foreach ($pattern in @("complete Android Gemma benchmark evidence is accepted", } $companionReadinessCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_companion_v1_readiness.ps1") -Raw -foreach ($pattern in @("stackchan.companion-v1-readiness.v1", "COMPANION_CROSS_PLATFORM_PLAN.md", "protocol-fixtures", "provenance/protocol-fixtures", "ProtocolFixtureConformanceTest.kt", "C0Spike.kt", "android-screen-off-soak-helper", "android-screen-off-soak-evidence-check", "android-screen-off-soak-evidence-contract", "android-v1-evidence-bundle-check", "desktop-v1-evidence-bundle-check", "desktop-v1-evidence-bundle-contract", "voice-source-readiness-contract", "android-play-release-prep", "android-play-store-evidence-check", "android-diagnostics-export-evidence-check", "android-diagnostics-export-evidence-contract", "android-speech-evidence-check", "android-speech-evidence-contract", "android-controls-evidence-check", "android-controls-evidence-contract", "android-pairing-evidence-check", "android-pairing-evidence-contract", "android-wifi-evidence-check", "android-wifi-evidence-contract", "android-gemma-evidence-check", "android-gemma-evidence-contract", "google-play-store-screenshots", "google-play-internal-testing-upload", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd", "bridge/android_companion_soak.py", "check_android_screen_off_soak_evidence.ps1", "physical-robot-hardware-validation", "android-push-to-talk-stt-on-target-phone", "android-settings-handoff-on-target-robot", "android-qr-short-code-pairing-on-target-robot", "android-wifi-provisioning-on-target-robot", "c8-tagged-release-distribution", "source-ready-pending-hardware")) { +foreach ($pattern in @("stackchan.companion-v1-readiness.v1", "COMPANION_CROSS_PLATFORM_PLAN.md", "protocol-fixtures", "provenance/protocol-fixtures", "ProtocolFixtureConformanceTest.kt", "C0Spike.kt", "android-screen-off-soak-helper", "android-screen-off-soak-evidence-check", "android-screen-off-soak-evidence-contract", "android-v1-evidence-bundle-check", "desktop-v1-evidence-bundle-check", "desktop-v1-evidence-bundle-contract", "companion-v1-consumer-promotion-binding", "desktop-package-evidence-export", "desktop-package-evidence-contract", "desktop-managed-runtime-native-package-matrix", "desktop-target-install-evidence", "desktop-target-install-evidence-contract", "voice-source-readiness-contract", "android-play-release-prep", "android-play-store-evidence-check", "android-diagnostics-export-evidence-check", "android-diagnostics-export-evidence-contract", "android-speech-evidence-check", "android-speech-evidence-contract", "android-controls-evidence-check", "android-controls-evidence-contract", "android-pairing-evidence-check", "android-pairing-evidence-contract", "android-wifi-evidence-check", "android-wifi-evidence-contract", "android-gemma-evidence-check", "android-gemma-evidence-contract", "google-play-store-screenshots", "google-play-internal-testing-upload", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd", "bridge/android_companion_soak.py", "check_android_screen_off_soak_evidence.ps1", "physical-robot-hardware-validation", "android-push-to-talk-stt-on-target-phone", "android-settings-handoff-on-target-robot", "android-qr-short-code-pairing-on-target-robot", "android-wifi-provisioning-on-target-robot", "desktop-target-installs-on-operator-workstations", "desktop-production-signing-credentials", "c8-tagged-release-distribution", "source-ready-pending-hardware")) { if ($companionReadinessCheckerText -notmatch [regex]::Escape($pattern)) { throw "tools/check_companion_v1_readiness.ps1 missing companion v1 readiness logic: $pattern" } } $companionReleaseEvidenceExporterText = Get-Content -LiteralPath (Join-PackagePath "tools/export_companion_release_evidence.ps1") -Raw -foreach ($pattern in @("stackchan.companion-release-evidence.v1", "COMPANION_RELEASE_EVIDENCE.json", "COMPANION_RELEASE_EVIDENCE.md", "toolchainPins", "Get-FileHash", "AndroidArtifactRoot", "DesktopArtifactRoot", "ApkSignerPath", "apksigner", "androidSigning", "android-release-apk-signature", "androidBundleSigning", "android-release-aab-signature", "jarsigner", "RequireArtifacts", "evidence-pending-artifacts", "blocked-missing-artifacts")) { +foreach ($pattern in @("stackchan.companion-release-evidence.v1", "COMPANION_RELEASE_EVIDENCE.json", "COMPANION_RELEASE_EVIDENCE.md", "toolchainPins", "Get-FileHash", "AndroidArtifactRoot", "AndroidEmulatorEvidencePath", "DesktopArtifactRoot", "DesktopPackageEvidenceRoot", "ApkSignerPath", "apksigner", "androidSigning", "android-release-apk-signature", "androidBundleSigning", "android-release-aab-signature", "jarsigner", "RequireArtifacts", "RequireUploadSigning", "RequireAndroidEmulatorEvidence", "androidEmulatorEvidenceRequired", "android-emulator-release-apk-evidence", "check_android_emulator_release_evidence.ps1", "RequireDesktopPackageEvidence", "desktopPackageEvidenceRequired", "RequireDesktopDistributionTrust", "desktopDistributionTrustRequired", "distributionTrustStatus", "authenticode-sha256-timestamped", "developer-id-notarized-stapled", "installerAppJarSha256", "installerRuntimeSha256", "installerBrainFiles", "desktop-native-package-runtime-evidence", "desktop-native-distribution-trust", "blockingPending", "evidence-pending-artifacts", "blocked-release-evidence")) { if ($companionReleaseEvidenceExporterText -notmatch [regex]::Escape($pattern)) { throw "tools/export_companion_release_evidence.ps1 missing companion release evidence export logic: $pattern" } @@ -2145,7 +2297,7 @@ foreach ($pattern in @("STACKCHAN_ENABLE_WIFI_BRIDGE", "STACKCHAN_WIFI_SSID", "S } $desktopPythonRuntimeDocText = Get-Content -LiteralPath (Join-PackagePath "docs/DESKTOP_PYTHON_RUNTIME.md") -Raw -foreach ($pattern in @("Desktop Managed Python Runtime", "python-runtime/", "runtime/python/", "stackchan-python-runtime.json", "prepare_desktop_python_runtime.ps1", "check_desktop_python_runtime_payload.ps1", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT")) { +foreach ($pattern in @("Desktop Managed Python Runtime", "python-runtime/", "runtime/python/", "stackchan-python-runtime.json", "prepare_desktop_python_runtime.ps1", "check_desktop_python_runtime_payload.ps1", "export_desktop_package_evidence.ps1", "PackageExtractionRoot", "RequireInstallerPayload", "installer application JAR", "package SHA-256", "STACKCHAN_DESKTOP_PYTHON_RUNTIME_ROOT")) { if ($desktopPythonRuntimeDocText -notmatch [regex]::Escape($pattern)) { throw "docs/DESKTOP_PYTHON_RUNTIME.md missing managed runtime guidance: $pattern" } @@ -2172,29 +2324,105 @@ foreach ($pattern in @("placeholder sha256 is rejected", "placeholder runtime so } } +$androidUploadSigningContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_android_upload_signing_contract.ps1") -Raw +foreach ($pattern in @("manual signing readiness workflow validates Android upload key without publishing", "tagged Android release requires upload signing readiness", "required upload signing credentials fail closed when missing", "valid 4096-bit private upload key is accepted", "missing upload-key alias is rejected", "wrong keystore password is rejected", "wrong private-key password is rejected", "weak 2048-bit upload key is rejected", "Android debug certificate subject is rejected", "upload certificate expiring before the Play minimum is rejected", "output exposed a contract credential")) { + if ($androidUploadSigningContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_android_upload_signing_contract.ps1 missing Android upload signing contract logic: $pattern" + } +} + +$androidEmulatorLaunchSmokeText = Get-Content -LiteralPath (Join-PackagePath "tools/test_android_emulator_launch.ps1") -Raw +foreach ($pattern in @("stackchan.android-emulator-launch-smoke.v1", "ro.kernel.qemu=1", "POST_NOTIFICATIONS", "MainActivity is not the top resumed activity", "CompanionBridgeService is absent after launch", "fatalProcessMatches", "substitutesForPhysicalEvidence", "emulator-install-launch-service-smoke-only")) { + if ($androidEmulatorLaunchSmokeText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_android_emulator_launch.ps1 missing Android emulator launch smoke logic: $pattern" + } +} + +$androidEmulatorReleaseEvidenceCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_android_emulator_release_evidence.ps1") -Raw +foreach ($pattern in @("stackchan.android-emulator-release-evidence-check.v1", "stackchan.android-emulator-launch-smoke.v1", "MinApiLevel = 35", "dev.stackchan.companion", "MainActivity was not resumed", "CompanionBridgeService was not present", "fatalProcessMatches must be zero", "substitutesForPhysicalEvidence=false", "APK SHA-256 does not match the release APK")) { + if ($androidEmulatorReleaseEvidenceCheckerText -notmatch [regex]::Escape($pattern)) { + throw "tools/check_android_emulator_release_evidence.ps1 missing release APK binding logic: $pattern" + } +} + +$androidEmulatorReleaseEvidenceContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_android_emulator_release_evidence_contract.ps1") -Raw +foreach ($pattern in @("matching release APK evidence", "stale APK hash is rejected", "old emulator API is rejected", "failed launch smoke is rejected", "non-resumed activity is rejected", "missing bridge service is rejected", "fatal process match is rejected", "physical-evidence substitution is rejected", "wrong package identity is rejected", "9/9 passed")) { + if ($androidEmulatorReleaseEvidenceContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_android_emulator_release_evidence_contract.ps1 missing contract coverage: $pattern" + } +} + +$desktopPackageEvidenceExporterText = Get-Content -LiteralPath (Join-PackagePath "tools/export_desktop_package_evidence.ps1") -Raw +foreach ($pattern in @("stackchan.desktop-package-evidence.v1", "stackchan.desktop-python-runtime-prepare.v1", "stackchan.desktop-python-runtime-payload.v1", "Get-RuntimePayloadHash", "native-app-resources", "Expand-DesktopPackage", "RequireInstallerPayload", "RequireLaunchEvidence", "launchEvidence", "processedPayloadSha256", "processedFileCount", "Installer runtime payload hash does not match processed Gradle resources", "PackageExtractionRoot")) { + if ($desktopPackageEvidenceExporterText -notmatch [regex]::Escape($pattern)) { + throw "tools/export_desktop_package_evidence.ps1 missing native package/runtime evidence logic: $pattern" + } +} + +$desktopPackageLaunchText = Get-Content -LiteralPath (Join-PackagePath "tools/test_desktop_package_launch.ps1") -Raw +foreach ($pattern in @("stackchan.desktop-package-launch-evidence.v1", "stackchan.desktop-packaged-runtime-smoke.v1", "exact-native-package-extraction-and-headless-launch", "package-extraction", "substitutesForTargetInstall", "--package-smoke-output=", "--package-smoke-context=")) { + if ($desktopPackageLaunchText -notmatch [regex]::Escape($pattern)) { throw "tools/test_desktop_package_launch.ps1 missing exact-package launch logic: $pattern" } +} + +$desktopTargetInstallToolText = Get-Content -LiteralPath (Join-PackagePath "tools/install_desktop_companion_package.ps1") -Raw +foreach ($pattern in @("stackchan.desktop-target-install-evidence.v1", "operator-target-workstation", "ci-native-runner", "msiexec-install", "dpkg-install", "dmg-application-copy", "installed-native-package-headless-runtime-probe", "exact-native-package-install-and-headless-launch", "substitutesForHumanAcceptance", "preExistingRegistrations", "replacementRequested", "replacementPerformed", "uninstallAttempts", "postInstallRegistrations", "elevatedAdministrator")) { + if ($desktopTargetInstallToolText -notmatch [regex]::Escape($pattern)) { throw "tools/install_desktop_companion_package.ps1 missing native target-install evidence logic: $pattern" } +} + +$desktopTargetInstallCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_desktop_target_install_evidence.ps1") -Raw +foreach ($pattern in @("stackchan.desktop-target-install-evidence-check.v1", "desktop-target-install-ready", "RequireOperatorTarget", "expected-package-sha256", "expected-source-commit", "installed-runtime-probe", "human-acceptance-scope", "windows-elevation", "windows-install-registration", "windows-exact-package-replacement")) { + if ($desktopTargetInstallCheckerText -notmatch [regex]::Escape($pattern)) { throw "tools/check_desktop_target_install_evidence.ps1 missing native target-install validation: $pattern" } +} + +$desktopTargetInstallContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_desktop_target_install_evidence_contract.ps1") -Raw +foreach ($pattern in @("operator target-install evidence is accepted for Windows, Linux, and macOS", "explicit Windows replacement evidence is accepted", "implicit Windows maintenance-mode replacement is rejected", "non-elevated Windows install evidence is rejected", "stale target-install package hash is rejected", "CI native-runner evidence cannot replace operator target evidence", "package extraction cannot replace installed launcher evidence", "missing install and launch exit codes are rejected", "mismatched target-install source commit is rejected", "Desktop target install evidence contract tests passed")) { + if ($desktopTargetInstallContractText -notmatch [regex]::Escape($pattern)) { throw "tools/test_desktop_target_install_evidence_contract.ps1 missing target-install contract coverage: $pattern" } +} + +$desktopPackageEvidenceContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_desktop_package_evidence_contract.ps1") -Raw +foreach ($pattern in @("tagged release requires native signing, notarization, and provenance attestation", "complete installer-derived desktop package evidence is accepted", "installed launch context cannot replace package-extraction evidence", "installer runtime tampering is rejected", "JAR-embedded executable runtime is rejected", "aggregate companion evidence accepts all three native package reports", "aggregate companion evidence rejects installer-derived runtime mismatch", "aggregate companion evidence rejects stale exact-package launch evidence", "aggregate companion evidence rejects missing native distribution trust", "strict aggregate evidence rejects a missing native package report", "wrong platform package extension is rejected", "processed runtime tampering is rejected", "runtime prepare platform mismatch is rejected", "Desktop package evidence contract tests passed")) { + if ($desktopPackageEvidenceContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_desktop_package_evidence_contract.ps1 missing native package/runtime evidence contract coverage: $pattern" + } +} + +$desktopSigningReadinessCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_desktop_release_signing_readiness.ps1") -Raw +foreach ($pattern in @("stackchan.desktop-signing-readiness.v1", "private code-signing certificate", "RequireNativeToolchain", "ValidateAppleNotaryCredentials", "notarytool", "signtool.exe", "does not chain to a root trusted by the native host", "temporary Authenticode signing probe", "temporary Developer ID signing probe", "pending-credentials")) { + if ($desktopSigningReadinessCheckerText -notmatch [regex]::Escape($pattern)) { + throw "tools/check_desktop_release_signing_readiness.ps1 missing signing credential validation: $pattern" + } +} + +$desktopSigningReadinessContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_desktop_release_signing_readiness_contract.ps1") -Raw +foreach ($pattern in @("manual signing readiness workflow validates without publishing", "tagged release runs native desktop signing preflight", "companion CI runs the desktop signing readiness contract", "invalid Windows PKCS12 base64 is rejected", "wrong Windows PKCS12 password is rejected", "Windows certificate without code-signing EKU is rejected", "near-expiry Windows certificate is rejected", "undersized Windows signing key is rejected", "valid Windows signing credential material is accepted", "valid macOS Developer ID credential material is accepted", "mismatched macOS signing identity is rejected", "mismatched Apple team ID is rejected", "invalid Apple notarization account shape is rejected", "Desktop release signing readiness contract passed")) { + if ($desktopSigningReadinessContractText -notmatch [regex]::Escape($pattern)) { + throw "tools/test_desktop_release_signing_readiness_contract.ps1 missing signing credential contract coverage: $pattern" + } +} + $desktopV1BundleCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_desktop_v1_evidence_bundle.ps1") -Raw -foreach ($pattern in @("stackchan.desktop-v1-evidence-bundle.v1", "desktop-v1-evidence-ready", "pending-desktop-v1-evidence-bundle", "stackchan.desktop-python-runtime-payload.v1", "pc-brain-deploy-ready", "pc-brain-quiet-soak-ready", "production-voice-source-ready", "companion-readiness-source-commit-match", "pc-brain-deploy-commit-match", "pc-brain-quiet-soak-commit-match", "voice-source-commit-match", "sourceCommit", "windowsMsiSha256", "macosDmgSha256", "linuxDebSha256", "runtime-windows-summary", "runtime-macos-summary", "runtime-linux-summary", "Test-RuntimePayloadSummary", "runtimeSha256", "runtimeSource", "probedPythonVersion", "Get-ReviewSourceCommit", "Source commit:", "DESKTOP_V1_REVIEW.md", "RequireReady")) { +foreach ($pattern in @("stackchan.desktop-v1-evidence-bundle.v1", "desktop-v1-evidence-ready", "pending-desktop-v1-evidence-bundle", "stackchan.desktop-python-runtime-payload.v1", "stackchan.desktop-target-install-evidence.v1", "windowsTargetInstallReport", "macosTargetInstallReport", "linuxTargetInstallReport", "Test-DesktopTargetInstallReport", "RequireOperatorTarget", "Target installation decision: pass", "pc-brain-deploy-ready", "pc-brain-quiet-soak-ready", "production-voice-source-ready", "companion-readiness-source-commit-match", "pc-brain-deploy-commit-match", "pc-brain-quiet-soak-commit-match", "voice-source-commit-match", "sourceCommit", "windowsMsiSha256", "macosDmgSha256", "linuxDebSha256", "runtime-windows-summary", "runtime-macos-summary", "runtime-linux-summary", "Test-RuntimePayloadSummary", "runtimeSha256", "runtimeSource", "probedPythonVersion", "Get-ReviewSourceCommit", "Source commit:", "DESKTOP_V1_REVIEW.md", "RequireReady")) { if ($desktopV1BundleCheckerText -notmatch [regex]::Escape($pattern)) { throw "tools/check_desktop_v1_evidence_bundle.ps1 missing desktop v1 evidence bundle logic: $pattern" } } $desktopV1BundleContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_desktop_v1_evidence_bundle_contract.ps1") -Raw -foreach ($pattern in @("placeholder Desktop v1 evidence bundle is pending", "complete Desktop v1 evidence bundle is accepted", "desktop package artifact hashes", "missing Desktop v1 runtime payload summary is rejected", "missing Desktop v1 runtime payload source is rejected", "mismatched Desktop v1 runtime payload platform is rejected", "mismatched Desktop v1 companion readiness source commit is rejected", "mismatched Desktop v1 review source commit is rejected", "mismatched Desktop v1 voice-source commit is rejected", "mismatched Desktop v1 PC Brain deploy commit is rejected", "mismatched Desktop v1 PC Brain quiet-soak commit is rejected", "desktop-v1-evidence-ready", "pending-desktop-v1-evidence-bundle", "Desktop v1 evidence bundle contract tests passed")) { +foreach ($pattern in @("placeholder Desktop v1 evidence bundle is pending", "complete Desktop v1 evidence bundle is accepted", "desktop package artifact hashes", "elevatedAdministrator", "postInstallRegistrations", "missing Desktop v1 runtime payload summary is rejected", "missing Desktop v1 runtime payload source is rejected", "mismatched Desktop v1 runtime payload platform is rejected", "stale Desktop v1 target-install package hash is rejected", "CI install rehearsal cannot replace Desktop v1 operator target evidence", "mismatched Desktop v1 companion readiness source commit is rejected", "mismatched Desktop v1 review source commit is rejected", "mismatched Desktop v1 voice-source commit is rejected", "mismatched Desktop v1 PC Brain deploy commit is rejected", "mismatched Desktop v1 PC Brain quiet-soak commit is rejected", "desktop-v1-evidence-ready", "pending-desktop-v1-evidence-bundle", "Desktop v1 evidence bundle contract tests passed")) { if ($desktopV1BundleContractText -notmatch [regex]::Escape($pattern)) { throw "tools/test_desktop_v1_evidence_bundle_contract.ps1 missing desktop v1 evidence bundle contract coverage: $pattern" } } $companionV1BundleCheckerText = Get-Content -LiteralPath (Join-PackagePath "tools/check_companion_v1_evidence_bundle.ps1") -Raw -foreach ($pattern in @("stackchan.companion-v1-evidence-bundle.v1", "companion-v1-evidence-ready", "pending-companion-v1-evidence-bundle", "stackchan.android-v1-evidence-bundle-check.v1", "stackchan.desktop-v1-evidence-bundle-check.v1", "stackchan.rollout-status.v1", "consumer-promotion-ready", "companion-readiness-commit-match", "release-evidence-commit-match", "github-actions-commit-match", "rollout-status-version-match", "android-v1-commit-match", "android-v1-application-id-match", "android-v1-version-name-match", "android-v1-version-code-match", "android-v1-gemma-benchmark-summary", "android-v1-dashboard-media-summary", "gemmaBenchmarkProfile", "gemmaBenchmarkMedianMs", "androidGemmaBenchmarkProfile", "androidGemmaBenchmarkMedianMs", "androidDashboardMediaIds", "phone-live-dashboard", "android-v1-release-apk-hash-match", "android-v1-release-aab-hash-match", "desktop-v1-commit-match", "desktop-v1-artifact-hashes-match", "release-package-evidence-present", "voice-source-commit-match", "rollout-hardware-root-match", "rollout-hardware-commit-match", "sourceCommit", "releaseVersion", "applicationId", "apkSha256", "versionCode", "packageEvidence", "Get-ReviewSourceCommit", "Get-ReviewReleaseVersion", "Get-Sha256Text", "Convert-ToAndroidVersionName", "Get-AndroidSourceApplicationId", "Test-AndroidApplicationIdMatchesSource", "Get-AndroidSourceVersionCode", "Test-AndroidVersionCodeMatchesSource", "Test-AndroidV1EvidenceSummary", "Test-AndroidReleaseApkHashMatchesReleaseEvidence", "Test-AndroidReleaseAabHashMatchesReleaseEvidence", "Test-DesktopArtifactHashesMatchReleaseEvidence", "Test-ReleasePackageEvidencePresent", "Test-RolloutHardwareEvidence", "Source commit:", "Release version:", "COMPANION_V1_REVIEW.md", "RequireReady")) { +foreach ($pattern in @("stackchan.companion-v1-evidence-bundle.v1", "companion-v1-evidence-ready", "pending-companion-v1-evidence-bundle", "stackchan.android-v1-evidence-bundle-check.v1", "stackchan.desktop-v1-evidence-bundle-check.v1", "stackchan.rollout-status.v1", "consumer-promotion-ready", "companion-readiness-commit-match", "release-evidence-commit-match", "github-actions-commit-match", "rollout-status-version-match", "android-v1-commit-match", "android-v1-application-id-match", "android-v1-version-name-match", "android-v1-version-code-match", "android-v1-gemma-benchmark-summary", "android-v1-dashboard-media-summary", "gemmaBenchmarkProfile", "gemmaBenchmarkMedianMs", "androidGemmaBenchmarkProfile", "androidGemmaBenchmarkMedianMs", "androidDashboardMediaIds", "phone-live-dashboard", "android-v1-release-apk-hash-match", "android-v1-release-aab-hash-match", "desktop-v1-commit-match", "desktop-v1-artifact-hashes-match", "desktopDistributionTrustRequired", "authenticode-sha256-timestamped", "developer-id-notarized-stapled", "release-package-evidence-present", "voice-source-commit-match", "rollout-hardware-root-match", "rollout-hardware-commit-match", "sourceCommit", "firmwareSourceCommit", "firmware-source-commit", "releaseVersion", "applicationId", "apkSha256", "versionCode", "packageEvidence", "Get-ReviewSourceCommit", "Get-ReviewReleaseVersion", "Get-Sha256Text", "Convert-ToAndroidVersionName", "Get-AndroidSourceApplicationId", "Test-AndroidApplicationIdMatchesSource", "Get-AndroidSourceVersionCode", "Test-AndroidVersionCodeMatchesSource", "Test-AndroidV1EvidenceSummary", "Test-AndroidReleaseApkHashMatchesReleaseEvidence", "Test-AndroidReleaseAabHashMatchesReleaseEvidence", "Test-DesktopArtifactHashesMatchReleaseEvidence", "Test-ReleasePackageEvidencePresent", "Test-RolloutHardwareEvidence", "Source commit:", "Release version:", "COMPANION_V1_REVIEW.md", "RequireReady")) { if ($companionV1BundleCheckerText -notmatch [regex]::Escape($pattern)) { throw "tools/check_companion_v1_evidence_bundle.ps1 missing companion v1 evidence bundle logic: $pattern" } } $companionV1BundleContractText = Get-Content -LiteralPath (Join-PackagePath "tools/test_companion_v1_evidence_bundle_contract.ps1") -Raw -foreach ($pattern in @("placeholder Companion v1 evidence bundle is pending", "complete Companion v1 evidence bundle is accepted", "stale Companion v1 Android evidence summary is rejected", "slow or incomplete Companion v1 Android evidence summary is rejected", "Android Gemma benchmark and dashboard media summaries", "mismatched Companion v1 release ZIP hash is rejected", "missing Companion v1 release package evidence is rejected", "mismatched Companion v1 source-readiness commit is rejected", "mismatched Companion v1 hardware evidence root is rejected", "mismatched Companion v1 hardware evidence commit is rejected", "mismatched Companion v1 report commit is rejected", "mismatched Companion v1 Android bundle commit is rejected", "mismatched Companion v1 Android applicationId is rejected", "mismatched Companion v1 Android app version is rejected", "mismatched Companion v1 Android app versionCode is rejected", "mismatched Companion v1 Android release APK hash is rejected", "mismatched Companion v1 Android release AAB hash is rejected", "mismatched Companion v1 Desktop package hash is rejected", "mismatched Companion v1 Desktop bundle commit is rejected", "mismatched Companion v1 voice-source commit is rejected", "mismatched Companion v1 review source commit is rejected", "mismatched Companion v1 review release version is rejected", "companion-v1-evidence-ready", "pending-companion-v1-evidence-bundle", "Companion v1 evidence bundle contract tests passed")) { +foreach ($pattern in @("placeholder Companion v1 evidence bundle is pending", "complete Companion v1 evidence bundle with distinct release and firmware commits is accepted", "missing desktop distribution trust is rejected by final Companion v1 evidence", "legacy same-commit Companion v1 evidence bundle remains accepted", "stale Companion v1 Android evidence summary is rejected", "slow or incomplete Companion v1 Android evidence summary is rejected", "Android Gemma benchmark and dashboard media summaries", "mismatched Companion v1 release ZIP hash is rejected", "missing Companion v1 release package evidence is rejected", "mismatched Companion v1 source-readiness commit is rejected", "mismatched Companion v1 hardware evidence root is rejected", "mismatched Companion v1 hardware evidence commit is rejected", "mismatched Companion v1 report commit is rejected", "mismatched Companion v1 Android bundle commit is rejected", "mismatched Companion v1 Android applicationId is rejected", "mismatched Companion v1 Android app version is rejected", "mismatched Companion v1 Android app versionCode is rejected", "mismatched Companion v1 Android release APK hash is rejected", "mismatched Companion v1 Android release AAB hash is rejected", "mismatched Companion v1 Desktop package hash is rejected", "mismatched Companion v1 Desktop bundle commit is rejected", "mismatched Companion v1 voice-source commit is rejected", "mismatched Companion v1 review source commit is rejected", "mismatched Companion v1 review release version is rejected", "companion-v1-evidence-ready", "pending-companion-v1-evidence-bundle", "Companion v1 evidence bundle contract tests passed")) { if ($companionV1BundleContractText -notmatch [regex]::Escape($pattern)) { throw "tools/test_companion_v1_evidence_bundle_contract.ps1 missing companion v1 evidence bundle contract coverage: $pattern" } @@ -2570,6 +2798,18 @@ if ($manifest.androidPlayPrivacyPolicy -ne "docs/ANDROID_PLAY_PRIVACY_POLICY.md" throw "Manifest androidPlayPrivacyPolicy mismatch: $($manifest.androidPlayPrivacyPolicy)" } +if ($manifest.androidPlayPrivacyPolicySite -ne "site/privacy/index.html") { + throw "Manifest androidPlayPrivacyPolicySite mismatch: $($manifest.androidPlayPrivacyPolicySite)" +} + +if ($manifest.androidPlayPrivacyPolicyUrl -ne "https://robvanprod.github.io/stackchan_alive/privacy/") { + throw "Manifest androidPlayPrivacyPolicyUrl mismatch: $($manifest.androidPlayPrivacyPolicyUrl)" +} + +if ($manifest.pagesWorkflow -ne "provenance/pages.yml") { + throw "Manifest pagesWorkflow mismatch: $($manifest.pagesWorkflow)" +} + if ($manifest.androidPlayIcon -ne "docs/store-assets/play/icon-512.png") { throw "Manifest androidPlayIcon mismatch: $($manifest.androidPlayIcon)" } @@ -2603,6 +2843,7 @@ foreach ($pattern in @("Persona pack author is required", "Persona pack author m throw "bridge/persona_pack.py missing author provenance guard: $pattern" } } + foreach ($pattern in @("stackchan.persona-index.v1", "runtime_hot_swap", "persona pack file escapes pack root")) { if ($personaPackLibraryText -notmatch [regex]::Escape($pattern)) { throw "bridge/persona_pack.py missing persona index guard: $pattern" @@ -3261,12 +3502,21 @@ foreach ($pattern in @("Cross-Platform Build & Distribution Plan", "Kotlin Multi } $androidCompanionTestPlan = Get-Content -LiteralPath (Join-PackagePath "docs/ANDROID_COMPANION_TEST_PLAN.md") -Raw -foreach ($pattern in @("Android Companion Physical Test Plan", "foreground bridge service", "UDP beacon fallback", "manual URL fallback", "check_companion_v1_readiness.cmd", "protocol fixtures", "pending hardware gates", "check_android_toolchain.cmd", "SDK Platform 36", "cd companion", ".\gradlew.bat :app-android:assembleRelease", "companion\app-android\build\outputs\apk\release\app-android-release.apk", "RUN_ANDROID_APK_INSTALL.cmd -ApkPath -SourceCommit ", "source commit", "tools\install_android_companion_apk.cmd", "android/apk-install/", "android_apk_install.json", "RUN_ANDROID_UDP_BEACON_PROBE.cmd", "android/udp-beacon-probe/", "RUN_ANDROID_COMPANION_PROBE.cmd -Url ws://:8765/bridge", "android/companion-probe/", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd -Url ws://:8765/bridge", "tools\run_android_companion_soak.cmd", "android/screen-off-soak/", "android_companion_soak.json", "check_android_screen_off_soak_evidence.cmd", "ANDROID_SCREEN_OFF_SOAK_REVIEW.md", "android-screen-off-soak-ready", "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "tools\capture_android_companion_logcat.cmd", "android/logcat/", "android_companion_logcat.txt", "check_android_speech_evidence.cmd", "ANDROID_SPEECH_REVIEW.md", "android-speech-ready", "robot_speech_serial.log", "check_android_controls_evidence.cmd", "ANDROID_CONTROLS_REVIEW.md", "android-controls-ready", "robot_controls_serial.log", "robot_hello_required", "check_android_pairing_evidence.cmd", "ANDROID_PAIRING_REVIEW.md", "android-pairing-ready", "robot_pairing_serial.log", "android_pairing_setup.jpg", "bridge_url_applied", "check_android_wifi_evidence.cmd", "ANDROID_WIFI_REVIEW.md", "android-wifi-ready", "robot_wifi_serial.log", "bridge_wifi_store_loads", "bridge_wifi_store_has_record=1", "check_android_gemma_evidence.cmd", "ANDROID_GEMMA_REVIEW.md", "android-gemma-real-device-ready", "mobile_brain_litert_turn", "mobile_brain_litert_error", "endpoint_hello", "screen off", "robot serial log", "Android dashboard switches from waiting to connected", "Add your Stack-chan", "Start phone bridge", "Connect Stack-chan", "Confirm robot ready", "waiting/setup action", "trusted companion nodes are stored", "raw WebSocket connection without the robot", "Talk screen enables text input", "app_text_turn", "audio_stream_start", "response_end", "ANDROID_DIAGNOSTICS_EXPORT.json", "stackchan.android.diagnostics-export.v1", "redacts the last text turn", "Removing a stored trusted companion endpoint", "robot identity", "firmware/version signal", "last bridge frame", "active brain owner", "foreground service state")) { +foreach ($pattern in @("Android Companion Physical Test Plan", "API 35 AOSP automated-test emulator smoke", "test_android_emulator_launch.ps1", "substitutesForPhysicalEvidence=false", "foreground bridge service", "UDP beacon fallback", "manual URL fallback", "check_companion_v1_readiness.cmd", "protocol fixtures", "pending hardware gates", "check_android_toolchain.cmd", "SDK Platform 36", "cd companion", ".\gradlew.bat :app-android:assembleRelease", "companion\app-android\build\outputs\apk\release\app-android-release.apk", "RUN_ANDROID_APK_INSTALL.cmd -ApkPath -SourceCommit ", "source commit", "tools\install_android_companion_apk.cmd", "android/apk-install/", "android_apk_install.json", "RUN_ANDROID_UDP_BEACON_PROBE.cmd", "android/udp-beacon-probe/", "RUN_ANDROID_COMPANION_PROBE.cmd -Url ws://:8765/bridge", "android/companion-probe/", "RUN_ANDROID_SCREEN_OFF_SOAK.cmd -Url ws://:8765/bridge", "tools\run_android_companion_soak.cmd", "android/screen-off-soak/", "android_companion_soak.json", "check_android_screen_off_soak_evidence.cmd", "ANDROID_SCREEN_OFF_SOAK_REVIEW.md", "android-screen-off-soak-ready", "RUN_ANDROID_LOGCAT_CAPTURE.cmd", "tools\capture_android_companion_logcat.cmd", "android/logcat/", "android_companion_logcat.txt", "check_android_speech_evidence.cmd", "ANDROID_SPEECH_REVIEW.md", "android-speech-ready", "robot_speech_serial.log", "check_android_controls_evidence.cmd", "ANDROID_CONTROLS_REVIEW.md", "android-controls-ready", "robot_controls_serial.log", "robot_hello_required", "check_android_pairing_evidence.cmd", "ANDROID_PAIRING_REVIEW.md", "android-pairing-ready", "robot_pairing_serial.log", "android_pairing_setup.jpg", "bridge_url_applied", "check_android_wifi_evidence.cmd", "ANDROID_WIFI_REVIEW.md", "android-wifi-ready", "robot_wifi_serial.log", "bridge_wifi_store_loads", "bridge_wifi_store_has_record=1", "check_android_gemma_evidence.cmd", "ANDROID_GEMMA_REVIEW.md", "android-gemma-real-device-ready", "mobile_brain_litert_turn", "mobile_brain_litert_error", "endpoint_hello", "screen off", "robot serial log", "Android dashboard switches from waiting to connected", "Add your Stack-chan", "Start phone bridge", "Connect Stack-chan", "Confirm robot ready", "waiting/setup action", "trusted companion nodes are stored", "raw WebSocket connection without the robot", "Talk screen enables text input", "app_text_turn", "audio_stream_start", "response_end", "ANDROID_DIAGNOSTICS_EXPORT.json", "stackchan.android.diagnostics-export.v1", "redacts the last text turn", "Removing a stored trusted companion endpoint", "robot identity", "firmware/version signal", "last bridge frame", "active brain owner", "foreground service state")) { if ($androidCompanionTestPlan -notmatch [regex]::Escape($pattern)) { throw "ANDROID_COMPANION_TEST_PLAN.md missing expected Android physical test guidance: $pattern" } } +foreach ($relativePath in @("docs/DEVICE_BRINGUP.md", "docs/ARRIVAL_DAY_RUNBOOK.md", "QUICKSTART.md", "docs/ANDROID_COMPANION_TEST_PLAN.md", "tools/install_android_companion_apk.ps1")) { + $labSigningText = Get-Content -LiteralPath (Join-PackagePath $relativePath) -Raw + foreach ($pattern in @("stackchan.allowLabDebugReleaseSigning=true", "intentionally rejected")) { + if ($labSigningText -notmatch [regex]::Escape($pattern)) { + throw "$relativePath missing explicit fail-closed Android lab signing guidance: $pattern" + } + } +} + $companionEndpointServer = Get-Content -LiteralPath (Join-PackagePath "provenance/companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/EndpointServer.kt") -Raw foreach ($pattern in @("robotHelloReceived", "robot_hello_required", "audio, settings writes, or app text turns", "Stack-chan has not completed the bridge hello yet.")) { if ($companionEndpointServer -notmatch [regex]::Escape($pattern)) { @@ -3275,26 +3525,66 @@ foreach ($pattern in @("robotHelloReceived", "robot_hello_required", "audio, set } $androidPlayRelease = Get-Content -LiteralPath (Join-PackagePath "docs/ANDROID_PLAY_RELEASE.md") -Raw -foreach ($pattern in @("Android Play Release Checklist", "app-android-release.aab", "Play App Signing", "STACKCHAN_ANDROID_KEYSTORE", "docs/store-assets/play/icon-512.png", "feature-graphic-1024x500.png", "fastlane/metadata/android/en-US/", "ANDROID_PLAY_POLICY_DECLARATIONS.md", "ANDROID_PLAY_PRIVACY_POLICY.md", "physical robot validation", "Play Console internal testing")) { +foreach ($pattern in @("Android Play Release Checklist", "app-android-release.aab", "Play App Signing", "STACKCHAN_ANDROID_KEYSTORE", "One-Time Upload Key Provisioning", "keytool -genkeypair", "cryptographically validates", "test_android_upload_signing_contract.ps1", "certificate SHA-256 fingerprint", "2033-10-22", "CI runtime smoke", "API 35 AOSP ATD", "exact release APK artifact", "RequireAndroidEmulatorEvidence", "gh secret set STACKCHAN_ANDROID_KEYSTORE_B64", "gh secret list --app actions", "Companion Signing Readiness", "companion-signing-readiness.yml", "two independent offline media", "docs/store-assets/play/icon-512.png", "feature-graphic-1024x500.png", "fastlane/metadata/android/en-US/", "ANDROID_PLAY_POLICY_DECLARATIONS.md", "ANDROID_PLAY_PRIVACY_POLICY.md", "physical robot validation", "Play Console internal testing")) { if ($androidPlayRelease -notmatch [regex]::Escape($pattern)) { throw "ANDROID_PLAY_RELEASE.md missing expected Play release guidance: $pattern" } } $androidPlayPolicyDeclarations = Get-Content -LiteralPath (Join-PackagePath "docs/ANDROID_PLAY_POLICY_DECLARATIONS.md") -Raw -foreach ($pattern in @("Google Play Data safety form", "Privacy policy URL", "ANDROID_PLAY_PRIVACY_POLICY.md", "Data Safety Draft", "Not collected", "RECORD_AUDIO", "raw microphone audio is not stored", "Foreground service Play Console draft", "connectedDevice", "not directed to children")) { +foreach ($pattern in @("Google Play Data safety form", "Google Play User Data policy", "Privacy policy URL", "https://robvanprod.github.io/stackchan_alive/privacy/", "ANDROID_PLAY_PRIVACY_POLICY.md", "Data Safety Draft", "Collected only for optional, ephemeral app functionality", "RECORD_AUDIO", "configured Android SpeechRecognizer may transmit microphone audio", "not represented as end-to-end encrypted", "Foreground service Play Console draft", "connectedDevice", "not directed to children")) { if ($androidPlayPolicyDeclarations -notmatch [regex]::Escape($pattern)) { throw "ANDROID_PLAY_POLICY_DECLARATIONS.md missing expected Play policy guidance: $pattern" } } $androidPlayPrivacyPolicy = Get-Content -LiteralPath (Join-PackagePath "docs/ANDROID_PLAY_PRIVACY_POLICY.md") -Raw -foreach ($pattern in @("Stackchan Companion Privacy Policy", "dev.stackchan.companion", "does not create accounts", "does not persist raw microphone audio", "diagnostics export", "password_redacted=true", "optional Mobile Brain model", "saved robot and trusted companion records", "not directed to children")) { +foreach ($pattern in @("Stackchan Companion Privacy Policy", "July 14, 2026", "https://robvanprod.github.io/stackchan_alive/privacy/", "dev.stackchan.companion", "does not create accounts", "does not persist raw microphone audio", "configured Android speech-recognition service", "may process microphone audio", "diagnostics export", "password_redacted=true", "optional Mobile Brain model", "saved robot and trusted companion records", "not represented as end-to-end encrypted", "not directed to children")) { if ($androidPlayPrivacyPolicy -notmatch [regex]::Escape($pattern)) { throw "ANDROID_PLAY_PRIVACY_POLICY.md missing expected Play privacy policy guidance: $pattern" } } +$privacySite = Get-Content -LiteralPath (Join-PackagePath "site/privacy/index.html") -Raw +foreach ($pattern in @("Stackchan Companion Privacy Policy", "July 14, 2026", "dev.stackchan.companion", "Privacy inquiries", "configured Android speech-recognition service", "may process audio", "password_redacted=true", "not represented as end-to-end encrypted", "not directed to children")) { + if ($privacySite -notmatch [regex]::Escape($pattern)) { + throw "site/privacy/index.html missing expected public privacy-policy content: $pattern" + } +} + +$privacyDeploymentRecord = Get-Content -LiteralPath (Join-PackagePath "docs/store-assets/play/PRIVACY_POLICY_DEPLOYMENT.json") -Raw | ConvertFrom-Json +if ($privacyDeploymentRecord.schema -ne "stackchan.privacy-policy-deployment.v1" -or + $privacyDeploymentRecord.status -ne "deployed" -or + $privacyDeploymentRecord.canonicalUrl -ne "https://robvanprod.github.io/stackchan_alive/privacy/" -or + $privacyDeploymentRecord.sourcePath -ne "site/privacy/index.html" -or + $privacyDeploymentRecord.sourceSha256 -ne $privacyDeploymentRecord.servedSha256 -or + $privacyDeploymentRecord.pagesBuildStatus -ne "built" -or + -not [bool]$privacyDeploymentRecord.httpsEnforced) { + throw "PRIVACY_POLICY_DEPLOYMENT.json does not bind a built HTTPS deployment to the tracked policy source." +} + +$pagesWorkflow = Get-Content -LiteralPath (Join-PackagePath "provenance/pages.yml") -Raw +foreach ($pattern in @("Deploy privacy policy", "branches:", "main", "site/**", "pages: write", "id-token: write", "actions/configure-pages@v5", "actions/upload-pages-artifact@v4", "path: site", "actions/deploy-pages@v4")) { + if ($pagesWorkflow -notmatch [regex]::Escape($pattern)) { + throw "provenance/pages.yml missing expected Pages deployment control: $pattern" + } +} + +$companionIdentitySource = Get-Content -LiteralPath (Join-PackagePath "provenance/companion/core/src/commonMain/kotlin/dev/stackchan/companion/core/CompanionIdentity.kt") -Raw +if ($companionIdentitySource -notmatch [regex]::Escape('privacyPolicyUrl = "https://robvanprod.github.io/stackchan_alive/privacy/"')) { + throw "CompanionIdentity.kt must bind the app to the canonical privacy-policy URL." +} + +$androidMainSource = Get-Content -LiteralPath (Join-PackagePath "provenance/companion/app-android/src/main/kotlin/dev/stackchan/companion/android/MainActivity.kt") -Raw +if ($androidMainSource -notmatch [regex]::Escape("Intent.ACTION_VIEW") -or $androidMainSource -notmatch [regex]::Escape("CompanionIdentity.privacyPolicyUrl")) { + throw "Android MainActivity.kt must open the canonical privacy-policy URL." +} + +$desktopMainSource = Get-Content -LiteralPath (Join-PackagePath "provenance/companion/app-desktop/src/main/kotlin/dev/stackchan/companion/desktop/Main.kt") -Raw +if ($desktopMainSource -notmatch [regex]::Escape("Desktop.Action.BROWSE") -or $desktopMainSource -notmatch [regex]::Escape("CompanionIdentity.privacyPolicyUrl")) { + throw "Desktop Main.kt must open the canonical privacy-policy URL." +} + $johnnyAlivePathway = Get-Content -LiteralPath (Join-PackagePath "docs/JOHNNY_ALIVE_PATHWAY.md") -Raw foreach ($pattern in @("Johnny Alive Pathway", "hardware baseline", "Release Baseline", "996b7e4b2de0c529a0f0e508891dec33598bf935", "Visitor Test", "LTR-553", "on-device wake phrase", "DirectML RVC", "YuNet", "privacy-filtered durable memory", "Phase Status", "P8 Continuity", "Sequenced Post-Release Work", "typed conversation lease", "Perceived latency", "persona hot-swap", "Evidence Rules", "Non-Negotiables", "strict 50 ms frame gate")) { if ($johnnyAlivePathway -notmatch [regex]::Escape($pattern)) {