diff --git a/.gitattributes b/.gitattributes index 145c06043bd..72fdad6e73a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,10 +23,15 @@ # runs `git apply` on one must force `-c core.autocrlf=input` rather than trust # the host's setting. See config/scripts/windows-process-tree-gyp-rebuild.mjs. /config/patches/*.patch -text +# Same reason, and pnpm parses these too: a CRLF checkout makes the mobile +# patches unparseable, so Windows packaging dies on ERR_PNPM_INVALID_PATCH. +/mobile/patches/*.patch -text # The xterm bundle hunks also make a diff nobody can read; review the hand-written # source patch under xterm-src/ instead. The sibling patches stay diffable. /config/patches/@xterm__xterm@*.patch -diff /config/patches/xterm-src/*.patch text eol=lf +# pnpm parses these unified diffs during Windows installs; keep checkout bytes stable. +/mobile/patches/*.patch -text # Generated wrapper fixtures: collapse them in the PR diff so they stop drowning # the reviewable change, and pin LF because they are compared byte-for-byte. # Not -diff: the shell diff is the review surface when a wrapper does change. @@ -48,3 +53,41 @@ /src/mobile-web/src/*.ts text eol=lf /src/mobile-web/src/*.css text eol=lf /src/mobile-web/src/*.png -text +# Mobile web page source. Same buildId hazard as src/mobile-web above: these bytes are +# hashed into the Phase C bundle, so a CRLF Windows checkout would ship a different +# buildId for identical source. web-entry/ does not exist yet; the pin lands ahead of it. +/mobile/src/** text eol=lf +/mobile/app/** text eol=lf +/mobile/web-entry/** text eol=lf +# The blanket pin above would mark a future binary as text; exempt the asset types an +# RN page actually carries, the same way src/mobile-web exempts its PNG. +/mobile/src/**/*.png -text +/mobile/src/**/*.jpg -text +/mobile/src/**/*.jpeg -text +/mobile/src/**/*.gif -text +/mobile/src/**/*.ico -text +/mobile/src/**/*.webp -text +/mobile/src/**/*.ttf -text +/mobile/src/**/*.otf -text +/mobile/src/**/*.woff -text +/mobile/src/**/*.woff2 -text +/mobile/app/**/*.png -text +/mobile/app/**/*.jpg -text +/mobile/app/**/*.jpeg -text +/mobile/app/**/*.gif -text +/mobile/app/**/*.ico -text +/mobile/app/**/*.webp -text +/mobile/app/**/*.ttf -text +/mobile/app/**/*.otf -text +/mobile/app/**/*.woff -text +/mobile/app/**/*.woff2 -text +/mobile/web-entry/**/*.png -text +/mobile/web-entry/**/*.jpg -text +/mobile/web-entry/**/*.jpeg -text +/mobile/web-entry/**/*.gif -text +/mobile/web-entry/**/*.ico -text +/mobile/web-entry/**/*.webp -text +/mobile/web-entry/**/*.ttf -text +/mobile/web-entry/**/*.otf -text +/mobile/web-entry/**/*.woff -text +/mobile/web-entry/**/*.woff2 -text diff --git a/.github/actions/install-mobile-dependencies/action.yml b/.github/actions/install-mobile-dependencies/action.yml new file mode 100644 index 00000000000..0ed2b45bb9a --- /dev/null +++ b/.github/actions/install-mobile-dependencies/action.yml @@ -0,0 +1,22 @@ +name: Install mobile dependencies +description: Frozen pnpm install for the mobile/ project, whose node_modules the mobile web bundle build and the mobile-aware lint passes resolve React Native and Expo from. + +runs: + using: composite + steps: + # Why a separate install: mobile is its own pnpm project, so the root install leaves + # mobile/node_modules empty and every mobile import resolves to nothing. + # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates the + # gitignored terminal/mermaid webview engine modules that tracked source imports. + # The drift guard mirrors the root install so a stale mobile lockfile fails by name -- + # mobile's lockfile carries patchedDependencies that a silent rewrite would drop. + - name: Install mobile dependencies + shell: bash + working-directory: mobile + run: | + pnpm install --frozen-lockfile + # Job containers can run composite steps from a source mirror without .git. + if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then + git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ + mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml + fi diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 4f667bf2778..c081831c28b 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -184,6 +184,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -205,6 +208,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: signing is what makes an adhoc build installable over an existing # Orca, so a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml index 617c852d304..fec048bf4b2 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap-job.yml @@ -78,7 +78,8 @@ jobs: [[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]] [[ "${EXPECTED_SELECTOR_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] [[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]] - [[ "${WAVE_INDEX}" =~ ^[0-3]$ ]] + # cell_1..cell_10 in the calling wave; the chain is static, so this range is too. + [[ "${WAVE_INDEX}" =~ ^[0-9]$ ]] # The caller validated this too; re-check here so a malformed override # can never reach a mutation through this reusable workflow. if test -n "${GATE_OVERRIDE_REASON}${GATE_OVERRIDE_CONFIRMATION}"; then @@ -537,9 +538,12 @@ jobs: - name: Reversibly isolate and drain only the selected cell if: ${{ inputs.mode != 'verify' && env.ROLLBACK_RESUME != 'true' }} + id: drain env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }} run: | + # Opens the window the report-only shadow health gate below judges this cell over. + echo "drain-started-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" echo "MUTATION_STARTED=true" >> "${GITHUB_ENV}" # A cell isolated by a failed canary is already migration-only, so # isolate is a no-op there that does not advance the selector; the @@ -590,11 +594,16 @@ jobs: # Zero resource changes prove the prior run's apply completed and no # restart will follow, keeping the incarnation check honest. Root # outputs may lag a targeted apply, so judge resource_changes only. + # The backend service is targeted too, so its reviewed drain timeout + # and request logging can be the only thing left here; neither + # restarts an instance, so the validator below clears that on its + # own, without the template-and-MIG pair. terraform -chdir=infra/terraform plan \ -var-file=environments/production.tfvars \ -var-file="${RUNNER_TEMP}/relay-same-cap.tfvars.json" \ "-target=google_compute_instance_template.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \ "-target=google_compute_instance_group_manager.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \ + "-target=google_compute_backend_service.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \ -out="${RUNNER_TEMP}/relay-same-cap-resume.tfplan" if ! terraform -chdir=infra/terraform show -json \ "${RUNNER_TEMP}/relay-same-cap-resume.tfplan" \ @@ -617,7 +626,7 @@ jobs: | select(.change.actions | any(. != "no-op" and . != "read")) | .address] | join(","))' echo 'requiring reviewed rollback-image drift' - terraform -chdir=infra/terraform show -json \ + RESUME_REVIEW="$(terraform -chdir=infra/terraform show -json \ "${RUNNER_TEMP}/relay-same-cap-resume.tfplan" \ | node dev/scripts/validate-relay-capacity-plan.mjs \ --mode same-cap-cell --cell-id "${TARGET_CELL_ID}" \ @@ -629,14 +638,30 @@ jobs: --rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \ --rehome-audience https://relay.onorca.dev/v1/admin/host-drain \ --regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" \ - "${POOL_ARGUMENTS[@]}" \ - | jq -e '.changes == 2' >/dev/null + "${POOL_ARGUMENTS[@]}")" + echo "${RESUME_REVIEW}" + jq -e '.changes == 2 + or (.changes == 0 and ((.backendUpdate // []) | length) > 0)' \ + <<< "${RESUME_REVIEW}" >/dev/null + # changes == 0 here means the template and MIG are converged and this cell's + # reviewed backend update is the only thing left, so the resume is not complete: + # apply it, or the cell silently keeps the 300-second drain and no request + # logging and the operator reads that as a finished roll. The plan holds nothing + # else (the validator bounded it to this cell's backend, and the template and MIG + # are no-ops in it), and neither attribute restarts an instance, so the + # incarnation check downstream stays honest. Template-and-MIG drift still applies + # nothing, which is what a resume means. + if test "$(jq -er '.changes' <<< "${RESUME_REVIEW}")" = 0; then + terraform -chdir=infra/terraform apply -auto-approve \ + "${RUNNER_TEMP}/relay-same-cap-resume.tfplan" + fi fi gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \ --project "${GCP_PROJECT_ID}" --zone "${TARGET_ZONE}" --timeout 900 - name: Apply only the selected same-cap template and MIG if: ${{ inputs.mode != 'verify' && env.ROLLBACK_RESUME != 'true' }} + id: apply shell: bash env: CAPACITY_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }} @@ -652,6 +677,7 @@ jobs: -var-file="${RUNNER_TEMP}/relay-same-cap.tfvars.json" \ "-target=google_compute_instance_template.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \ "-target=google_compute_instance_group_manager.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \ + "-target=google_compute_backend_service.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \ -out="${RUNNER_TEMP}/relay-same-cap.tfplan" PLAN_REVIEW="$(terraform -chdir=infra/terraform show -json \ "${RUNNER_TEMP}/relay-same-cap.tfplan" \ @@ -666,6 +692,10 @@ jobs: --regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" \ "${POOL_ARGUMENTS[@]}")" echo "${PLAN_REVIEW}" + # Stamped before the apply, not after it: the new container announces its listener while + # the MIG is still converging, so a bound taken at the end of this step is already past + # the announcement the shadow gate looks for. + echo "apply-started-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" terraform -chdir=infra/terraform apply -auto-approve \ "${RUNNER_TEMP}/relay-same-cap.tfplan" gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \ @@ -684,6 +714,9 @@ jobs: gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \ --project "${GCP_PROJECT_ID}" --zone "${TARGET_ZONE}" --timeout 900 fi + # Recorded for the operator comparing verdicts; the gate's boot search opens at the + # apply-started-at stamp above, not here. + echo "apply-completed-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" - id: post-auth if: ${{ inputs.mode != 'verify' }} @@ -697,6 +730,7 @@ jobs: - name: Verify new incarnation, exact image, protocol, and durable safety if: ${{ inputs.mode != 'verify' }} + id: verify-target env: ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }} run: | @@ -748,6 +782,7 @@ jobs: --expected-general-cells "${ISOLATED_GENERAL_CELLS}" \ --expected-control-generation "${EXPECTED_REHOME_GENERATION}" \ | jq -e '.control.enabled == false' >/dev/null + echo "verify-ended-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}" - name: Prove exact per-host trust and idempotent no-neighbor behavior if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol != '0') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol != '0')) }} @@ -794,6 +829,63 @@ jobs: --expected-control-generation "${EXPECTED_REHOME_GENERATION}" \ | jq -e '.control.enabled == false' >/dev/null + # Report only: this evaluates the oracles an operator reads by hand after a cell and records + # what it would have called, so its verdicts can be compared with the operator's over a full + # roll before it is ever allowed to block. Two independent guarantees keep it inert: the + # script exits 0 on every verdict, and continue-on-error keeps even a crash off the job's + # outcome. The failsafe below therefore cannot fire on anything this step observes. + # + # It runs after the restore, not before it, for two reasons: the cell goes back into + # admission on exactly today's schedule rather than waiting out a minute of log reads, and + # the window it judges has closed by then, so Cloud Logging's ingestion lag is behind it. + # These are fleet oracles anyway; when this does gate, what it gates is the next cell. + - name: Shadow health gate (report only) + id: shadow-gate + if: ${{ inputs.mode != 'verify' }} + continue-on-error: true + # continue-on-error bounds this step's contribution to the job outcome, not its clock, + # and its reads are serialised. A timed-out step is a failed step, which continue-on-error + # absorbs; without this bound a Logging 429 storm or an expired credential makes every + # read cost its full retry budget and can push the job past timeout-minutes, and a + # cancelled job takes the whole wave with it. A healthy gate is already minutes of + # serial reads, so both bounds sit above that: the script settles at seven minutes and + # reaching this eight is the pathological case. Eight on top of a ~14-minute cell still + # leaves the job's 75 minutes intact. + timeout-minutes: 8 + env: + DRAIN_STARTED_AT: ${{ steps.drain.outputs.drain-started-at }} + APPLY_STARTED_AT: ${{ steps.apply.outputs.apply-started-at }} + APPLY_COMPLETED_AT: ${{ steps.apply.outputs.apply-completed-at }} + VERIFY_ENDED_AT: ${{ steps.verify-target.outputs.verify-ended-at }} + SHADOW_GATE_DIRECTORY: ${{ runner.temp }}/relay-same-cap-shadow-gate + SHADOW_GATE_NAME: relay-same-cap-shadow-gate-${{ inputs.target-cell-id }}-${{ github.run_id }}.json + run: | + mkdir -p "${SHADOW_GATE_DIRECTORY}" + node dev/scripts/relay-same-cap-shadow-gate.mjs \ + --cell-id "${TARGET_CELL_ID}" \ + --cell-host "${TARGET_HOSTNAME}.relay.onorca.dev" \ + --project-id "${GCP_PROJECT_ID}" \ + --director-service orca-cloud-relay \ + --drain-started-at "${DRAIN_STARTED_AT}" \ + --apply-started-at "${APPLY_STARTED_AT}" \ + --apply-completed-at "${APPLY_COMPLETED_AT}" \ + --verify-ended-at "${VERIFY_ENDED_AT}" \ + --summary-file "${GITHUB_STEP_SUMMARY}" \ + --output-file "${SHADOW_GATE_DIRECTORY}/${SHADOW_GATE_NAME}" + + - name: Publish the shadow health gate verdict + if: ${{ inputs.mode != 'verify' }} + continue-on-error: true + # One small JSON file; a retrying upload must not spend the wave's remaining minutes either. + timeout-minutes: 2 + uses: actions/upload-artifact@v4 + with: + name: relay-same-cap-shadow-gate-${{ inputs.target-cell-id }}-${{ github.run_id }}.json + path: ${{ runner.temp }}/relay-same-cap-shadow-gate + if-no-files-found: warn + retention-days: 14 + overwrite: true + - id: cleanup-auth if: ${{ failure() && inputs.mode != 'verify' }} uses: google-github-actions/auth@v2 diff --git a/.github/workflows/cloud-deploy-relay-production-same-cap.yml b/.github/workflows/cloud-deploy-relay-production-same-cap.yml index 421fa080072..cd24792cc8c 100644 --- a/.github/workflows/cloud-deploy-relay-production-same-cap.yml +++ b/.github/workflows/cloud-deploy-relay-production-same-cap.yml @@ -10,7 +10,7 @@ on: type: choice options: [verify, canary-apply, batch-apply, rollback] cell-ids: - description: Ordered comma-separated serving cells; one canary or two to four batch cells + description: Ordered comma-separated serving cells; one canary or two to ten batch cells required: true type: string target-image-digest: @@ -313,6 +313,144 @@ jobs: wave-index: '3' secrets: inherit + cell_5: + if: ${{ needs.cell_4.result == 'success' && fromJSON(needs.gate.outputs.cells)[4] != null }} + needs: [gate, cell_4] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[4] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '4' + secrets: inherit + + cell_6: + if: ${{ needs.cell_5.result == 'success' && fromJSON(needs.gate.outputs.cells)[5] != null }} + needs: [gate, cell_5] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[5] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '5' + secrets: inherit + + cell_7: + if: ${{ needs.cell_6.result == 'success' && fromJSON(needs.gate.outputs.cells)[6] != null }} + needs: [gate, cell_6] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[6] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '6' + secrets: inherit + + cell_8: + if: ${{ needs.cell_7.result == 'success' && fromJSON(needs.gate.outputs.cells)[7] != null }} + needs: [gate, cell_7] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[7] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '7' + secrets: inherit + + cell_9: + if: ${{ needs.cell_8.result == 'success' && fromJSON(needs.gate.outputs.cells)[8] != null }} + needs: [gate, cell_8] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[8] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '8' + secrets: inherit + + cell_10: + if: ${{ needs.cell_9.result == 'success' && fromJSON(needs.gate.outputs.cells)[9] != null }} + needs: [gate, cell_9] + uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml + with: + mode: ${{ needs.gate.outputs.job-mode }} + target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[9] }} + target-image-digest: ${{ inputs.target-image-digest }} + rollback-image-digest: ${{ inputs.rollback-image-digest }} + target-rehome-protocol: ${{ inputs.target-rehome-protocol }} + rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }} + expected-selector-generation: ${{ inputs.expected-selector-generation }} + expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }} + expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }} + expected-general-cells: ${{ inputs.expected-general-cells }} + expected-rehome-generation: ${{ inputs.expected-rehome-generation }} + monitor-run-id: ${{ inputs.monitor-run-id }} + monitor-run-attempt: ${{ inputs.monitor-run-attempt }} + gate-override-reason: ${{ inputs.gate-override-reason }} + gate-override-confirmation: ${{ inputs.gate-override-confirmation }} + wave-index: '9' + secrets: inherit + seal_canary: if: ${{ inputs.mode == 'canary-apply' }} needs: [gate, cell_1] @@ -360,6 +498,12 @@ jobs: - cell_2 - cell_3 - cell_4 + - cell_5 + - cell_6 + - cell_7 + - cell_8 + - cell_9 + - cell_10 - seal_canary runs-on: blacksmith-2vcpu-ubuntu-2204 timeout-minutes: 10 diff --git a/.github/workflows/cloud-operate-relay-production-rehome-job.yml b/.github/workflows/cloud-operate-relay-production-rehome-job.yml index a34552b898f..b16132d9ba5 100644 --- a/.github/workflows/cloud-operate-relay-production-rehome-job.yml +++ b/.github/workflows/cloud-operate-relay-production-rehome-job.yml @@ -320,7 +320,7 @@ jobs: echo '### Regional rehome control' jq -r '"- mode: `\(.mode)`\n- generation: `\(.control.generation)`\n- enabled: `\(.control.enabled)`"' \ "${RUNNER_TEMP}/relay-rehome-control.json" - jq -r '"- active: `\(.active)`\n- awaiting receipt: `\(.awaitingReceipt)`\n- target registered: `\(.targetRegistered)`\n- completed (24h): `\(.completedLast24Hours)`\n- aborted (24h): `\(.abortedLast24Hours)`"' \ + jq -r '"- active: `\(.active)`\n- awaiting receipt: `\(.awaitingReceipt)`\n- target registered: `\(.targetRegistered)`\n- completed (24h): `\(.completedLast24Hours)`\n- aborted (24h): `\(.abortedLast24Hours)`\n- host not arrived (24h): `\(.hostNotArrivedLast24Hours // "not reported")`\n- oldest active age (ms): `\(.oldestActiveAgeMs // "none")`"' \ "${RUNNER_TEMP}/relay-rehome-inventory.json" } >> "${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/daemon-relocation-spike.yml b/.github/workflows/daemon-relocation-spike.yml index 2ffd4a58661..ac9c1bd4ba4 100644 --- a/.github/workflows/daemon-relocation-spike.yml +++ b/.github/workflows/daemon-relocation-spike.yml @@ -57,7 +57,28 @@ jobs: uses: actions/cache@v4 with: path: dist/win-unpacked - key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }} + # mobile/ is in the key because beforePack requires out/mobile-web, whose bytes come from + # the mobile install and, once Phase C flips the bundle, from the page trees below; a + # mobile-only change must miss this cache, not reuse a stale installer. src/** and + # config/** already cover src/mobile-web and the two bundle builders. + key: >- + win-unpacked-${{ hashFiles( + 'src/**', + 'config/**', + 'package.json', + 'pnpm-lock.yaml', + 'mobile/package.json', + 'mobile/pnpm-lock.yaml', + 'mobile/app/**', + 'mobile/src/**', + 'mobile/web-entry/**' + ) }} + + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-unpacked.outputs.cache-hit != 'true' - name: Build unpacked app if: steps.cache-unpacked.outputs.cache-hit != 'true' diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 41b87526fea..4e89b1f5a4b 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -156,6 +156,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads if: steps.freshness.outputs.should_build == 'true' @@ -179,6 +182,11 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.freshness.outputs.should_build == 'true' + # Why: signing is what makes a daily installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/dev-channel-win-build.yml b/.github/workflows/dev-channel-win-build.yml index 7913e7e6e80..3f8e162e073 100644 --- a/.github/workflows/dev-channel-win-build.yml +++ b/.github/workflows/dev-channel-win-build.yml @@ -203,6 +203,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Caches the Electron binary and electron-builder's tool downloads (nsis, # winCodeSign). Same key shape as release-cut's Windows leg. @@ -229,6 +232,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why the packaging check runs before the 20-minute build: it only needs # node_modules, and a stale config should cost seconds rather than a build. - name: Verify dev-channel packaging identity diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index 1aed485a666..8e061b83563 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -164,6 +164,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -185,6 +188,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: signing is what makes an hourly installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 730129d674d..aae50234bf9 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -103,6 +103,12 @@ jobs: - name: Typecheck tests (ratchet) run: pnpm run check:tests-typecheck + # This includes the bridged replay of the whole recording corpus, which used to be a second + # step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point: + # it fails when a divergence class grows, when a divergence lands in no class at all, or when + # one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is + # ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite, + # so folding it in costs a fraction of that in wall time and one step less to skip. - name: Test run: pnpm test diff --git a/.github/workflows/pi-owner-runtime.yml b/.github/workflows/pi-owner-runtime.yml index 373afb7a539..9f9df4f9b0b 100644 --- a/.github/workflows/pi-owner-runtime.yml +++ b/.github/workflows/pi-owner-runtime.yml @@ -2,8 +2,9 @@ name: Pi owner runtime verification on: pull_request: paths: - - 'src/main/pi/agent-status-handler-source.ts' + - 'src/main/pi/**' - 'tests/tools/pi-owner-runtime-smoke.mjs' + - 'tests/tools/omp-completion-runtime-smoke.mjs' - '.github/workflows/pi-owner-runtime.yml' workflow_dispatch: permissions: @@ -27,3 +28,5 @@ jobs: run: npm install --prefix .cache/pi-owner --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.83.0 - name: Verify real owner exit and hook delivery run: node tests/tools/pi-owner-runtime-smoke.mjs .cache/pi-owner/node_modules/@earendil-works/pi-coding-agent + - name: Verify OMP completion over native HTTP + run: node tests/tools/omp-completion-runtime-smoke.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e2112de521b..4eee03c5632 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -29,6 +29,7 @@ jobs: should_run: ${{ steps.filter.outputs.should_run }} native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }} mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }} + mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }} static_analysis: ${{ steps.filter.outputs.static_analysis }} typecheck: ${{ steps.filter.outputs.typecheck }} git_compatibility: ${{ steps.filter.outputs.git_compatibility }} @@ -142,24 +143,11 @@ jobs: - name: Enforce type-aware code-quality baseline run: pnpm run audit:code-quality:type-aware - # Why: the changed-code gate lints mobile files too, and its type-aware pass - # resolves types from mobile/node_modules. Mobile is a separate pnpm project, - # so the root install above leaves it empty and every mobile type degrades to - # an `error` type — reported as phantom findings against the changed lines. - # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates - # the gitignored terminal/mermaid webview engine modules that tracked source imports, - # and skipping it degrades those very types the step exists to resolve. The drift - # guard mirrors the root install so a stale mobile lockfile fails by name — mobile's - # lockfile carries patchedDependencies that a silent rewrite would drop. - - name: Install mobile dependencies + # Why here: the changed-code gate lints mobile files too, and its type-aware pass + # resolves types from mobile/node_modules. Without the install every mobile type + # degrades to an `error` type — reported as phantom findings against the changed lines. + - uses: ./.github/actions/install-mobile-dependencies if: needs.code_paths.outputs.mobile_dependencies == 'true' - working-directory: mobile - run: | - pnpm install --frozen-lockfile - if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then - git -C "$GITHUB_WORKSPACE" diff --exit-code -- \ - mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml - fi - name: Enforce changed-code quality run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}" @@ -661,6 +649,79 @@ jobs: pnpm exec vitest run --config config/vitest.config.ts \ src/main/orcad/external-chromium-browser-process.integration.test.ts + # Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test` + # matrix would pay for both on every shard to run two files. Dark through Phase C: this proves + # `build:mobile-web:app` on every PR that touches the page, and ships nothing -- packaging still + # builds the Phase A bootstrap via build:mobile-web. + mobile_web_app: + name: mobile web app bundle + needs: [code_paths] + if: needs.code_paths.outputs.mobile_web_app == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + # Why no native-runtime: the builder is esbuild and the render check is a browser. Nothing + # in this job loads node-pty. + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # The entry lives in mobile/ so one React resolves; without this every RN import is nothing. + - uses: ./.github/actions/install-mobile-dependencies + + # Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad + # browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb. + # Why fail instead of skip: a silently skipped render check is the failure this job exists + # to prevent. + - name: Resolve Chrome for the render check + run: | + set -euo pipefail + chrome="$(command -v google-chrome || command -v google-chrome-stable || true)" + if [ -z "$chrome" ]; then + echo "::error::No Google Chrome on the runner; the render check would silently skip." + exit 1 + fi + "$chrome" --version + echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV" + + # The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is + # WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from + # the runner: Ubuntu ships no WebKit build to point at. + - name: Install WebKit for the drawer check + run: pnpm exec playwright install --with-deps webkit + + - name: Build and verify the app bundle + run: pnpm run build:mobile-web:app + + # The bundling tests skip themselves where mobile dependencies are absent, which is how they + # stay green in the sharded `test` job. This is the job that installs them, so here a missing + # install has to fail rather than skip everything the job exists to run. + # Why a prefix and not a file list: the list this replaces had gone stale twice without + # anyone noticing, because a census whose closure block skips without the env flag below is + # green in the sharded `test` job whether or not it ever runs here. The prefix is the same + # one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all + # it takes to have it run. Quoted because these are vitest filename filters, matched as + # substrings against the discovered files, and the shell must not touch them. + # + # Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes + # 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport + # set; adding rows to that set is a decision about this job's runtime. + - name: Builder, override census and render checks + env: + ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1' + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + 'config/scripts/mobile-web-app-' \ + 'config/scripts/build-mobile-web-app-bundle.test.mjs' + cross-version-wire: name: cross-version wire compatibility needs: [code_paths] @@ -695,6 +756,8 @@ jobs: tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts + tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts + tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 @@ -746,6 +809,13 @@ jobs: - uses: ./.github/actions/install-node-dependencies with: native-runtime: electron + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies # Why --no-file-parallelism: every file here launches a full Electron stack twice, and each # probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other @@ -859,6 +929,13 @@ jobs: with: native-runtime: node persist-native-cache: 'false' + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml + + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies - name: Save compiled Node native modules if: steps.deps.outputs.native-cache-hit != 'true' @@ -1017,6 +1094,7 @@ jobs: - shell_contracts - test - orcad_browser + - mobile_web_app - cross-version-wire - managed_hook_node18 - package @@ -1053,6 +1131,8 @@ jobs: TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }} ORCAD_BROWSER: ${{ needs.orcad_browser.result }} ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }} + MOBILE_WEB_APP: ${{ needs.mobile_web_app.result }} + MOBILE_WEB_APP_SHOULD_RUN: ${{ needs.code_paths.outputs.mobile_web_app }} CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }} CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }} MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }} @@ -1095,6 +1175,7 @@ jobs: check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN" check_job test "$TEST" "$TEST_SHOULD_RUN" check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN" + check_job mobile_web_app "$MOBILE_WEB_APP" "$MOBILE_WEB_APP_SHOULD_RUN" check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN" check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN" check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN" diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index ac2e7f904e3..f7bdc13caac 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -107,6 +107,10 @@ jobs: with: ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }} fetch-depth: 0 + # Why: version math recovers unpublished tags; checkout's default + # fetch-tags:false hides them, so a patch cut recreates vX.Y.Z and + # `git push` overwrites the existing tag. + fetch-tags: true - name: Setup Node.js uses: actions/setup-node@v6 @@ -805,6 +809,16 @@ jobs: with: ref: refs/tags/${{ needs.cut.outputs.tag }} + - name: Restore draft-release scripts from the workflow ref + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- \ + config/scripts/create-draft-release.mjs \ + config/scripts/assert-github-release-is-draft.mjs + - name: Create draft release with bounded generated notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -846,7 +860,8 @@ jobs: git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" git checkout "$WORKFLOW_SHA" -- \ tests/e2e/golden-source-control-open-diff.spec.ts \ - tests/e2e/golden-terminal-file-link.spec.ts + tests/e2e/golden-terminal-file-link.spec.ts \ + tests/e2e/golden-worktree-create-switch.spec.ts - name: Install native build tools if: runner.os == 'Linux' @@ -871,8 +886,17 @@ jobs: npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + # Why: this install runs lifecycle scripts, so node-gyp rebuilds + # native/windows-registry and fetches that Node version's headers from + # nodejs.org. One `read ECONNRESET` there failed this blocking gate and the + # whole cut. Retry like the release build's install below. - name: Install dependencies - run: pnpm install --frozen-lockfile + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile - name: Build Electron app for platform golden run: npx electron-vite build --mode e2e @@ -1088,8 +1112,14 @@ jobs: npm install -g node-gyp@11.5.0 echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + # Same node-gyp header fetch as the blocking golden gate above. - name: Install dependencies - run: pnpm install --frozen-lockfile + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_wait_seconds: 30 + command: pnpm install --frozen-lockfile - name: Build Electron app for terminal rendering evidence run: npx electron-vite build --mode e2e @@ -1161,14 +1191,14 @@ jobs: ~\AppData\Local\electron-builder\Cache - os: ubuntu-latest platform: linux-x64 - release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always -c.publish.releaseType=draft unpacked_dir: dist/linux-unpacked eb_cache_path: | ~/.cache/electron ~/.cache/electron-builder - os: ubuntu-24.04-arm platform: linux-arm64 - release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always + release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always -c.publish.releaseType=draft unpacked_dir: dist/linux-arm64-unpacked eb_cache_path: | ~/.cache/electron @@ -1204,22 +1234,45 @@ jobs: # ref, so cutting from an older/off-main ref whose tree predates a composite # action would fail the step with "Can't find 'action.yml'". Restore the # actions directory from the commit this workflow file itself came from. - - name: Restore composite actions from the workflow ref - if: matrix.platform == 'win' && github.run_attempt == 1 + # Not Windows-only: every platform now consumes install-mobile-dependencies, so + # any of them can be the one whose cut ref predates the action. + - name: Restore draft-publish scripts from the workflow ref + # Why: this job checks out the release tag, so a cut from an older SHA + # still has electron-builder releaseType:release and no re-draft helper. + # The workflow YAML is from main; restore the scripts it invokes. shell: bash env: WORKFLOW_SHA: ${{ github.workflow_sha }} run: | set -euo pipefail - action_path=".github/actions/install-signpath-module/action.yml" - if [ -f "$action_path" ]; then + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs + + - name: Restore composite actions from the workflow ref + shell: bash + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + PLATFORM: ${{ matrix.platform }} + run: | + set -euo pipefail + required=(.github/actions/install-mobile-dependencies/action.yml) + if [ "$PLATFORM" = win ] && [ "$GITHUB_RUN_ATTEMPT" = 1 ]; then + required+=(.github/actions/install-signpath-module/action.yml) + fi + missing=() + for action_path in "${required[@]}"; do + [ -f "$action_path" ] || missing+=("$action_path") + done + if [ "${#missing[@]}" -eq 0 ]; then echo "Composite actions already present at the cut ref." exit 0 fi - echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA." + echo "Cut ref predates ${missing[*]}; restoring from $WORKFLOW_SHA." git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" git checkout "$WORKFLOW_SHA" -- .github/actions - test -f "$action_path" + for action_path in "${required[@]}"; do + test -f "$action_path" + done # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - name: Setup pnpm @@ -1232,6 +1285,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Why: release builds hit the same native-module postinstall path as # PR CI, so keep the pinned node-gyp override here too instead of @@ -1272,6 +1328,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: `pnpm build:release` verifies the Linux computer-use provider by # importing AT-SPI bindings, which are runtime package deps but are not # present on stock GitHub Ubuntu release runners. @@ -2160,36 +2220,21 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Verify release remains draft after artifact upload - # Why: the build matrix must never be the actor that exposes a partial - # release. If an uploader or GitHub transition flips draft early, fail - # this platform leg and leave the diagnostic monitor artifact behind. + # Why: electron-builder `--publish always` can create a public release + # as soon as this platform uploads. Re-draft immediately, then fail, so + # /releases/latest never keeps serving a missing Windows exe. + # Why bash: the Windows matrix defaults to pwsh, which does not expand + # "$TAG" into argv. shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.cut.outputs.tag }} - run: | - set -euo pipefail - releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")" - # Why: release upload must validate the draft before it is publicly visible. - draft="$(jq -e -r --arg tag "$TAG" ' - map(select(.tag_name == $tag)) - | if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end - ' <<<"$releases_json")" || { - echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing." - exit 1 - } - if [[ "$draft" != "true" ]]; then - echo "::error::Release $TAG was published during the ${{ matrix.platform }} artifact upload." - exit 1 - fi + run: node config/scripts/assert-github-release-is-draft.mjs "${{ needs.cut.outputs.tag }}" - # Why post-publish for Linux: electron-builder packs and uploads in a - # single `--publish always` invocation, so there is no cheap insertion - # point between pack and upload without splitting those steps. Running - # verify last still blocks the bad release: the binary is uploaded to the - # draft, but a failed matrix job blocks `publish-release` from flipping - # the release from draft → published, so users never see it. A human then - # deletes the draft and re-cuts. + # Why post-pack for Linux: electron-builder packs and uploads in one + # `--publish always` invocation. The previous step re-drafts if that + # upload flipped the GitHub release public; this telemetry check still + # blocks `publish-release` from undrafting a bad binary. # # Why this guards against: a misconfigured CI run where # `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index 3d7e4dd05bf..7f0149fc74e 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -37,6 +37,14 @@ jobs: with: ref: refs/tags/${{ inputs.tag }} + - name: Restore draft-publish scripts from the workflow ref + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs + - name: Setup pnpm uses: pnpm/setup@v2 with: @@ -47,6 +55,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml # Cache the Electron binary + electron-builder tool downloads (notarytool, # winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac. @@ -74,6 +85,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile --cpu=current,x64,arm64 + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + - name: Verify macOS signing environment run: node config/scripts/verify-macos-release-env.mjs env: @@ -135,13 +150,28 @@ jobs: # Kill only its child and require both PTY and watch recovery before packaging. node config/scripts/relay-watcher-fault-harness.mjs + - name: Abort if the parent release-cut run was cancelled + # Why: this workflow is dispatched separately, so cancelling release-cut + # does not stop mac `--publish always`. A cancelled parent left v1.4.206 + # public with only a partial mac upload. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PARENT_RUN: ${{ inputs.release_run_id }} + run: | + set -euo pipefail + conclusion="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$PARENT_RUN" --jq '.conclusion // empty')" + if [[ "$conclusion" == "cancelled" || "$conclusion" == "failure" || "$conclusion" == "timed_out" ]]; then + echo "::error::Parent release-cut run $PARENT_RUN is $conclusion; refusing to publish mac artifacts." + exit 1 + fi + - name: Publish release artifacts (macOS) uses: nick-fields/retry@v4 with: timeout_minutes: 45 max_attempts: 3 retry_wait_seconds: 30 - command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always + command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always -c.publish.releaseType=draft env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTS }} @@ -151,28 +181,14 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Verify release remains draft after artifact upload - # Why: the macOS build must never be the actor that exposes a partial - # release. If an uploader or GitHub transition flips draft early, fail - # this job so release-cut never publishes the release. + # Why: re-draft immediately if electron-builder flipped the GitHub + # release public, then fail. Checking without restoring leaves + # /releases/latest serving a missing Windows exe. shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ inputs.tag }} - run: | - set -euo pipefail - releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")" - # Why: release upload must validate the draft before it is publicly visible. - draft="$(jq -e -r --arg tag "$TAG" ' - map(select(.tag_name == $tag)) - | if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end - ' <<<"$releases_json")" || { - echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing." - exit 1 - } - if [[ "$draft" != "true" ]]; then - echo "::error::Release $TAG was published during the mac artifact upload." - exit 1 - fi + run: node config/scripts/assert-github-release-is-draft.mjs "${{ inputs.tag }}" # Why post-publish for macOS: electron-builder packs and uploads in a # single `--publish always` invocation, so there is no cheap insertion diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 490eda88c33..4ed5063e318 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -33,17 +33,13 @@ jobs: native-runtime: node node-version: ${{ matrix.node }} cache-electron-package: 'true' + cache-dependency-path: | + pnpm-lock.yaml + cloud/pnpm-lock.yaml - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs - # The real two-cell transport test imports cloud relay source and its contracts. - - name: Install relay integration dependencies - working-directory: cloud - run: | - npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts - npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build - - name: Test shard env: ORCA_BALANCE_UNIT_SHARDS: '1' @@ -68,6 +64,8 @@ jobs: --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ --exclude=src/shared/startup-shell-portability.live-shell.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ + --exclude=tests/e2e/relay-region-compatibility.unit.test.ts \ + --exclude=tests/e2e/relay-region-correction.unit.test.ts \ --exclude=tests/e2e/cross-version-wire/** \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} @@ -81,3 +79,38 @@ jobs: path: ci-shards/ retention-days: 14 if-no-files-found: warn + + relay_integration: + name: relay integration node ${{ fromJSON(inputs.node_versions)[0] }} + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + node-version: ${{ fromJSON(inputs.node_versions)[0] }} + cache-electron-package: 'true' + cache-dependency-path: | + pnpm-lock.yaml + cloud/pnpm-lock.yaml + + # These two tests import the cloud relay workspace directly. Keeping them in one job + # avoids installing and building the same workspace once per unit-test shard. + - name: Install relay integration dependencies + working-directory: cloud + run: | + npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts + npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build + + - name: Test relay integration contracts + env: + ORCA_BACKGROUND_LAUNCH: '1' + run: >- + pnpm exec vitest run --config config/vitest.config.ts + tests/e2e/relay-region-compatibility.unit.test.ts + tests/e2e/relay-region-correction.unit.test.ts diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index f3d22cc1227..91ac8fcf226 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -55,6 +55,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -67,6 +70,9 @@ jobs: uses: actions/cache@v4 with: path: dist/orca-windows-setup.exe + # The mobile page trees are in the key because beforePack builds the mobile web bundle + # into the installer; src/** and config/** already cover src/mobile-web and the two + # bundle builders. A mobile-only change must miss this cache, not reuse a stale exe. key: >- crash-survival-installer-${{ hashFiles( 'src/**', @@ -85,7 +91,12 @@ jobs: '.npmrc', 'package.json', 'pnpm-lock.yaml', - 'pnpm-workspace.yaml' + 'pnpm-workspace.yaml', + 'mobile/package.json', + 'mobile/pnpm-lock.yaml', + 'mobile/app/**', + 'mobile/src/**', + 'mobile/web-entry/**' ) }} # Why: production edits miss the installer cache by design, but Electron @@ -101,6 +112,12 @@ jobs: restore-keys: | crash-survival-electron-builder- + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-installer.outputs.cache-hit != 'true' + - name: Build Windows installer (unsigned) if: steps.cache-installer.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/win-update-survival-e2e.yml b/.github/workflows/win-update-survival-e2e.yml index e7ed41125e9..50c38f2e3ad 100644 --- a/.github/workflows/win-update-survival-e2e.yml +++ b/.github/workflows/win-update-survival-e2e.yml @@ -75,6 +75,12 @@ jobs: path: dist/orca-windows-setup.exe key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }} + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. Gated with the + # build it feeds, so a cache hit does not pay for an install nothing consumes. + - uses: ./.github/actions/install-mobile-dependencies + if: steps.cache-installer.outputs.cache-hit != 'true' + - name: Build Windows installer (unsigned) if: steps.cache-installer.outputs.cache-hit != 'true' run: | diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 244ee4d3e08..0fa2a31cd97 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -57,6 +57,9 @@ jobs: with: node-version-file: package.json cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + mobile/pnpm-lock.yaml - name: Cache electron-builder downloads uses: actions/cache@v5 @@ -78,6 +81,10 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why here: electron-builder's beforePack requires out/mobile-web, and the bundle + # build resolves React Native and Expo from mobile/node_modules. + - uses: ./.github/actions/install-mobile-dependencies + # Why: rehearsal builds are never published, so the official-build # secrets (telemetry key, diagnostics URL) are intentionally omitted. - name: Build app diff --git a/.gitignore b/.gitignore index 424cf5a2a0c..56ecc5854b6 100644 --- a/.gitignore +++ b/.gitignore @@ -98,11 +98,20 @@ docs/** # The deployable docs app is source, not local engineering notes. !docs/site/ !docs/site/** +!docs/audits/ +!docs/audits/closed-editor-model-lifetime/ +!docs/audits/closed-editor-model-lifetime/** !docs/assets/ !docs/assets/** +!docs/audits/ +!docs/audits/plugin-uninstall-log-retirement/ +!docs/audits/plugin-uninstall-log-retirement/** !docs/readme/ !docs/readme/** !docs/STYLEGUIDE.md +!docs/audits/ +!docs/audits/crashpad-read-limit/ +!docs/audits/crashpad-read-limit/source-hashes.json !docs/agent-skill-sharing-implementation-checklist.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ diff --git a/README.md b/README.md index aeb8f355450..04f5e309da3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)