diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70a2f6b94..7a1ac3752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -790,10 +790,35 @@ jobs: android-test: if: github.event_name != 'schedule' runs-on: ubuntu-latest + outputs: + android_changed: ${{ steps.android_changes.outputs.changed }} permissions: contents: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + - name: Select Android emulator coverage + id: android_changes + env: + PR_BASE: ${{ github.event.pull_request.base.sha }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + PUSH_BASE: ${{ github.event.before }} + shell: bash + run: | + set -euo pipefail + HEAD_SHA="${PR_HEAD:-$GITHUB_SHA}" + BASE_SHA="${PR_BASE:-$PUSH_BASE}" + if [ -z "$BASE_SHA" ] || [[ "$BASE_SHA" =~ ^0+$ ]]; then + BASE_SHA="$(git rev-parse "$HEAD_SHA^" 2>/dev/null || true)" + fi + if [ -n "$BASE_SHA" ]; then + CHANGED="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- android/)" + else + CHANGED="$(git diff-tree --root --no-commit-id --name-only -r "$HEAD_SHA" -- android/)" + fi + if [ -n "$CHANGED" ]; then echo 'changed=true' >> "$GITHUB_OUTPUT"; + else echo 'changed=false' >> "$GITHUB_OUTPUT"; fi - uses: ./.github/actions/setup-python-env - name: Cache Android build dependencies uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -835,7 +860,8 @@ jobs: # releases. This is an emulator compatibility signal, not a substitute for # rooted ARM64, SELinux, launcher, WebView and physical-device qualification. android-emulator-smoke: - if: github.event_name != 'schedule' + needs: android-test + if: github.event_name != 'schedule' && (startsWith(github.ref, 'refs/tags/v') || needs.android-test.outputs.android_changed == 'true') name: Android emulator smoke (API ${{ matrix.api-level }}) runs-on: ubuntu-latest timeout-minutes: 20 @@ -1828,7 +1854,12 @@ jobs: # Release: Create GitHub Release with all artifacts # ────────────────────────────────────────────────────────────────── release: - if: startsWith(github.ref, 'refs/tags/v') + if: >- + ${{ always() && !cancelled() && startsWith(github.ref, 'refs/tags/v') && + needs.build.result == 'success' && needs.release-preflight.result == 'success' && + needs.marker-guards.result == 'success' && needs.ui-smoke.result == 'success' && + needs.docker-ui-smoke.result == 'success' && needs.docker-portable-test.result == 'success' && + needs.skill-smoke.result == 'success' }} needs: [build, android-build, release-preflight, marker-guards, ui-smoke, docker-ui-smoke, docker-portable-test, skill-smoke] runs-on: ubuntu-latest concurrency: @@ -1854,7 +1885,52 @@ jobs: path: release-artifacts/ merge-multiple: true + - name: Verify artifact attestations + id: verify_artifacts + env: + GH_TOKEN: ${{ github.token }} + ANDROID_BUILD_RESULT: ${{ needs.android-build.result }} + shell: bash + run: | + set -euo pipefail + python - <<'PYCODE' > "$RUNNER_TEMP/attestation-targets.tsv" + import os, pathlib, runpy + registry = runpy.run_path("ouroboros/tools/release_sync.py") + version = pathlib.Path("VERSION").read_text(encoding="utf-8").strip() + ids = list(registry["DESKTOP_DOWNLOAD_IDS"]) + if os.environ["ANDROID_BUILD_RESULT"] == "success": + ids.extend(registry["ANDROID_DOWNLOAD_IDS"]) + for proof_id in ids: + name = registry["release_asset_name"](proof_id, version) + print(proof_id + "\t" + str(pathlib.Path("release-artifacts") / name)) + PYCODE + SOURCE_ARGS=( + --repo "$GITHUB_REPOSITORY" + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/ci.yml" + --source-digest "$GITHUB_SHA" + --source-ref "$GITHUB_REF" + ) + ANDROID_RESULT=not_run + if [ "$ANDROID_BUILD_RESULT" = success ]; then ANDROID_RESULT=success; fi + while IFS=$'\t' read -r proof_id file; do + if [[ "$proof_id" == android-* ]]; then + if ! gh attestation verify "$file" "${SOURCE_ARGS[@]}" || + ! gh attestation verify "$file" "${SOURCE_ARGS[@]}" --predicate-type https://cyclonedx.org/bom; then + ANDROID_RESULT=failure + echo "::warning::Experimental Android verification failed for $file; neither Android asset will be published." + fi + else + gh attestation verify "$file" "${SOURCE_ARGS[@]}" + gh attestation verify "$file" "${SOURCE_ARGS[@]}" --predicate-type https://cyclonedx.org/bom + fi + done < "$RUNNER_TEMP/attestation-targets.tsv" + echo "android_result=$ANDROID_RESULT" >> "$GITHUB_OUTPUT" + - name: Assemble release proof capsule and notes + id: release_proof + env: + ANDROID_BUILD_RESULT: ${{ needs.android-build.result }} + ANDROID_ATTESTATION_RESULT: ${{ steps.verify_artifacts.outputs.android_result }} shell: bash run: | set -euo pipefail @@ -1867,35 +1943,12 @@ jobs: --tag "$GITHUB_REF_NAME" \ --commit "$GITHUB_SHA" \ --run-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + --android-build-result "$ANDROID_BUILD_RESULT" \ + --android-attestation-result "$ANDROID_ATTESTATION_RESULT" \ + --github-output "$GITHUB_OUTPUT" \ --notes-output release-notes.md \ "${ARGS[@]}" - - name: Verify artifact attestations - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - for file in release-artifacts/Ouroboros-*.dmg \ - release-artifacts/Ouroboros-*-linux-x86_64.tar.gz \ - release-artifacts/Ouroboros-*-linux-x86_64.AppImage \ - release-artifacts/ouroboros_*_amd64.deb \ - release-artifacts/ouroboros-*-1.x86_64.rpm \ - release-artifacts/ouroboros-*-1.red80.x86_64.rpm \ - release-artifacts/Ouroboros-*-windows-x64.zip \ - release-artifacts/Ouroboros-*-android-arm64.tar.gz \ - release-artifacts/Ouroboros-*-android.apk; do - SOURCE_ARGS=( - --repo "$GITHUB_REPOSITORY" - --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/ci.yml" - --source-digest "$GITHUB_SHA" - --source-ref "$GITHUB_REF" - ) - gh attestation verify "$file" "${SOURCE_ARGS[@]}" - gh attestation verify "$file" "${SOURCE_ARGS[@]}" \ - --predicate-type https://cyclonedx.org/bom - done - - name: Require an unpublished release slot env: GH_TOKEN: ${{ github.token }} @@ -1949,36 +2002,7 @@ jobs: - name: Create draft GitHub Release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: - files: | - release-artifacts/Ouroboros-*.dmg - release-artifacts/Ouroboros-*-linux-x86_64.tar.gz - release-artifacts/Ouroboros-*-linux-x86_64.AppImage - release-artifacts/ouroboros_*_amd64.deb - release-artifacts/ouroboros-*-1.x86_64.rpm - release-artifacts/ouroboros-*-1.red80.x86_64.rpm - release-artifacts/Ouroboros-*-windows-x64.zip - release-artifacts/Ouroboros-*-android-arm64.tar.gz - release-artifacts/Ouroboros-*-android.apk - release-artifacts/release-smoke-macos-arm64.json - release-artifacts/release-smoke-linux-x86_64.json - release-artifacts/release-smoke-linux-appimage-x86_64.json - release-artifacts/release-smoke-linux-deb-amd64.json - release-artifacts/release-smoke-linux-rpm-x86_64.json - release-artifacts/release-smoke-linux-rpm-red80-x86_64.json - release-artifacts/release-smoke-windows-x64.json - release-artifacts/release-smoke-android-arm64.json - release-artifacts/release-smoke-android-apk.json - release-artifacts/sbom-macos-arm64.cdx.json - release-artifacts/sbom-linux-x86_64.cdx.json - release-artifacts/sbom-linux-appimage-x86_64.cdx.json - release-artifacts/sbom-linux-deb-amd64.cdx.json - release-artifacts/sbom-linux-rpm-x86_64.cdx.json - release-artifacts/sbom-linux-rpm-red80-x86_64.cdx.json - release-artifacts/sbom-windows-x64.cdx.json - release-artifacts/sbom-android-arm64.cdx.json - release-artifacts/sbom-android-apk.cdx.json - release-artifacts/SHA256SUMS - release-artifacts/release-evidence.json + files: ${{ fromJSON(steps.release_proof.outputs.files_json) }} body_path: release-notes.md fail_on_unmatched_files: true # The existing annotated tag is exact-SHA verified above. Passing an diff --git a/android/host/src/ai/ouroboros/android/CoreService.java b/android/host/src/ai/ouroboros/android/CoreService.java index 7b35a22c9..9da4e1920 100644 --- a/android/host/src/ai/ouroboros/android/CoreService.java +++ b/android/host/src/ai/ouroboros/android/CoreService.java @@ -24,7 +24,7 @@ public final class CoreService extends Service { private final java.util.concurrent.atomic.AtomicInteger dnsRevision = new java.util.concurrent.atomic.AtomicInteger(); private volatile boolean closed; private volatile String networkNote = ""; - private volatile String runtimeText = "Проверяю состояние ядра"; + private volatile String runtimeText = "Checking core status"; private volatile String queuedDns = null; private String queuedNetworkNote = null; @@ -57,16 +57,16 @@ public final class CoreService extends Service { PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); return new Notification.Builder(this, CHANNEL).setSmallIcon(android.R.drawable.ic_menu_manage) .setContentTitle("Ouroboros").setContentText(text).setContentIntent(open) - .addAction(new Notification.Action.Builder(null, "Остановить агента", panic).build()) + .addAction(new Notification.Action.Builder(null, "Stop agent", panic).build()) .setOngoing(true).build(); } private void updateNetworkDns(LinkProperties properties) { if (closed) return; String dns = properties == null ? "" : RuntimeClient.dnsConfiguration(properties.getDnsServers()); - String note = dns.isEmpty() ? "Нет текущих DNS: файл Linux оставлен без изменений" : ""; + String note = dns.isEmpty() ? "No current DNS servers; the Linux file was left unchanged" : ""; if (properties != null && android.os.Build.VERSION.SDK_INT >= 28 && properties.isPrivateDnsActive()) - note += (note.isEmpty() ? "" : "; ") + "Android Private DNS не переносится в обычный DNS Linux"; + note += (note.isEmpty() ? "" : "; ") + "Android Private DNS is not transferred to ordinary Linux DNS"; if (dns.equals(queuedDns) && note.equals(queuedNetworkNote)) return; queuedDns = dns; queuedNetworkNote = note; networkNote = note; @@ -77,7 +77,7 @@ public final class CoreService extends Service { catch (Exception error) { if (closed || revision != dnsRevision.get()) return; queuedDns = null; // A later network/control event may retry after root is granted. - networkNote = "Не удалось обновить DNS Linux"; + networkNote = "Could not update Linux DNS"; android.util.Log.e("OuroborosHost", "Linux DNS update failed", error); } if (!closed && revision == dnsRevision.get()) @@ -120,13 +120,13 @@ public final class CoreService extends Service { if ("start".equals(action) || "boot".equals(action)) { String result = RuntimeClient.control("start", "boot".equals(action) ? "automatic" : "owner"); if (result.equals("stopped")) { - getSystemService(NotificationManager.class).notify(1, notification("Остановлен владельцем. Нажмите «Запустить» для продолжения.")); + getSystemService(NotificationManager.class).notify(1, notification("Stopped by the owner. Choose Start core to continue.")); stopForeground(STOP_FOREGROUND_DETACH); stopSelf(startId); return; } starting = result.equals("starting"); } - if (starting) getSystemService(NotificationManager.class).notify(1, notification("Ядро запускается…")); + if (starting) getSystemService(NotificationManager.class).notify(1, notification("The core is starting…")); long deadline = android.os.SystemClock.elapsedRealtime() + (starting ? START_OBSERVATION_MS : 0); while (true) { if (request != operation.get()) return; @@ -139,7 +139,7 @@ public final class CoreService extends Service { if (android.os.SystemClock.elapsedRealtime() >= deadline) { if (request != operation.get()) return; getSystemService(NotificationManager.class).notify(1, notification( - "Запуск запрошен. Готовность ядра пока не подтверждена; проверьте статус позже.")); + "Start requested. Core readiness is not yet confirmed; check status later.")); return; } Thread.sleep(1000); @@ -147,20 +147,20 @@ public final class CoreService extends Service { } if (request != operation.get()) return; getSystemService(NotificationManager.class).notify(1, notification(bridgeFailure == null - ? "Ядро работает на телефоне" : "Ядро работает. Android-инструменты недоступны: " + ? "The core is running on this phone" : "The core is running. Android tools are unavailable: " + bridgeFailure.getMessage())); } catch (Exception error) { if (request != operation.get()) return; android.util.Log.e("OuroborosHost", "Native control failed: " + action, error); getSystemService(NotificationManager.class).notify(1, - notification("Действие не выполнено. Откройте статус: " + error.getMessage())); + notification("Action failed. Open status: " + error.getMessage())); } }); return "panic".equals(action) ? START_NOT_STICKY : START_STICKY; } private void startForegroundOwnerNotification() { - Notification value = notification("Проверяю состояние ядра"); + Notification value = notification("Checking core status"); if (android.os.Build.VERSION.SDK_INT >= 34) { int type = android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE; boolean background = checkSelfPermission(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION) diff --git a/android/host/src/ai/ouroboros/android/MainActivity.java b/android/host/src/ai/ouroboros/android/MainActivity.java index 61b89b7db..5fdc9a2c7 100644 --- a/android/host/src/ai/ouroboros/android/MainActivity.java +++ b/android/host/src/ai/ouroboros/android/MainActivity.java @@ -73,19 +73,19 @@ public final class MainActivity extends Activity { title.setOnClickListener(v -> showStatus()); menu = new Button(this); menu.setText("⋮"); menu.setTextSize(23); menu.setTextColor(android.graphics.Color.WHITE); menu.setBackgroundColor(android.graphics.Color.TRANSPARENT); - menu.setContentDescription("Меню приложения"); + menu.setContentDescription("Application menu"); menu.setOnClickListener(v -> { PopupMenu popup = new PopupMenu(this, menu); - popup.getMenu().add(0, 1, 0, "Запустить ядро"); - popup.getMenu().add(0, 2, 1, "Обновить окно"); - popup.getMenu().add(0, 3, 2, "Статус ядра"); - popup.getMenu().add(0, 4, 3, "Остановить агента"); - popup.getMenu().add(0, 5, 4, "Разрешения Android"); - popup.getMenu().add(0, 6, 5, "Назначить ассистентом Android"); + popup.getMenu().add(0, 1, 0, "Start core"); + popup.getMenu().add(0, 2, 1, "Refresh view"); + popup.getMenu().add(0, 3, 2, "Core status"); + popup.getMenu().add(0, 4, 3, "Stop agent"); + popup.getMenu().add(0, 5, 4, "Android permissions"); + popup.getMenu().add(0, 6, 5, "Set as Android assistant"); popup.setOnMenuItemClickListener(item -> { switch (item.getItemId()) { case 1: - status.setVisibility(View.VISIBLE); status.setText("Запускаю ядро…"); + status.setVisibility(View.VISIBLE); status.setText("Starting the core…"); startForegroundService(new Intent(this, CoreService.class).setAction("start")); break; case 2: if (loaded && web != null) web.reload(); check(true); break; case 3: showStatus(); break; @@ -104,7 +104,7 @@ public final class MainActivity extends Activity { title.setGravity(Gravity.CENTER_VERTICAL); toolbar.addView(menu, new LinearLayout.LayoutParams(dp(48), dp(40))); root.addView(toolbar); - status = new TextView(this); status.setText("Подключаюсь к ядру на телефоне…"); + status = new TextView(this); status.setText("Connecting to the core on this phone…"); status.setTextColor(android.graphics.Color.LTGRAY); status.setPadding(16, 4, 16, 8); root.addView(status); web = new WebView(this); @@ -142,7 +142,7 @@ public final class MainActivity extends Activity { @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { if (request.isForMainFrame()) { loaded = false; status.setVisibility(View.VISIBLE); - status.setText("Жду подключения к ядру…"); + status.setText("Waiting for the core connection…"); } } @Override public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { @@ -180,10 +180,10 @@ public final class MainActivity extends Activity { }); web.setDownloadListener((url, agent, disposition, mime, length) -> { if (!url.startsWith(RuntimeClient.baseUrl() + "/")) { - notifyOutcome("Этот формат скачивания пока не поддержан приложением"); return; + notifyOutcome("This download format is not yet supported by the app"); return; } if (pendingDownload != null) { - notifyOutcome("Сначала выберите место для предыдущего файла"); return; + notifyOutcome("Choose a destination for the previous file first"); return; } pendingDownload = url; Intent save = new Intent(Intent.ACTION_CREATE_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE) @@ -191,7 +191,7 @@ public final class MainActivity extends Activity { .putExtra(Intent.EXTRA_TITLE, URLUtil.guessFileName(url, disposition, mime)); try { startActivityForResult(save, 11); } catch (ActivityNotFoundException error) { - pendingDownload = null; notifyOutcome("На телефоне нет приложения выбора файла"); + pendingDownload = null; notifyOutcome("No file picker is available on this phone"); } }); root.addView(web, new LinearLayout.LayoutParams(-1, 0, 1)); @@ -207,23 +207,23 @@ public final class MainActivity extends Activity { /** Ask Android's ordinary user-consent flow for the assistant role. */ private void requestAssistantRole() { if (Build.VERSION.SDK_INT < 29) { - notifyOutcome("Роль системного ассистента доступна начиная с Android 10"); + notifyOutcome("The system assistant role requires Android 10 or later"); return; } RoleManager roles = (RoleManager) getSystemService(RoleManager.class); if (roles == null || !roles.isRoleAvailable(RoleManager.ROLE_ASSISTANT)) { - notifyOutcome("На этом Android нет роли системного ассистента"); + notifyOutcome("The system assistant role is unavailable on this Android device"); return; } if (roles.isRoleHeld(RoleManager.ROLE_ASSISTANT)) { - notifyOutcome("Ouroboros уже выбран системным ассистентом"); + notifyOutcome("Ouroboros is already the system assistant"); return; } try { startActivityForResult(roles.createRequestRoleIntent(RoleManager.ROLE_ASSISTANT), ASSISTANT_ROLE_REQUEST); } catch (RuntimeException error) { - notifyOutcome("Android не открыл выбор системного ассистента"); + notifyOutcome("Android could not open the system assistant chooser"); } } @@ -247,30 +247,30 @@ public final class MainActivity extends Activity { } } } catch (android.content.pm.PackageManager.NameNotFoundException error) { - notifyOutcome("Не удалось прочитать разрешения приложения"); + notifyOutcome("Could not read the app permissions"); } return missing.toArray(new String[0]); } private void showAccessSetup() { if (isFinishing() || isDestroyed()) return; - new AlertDialog.Builder(this).setTitle("Доступ Ouroboros к телефону") - .setMessage("Ouroboros может использовать камеру, микрофон, геопозицию, контакты, календарь " - + "и медиафайлы по вашим поручениям. В следующих окнах Android выберите, что разрешить. " - + "Уже выданные разрешения не запрашиваются перед каждым действием. " - + "Root-доступ выдаётся отдельно в Magisk. Изменить доступ можно в меню приложения.") - .setPositiveButton("Настроить доступ", (dialog, which) -> { + new AlertDialog.Builder(this).setTitle("Ouroboros access to your phone") + .setMessage("Ouroboros can use the camera, microphone, location, contacts, calendar " + + "and media files for your tasks. Choose what to allow in the following Android dialogs. " + + "Existing permissions are not requested again before each action. " + + "Root access is granted separately in Magisk. You can change access from the app menu.") + .setPositiveButton("Set up access", (dialog, which) -> { getPreferences(MODE_PRIVATE).edit().putBoolean("native_access_setup_seen", true).apply(); enqueuePermissionPrompt(new PermissionPrompt(missingRuntimePermissions(), proceed -> { if (!isFinishing() && !isDestroyed()) { String[] missing = missingRuntimePermissions(); - notifyOutcome(missing.length == 0 ? "Разрешения настроены" : "Доступ настроен с выбранными ограничениями"); + notifyOutcome(missing.length == 0 ? "Permissions configured" : "Access configured with your selected restrictions"); } })); }) - .setNeutralButton("Настройки Android", (dialog, which) -> startActivity(new Intent( + .setNeutralButton("Android settings", (dialog, which) -> startActivity(new Intent( android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + getPackageName())))) - .setNegativeButton("Позже", (dialog, which) -> + .setNegativeButton("Later", (dialog, which) -> getPreferences(MODE_PRIVATE).edit().putBoolean("native_access_setup_seen", true).apply()) .show(); } @@ -396,12 +396,12 @@ public final class MainActivity extends Activity { } else if (startWhenMissing) { startWhenMissing = false; status.setVisibility(View.VISIBLE); - status.setText("Запускаю ядро…"); + status.setText("Starting the core…"); // Passive window entry must not clear a previous Panic. startForegroundService(new Intent(this, CoreService.class).setAction("boot")); } else { status.setVisibility(View.VISIBLE); - status.setText("Ядро не отвечает. Откройте меню запуска или статуса."); + status.setText("The core is not responding. Open the Start or Status menu."); } handler.removeCallbacks(refresh); if (visible) handler.postDelayed(refresh, 3000); @@ -414,19 +414,19 @@ public final class MainActivity extends Activity { String text; try { JSONObject state = RuntimeClient.request("/api/state", "GET"); - text = "Ядро: " + RuntimeClient.baseUrl() + "\nРаботники: " + text = "Core: " + RuntimeClient.baseUrl() + "\nWorkers: " + state.optInt("workers_alive") + "/" + state.optInt("workers_total") - + "\nРежим: " + state.optString("runtime_mode") - + "\nОжидают: " + state.optInt("pending_count") - + "\nАктивные задачи: " + state.optInt("running_count"); + + "\nMode: " + state.optString("runtime_mode") + + "\nPending: " + state.optInt("pending_count") + + "\nActive tasks: " + state.optInt("running_count"); } catch (Exception error) { try { text = RuntimeClient.control("status", "owner"); } - catch (Exception controlError) { text = "Запуск недоступен: " + controlError.getMessage(); } + catch (Exception controlError) { text = "Start unavailable: " + controlError.getMessage(); } } final String message = text; runOnUiThread(() -> { if (!isDestroyed()) new AlertDialog.Builder(this).setTitle("Ouroboros") - .setMessage(message).setPositiveButton("Закрыть", null).show(); + .setMessage(message).setPositiveButton("Close", null).show(); }); }); } @@ -435,7 +435,7 @@ public final class MainActivity extends Activity { String scheme = uri.getScheme(); if (!"https".equals(scheme) && !"http".equals(scheme) && !"mailto".equals(scheme)) return; try { startActivity(new Intent(Intent.ACTION_VIEW, uri)); } - catch (ActivityNotFoundException error) { notifyOutcome("Нет приложения для этой ссылки"); } + catch (ActivityNotFoundException error) { notifyOutcome("No app can open this link"); } } @Override protected void onActivityResult(int request, int result, Intent data) { @@ -443,8 +443,8 @@ public final class MainActivity extends Activity { if (request == ASSISTANT_ROLE_REQUEST && Build.VERSION.SDK_INT >= 29) { RoleManager roles = (RoleManager) getSystemService(RoleManager.class); boolean held = roles != null && roles.isRoleHeld(RoleManager.ROLE_ASSISTANT); - notifyOutcome(held ? "Ouroboros выбран системным ассистентом" - : "Выбор системного ассистента отменён"); + notifyOutcome(held ? "Ouroboros is now the system assistant" + : "System assistant selection cancelled"); return; } if (request == 10 && chooser != null) { @@ -468,19 +468,19 @@ public final class MainActivity extends Activity { while ((count = input.read(buffer)) >= 0) output.write(buffer, 0, count); } runOnUiThread(() -> showSavedFile(target)); - } catch (Exception error) { notifyOutcome("Не удалось сохранить файл"); } + } catch (Exception error) { notifyOutcome("Could not save the file"); } finally { if (connection != null) connection.disconnect(); } }); } } private void showSavedFile(Uri file) { - if (isFinishing() || isDestroyed()) { notifyOutcome("Файл сохранён"); return; } - new AlertDialog.Builder(this).setTitle("Файл сохранён") - .setMessage("Открыть файл или передать его в другое приложение?") - .setPositiveButton("Открыть", (dialog, which) -> openSavedFile(file, false)) - .setNeutralButton("Поделиться", (dialog, which) -> openSavedFile(file, true)) - .setNegativeButton("Закрыть", null).show(); + if (isFinishing() || isDestroyed()) { notifyOutcome("File saved"); return; } + new AlertDialog.Builder(this).setTitle("File saved") + .setMessage("Open this file or share it with another app?") + .setPositiveButton("Open", (dialog, which) -> openSavedFile(file, false)) + .setNeutralButton("Share", (dialog, which) -> openSavedFile(file, true)) + .setNegativeButton("Close", null).show(); } private void openSavedFile(Uri file, boolean share) { @@ -490,8 +490,8 @@ public final class MainActivity extends Activity { : new Intent(Intent.ACTION_VIEW).setDataAndType(file, mime); action.setClipData(ClipData.newUri(getContentResolver(), "Ouroboros", file)); action.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - try { startActivity(Intent.createChooser(action, share ? "Поделиться файлом" : "Открыть файл")); } - catch (ActivityNotFoundException error) { notifyOutcome("Нет приложения для этого файла"); } + try { startActivity(Intent.createChooser(action, share ? "Share file" : "Open file")); } + catch (ActivityNotFoundException error) { notifyOutcome("No app can open this file"); } } @Override protected void onSaveInstanceState(Bundle saved) { diff --git a/android/host/src/ai/ouroboros/android/RuntimeClient.java b/android/host/src/ai/ouroboros/android/RuntimeClient.java index 0f4e87d6d..b943d9b62 100644 --- a/android/host/src/ai/ouroboros/android/RuntimeClient.java +++ b/android/host/src/ai/ouroboros/android/RuntimeClient.java @@ -93,7 +93,7 @@ final class RuntimeClient { if (input != null) stream.write(input.getBytes(StandardCharsets.UTF_8)); } if (!process.waitFor(30, TimeUnit.SECONDS)) - throw new java.io.IOException("Действие ещё не подтверждено. Проверьте статус перед повтором."); + throw new java.io.IOException("The action is not yet confirmed. Check its status before retrying."); reader.join(1000); String output = bytes.toString("UTF-8").trim(); if (process.exitValue() != 0) throw new java.io.IOException(output); diff --git a/android/tests/test_accessibility_adapter.py b/android/tests/test_accessibility_adapter.py index 648dbecbd..103570a74 100644 --- a/android/tests/test_accessibility_adapter.py +++ b/android/tests/test_accessibility_adapter.py @@ -10,7 +10,7 @@ CONFIG = HOST / "res/xml/accessibility_service_config.xml" class AccessibilityAdapterTest(unittest.TestCase): def test_service_exposes_bounded_snapshot_and_stable_snapshot_addresses(self): - source = SERVICE.read_text() + source = SERVICE.read_text(encoding="utf-8") for marker in ("max_windows", "max_nodes", "max_depth", "truncation_reason", "node_address", "stale_or_missing_node_address"): self.assertIn(marker, source) self.assertIn('"bounded_interactive_window_tree"', source) @@ -18,13 +18,13 @@ class AccessibilityAdapterTest(unittest.TestCase): self.assertIn("child.recycle()", source) def test_generic_node_actions_and_gesture_have_typed_failure_paths(self): - source = SERVICE.read_text() + source = SERVICE.read_text(encoding="utf-8") for marker in ("ACTION_CLICK", "ACTION_SET_TEXT", "ACTION_SCROLL_FORWARD", "dispatchGesture", "gesture_result_timeout", "accessibility_not_enabled"): self.assertIn(marker, source) def test_service_requests_interactive_windows_and_view_ids(self): - config = CONFIG.read_text() + config = CONFIG.read_text(encoding="utf-8") self.assertIn("flagRetrieveInteractiveWindows", config) self.assertIn("flagReportViewIds", config) self.assertIn('android:canRetrieveWindowContent="true"', config) diff --git a/android/tests/test_android_host.py b/android/tests/test_android_host.py index 335598840..d238eb76a 100644 --- a/android/tests/test_android_host.py +++ b/android/tests/test_android_host.py @@ -18,7 +18,7 @@ class AndroidHostTest(unittest.TestCase): for action in intent_filter.findall("action") } self.assertIn("android.intent.action.ASSIST", actions) - source = (HOST / "src/ai/ouroboros/android/MainActivity.java").read_text() + source = (HOST / "src/ai/ouroboros/android/MainActivity.java").read_text(encoding="utf-8") self.assertIn("createRequestRoleIntent(RoleManager.ROLE_ASSISTANT)", source) self.assertIn("isRoleHeld(RoleManager.ROLE_ASSISTANT)", source) @@ -45,7 +45,7 @@ class AndroidHostTest(unittest.TestCase): self.assertEqual(ET.parse(HOST / "res/xml/wallpaper_service.xml").getroot().tag, "wallpaper") def test_location_bridge_has_state_and_bounded_current_fix_methods(self): - source = (HOST / "src/ai/ouroboros/android/AndroidBridge.java").read_text() + source = (HOST / "src/ai/ouroboros/android/AndroidBridge.java").read_text(encoding="utf-8") self.assertIn('"location.state"', source) self.assertIn('"location.get"', source) self.assertIn("getCurrentLocation", source) @@ -58,7 +58,7 @@ class AndroidHostTest(unittest.TestCase): app = manifest.find("application") self.assertEqual(app.get(A + "usesCleartextTraffic"), "false") self.assertEqual(app.get(A + "networkSecurityConfig"), "@xml/network_security_config") - config = (HOST / "res/xml/network_security_config.xml").read_text() + config = (HOST / "res/xml/network_security_config.xml").read_text(encoding="utf-8") self.assertIn('cleartextTrafficPermitted="false"', config) self.assertIn("localhost", config) self.assertIn("127.0.0.1", config) @@ -82,12 +82,12 @@ class AndroidHostTest(unittest.TestCase): self.assertIn("android.permission.REQUEST_INSTALL_PACKAGES", permissions) receiver = manifest.find("application/receiver[@android:name='.PackageInstallReceiver']", {"android": "http://schemas.android.com/apk/res/android"}) self.assertIsNotNone(receiver) - source = (HOST / "src/ai/ouroboros/android/AndroidBridge.java").read_text() + source = (HOST / "src/ai/ouroboros/android/AndroidBridge.java").read_text(encoding="utf-8") for marker in ("\"packages.sessions\"", "\"packages.install\"", "\"packages.install.abandon\"", "idempotency_key", "source_sha256", "completion_observed", "retry_automatically", "rollback"): self.assertIn(marker, source) - callback = (HOST / "src/ai/ouroboros/android/PackageInstallReceiver.java").read_text() + callback = (HOST / "src/ai/ouroboros/android/PackageInstallReceiver.java").read_text(encoding="utf-8") self.assertIn("STATUS_PENDING_USER_ACTION", callback) self.assertIn("pending_user_action", callback) self.assertNotIn("startActivity", callback) @@ -100,10 +100,10 @@ class AndroidHostTest(unittest.TestCase): self.assertEqual(instrumentation.get(A + "targetPackage"), "ai.ouroboros.android") self.assertEqual(instrumentation.get(A + "name"), "ai.ouroboros.android.device.DeviceSdkSmoke") - source = (device / "src/ai/ouroboros/android/device/DeviceSdkSmoke.java").read_text() + source = (device / "src/ai/ouroboros/android/device/DeviceSdkSmoke.java").read_text(encoding="utf-8") self.assertIn("OBO_DEVICE_SDK_SMOKE=PASS", source) self.assertIn("packages.sessions", source) - workflow = (Path(__file__).resolve().parents[2] / ".github/workflows/ci.yml").read_text() + workflow = (Path(__file__).resolve().parents[2] / ".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertIn("api-level: [26, 29, 30, 33, 36]", workflow) self.assertIn("OBO_DEVICE_SDK_SMOKE=PASS", workflow) diff --git a/android/tests/test_android_lifecycle.py b/android/tests/test_android_lifecycle.py index cc9a8061d..e35f4d14d 100644 --- a/android/tests/test_android_lifecycle.py +++ b/android/tests/test_android_lifecycle.py @@ -109,7 +109,7 @@ import java.lang.reflect.Field; import java.util.*; import java.util.concurrent.*; public class LifecycleTest { - static final String PENDING="Запуск запрошен. Готовность ядра пока не подтверждена; проверьте статус позже."; + static final String PENDING="Start requested. Core readiness is not yet confirmed; check status later."; static class Queue extends AbstractExecutorService { final ArrayDeque items=new ArrayDeque<>(); boolean closed; public void execute(Runnable r) { items.add(r); } @@ -142,19 +142,19 @@ public class LifecycleTest { send(service,"start",1); work.drain(); require(RuntimeClient.stopped && RuntimeClient.calls.contains("/api/command"),"in-flight Panic lost"); require(service.notifications.messages.stream().noneMatch(m->m.startsWith(PENDING) - || m.startsWith("Действие не выполнено")),"stale start outcome overwrote Panic"); + || m.startsWith("Action failed")),"stale start outcome overwrote Panic"); } else if (scenario.equals("starting_pending")) { RuntimeClient.startResult="starting"; RuntimeClient.healthFailure=true; send(service,"start",1); work.drain(); require(service.notifications.messages.stream().anyMatch(m->m.startsWith(PENDING)),"missing unconfirmed readiness"); - require(service.notifications.messages.stream().noneMatch(m->m.startsWith("Действие не выполнено")),"start was falsely reported failed"); + require(service.notifications.messages.stream().noneMatch(m->m.startsWith("Action failed")),"start was falsely reported failed"); require(Collections.frequency(RuntimeClient.calls,"start:owner")==1,"start was repeated"); } else if (scenario.equals("health_failure") || scenario.equals("control_failure")) { RuntimeClient.healthFailure=scenario.equals("health_failure"); RuntimeClient.controlFailure=scenario.equals("control_failure"); send(service,RuntimeClient.healthFailure ? "status" : "start",1); work.drain(); String cause=RuntimeClient.healthFailure ? "health-failure" : "control-failure"; - require(service.notifications.messages.stream().anyMatch(m->m.startsWith("Действие не выполнено") && m.contains(cause)),"ordinary failure hidden"); + require(service.notifications.messages.stream().anyMatch(m->m.startsWith("Action failed") && m.contains(cause)),"ordinary failure hidden"); require(service.notifications.messages.stream().noneMatch(m->m.startsWith(PENDING)),"ordinary failure became readiness pending"); } else if (scenario.equals("sticky_status")) { send(service,null,1); work.drain(); diff --git a/android/tests/test_install.py b/android/tests/test_install.py index 169275906..ce117d21f 100644 --- a/android/tests/test_install.py +++ b/android/tests/test_install.py @@ -250,7 +250,7 @@ def test_streamed_output_is_visible_saved_and_keeps_the_producer_exit(monkeypatc else: assert installer.run_stream(["adb", "shell", "-T", "remote"], log) == b"" assert log.read_bytes() == output - assert Path(str(log) + ".exit-code").read_text() == str(code) + "\n" + assert Path(str(log) + ".exit-code").read_text(encoding="utf-8") == str(code) + "\n" assert observed[0][1]["stderr"] == installer.subprocess.STDOUT diff --git a/android/tests/test_platform_update.py b/android/tests/test_platform_update.py index e750ee361..7cc25c489 100644 --- a/android/tests/test_platform_update.py +++ b/android/tests/test_platform_update.py @@ -50,7 +50,7 @@ def platform(tmp_path, monkeypatch): def change_pin(repo, name, suffix): path = repo / "android/provision/artifacts.json" - pins = json.loads(path.read_text()) + pins = json.loads(path.read_text(encoding="utf-8")) pin = next(item for item in pins if item['name'] == name) pin['url'] += suffix path.write_text(json.dumps(pins)) @@ -65,7 +65,7 @@ def test_unchanged_recipe_skips_all_dependency_operations(platform): def test_sdk_pin_change_prepares_from_current_repo_and_keeps_identity(platform): module, repo, receipt, calls = platform - old = json.loads(receipt.read_text())['platform_inputs'] + old = json.loads(receipt.read_text(encoding="utf-8"))['platform_inputs'] key = (module.APP / 'signing/host.keystore').read_bytes() change_pin(repo, 'android-platform', '?new-pinned-release') assert not module.platform_current(repo)[0] @@ -74,7 +74,7 @@ def test_sdk_pin_change_prepares_from_current_repo_and_keeps_identity(platform): downloaded = [name for action, name in calls if action == 'download'] assert 'android-platform' in downloaded and 'ubuntu-base' not in downloaded assert 'node' not in downloaded and 'playwright-chromium' not in downloaded - installed = json.loads(receipt.read_text()) + installed = json.loads(receipt.read_text(encoding="utf-8")) assert installed['platform_inputs']['sdk'] != old['sdk'] assert installed['platform_inputs']['node'] == old['node'] assert 'platform_preparing' not in installed and module.platform_current(repo)[0] @@ -91,14 +91,14 @@ def test_tracked_recipe_and_patch_changes_are_inputs(platform, path, group): old = module.platform_inputs(repo) file = repo / 'android/provision' / path file.parent.mkdir(exist_ok=True) - file.write_text((file.read_text() if file.exists() else '') + '\n# changed recipe\n') + file.write_text((file.read_text(encoding="utf-8") if file.exists() else '') + '\n# changed recipe\n') assert module.platform_inputs(repo)[group] != old[group] def test_package_recipe_update_uses_current_snapshot_without_rootfs_overlay(platform): module, repo, receipt, calls = platform sources = repo / 'android/provision/ubuntu.sources' - sources.write_text(sources.read_text().replace('20260911T000000Z', '20260912T000000Z')) + sources.write_text(sources.read_text(encoding="utf-8").replace('20260911T000000Z', '20260912T000000Z')) assert module.ensure_platform(repo) assert module.APT_SOURCES.read_bytes() == sources.read_bytes() assert calls == [('run', ('/bin/sh', repo / 'android/provision/packages.sh'))] @@ -123,7 +123,7 @@ def test_interrupted_sdk_update_remains_stale_after_git_source_rollback(platform monkeypatch.setattr(module, 'install_sdk', fail) with pytest.raises(RuntimeError, match='interrupted'): module.ensure_platform(repo) - assert json.loads(receipt.read_text())['platform_preparing'] == ['sdk'] + assert json.loads(receipt.read_text(encoding="utf-8"))['platform_preparing'] == ['sdk'] manifest.write_bytes(original) assert not module.platform_current(repo)[0] rebuilt = [] @@ -134,19 +134,19 @@ def test_interrupted_sdk_update_remains_stale_after_git_source_rollback(platform def test_source_drift_during_preparation_does_not_advance_completed_receipt(platform, monkeypatch): module, repo, receipt, calls = platform - previous = json.loads(receipt.read_text())['platform_inputs'] + previous = json.loads(receipt.read_text(encoding="utf-8"))['platform_inputs'] change_pin(repo, 'android-platform', '?candidate') monkeypatch.setattr(module, 'install_sdk', lambda *a, **kw: change_pin(repo, 'android-platform', '?concurrent-edit')) with pytest.raises(RuntimeError, match='source changed'): module.ensure_platform(repo) - assert json.loads(receipt.read_text())['platform_inputs'] == previous + assert json.loads(receipt.read_text(encoding="utf-8"))['platform_inputs'] == previous assert not module.platform_current(repo)[0] def test_common_node_pin_changes_refresh_node_group(platform): module, repo, receipt, calls = platform file = repo / 'ouroboros/claudexor_runtime_pin.json' - pin = json.loads(file.read_text()) + pin = json.loads(file.read_text(encoding="utf-8")) pin['release']['node_artifacts']['linux-arm64']['archive_url'] += '?next' file.write_text(json.dumps(pin)) assert module.ensure_platform(repo) @@ -189,7 +189,7 @@ def test_common_node_manager_replaces_owned_executable_links(tmp_path, monkeypat monkeypatch.setattr(module.subprocess, 'check_output', lambda *a, **kw: json.dumps([str(tmp_path / 'new/node')]).encode()) monkeypatch.setattr(module, 'run', lambda *a: None) module.install_node(tmp_path / 'repo', 'python', tools, []) - assert all((tools / name).read_text() == 'new' for name in ('node', 'npm', 'npx')) + assert all((tools / name).read_text(encoding="utf-8") == 'new' for name in ('node', 'npm', 'npx')) def test_actual_sdk_jar_replacement_skips_native_source_compile(platform, tmp_path, monkeypatch): @@ -215,5 +215,5 @@ def test_actual_sdk_jar_replacement_skips_native_source_compile(platform, tmp_pa assert module.ensure_platform(repo) actual = receipt.parent / 'platforms/android-36/android.jar' assert actual.read_bytes() == b'new verified platform fixture' - assert json.loads(receipt.read_text())['outputs']['platforms/android-36/android.jar'] == module.sha(actual) + assert json.loads(receipt.read_text(encoding="utf-8"))['outputs']['platforms/android-36/android.jar'] == module.sha(actual) assert (receipt.parent / 'build-tools/36.0.0/aapt2').read_bytes() == before diff --git a/android/tests/test_preflight_entry.py b/android/tests/test_preflight_entry.py index 2c960a8a0..1600458c3 100644 --- a/android/tests/test_preflight_entry.py +++ b/android/tests/test_preflight_entry.py @@ -8,7 +8,7 @@ import pytest @pytest.mark.parametrize('override,expected', [(None, '3600'), ('5400', '5400')]) def test_android_entry_forwards_default_or_explicit_total_test_budget(tmp_path, override, expected): - source = (Path(__file__).parents[1] / 'bootstrap/enter-linux').read_text() + source = (Path(__file__).parents[1] / 'bootstrap/enter-linux').read_text(encoding="utf-8") assignments = '\n'.join(line for line in source.splitlines() if line.startswith(('preflight_workers=', 'preflight_timeout='))) invocation = source[source.index('exec chroot '):] # Replace only the physical chroot boundary. Its real env -i argument list diff --git a/android/tests/test_provision.py b/android/tests/test_provision.py index 5c4e5255b..1279f3c64 100644 --- a/android/tests/test_provision.py +++ b/android/tests/test_provision.py @@ -99,8 +99,8 @@ def test_browser_cli_reads_exact_cached_input_and_local_server_is_closed(monkeyp def test_manifest_has_no_private_paths_and_node_matches_the_common_pin(): - pins = json.loads((PROVISION / "artifacts.json").read_text()) - common = json.loads((PROVISION.parents[1] / "ouroboros/claudexor_runtime_pin.json").read_text()) + pins = json.loads((PROVISION / "artifacts.json").read_text(encoding="utf-8")) + common = json.loads((PROVISION.parents[1] / "ouroboros/claudexor_runtime_pin.json").read_text(encoding="utf-8")) node = next(item for item in pins if item["name"] == "node") expected = common["release"]["node_artifacts"]["linux-arm64"] assert node["url"] == expected["archive_url"] diff --git a/android/tests/test_update_host.py b/android/tests/test_update_host.py index dd8aec79e..799ae2312 100644 --- a/android/tests/test_update_host.py +++ b/android/tests/test_update_host.py @@ -288,7 +288,7 @@ def test_platform_pin_delta_prepares_before_native_build_with_same_key(device, m host.update(args) repo = args.app_root / 'repo' manifest = repo / 'android/provision/artifacts.json' - pins = json.loads(manifest.read_text()) + pins = json.loads(manifest.read_text(encoding="utf-8")) next(pin for pin in pins if pin['name'] == 'android-platform')['sha256'] = 'e' * 64 manifest.write_text(json.dumps(pins)) desired_before = host.source_identity(repo, args.app_root / 'android-sdk')[0] diff --git a/docs/ANDROID_INSTALL.md b/docs/ANDROID_INSTALL.md index c068288c3..77e2d90f8 100644 --- a/docs/ANDROID_INSTALL.md +++ b/docs/ANDROID_INSTALL.md @@ -43,7 +43,12 @@ patched boot image and performs no firmware flashing. Use an Android-capable release in the [common Ouroboros release list](https://github.com/razzant/ouroboros/releases). -Keep all downloaded files from the same exact release tag. +Use a release whose `release-evidence.json` contains both Android artifacts below, +and keep all downloaded files from that exact tag. During the experimental phase, +a desktop release can ship without Android: its notes disclose the Android build +or verification failure and link the CI run, without offering unavailable APK links. +Mandatory Android release coverage will be reconsidered after +[stabilization](https://github.com/razzant/ouroboros/issues/1036). | File | Purpose | | --- | --- | @@ -155,15 +160,34 @@ operation afterward. Open Ouroboros and allow its Magisk root request if you want the selected broad device access. Complete the ordinary provider/account setup with your own -credentials. The native **⋮** menu currently labels Start as **Запустить ядро**, -Status as **Статус ядра**, and Android access setup as **Разрешения Android**. +credentials. The native **⋮** menu provides **Start core**, **Core status**, and +**Android permissions**. ## Access and permissions +### Known experimental limitations + +The recorded phone dogfood pass exposed these follow-ups; experimental integration +does not claim that they are fixed or that every planned scenario passed: + +- [Local APK staging](https://github.com/razzant/ouroboros/issues/1029): a downloaded + Linux path can be unreadable to the Android installer; a readable content URI is distinct. +- [Home-screen shortcuts](https://github.com/razzant/ouroboros/issues/1030): an accepted + pin request does not prove that the launcher placed an icon. +- [Widget placement](https://github.com/razzant/ouroboros/issues/1031): an installed + provider does not supply a working shell command for placing its widget. +- [Permission dialogs](https://github.com/razzant/ouroboros/issues/1032): a dialog + visible in a screenshot may be absent from the Accessibility tree. +- [Quick Settings tile placement](https://github.com/razzant/ouroboros/issues/1033): + the declared tile can still require the owner's manual addition to the active grid. +- [Phone-facing Linux files](https://github.com/razzant/ouroboros/issues/1034): + copying a file to Documents is not a live file-manager view of the Linux workspace. +- [Locked-screen capabilities](https://github.com/razzant/ouroboros/issues/1035): + a running core and bridge do not prove that Accessibility can reach apps behind keyguard. + ### Choosing Ouroboros as the Android assistant -On Android 10 and later, open the Ouroboros menu and choose **Назначить -ассистентом Android**. The app uses Android's normal `RoleManager` consent +On Android 10 and later, open the Ouroboros menu and choose **Set as Android assistant**. The app uses Android's normal `RoleManager` consent screen, then reads the role back. Root-only `cmd role add-role-holder` is useful for qualification scripts, but it is not the public user flow. The APK also declares `ACTION_ASSIST`, which makes the existing Activity a candidate. @@ -198,7 +222,7 @@ Three independent permissions are involved: - **Android app permissions** govern native app-UID access: reading or changing contacts/calendar entries, reading permitted photos/video/audio, using the camera/microphone, and requesting approximate or precise location. First-open - access setup lets you choose those grants in Android dialogs; **Later (Позже)** defers + access setup lets you choose those grants in Android dialogs; **Later** defers setup and the native menu reopens it. Android settings retain denial, revocation and available limited-access choices. Root-side tools retain their separate, broader authority. @@ -316,7 +340,7 @@ fixed and checked on the phone, including an unbounded mock response that inflat test memory. A complete passing preflight and self-edit → review → commit → restart proof remain required; those focused checks do not substitute for them. -Panic is a full stop; the native **Остановить агента** action invokes it too. +Panic is a full stop; the native **Stop agent** action invokes it too. Automatic entry must keep it stopped; use the explicit Start action when you want to resume. Reopening the interface or rebooting is not permission to silently resume stopped work. diff --git a/docs/architecture/08-git-branching-ci-and-build.md b/docs/architecture/08-git-branching-ci-and-build.md index 14d30a747..4d93e4389 100644 --- a/docs/architecture/08-git-branching-ci-and-build.md +++ b/docs/architecture/08-git-branching-ci-and-build.md @@ -1,6 +1,6 @@ # 8. Git Branching, CI, and Build -This chapter owns release topology: the branch and channel model behind the three official update feeds, the CI job graph with its trust boundaries, the keyless system-E2E suite, the build scripts, the dependency-lock authority and the release proof that accepts only the seven expected assets. It exists because every guarantee here is a rule about what a published artifact may claim, not a build convenience. +This chapter owns release topology: the branch and channel model behind the three official update feeds, the CI job graph with its trust boundaries, the keyless system-E2E suite, the build scripts, the dependency-lock authority and the release proof for seven required desktop assets and the optional verified Android pair. It exists because every guarantee here is a rule about what a published artifact may claim, not a build convenience. `ouroboros` is the local working branch; the runtime setting independently selects one official feed: Stable is the newest plain release tag reachable from both `main` and `ouroboros-stable`, QA is the `ouroboros-stable` tip, Development is the `ouroboros` tip. Promotion and rollback are owner-controlled exact-SHA movements; ordinary restart preserves the local tip, while explicit update owns fetch, target validation, rescue, apply, and rollback. `managed` is the official read/update remote; `origin` is optional personal persistence. Desktop and Colab reject a target without a regular non-empty `BIBLE.md`. External pull requests target `ouroboros`, do not allocate a release version, and receive the collision-free version when maintainers land and re-review them (procedure: CONTRIBUTING.md, docs/DEVELOPMENT.md). @@ -14,13 +14,13 @@ The local `ouroboros-stable` ref is also a recovery fallback maintained by expli |---|---|---| | Fork-safe PR validation | `quick-test`, `betterleaks-platform-smoke`, `benchmark-methodology` (also on `ouroboros-stable` pushes and `v*` tags) | `ouroboros` pushes, pull requests into `ouroboros`, manual runs; read-only, no provider secrets, never `pull_request_target` | | Android source/build proof | `android-test` | Every non-scheduled workflow run; explicitly collects `android/tests` plus release tests, compiles a disposable-key APK, no publisher secrets or physical-device claim. | -| Android emulator proof | `android-emulator-smoke` | Every non-scheduled workflow run; API 26/29/30/33/36 x86_64 install/start plus a same-UID PackageInstaller session readback, no publisher secrets or physical-device claim. | +| Android emulator proof | `android-emulator-smoke` | Tags or a verified `android/` change; fork-safe base/head comparison is an output of `android-test`. API 26/29/30/33/36 x86_64 install/start plus a same-UID PackageInstaller session readback; no publisher secrets or physical-device claim. | | Browser consumer proof | `ui-smoke` | PRs run only the real Publish admission/card/history scenario on Chromium; manual runs and tags retain full host UI and browser-tool Chromium/WebKit coverage. No provider secrets or added default local pytest lane. | | Scheduled lanes | `system-e2e-mock` (keyless; daily 04:37 UTC, manual runs, `v*` tags — on the release bar), `e2e-live` (`OUROBOROS_E2E_LIVE_OPENROUTER_KEY`, `$30` cap) | `e2e-live` fires only on its own nightly cron (03:17 UTC, seeding the `ouroboros` branch tip rather than the default branch the schedule fires on) or a dispatch that opts in through the `e2e_live` input; without the secret it is one summary line and green, never a pretend run | | Ordinary desktop tests | `full-test` (no secrets) | Every PR into `ouroboros` runs Windows/macOS alongside Ubuntu quick-test; stable pushes, manual runs and `v*` tags retain the full three-OS matrix | | Trusted skill matrix | `skill-smoke` (`OPENROUTER_API_KEY`) | `ouroboros-stable` pushes, manual runs, `v*` tags — never pull requests | | Trusted provider run | `integration-test` | provider secrets; `main`/`ouroboros`/`ouroboros-stable` pushes, manual runs and `v*` tags (the release chain needs it) — never pull requests | -| Tag-only gates and release chain | `marker-guards`, `docker-ui-smoke`, `docker-portable-test` (manual runs or tags, no secrets); `release-preflight` (needs `full-test` + `integration-test` + `system-e2e-mock`) → `build` and `android-build` (signing secrets; Android additionally needs `android-test` + `android-emulator-smoke`) → `release` (needs both builds, `release-preflight`, `skill-smoke`, `ui-smoke` and these three smoke gates); `vendor-package-smoke` needs `build` and stays informational | tag-triggered; a reproducible provider-contract failure blocks tag builds, a typed inconclusive provider outage does not | +| Tag-only gates and release chain | `marker-guards`, `docker-ui-smoke`, `docker-portable-test` (manual runs or tags, no secrets); `release-preflight` (needs `full-test` + `integration-test` + `system-e2e-mock`) → `build` and `android-build` (signing secrets; Android additionally needs `android-test` + `android-emulator-smoke`) → `release` (waits for both builds but requires success only from the desktop build, `release-preflight`, `skill-smoke`, `ui-smoke` and these three smoke gates; workflow cancellation is respected); `vendor-package-smoke` needs `build` and stays informational | tag-triggered; a reproducible provider-contract failure blocks tag builds, a typed inconclusive provider outage does not | Quick and full jobs each run a dedicated blocking `size_ratchet` pytest step — the ONLY enforcing surface for the repository size gates (local runs exclude the marker and warn): manifest exactness on the tip plus the pairwise shrink-only transition against the event base in `OURO_SIZE_RATCHET_BASE_REF`; an unresolvable base degrades to the tip's parent manifest verified against the parent's own tree — never a skip — while a resolvable base without a manifest fails closed — accepting a copied manifest would allow debt laundering. Both jobs also run the browser-module suite (`cd web && node --test tests/*.test.js`), the same node lane the hermetic commit gate executes through `ouroboros/preflight_node.py`. Secret-bearing skill review runs before any step that imports downloaded plugin code — untrusted payload code must never share a process with provider credentials — and a missing required key is red rather than skipped. @@ -48,11 +48,14 @@ Python dependency resolution has one authority: direct requirements and group me Platform builds precompile bundled Python with unchecked-hash bytecode: sealing valid bytecode prevents runtime `__pycache__` writes from invalidating a macOS signature, and runtime children route caches outside the bundle. When signing is enabled, hardened runtime, notarization, xattr hygiene, and strict verification remain part of the stable-release path; prerelease artifacts may be unsigned and their evidence must report the actual signing state. -The release proof begins with the final DMG/AppImage/tarball/ZIP, never its staging directory — validating a staging tree does not establish that the published bytes contain or execute the same payload. Each platform shard checks the embedded repository bundle, packaged CLI, and managed Claudexor seed + Node by starting the owned daemon, completing a fixture task, and verifying an identity-bound stop. The AppImage is extracted for metadata/SBOM inspection, then run FUSE-independently to prove version output, CLI dispatch, browser-fallback readiness, payload lifetime, main-executable libraries, and clean shutdown (browser-fallback evidence, not a claim of a native GTK/Qt backend); the nested cleanup proof follows the live `runtime → AppRun custodian → launcher` chain and requires both the extraction and its private base absent. The proven Linux tarball payload is wrapped into the three native packages, each receiving its own digest-bound package-manager smoke receipt and provenance attestation; a digest-pinned Syft build produces CycloneDX inventories from extracted payload bytes, and the tarball inventory is reused for the identical-byte native wrappers while each wrapper keeps its own digest-bound installation proof. The release job accepts only the nine expected assets (seven desktop/Linux packages, Android source setup archive and reference APK), recalculates digests, verifies both predicate types, writes the checksum/evidence capsule, and rechecks the remote annotated tag immediately before publication. Signing credentials stay step-scoped and absent from SBOM/attestation steps. +The release proof begins with the final DMG/AppImage/tarball/ZIP, never its staging directory — validating a staging tree does not establish that the published bytes contain or execute the same payload. Each platform shard checks the embedded repository bundle, packaged CLI, and managed Claudexor seed + Node by starting the owned daemon, completing a fixture task, and verifying an identity-bound stop. The AppImage is extracted for metadata/SBOM inspection, then run FUSE-independently to prove version output, CLI dispatch, browser-fallback readiness, payload lifetime, main-executable libraries, and clean shutdown (browser-fallback evidence, not a claim of a native GTK/Qt backend); the nested cleanup proof follows the live `runtime → AppRun custodian → launcher` chain and requires both the extraction and its private base absent. The proven Linux tarball payload is wrapped into the three native packages, each receiving its own digest-bound package-manager smoke receipt and provenance attestation; a digest-pinned Syft build produces CycloneDX inventories from extracted payload bytes, and the tarball inventory is reused for the identical-byte native wrappers while each wrapper keeps its own digest-bound installation proof. The release job requires the seven desktop assets and accepts the complete experimental Android pair only after its build, smoke/SBOM/digest checks and attestations verify, recalculates digests, verifies both predicate types, writes the checksum/evidence capsule, and rechecks the remote annotated tag immediately before publication. Signing credentials stay step-scoped and absent from SBOM/attestation steps. -Public installer naming and links ride the same projection: `release_sync.py::RELEASE_ASSET_TEMPLATES` is the filename SSOT shared by the proof builder, README, and the install pages. A version bump rewrites only named download references and `data-release-download` anchors to immutable `/releases/download/v{VERSION}/...` URLs; the `/releases/latest/download/...` shape is forbidden because GitHub excludes prereleases from `latest`. Generated release notes expose direct links only for the nine proof-accepted assets; the release notes pair the Android artifact links with its USB setup guide, and the reference APK alone does not install the runtime. The default README and GitHub Pages deployment use the stable `main` boundary (`main:/docs` for Pages); stable promotion advances `main` only after the release is published with all nine proof-bound release assets, so an unreleased development VERSION never exposes dead installer links and an omitted promotion leaves the previous working release public. +Public installer naming and links ride the same projection: `release_sync.py::RELEASE_ASSET_TEMPLATES` is the filename SSOT shared by the proof builder, README, and the install pages. A version bump rewrites only named download references and `data-release-download` anchors to immutable `/releases/download/v{VERSION}/...` URLs; the `/releases/latest/download/...` shape is forbidden because GitHub excludes prereleases from `latest`. Generated release notes expose direct links only for proof-accepted assets (seven required desktop installers, plus the optional verified Android pair); the release notes pair the Android artifact links with its USB setup guide, and the reference APK alone does not install the runtime. The default README and GitHub Pages deployment use the stable `main` boundary (`main:/docs` for Pages); stable promotion advances `main` only after the release is published with all seven required proof-bound desktop installers, so an unreleased development VERSION never exposes dead installer links and an omitted promotion leaves the previous working release public. -`scripts/build_android_release.py` adds the two Android assets to the same release capsule: an archive containing first-party source, the common Git seed and a source manifest, plus a publisher-signed reference APK. `android-build` verifies final archive contents/seed SHA and APK package/version/certificate, then binds hashes, CycloneDX inventories and attestations to the tag's exact source. Its archive SBOM describes the embedded source tree and its APK SBOM the extracted reference APK; neither describes the external rootfs/SDK/browser bytes provisioned on a phone. Those installed inputs remain in the provisioner's own pinned downloads and installed-package records. Android build failure blocks publication of the promised shared release; CI compilation/archive checks do not certify root, boot, hardware or phone runtime behavior. +`scripts/build_android_release.py` adds the two Android assets to the same release capsule: an archive containing first-party source, the common Git seed and a source manifest, plus a publisher-signed reference APK. `android-build` verifies final archive contents/seed SHA and APK package/version/certificate, then binds hashes, CycloneDX inventories and attestations to the tag's exact source. Its archive SBOM describes the embedded source tree and its APK SBOM the extracted reference APK; neither describes the external rootfs/SDK/browser bytes provisioned on a phone. Those installed inputs remain in the provisioner's own pinned downloads and installed-package records. Android failure is disclosed without blocking the independently verified desktop release; CI compilation/archive checks do not certify root, boot, hardware or phone runtime behavior. + + +Experimental Android is nonblocking for desktop publication. `release_proof.py` records the observed Android build outcome separately from artifact verification; a failed/cancelled/skipped build, partial pair or failed digest, smoke, SBOM or attestation excludes BOTH Android artifacts. Missing or invalid required desktop proof remains fatal. The existing release job supplies only proof-accepted files to publication and checks the uploaded set against that same evidence, so rejected APK/SBOM files in the download directory cannot leak through globs. Release notes disclose Android unavailability with its CI run instead of dead download links. [Issue #1036](https://github.com/razzant/ouroboros/issues/1036) tracks revisiting mandatory Android gates after stabilization. ### Docker diff --git a/docs/development/09-process-custody-rule.md b/docs/development/09-process-custody-rule.md index c6700045a..5c1cb6ead 100644 --- a/docs/development/09-process-custody-rule.md +++ b/docs/development/09-process-custody-rule.md @@ -195,7 +195,7 @@ Keep reusable large downloads in the installer's durable cache. not cover that directory. Portable source/transport fixtures and host compilation are separate from physical root, boot, permissions, hardware and battery evidence. The same-key instrumentation under `android/tests/device` owns a temporary SDK -bridge and an uncommitted PackageInstaller session. The emulator job executes +bridge and an uncommitted PackageInstaller session. The emulator job runs for Android source changes and tags, and executes session readback on API 26/29/30/33/36 and accepts its explicit PASS only after abandon and bridge cleanup. It requires neither root nor a provisioned Linux core; it does not certify the phone bootstrap or owner consent UI. diff --git a/docs/development/14-build-and-ci.md b/docs/development/14-build-and-ci.md index 5bc9a3b3c..0add317c8 100644 --- a/docs/development/14-build-and-ci.md +++ b/docs/development/14-build-and-ci.md @@ -218,7 +218,7 @@ across runs. The artifact pipeline — per-platform archive smokes, native Linux packages, the AppImage custody chain, SBOM and attestation binding, and the -nine-asset release job — lives in ARCHITECTURE "8. Git Branching, CI, and +seven-required-desktop plus optional-Android release job — lives in ARCHITECTURE "8. Git Branching, CI, and Build" and `.github/workflows/ci.yml`. The honesty invariants a change must preserve: diff --git a/ouroboros/tools/release_sync.py b/ouroboros/tools/release_sync.py index a3df09276..10cff9dd5 100644 --- a/ouroboros/tools/release_sync.py +++ b/ouroboros/tools/release_sync.py @@ -97,9 +97,10 @@ RELEASE_ASSET_TEMPLATES = { # Android has a rooted-device USB setup guide, not the desktop download flow. # Keep its artifacts in the same release registry without inventing missing # Android buttons in already-published desktop onboarding/version carriers. +ANDROID_DOWNLOAD_IDS = ("android-arm64", "android-apk") DESKTOP_DOWNLOAD_IDS = tuple( proof_id for proof_id in RELEASE_ASSET_TEMPLATES - if proof_id not in {"android-arm64", "android-apk"} + if proof_id not in ANDROID_DOWNLOAD_IDS ) _PUBLIC_REPOSITORY = "razzant/ouroboros" diff --git a/scripts/release_proof.py b/scripts/release_proof.py index bb12480c9..b68e5ad9c 100644 --- a/scripts/release_proof.py +++ b/scripts/release_proof.py @@ -21,6 +21,8 @@ _RELEASE_SYNC = runpy.run_path( Path(__file__).resolve().parents[1] / "ouroboros" / "tools" / "release_sync.py" ) RELEASE_ASSET_TEMPLATES = _RELEASE_SYNC["RELEASE_ASSET_TEMPLATES"] +DESKTOP_DOWNLOAD_IDS = _RELEASE_SYNC["DESKTOP_DOWNLOAD_IDS"] +ANDROID_DOWNLOAD_IDS = _RELEASE_SYNC["ANDROID_DOWNLOAD_IDS"] release_asset_download_url = _RELEASE_SYNC["release_asset_download_url"] release_asset_name = _RELEASE_SYNC["release_asset_name"] @@ -208,66 +210,94 @@ def _proof_files( *, commit: str, tag: str, -) -> list[dict]: + android_build_result: str, + android_attestation_result: str, +) -> tuple[list[dict], dict]: archives = { path.name: path for path in _release_assets(directory, RELEASE_ASSET_SUFFIXES) } expected = {proof_id: factory(version) for proof_id, factory in PROOF_IDS.items()} - if set(archives) != set(expected.values()): + required = {expected[proof_id] for proof_id in DESKTOP_DOWNLOAD_IDS} + android_suffixes = tuple(RELEASE_ASSET_TEMPLATES[key].split("{version}", 1)[1] + for key in ANDROID_DOWNLOAD_IDS) + android_names = {name for name in archives if name.endswith(android_suffixes)} + if not required <= archives.keys() or set(archives) - required - android_names: raise ValueError( "release asset set does not match the expected platform assets: " - f"expected {sorted(expected.values())}, found {sorted(archives)}" + f"required {sorted(required)}, found {sorted(archives)}" ) + android = {"status": "unavailable", "buildResult": android_build_result, + "attestationResult": android_attestation_result, "reason": ""} + selected = list(DESKTOP_DOWNLOAD_IDS) + if android_build_result != "success": + android["reason"] = f"Android build result: {android_build_result}" + elif android_names != {expected[key] for key in ANDROID_DOWNLOAD_IDS}: + android["reason"] = "Android source archive and reference APK are not a complete matching pair" + elif android_attestation_result != "success": + android["reason"] = f"Android artifact attestation result: {android_attestation_result}" + else: + selected.extend(ANDROID_DOWNLOAD_IDS) records: list[dict] = [] - for proof_id, artifact_name in expected.items(): - artifact = archives[artifact_name] - digest = sha256_file(artifact) - smoke_path = directory / f"release-smoke-{proof_id}.json" - sbom_path = directory / f"sbom-{proof_id}.cdx.json" - if not smoke_path.is_file() or not sbom_path.is_file(): - raise ValueError(f"missing smoke receipt or SBOM for {proof_id}") - smoke = _load_json(smoke_path) - sbom = _load_json(sbom_path) - if smoke.get("status") != "passed": - raise ValueError(f"smoke receipt is not passed: {smoke_path}") - expected_identity = { - "schemaVersion": 1, - "kind": "packaged_artifact_smoke", - "proofId": proof_id, - "sourceCommit": commit, - "releaseTag": tag, - } - if any(smoke.get(key) != value for key, value in expected_identity.items()): - raise ValueError(f"smoke receipt identity does not match {proof_id}") - if smoke.get("artifact") != artifact.name or smoke.get("sha256") != digest: - raise ValueError(f"smoke receipt is not bound to {artifact.name}") - checks = smoke.get("checks") - if not isinstance(checks, list) or not all(isinstance(item, str) for item in checks): - raise ValueError(f"smoke receipt checks are invalid: {smoke_path}") - missing_checks = REQUIRED_SMOKE_CHECKS[proof_id] - set(checks) - if missing_checks: - raise ValueError( - f"smoke receipt is missing required checks for {proof_id}: " - f"{sorted(missing_checks)}" - ) - if ( - sbom.get("bomFormat") != "CycloneDX" - or not isinstance(sbom.get("specVersion"), str) - or not isinstance(sbom.get("serialNumber"), str) - ): - raise ValueError(f"SBOM is not CycloneDX JSON: {sbom_path}") - records.append( - { + for proof_id in selected: + artifact_name = expected[proof_id] + try: + artifact = archives[artifact_name] + digest = sha256_file(artifact) + smoke_path = directory / f"release-smoke-{proof_id}.json" + sbom_path = directory / f"sbom-{proof_id}.cdx.json" + if not smoke_path.is_file() or not sbom_path.is_file(): + raise ValueError(f"missing smoke receipt or SBOM for {proof_id}") + smoke = _load_json(smoke_path) + sbom = _load_json(sbom_path) + if smoke.get("status") != "passed": + raise ValueError(f"smoke receipt is not passed: {smoke_path}") + expected_identity = { + "schemaVersion": 1, + "kind": "packaged_artifact_smoke", "proofId": proof_id, - "name": artifact.name, - "size": artifact.stat().st_size, - "sha256": digest, - "smokeReceipt": smoke_path.name, - "sbom": sbom_path.name, + "sourceCommit": commit, + "releaseTag": tag, } - ) - return records + if any(smoke.get(key) != value for key, value in expected_identity.items()): + raise ValueError(f"smoke receipt identity does not match {proof_id}") + if smoke.get("artifact") != artifact.name or smoke.get("sha256") != digest: + raise ValueError(f"smoke receipt is not bound to {artifact.name}") + checks = smoke.get("checks") + if not isinstance(checks, list) or not all(isinstance(item, str) for item in checks): + raise ValueError(f"smoke receipt checks are invalid: {smoke_path}") + missing_checks = REQUIRED_SMOKE_CHECKS[proof_id] - set(checks) + if missing_checks: + raise ValueError( + f"smoke receipt is missing required checks for {proof_id}: " + f"{sorted(missing_checks)}" + ) + if ( + sbom.get("bomFormat") != "CycloneDX" + or not isinstance(sbom.get("specVersion"), str) + or not isinstance(sbom.get("serialNumber"), str) + ): + raise ValueError(f"SBOM is not CycloneDX JSON: {sbom_path}") + records.append( + { + "proofId": proof_id, + "name": artifact.name, + "size": artifact.stat().st_size, + "sha256": digest, + "smokeReceipt": smoke_path.name, + "sbom": sbom_path.name, + } + ) + except (OSError, ValueError) as exc: + if proof_id not in ANDROID_DOWNLOAD_IDS: + raise + # Android is one optional pair: never retain just its successful half. + records = [row for row in records if row["proofId"] not in ANDROID_DOWNLOAD_IDS] + android["reason"] = str(exc) + break + if sum(row["proofId"] in ANDROID_DOWNLOAD_IDS for row in records) == len(ANDROID_DOWNLOAD_IDS): + android["status"] = "verified" + return records, android def _checksum_targets(directory: Path, records: Iterable[dict]) -> list[Path]: @@ -289,6 +319,8 @@ def _release_notes( tag: str, previous_tag: str | None, records: Iterable[dict], + android: dict, + run_url: str, ) -> str: short_commit = commit[:12] verify_base = ( @@ -312,6 +344,8 @@ def _release_notes( for proof_id, label in DOWNLOAD_LABELS.items(): record = records_by_id.get(proof_id) if not record: + if proof_id in ANDROID_DOWNLOAD_IDS: + continue raise ValueError(f"release notes missing verified asset: {proof_id}") name = str(record.get("name") or "") expected_name = release_asset_name(proof_id, version) @@ -325,6 +359,13 @@ def _release_notes( repository=repository, ) lines.append(f"- **{label}:** [{name}]({url})") + if android["status"] != "verified": + lines.extend([ + "", + "Experimental Android artifacts are unavailable for this release: " + f"{android['reason']}. [CI run]({run_url}). " + "The independently verified desktop installers remain available above.", + ]) lines.extend([ "", f"[Android setup guide](https://github.com/{repository}/blob/{tag}/docs/ANDROID_INSTALL.md): " @@ -368,11 +409,13 @@ def command_assemble(args: argparse.Namespace) -> None: release_date, description = _read_release_description(args.readme, version) if not re.fullmatch(r"[0-9a-f]{40}", args.commit): raise ValueError("commit must be a full lowercase Git SHA") - records = _proof_files( + records, android = _proof_files( args.directory, version, commit=args.commit, tag=args.tag, + android_build_result=args.android_build_result, + android_attestation_result=args.android_attestation_result, ) checksum_targets = _checksum_targets(args.directory, records) checksums = "".join( @@ -394,8 +437,10 @@ def command_assemble(args: argparse.Namespace) -> None: }, "workflow": { "runUrl": args.run_url, - "gates": [{"name": name, "status": "passed"} for name in RELEASE_GATES], + "gates": [{"name": name, "status": args.android_build_result + if name == "android-build" else "passed"} for name in RELEASE_GATES], }, + "experimentalAndroid": android, "generatedAt": generated_at, "artifacts": records, "verification": { @@ -425,9 +470,17 @@ def command_assemble(args: argparse.Namespace) -> None: tag=args.tag, previous_tag=args.previous_tag, records=records, + android=android, + run_url=args.run_url, ), encoding="utf-8", ) + if args.github_output: + files = [*_checksum_targets(args.directory, records), + args.directory / "SHA256SUMS", args.directory / "release-evidence.json"] + _append_github_output(args.github_output, { + "files_json": json.dumps("\n".join(path.as_posix() for path in files)), + }) def command_verify_uploaded(args: argparse.Namespace) -> None: @@ -440,11 +493,12 @@ def command_verify_uploaded(args: argparse.Namespace) -> None: for row in remote_rows if isinstance(row, dict) and isinstance(row.get("name"), str) } - local_names = { - path.name - for path in args.directory.iterdir() - if path.is_file() and path.name != args.metadata.name - } + evidence = _load_json(args.directory / "release-evidence.json") + records = evidence.get("artifacts") + if not isinstance(records, list): + raise ValueError("release evidence has no proof-accepted artifact list") + local_names = {path.name for path in _checksum_targets(args.directory, records)} + local_names.update({"SHA256SUMS", "release-evidence.json"}) if set(remote) != local_names: raise ValueError( "uploaded asset set differs from the local allowlist: " @@ -488,6 +542,9 @@ def build_parser() -> argparse.ArgumentParser: assemble.add_argument("--previous-tag") assemble.add_argument("--generated-at") assemble.add_argument("--notes-output", type=Path, required=True) + assemble.add_argument("--android-build-result", choices=("success", "failure", "cancelled", "skipped", "not_run"), default="not_run") + assemble.add_argument("--android-attestation-result", choices=("success", "failure", "not_run"), default="not_run") + assemble.add_argument("--github-output", type=Path) assemble.set_defaults(func=command_assemble) verify = commands.add_parser( diff --git a/tests/test_android_release.py b/tests/test_android_release.py index b3864ff1b..00ca7d1e3 100644 --- a/tests/test_android_release.py +++ b/tests/test_android_release.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse import importlib.util import json +import os import tarfile from pathlib import Path @@ -126,7 +127,7 @@ def test_release_builder_refuses_missing_key_before_source_or_compiler_work(tmp_ assert not (tmp_path / "out").exists() -def test_android_ci_is_fork_safe_and_required_for_publication(): +def test_android_ci_is_fork_safe_and_experimental_for_publication(): workflow = (REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8") validation = workflow.split(" android-test:", 1)[1].split(" android-build:", 1)[0] release = workflow.split(" android-build:", 1)[1].split(" release-preflight:", 1)[0] @@ -140,8 +141,14 @@ def test_android_ci_is_fork_safe_and_required_for_publication(): assert "publisher signing credentials are required" in release assert "android-build" in next(line for line in publication.splitlines() if "needs:" in line) assert "secrets." not in release.split("- name: Generate Android source", 1)[1] - for suffix in ("android-arm64.tar.gz", "android.apk"): - assert f"release-artifacts/Ouroboros-*-{suffix}" in publication + assert "always() && !cancelled()" in publication + assert "needs.android-build.result == 'success'" not in publication + assert "needs.build.result == 'success'" in publication + assert "needs.skill-smoke.result == 'success'" in publication + assert "fromJSON(steps.release_proof.outputs.files_json)" in publication + assert "--android-build-result" in publication + assert "--android-attestation-result" in publication + assert "continue-on-error" not in publication assert "draft: true" in publication @@ -149,6 +156,9 @@ def test_android_ci_has_representative_emulator_matrix_without_calling_it_device workflow = (REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8") smoke = workflow.split(" android-emulator-smoke:", 1)[1].split(" # The publisher key", 1)[0] assert "api-level: [26, 29, 30, 33, 36]" in smoke + assert "needs: android-test" in smoke + assert "needs.android-test.outputs.android_changed == 'true'" in smoke + assert "startsWith(github.ref, 'refs/tags/v')" in smoke assert "adb install -r" in smoke assert "dumpsys package ai.ouroboros.android" in smoke assert "SELinux" not in smoke @@ -164,3 +174,111 @@ def test_default_host_build_uses_the_shared_root_asset_path(): source = (REPO / "android/host/build.py").read_text(encoding="utf-8") assert 'source.parents[1] / "assets" / "icon_1024.png"' in source assert (REPO / "assets/icon_1024.png").is_file() + + +@pytest.mark.parametrize(("event", "path", "ref", "expected"), [ + ("pull_request", "docs/readme.md", "refs/pull/1/merge", False), + ("pull_request", "ouroboros/core.py", "refs/pull/1/merge", False), + ("pull_request", "android/host/change.java", "refs/pull/1/merge", True), + ("push", "android/host/change.java", "refs/heads/ouroboros", True), + ("push", "docs/readme.md", "refs/tags/v7.0.0", True), + ("schedule", "android/host/change.java", "refs/heads/main", False), +]) +@pytest.mark.skipif(os.name == "nt", reason="Exercises the Ubuntu workflow's POSIX Bash step") +def test_android_emulator_selection_uses_event_diff(tmp_path, event, path, ref, expected): + import os + import shutil + import subprocess + import yaml + + bash = shutil.which("bash") + if not bash: + pytest.skip("the workflow's Bash runner is unavailable") + jobs = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8"))["jobs"] + script = next(step["run"] for step in jobs["android-test"]["steps"] if step.get("id") == "android_changes") + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / "README").write_text("base", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "base"], cwd=tmp_path, check=True) + base = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=tmp_path, text=True).strip() + changed = tmp_path / path + changed.parent.mkdir(parents=True, exist_ok=True) + changed.write_text("change", encoding="utf-8") + subprocess.run(["git", "add", "."], cwd=tmp_path, check=True) + subprocess.run(["git", "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "change"], cwd=tmp_path, check=True) + head = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=tmp_path, text=True).strip() + output = tmp_path / "output" + env = {**os.environ, "GITHUB_SHA": head, "GITHUB_OUTPUT": str(output), + "PR_BASE": base if event == "pull_request" else "", "PR_HEAD": head if event == "pull_request" else "", + "PUSH_BASE": base if event == "push" else ""} + result = subprocess.run([bash, "-c", script], cwd=tmp_path, env=env, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + detected = output.read_text(encoding="utf-8").strip().split("=", 1)[1] + expression = jobs["android-emulator-smoke"]["if"] + for key, value in {"github.event_name": event, "github.ref": ref, + "needs.android-test.outputs.android_changed": detected}.items(): + expression = expression.replace(key, repr(value)) + expression = expression.replace("&&", " and ").replace("||", " or ") + assert eval(expression, {"__builtins__": {}}, {"startsWith": str.startswith}) is expected + + +@pytest.mark.parametrize(("failed_job", "result", "cancelled", "expected"), [ + ("android-build", "failure", False, True), + ("android-build", "cancelled", False, True), + ("android-build", "skipped", False, True), + *[(job, "failure", False, False) for job in ("build", "release-preflight", "marker-guards", + "ui-smoke", "docker-ui-smoke", "docker-portable-test", "skill-smoke")], + (None, "success", True, False), +]) +def test_release_requires_desktop_gates_and_respects_workflow_cancel(failed_job, result, cancelled, expected): + import re + import yaml + + job = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8"))["jobs"]["release"] + expression = job["if"].removeprefix("${{").removesuffix("}}") + for name in job["needs"]: + expression = expression.replace(f"needs.{name}.result", repr(result if name == failed_job else "success")) + expression = expression.replace("github.ref", repr("refs/tags/v7.0.0")) + expression = re.sub(r"!(?!=)", "not ", expression).replace("&&", " and ").replace("||", " or ") + assert eval(f"({expression.strip()})", {"__builtins__": {}}, { + "always": lambda: True, "cancelled": lambda: cancelled, "startsWith": str.startswith, + }) is expected + + +@pytest.mark.parametrize(("failed_platform", "expected"), [("android-apk", 0), ("macos-arm64", 1), (None, 0)]) +@pytest.mark.skipif(os.name == "nt", reason="Executes the Ubuntu release step with a POSIX gh fixture") +def test_attestation_command_failure_excludes_android_but_stops_desktop(tmp_path, failed_platform, expected): + import os + import shutil + import subprocess + import sys + import yaml + + bash = shutil.which("bash") + if not bash: + pytest.skip("the workflow's Bash runner is unavailable") + workflow = yaml.safe_load((REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8")) + script = next(step["run"] for step in workflow["jobs"]["release"]["steps"] if step.get("id") == "verify_artifacts") + binaries = tmp_path / "bin" + binaries.mkdir() + (binaries / "python").symlink_to(sys.executable) + gh = binaries / "gh" + gh.write_text(f"#!{sys.executable}\nimport os,sys,pathlib\n" + "with open(os.environ['GH_CALL_LOG'],'a',encoding='utf-8') as stream:\n" + " stream.write(pathlib.Path(sys.argv[3]).name+'\\n')\n" + "raise SystemExit(1 if pathlib.Path(sys.argv[3]).name == os.environ['FAIL_ARTIFACT'] else 0)\n", + encoding="utf-8") + gh.chmod(0o755) + version = (REPO / "VERSION").read_text(encoding="utf-8").strip() + output, calls = tmp_path / "output", tmp_path / "calls" + env = {**os.environ, "PATH": str(binaries) + os.pathsep + os.environ.get("PATH", ""), + "RUNNER_TEMP": str(tmp_path), "GITHUB_OUTPUT": str(output), "ANDROID_BUILD_RESULT": "success", + "GITHUB_REPOSITORY": "example/source", "GITHUB_SHA": "a" * 40, "GITHUB_REF": "refs/tags/v" + version, + "GH_CALL_LOG": str(calls), "FAIL_ARTIFACT": release_asset_name(failed_platform, version) if failed_platform else ""} + result = subprocess.run([bash, "-c", script], cwd=REPO, env=env, capture_output=True, text=True) + assert result.returncode == expected, result.stderr + if expected == 0: + outcome = "failure" if failed_platform else "success" + assert output.read_text(encoding="utf-8").strip() == "android_result=" + outcome + observed = calls.read_text(encoding="utf-8").splitlines() + assert {release_asset_name(key, version) for key in DESKTOP_DOWNLOAD_IDS} <= set(observed) diff --git a/tests/test_delegated_full_access.py b/tests/test_delegated_full_access.py index 8572bb158..06029243d 100644 --- a/tests/test_delegated_full_access.py +++ b/tests/test_delegated_full_access.py @@ -6,6 +6,7 @@ import httpx import pytest from ouroboros import delegate_custody as custody, subagents +from ouroboros.delegate_shared import delegate_payload from ouroboros.delegate_registration_policy import persistent_registration, record_persistent from ouroboros.tools import delegate from tests.test_owner_settings_write_seam import _settings_app, isolated_settings as isolated_settings @@ -98,7 +99,7 @@ def test_full_start_http_contract_and_real_snapshot_capture(full_run): from ouroboros.tools.subagent_integration import _integrate_delegated_patch ctx, target, facts = full_run - result = json.loads(delegate._delegate_start(ctx, 'Implement the fixture.')) + result = delegate_payload(delegate._delegate_start(ctx, 'Implement the fixture.')) assert result['status'] == 'started', result request, key = facts['requests'][0] assert request['access'] == 'full' and request['mode'] == 'agent' @@ -115,7 +116,7 @@ def test_full_start_http_contract_and_real_snapshot_capture(full_run): assert 'OS-enforced boundary' not in result['note'] assert facts['trust_posts'] == [{'repoRoot': target, 'allowFullAccess': True}] snapshot = Path(result['execution_root']) - assert snapshot != Path(target) and (snapshot / 'README.md').read_text() == 'seed\n' + assert snapshot != Path(target) and (snapshot / 'README.md').read_text(encoding="utf-8") == 'seed\n' (snapshot / 'native-result.py').write_text('result = 42\n') assert not (Path(target) / 'native-result.py').exists() row = custody.replay(custody.custody_root(ctx))['full-run'] @@ -128,7 +129,7 @@ def test_full_start_http_contract_and_real_snapshot_capture(full_run): assert not (Path(target) / 'native-result.py').exists() outcome = _integrate_delegated_patch(ctx, 'full-run', 'apply', 'Fixture verified.') assert '✅ Integrated' in outcome, outcome - assert (Path(target) / 'native-result.py').read_text() == 'result = 42\n' + assert (Path(target) / 'native-result.py').read_text(encoding="utf-8") == 'result = 42\n' @pytest.mark.parametrize('recorded_access,current_access', [('full', 'workspace_write'), ('workspace_write', 'full')]) @@ -136,13 +137,13 @@ def test_retry_replays_original_access_and_snapshot(full_run, monkeypatch, recor ctx, target, facts = full_run facts['selected_access'] = recorded_access facts['lost_start'] = True - initial = json.loads(delegate._delegate_start(ctx, 'Same complete work order.')) + initial = delegate_payload(delegate._delegate_start(ctx, 'Same complete work order.')) invocation = initial['pending_invocation_id'] first_request = facts['requests'][0] grants_before = len(facts['trust_posts']) facts['selected_access'] = current_access facts['lost_start'] = False - result = json.loads(delegate._delegate_start(ctx, 'Same complete work order.', retry_of=invocation)) + result = delegate_payload(delegate._delegate_start(ctx, 'Same complete work order.', retry_of=invocation)) assert result['status'] == 'started' and result['idempotent_recovery'], result assert facts['requests'] == [first_request, first_request] assert len(facts['trust_posts']) == grants_before @@ -159,7 +160,7 @@ def test_trust_refusal_does_not_start_run_or_leave_pending_snapshot(full_run, fa ctx, target, facts = full_run facts['recorded'] = failure == 'revoked' facts['fail_trust'] = failure == 'transport' - result = json.loads(delegate._delegate_start(ctx, 'A new assignment.')) + result = delegate_payload(delegate._delegate_start(ctx, 'A new assignment.')) assert result['status'] == 'refused' and result['definitely_unrun'], result assert not facts['requests'] and not facts['trust_posts'] assert not custody.pending_invocations(custody.custody_root(ctx)) @@ -174,7 +175,7 @@ def test_full_mutation_still_requires_active_matching_workspace(tmp_path, monkey shape = subagents.delegated_run_shape(True, 'full') ctx.workspace_mode = '' record, refusal = delegate._mutation_authority(ctx, shape) - assert not record and 'workspace_not_active' in refusal + assert not record and delegate_payload(refusal)['reason'] == 'workspace_not_active' def test_full_retry_without_snapshot_binding_is_refused(tmp_path): @@ -186,7 +187,7 @@ def test_full_retry_without_snapshot_binding_is_refused(tmp_path): 'execution': {'isolation': 'live', 'delegated': True}, 'primaryHarness': 'some-route'}, project_id='stable', project_owned=False, route='some-route') binding, refusal = delegate._resolve_retry_invocation(ctx, drive, 'original', 'work') - assert binding is None and 'retry_binding_absent' in refusal + assert binding is None and delegate_payload(refusal)['reason'] == 'retry_binding_absent' def test_full_registration_and_access_evidence_keep_their_existing_owners(): @@ -266,7 +267,7 @@ def test_owner_http_save_projects_full_choice_into_task_start_snapshot(monkeypat with TestClient(app) as client: response = client.post('/api/settings', json={SUBAGENTS_SETTING: config}) assert response.status_code == 200, response.text - assert json.loads(json.loads(isolated_settings.read_text())[SUBAGENTS_SETTING])['items'][0]['access'] == 'full' + assert json.loads(json.loads(isolated_settings.read_text(encoding="utf-8"))[SUBAGENTS_SETTING])['items'][0]['access'] == 'full' start = apply_task_start_settings() selected, _ = select_subagent_snapshot(start.settings, subagent_id='phone-coder') assert selected['access'] == 'full' diff --git a/tests/test_launcher_headless_fallback.py b/tests/test_launcher_headless_fallback.py index ec7ab43fc..e272937b3 100644 --- a/tests/test_launcher_headless_fallback.py +++ b/tests/test_launcher_headless_fallback.py @@ -73,7 +73,7 @@ def test_native_boot_preserves_panic_but_owner_can_start( launcher.main(["--no-ui", "--launch-intent", intent]) assert bool(observed) == launches if marker is not None: - assert flag.read_text() == marker + assert flag.read_text(encoding="utf-8") == marker def test_external_ui_keepalive_preserves_crash_shutdown(external_launcher, monkeypatch): diff --git a/tests/test_launcher_native_preparation.py b/tests/test_launcher_native_preparation.py index c689069c7..b4c982f7c 100644 --- a/tests/test_launcher_native_preparation.py +++ b/tests/test_launcher_native_preparation.py @@ -123,7 +123,7 @@ def test_preparation_shutdown_reaps_actual_detached_child_before_launcher_exit(t while not child_file.exists() and time.monotonic() < deadline: time.sleep(0.02) assert child_file.exists() - child = int(child_file.read_text()) + child = int(child_file.read_text(encoding="utf-8")) assert pid_is_alive(child) shutdown.set() monkeypatch.setattr(launcher, '_shutdown_event', shutdown) diff --git a/tests/test_launcher_sync.py b/tests/test_launcher_sync.py index 0a48ba3b9..bd7315d2b 100644 --- a/tests/test_launcher_sync.py +++ b/tests/test_launcher_sync.py @@ -661,7 +661,7 @@ def test_external_source_launcher_keeps_older_seed_version_and_local_head(tmp_pa assert context.bundle_dir == seed assert bootstrap.ensure_managed_repo(context) == 'unchanged' assert _git_output(repo, 'rev-parse', 'HEAD') == personal_head - assert (repo / 'VERSION').read_text().strip() == '4.50.1' + assert (repo / 'VERSION').read_text(encoding="utf-8").strip() == '4.50.1' assert (seed / 'repo_bundle_manifest.json').read_bytes() == original_manifest diff --git a/tests/test_onboarding_process_controls.py b/tests/test_onboarding_process_controls.py index 4f47f1978..9318f77a1 100644 --- a/tests/test_onboarding_process_controls.py +++ b/tests/test_onboarding_process_controls.py @@ -73,8 +73,8 @@ def test_onboarding_restart_uses_existing_no_resume_flags_and_exit_signal(startu assert calls == [('checked', {'reason': 'owner_restart', 'unsynced_policy': 'rescue_and_reset'}), ('stopped', {})] assert obj.server._restart_requested.is_set() and obj.server._owner_restart_requested.is_set() assert obj.server.RESTART_EXIT_CODE == 42 - assert (obj.data / 'state/owner_restart_no_resume.flag').read_text() == 'owner_restart' - assert (obj.data / 'state/panic_stop.flag').read_text() == 'owner_restart_no_resume' + assert (obj.data / 'state/owner_restart_no_resume.flag').read_text(encoding="utf-8") == 'owner_restart' + assert (obj.data / 'state/panic_stop.flag').read_text(encoding="utf-8") == 'owner_restart_no_resume' assert not (obj.data / 'settings.json').exists() from supervisor import state, git_ops assert state.DRIVE_ROOT == git_ops.DRIVE_ROOT == obj.data @@ -102,7 +102,7 @@ def test_onboarding_panic_runs_real_panic_marker_and_exit_99(startup_controls, m response = obj.client.post('/api/command', json={'cmd': '/panic'}) assert response.status_code == 200 and response.json() == {'status': 'ok'} assert exits == [99] - assert (obj.data / 'state/panic_stop.flag').read_text() == 'panic' + assert (obj.data / 'state/panic_stop.flag').read_text(encoding="utf-8") == 'panic' assert ('daemon',) in stops and ('port', 19876) in stops assert not (obj.data / 'settings.json').exists() from supervisor import state diff --git a/tests/test_preflight_node.py b/tests/test_preflight_node.py index 961a2d92d..d6d2d01c8 100644 --- a/tests/test_preflight_node.py +++ b/tests/test_preflight_node.py @@ -274,7 +274,7 @@ def test_relative_root_reaps_owned_children_and_preserves_unrelated_process( assert not broad_queries, "preflight rediscovered process ownership from command-line text" assert stranger.poll() is None, "an unrelated process was killed because its argv named a path" assert pid_file.exists(), "the owned fixture never ran" - owned = json.loads(pid_file.read_text()) + owned = json.loads(pid_file.read_text(encoding="utf-8")) deadline = time.monotonic() + 10 while any(pid_is_alive(pid) for pid in owned.values()) and time.monotonic() < deadline: time.sleep(0.05) @@ -287,7 +287,7 @@ def test_relative_root_reaps_owned_children_and_preserves_unrelated_process( assert result["returncode"] == 0 and result["error"] is None finally: if pid_file.exists(): - for pid in json.loads(pid_file.read_text()).values(): + for pid in json.loads(pid_file.read_text(encoding="utf-8")).values(): if pid_is_alive(pid): force_kill_pid(pid) stranger.terminate() diff --git a/tests/test_release_proof.py b/tests/test_release_proof.py index 5938f8aae..47a678922 100644 --- a/tests/test_release_proof.py +++ b/tests/test_release_proof.py @@ -110,6 +110,9 @@ def test_assemble_binds_every_asset_smoke_and_sbom(tmp_path: Path): run_url="https://github.com/razzant/ouroboros/actions/runs/1", previous_tag="v6.87.4", generated_at="2026-08-02T00:00:00+00:00", + android_build_result="success", + android_attestation_result="success", + github_output=tmp_path / "github-output", notes_output=notes, ) release_proof.command_assemble(args) @@ -117,6 +120,10 @@ def test_assemble_binds_every_asset_smoke_and_sbom(tmp_path: Path): evidence = json.loads((release_dir / "release-evidence.json").read_text()) assert evidence["source"]["commit"] == "a" * 40 assert len(evidence["artifacts"]) == 9 + assert evidence["experimentalAndroid"]["status"] == "verified" + upload = json.loads(args.github_output.read_text(encoding="utf-8").split("=", 1)[1]).splitlines() + assert len(upload) == 29 + assert {Path(path).name for path in upload} >= {row["name"] for row in evidence["artifacts"]} assert {row["proofId"] for row in evidence["artifacts"]} == set( release_proof.PROOF_IDS ) @@ -165,6 +172,9 @@ def test_prerelease_notes_link_to_the_exact_prerelease_assets(tmp_path: Path): run_url="https://github.com/razzant/ouroboros/actions/runs/1", previous_tag="v6.87.4", generated_at="2026-08-02T00:00:00+00:00", + android_build_result="success", + android_attestation_result="success", + github_output=None, notes_output=notes, ) @@ -191,24 +201,67 @@ def test_assemble_rejects_smoke_digest_drift(tmp_path: Path): run_url="https://example.test/run", previous_tag=None, generated_at="2026-08-02T00:00:00+00:00", + android_build_result="success", + android_attestation_result="success", + github_output=None, notes_output=tmp_path / "notes.md", ) with pytest.raises(ValueError, match="not bound"): release_proof.command_assemble(args) -@pytest.mark.parametrize("proof_id", ["android-arm64", "android-apk"]) -def test_release_cannot_omit_an_android_artifact_or_its_verification(tmp_path, proof_id): - release_dir, _version, _readme = _fixture_release(tmp_path) - receipt_path = release_dir / f"release-smoke-{proof_id}.json" - receipt = json.loads(receipt_path.read_text()) - receipt["checks"] = [] - receipt_path.write_text(json.dumps(receipt)) - with pytest.raises(ValueError, match="missing required checks"): - release_proof._proof_files(release_dir, "6.87.5", commit="a" * 40, tag="v6.87.5") - (release_dir / release_proof.release_asset_name(proof_id, "6.87.5")).unlink() - with pytest.raises(ValueError, match="release asset set"): - release_proof._proof_files(release_dir, "6.87.5", commit="a" * 40, tag="v6.87.5") +@pytest.mark.parametrize("failure", ["failure", "cancelled", "skipped", "missing", "partial", "digest", "smoke", "sbom", "attestation"]) +def test_unavailable_android_pair_keeps_verified_desktop_release(tmp_path, failure): + release_dir, version_file, readme = _fixture_release(tmp_path) + build_result = failure if failure in {"failure", "cancelled", "skipped"} else "success" + attestation_result = "failure" if failure == "attestation" else "success" + apk = release_dir / release_proof.release_asset_name("android-apk", "6.87.5") + if failure == "missing": + for proof_id in release_proof.ANDROID_DOWNLOAD_IDS: + for path in (release_dir / release_proof.release_asset_name(proof_id, "6.87.5"), + release_dir / f"release-smoke-{proof_id}.json", release_dir / f"sbom-{proof_id}.cdx.json"): + path.unlink() + elif failure == "partial": + apk.unlink() + elif failure == "digest": + apk.write_bytes(b"different APK bytes") + elif failure == "smoke": + path = release_dir / "release-smoke-android-apk.json" + receipt = json.loads(path.read_text(encoding="utf-8")) + receipt["checks"] = [] + path.write_text(json.dumps(receipt), encoding="utf-8") + elif failure == "sbom": + (release_dir / "sbom-android-apk.cdx.json").write_text("{}", encoding="utf-8") + output, notes = tmp_path / "github-output", tmp_path / "notes.md" + args = argparse.Namespace(directory=release_dir, version_file=version_file, readme=readme, + repository="razzant/ouroboros", tag="v6.87.5", commit="a" * 40, + run_url="https://example.test/failed-run", previous_tag=None, generated_at=None, + notes_output=notes, android_build_result=build_result, + android_attestation_result=attestation_result, github_output=output) + release_proof.command_assemble(args) + evidence = json.loads((release_dir / "release-evidence.json").read_text(encoding="utf-8")) + assert {row["proofId"] for row in evidence["artifacts"]} == set(release_proof.DESKTOP_DOWNLOAD_IDS) + assert evidence["experimentalAndroid"]["status"] == "unavailable" + assert evidence["experimentalAndroid"]["buildResult"] == build_result + assert evidence["experimentalAndroid"]["reason"] + assert next(row for row in evidence["workflow"]["gates"] if row["name"] == "android-build")["status"] == build_result + assert len((release_dir / "SHA256SUMS").read_text(encoding="utf-8").splitlines()) == 21 + upload = json.loads(output.read_text(encoding="utf-8").split("=", 1)[1]).splitlines() + assert len(upload) == 23 + assert not any("android" in Path(path).name for path in upload) + text = notes.read_text(encoding="utf-8") + assert "Android artifacts are unavailable" in text and args.run_url in text + assert "-android.apk]" not in text and "-android-arm64.tar.gz]" not in text + assert "Ouroboros-6.87.5.dmg" in text + remote = tmp_path / "remote.json" + rows = [{"name": Path(path).name, "size": Path(path).stat().st_size, + "digest": "sha256:" + _digest(Path(path))} for path in upload] + remote.write_text(json.dumps({"assets": rows}), encoding="utf-8") + release_proof.command_verify_uploaded(argparse.Namespace(directory=release_dir, metadata=remote)) + rows.append({"name": apk.name, "size": 1, "digest": "sha256:unverified"}) + remote.write_text(json.dumps({"assets": rows}), encoding="utf-8") + with pytest.raises(ValueError, match="uploaded asset set"): + release_proof.command_verify_uploaded(argparse.Namespace(directory=release_dir, metadata=remote)) @pytest.mark.parametrize( @@ -238,6 +291,9 @@ def test_assemble_rejects_unbound_or_incomplete_smoke_receipt( run_url="https://example.test/run", previous_tag=None, generated_at="2026-08-02T00:00:00+00:00", + android_build_result="success", + android_attestation_result="success", + github_output=None, notes_output=tmp_path / "notes.md", ) with pytest.raises(ValueError, match=message): @@ -256,6 +312,9 @@ def test_assemble_rejects_tag_version_mismatch(tmp_path: Path): run_url="https://example.test/run", previous_tag=None, generated_at=None, + android_build_result="success", + android_attestation_result="success", + github_output=None, notes_output=tmp_path / "notes.md", ) with pytest.raises(ValueError, match="tag/version mismatch"): @@ -263,33 +322,23 @@ def test_assemble_rejects_tag_version_mismatch(tmp_path: Path): def test_verify_uploaded_requires_exact_names_sizes_and_digests(tmp_path: Path): - release_dir = tmp_path / "release" - release_dir.mkdir() - asset = release_dir / "Ouroboros-1.0.0.dmg" - asset.write_bytes(b"artifact") + release_dir, version_file, readme = _fixture_release(tmp_path) + release_proof.command_assemble(argparse.Namespace( + directory=release_dir, version_file=version_file, readme=readme, + repository="razzant/ouroboros", tag="v6.87.5", commit="a" * 40, + run_url="https://example.test/run", previous_tag=None, generated_at=None, + notes_output=tmp_path / "notes.md", android_build_result="success", + android_attestation_result="success", github_output=None)) metadata = tmp_path / "remote.json" - metadata.write_text( - json.dumps( - { - "assets": [ - { - "name": asset.name, - "size": asset.stat().st_size, - "digest": f"sha256:{_digest(asset)}", - } - ] - } - ), - encoding="utf-8", - ) - release_proof.command_verify_uploaded( - argparse.Namespace(directory=release_dir, metadata=metadata) - ) - asset.write_bytes(b"ARTIFACT") + metadata.write_text(json.dumps({"assets": [ + {"name": path.name, "size": path.stat().st_size, "digest": "sha256:" + _digest(path)} + for path in release_dir.iterdir() + ]}), encoding="utf-8") + release_proof.command_verify_uploaded(argparse.Namespace(directory=release_dir, metadata=metadata)) + asset = release_dir / release_proof.release_asset_name("macos-arm64", "6.87.5") + asset.write_bytes(asset.read_bytes().upper()) with pytest.raises(ValueError, match="digest mismatch"): - release_proof.command_verify_uploaded( - argparse.Namespace(directory=release_dir, metadata=metadata) - ) + release_proof.command_verify_uploaded(argparse.Namespace(directory=release_dir, metadata=metadata)) def test_linux_package_smoke_pins_third_party_vendor_images_by_digest(): @@ -466,8 +515,9 @@ def test_release_workflow_orders_smoke_sbom_attestation_and_draft_verification() "- name: Record Linux package smoke and reuse payload SBOM", "- name: Attest Linux package provenance", "- name: Upload build artifact", - "- name: Assemble release proof capsule and notes", "- name: Verify artifact attestations", + # Proof acceptance consumes the actual optional-Android verification outcome. + "- name: Assemble release proof capsule and notes", "- name: Require an unpublished release slot", "- name: Verify remote release tag before draft", "- name: Create draft GitHub Release", @@ -490,7 +540,8 @@ def test_release_workflow_orders_smoke_sbom_attestation_and_draft_verification() assert "steps.smoke_appimage.outputs.sbom_path" in workflow assert "release-smoke-linux-appimage-x86_64.json" in workflow assert "sbom-linux-appimage-x86_64.cdx.json" in workflow - assert "Ouroboros-*-linux-x86_64.AppImage" in workflow + assert 'ids = list(registry["DESKTOP_DOWNLOAD_IDS"])' in workflow + assert 'registry["release_asset_name"](proof_id, version)' in workflow assert "--check appimage_extract_and_run" in workflow assert "--check appimage_metadata" in workflow assert "--check product_version" in workflow @@ -520,9 +571,7 @@ def test_release_workflow_orders_smoke_sbom_attestation_and_draft_verification() assert "--check runtime_dependency" in workflow assert "--check systemd_user_unit" in workflow assert "--check desktop_launcher_start" in workflow - assert "release-artifacts/ouroboros_*_amd64.deb" in workflow - assert "release-artifacts/ouroboros-*-1.x86_64.rpm" in workflow - assert "release-artifacts/ouroboros-*-1.red80.x86_64.rpm" in workflow + assert "files: ${{ fromJSON(steps.release_proof.outputs.files_json) }}" in workflow assert "sbom-path: dist/sbom-linux-deb-amd64.cdx.json" in workflow assert "sbom-path: dist/sbom-linux-rpm-x86_64.cdx.json" in workflow assert "sbom-path: dist/sbom-linux-rpm-red80-x86_64.cdx.json" in workflow @@ -560,3 +609,11 @@ def test_release_workflow_orders_smoke_sbom_attestation_and_draft_verification() assert "BUILD_CERTIFICATE_BASE64:" not in job_env assert "P12_PASSWORD:" not in job_env assert "KEYCHAIN_PASSWORD:" not in job_env + + +def test_optional_android_does_not_waive_missing_desktop_asset(tmp_path): + release_dir, _version, _readme = _fixture_release(tmp_path) + (release_dir / release_proof.release_asset_name("macos-arm64", "6.87.5")).unlink() + with pytest.raises(ValueError, match="required"): + release_proof._proof_files(release_dir, "6.87.5", commit="a" * 40, tag="v6.87.5", + android_build_result="failure", android_attestation_result="not_run") diff --git a/tests/test_restart_reconnect.py b/tests/test_restart_reconnect.py index 818db6e42..8948618a2 100644 --- a/tests/test_restart_reconnect.py +++ b/tests/test_restart_reconnect.py @@ -402,8 +402,8 @@ def test_owner_restart_copy_is_explicit_about_stopped_task(tmp_path, monkeypatch def stopped(actual): assert actual is ctx - assert (flags / "owner_restart_no_resume.flag").read_text() == "owner_restart" - assert (flags / "panic_stop.flag").read_text() == "owner_restart_no_resume" + assert (flags / "owner_restart_no_resume.flag").read_text(encoding="utf-8") == "owner_restart" + assert (flags / "panic_stop.flag").read_text(encoding="utf-8") == "owner_restart_no_resume" calls.append("stopped") return ["active-task"]