From 5b15bbc34cc2802ef2ed20f540f54bff969df74f Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:43:06 -0700 Subject: [PATCH] =?UTF-8?q?feat(ci):=20two-branch=20CI=20=E2=80=94=20chang?= =?UTF-8?q?ed-files-only=20PR=20lane,=20full-suite=20release=20lane,=20rel?= =?UTF-8?q?ease-branch=20cuts,=20cancellation=20watchdog=20(#4486)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/android-compile.yml | 8 + .github/workflows/build-desktop.yml | 3 + .github/workflows/e2e-agent-review.yml | 8 + .github/workflows/e2e-playwright.yml | 8 + .github/workflows/e2e-reusable.yml | 8 + .github/workflows/e2e.yml | 8 + .github/workflows/ios-appstore.yml | 8 + .github/workflows/ios-compile.yml | 8 + .github/workflows/pr-ci.yml | 339 +++++----------------- .github/workflows/prepare-release-pr.yml | 128 +++++++++ .github/workflows/release-ci.yml | 341 +++++++++++++++++++++++ .github/workflows/release-packages.yml | 7 + .github/workflows/release-production.yml | 72 +++-- .github/workflows/release-staging.yml | 53 ++-- .github/workflows/test-reusable.yml | 8 + AGENTS.md | 2 + scripts/ci-cancel-aware.sh | 58 ++++ scripts/ci/rust-coverage-changed.sh | 163 +++++++++++ scripts/ci/vitest-changed-coverage.sh | 81 ++++++ 19 files changed, 996 insertions(+), 315 deletions(-) create mode 100644 .github/workflows/prepare-release-pr.yml create mode 100644 .github/workflows/release-ci.yml create mode 100755 scripts/ci/rust-coverage-changed.sh create mode 100755 scripts/ci/vitest-changed-coverage.sh diff --git a/.github/workflows/android-compile.yml b/.github/workflows/android-compile.yml index 737946421..7eff50674 100644 --- a/.github/workflows/android-compile.yml +++ b/.github/workflows/android-compile.yml @@ -65,9 +65,17 @@ on: required: false permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read pull-requests: read +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + concurrency: group: ${{ github.workflow }}-${{ inputs.build_ref || github.event.pull_request.number || github.head_ref || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 240f14793..919fe0e6b 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -129,6 +129,9 @@ jobs: # Keep in sync with DEFAULT_TELEGRAM_BOT_USERNAME_* in channels/controllers/ops.rs OPENHUMAN_TELEGRAM_BOT_USERNAME: ${{ inputs.telegram_bot_username }} VITE_TELEGRAM_BOT_USERNAME: ${{ inputs.telegram_bot_username }} + env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} steps: - name: Checkout build ref uses: actions/checkout@v5 diff --git a/.github/workflows/e2e-agent-review.yml b/.github/workflows/e2e-agent-review.yml index 91406f933..d49e74a65 100644 --- a/.github/workflows/e2e-agent-review.yml +++ b/.github/workflows/e2e-agent-review.yml @@ -11,7 +11,15 @@ name: E2E (Linux) - agent-review on: workflow_dispatch: permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + concurrency: group: e2e-agent-review-${{ github.event.pull_request.number || github.head_ref || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/e2e-playwright.yml b/.github/workflows/e2e-playwright.yml index 5db93e7e0..6c80b6fdf 100644 --- a/.github/workflows/e2e-playwright.yml +++ b/.github/workflows/e2e-playwright.yml @@ -5,9 +5,17 @@ on: workflow_dispatch: {} permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read packages: read +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 0efcc2d1e..fed1444cf 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -64,9 +64,17 @@ on: default: mega-flow permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read packages: read +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + jobs: # Selected-spec gate for PR/push (full=false). The full-suite path lives in # `e2e-linux-full` below, which fans out across 4 parallel shards via diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c135d3404..2a7fb1d45 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -39,10 +39,18 @@ on: default: mega-flow permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read packages: read pull-requests: read +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/ios-appstore.yml b/.github/workflows/ios-appstore.yml index 06de85483..41ebaef8c 100644 --- a/.github/workflows/ios-appstore.yml +++ b/.github/workflows/ios-appstore.yml @@ -21,8 +21,16 @@ on: default: true permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + concurrency: group: ios-appstore-${{ github.ref }} cancel-in-progress: false diff --git a/.github/workflows/ios-compile.yml b/.github/workflows/ios-compile.yml index 351681e05..f0b4bd356 100644 --- a/.github/workflows/ios-compile.yml +++ b/.github/workflows/ios-compile.yml @@ -5,9 +5,17 @@ on: workflow_dispatch: permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read pull-requests: read +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} cancel-in-progress: true diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 9a3b9f104..b79cdcbc6 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -1,17 +1,30 @@ --- +# PR fast lane (PRs targeting main + pushes to main). +# +# Runs quality checks per changed area and unit tests ONLY for the files the +# PR changed (vitest related / domain-scoped cargo-llvm-cov), with diff-cover +# enforcing >= 80% coverage on changed lines. Integration and E2E suites do +# NOT run here — they run in full on main→release PRs via release-ci.yml. name: PR CI on: push: branches: [main] pull_request: + branches: [main] permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read packages: read pull-requests: read env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} NPM_CONFIG_STORE_DIR: .pnpm-store # Keep only the newest run per PR/branch. Long Rust/Tauri build commands must @@ -31,11 +44,20 @@ jobs: timeout-minutes: 5 outputs: frontend: ${{ steps.filter.outputs.frontend }} + # "true" when the change touches config that invalidates test scoping + # (lockfile, vitest/vite/ts config, test setup) → run the full suite. + frontend-full: ${{ steps.filter.outputs['frontend-full'] }} + # Shell-quoted list of changed app/src source files, consumed by + # scripts/ci/vitest-changed-coverage.sh (vitest related). + frontend-src-files: ${{ steps.filter.outputs['frontend-src_files'] }} i18n: ${{ steps.filter.outputs.i18n }} docs: ${{ steps.filter.outputs.docs }} rust-core: ${{ steps.filter.outputs['rust-core'] }} + rust-core-full: ${{ steps.filter.outputs['rust-core-full'] }} + # Shell-quoted list of changed src/ + tests/ files, consumed by + # scripts/ci/rust-coverage-changed.sh (domain-scoped llvm-cov). + rust-core-src-files: ${{ steps.filter.outputs['rust-core-src_files'] }} rust-tauri: ${{ steps.filter.outputs['rust-tauri'] }} - playwright: ${{ steps.filter.outputs.playwright }} coverage: ${{ steps.filter.outputs.frontend == 'true' || steps.filter.outputs['rust-core'] == 'true' || steps.filter.outputs['rust-tauri'] == 'true' }} steps: - name: Checkout code @@ -45,6 +67,7 @@ jobs: id: filter uses: dorny/paths-filter@v3 with: + list-files: shell filters: | frontend: - '.github/workflows/pr-ci.yml' @@ -59,6 +82,24 @@ jobs: - 'app/vite.config.*' - 'app/tailwind.config.*' - 'app/postcss.config.*' + # Changes here invalidate per-file test scoping → full Vitest run. + frontend-full: + - '.github/workflows/pr-ci.yml' + - 'scripts/ci/vitest-changed-coverage.sh' + - 'package.json' + - 'pnpm-lock.yaml' + - 'app/package.json' + - 'app/index.html' + - 'app/public/**' + - 'app/test/vitest.config.ts' + - 'app/tsconfig*.json' + - 'app/vite.config.*' + - 'app/tailwind.config.*' + - 'app/postcss.config.*' + - 'app/src/test/**' + frontend-src: + - 'app/src/**/*.ts' + - 'app/src/**/*.tsx' i18n: - '.github/workflows/pr-ci.yml' - 'app/src/**' @@ -79,27 +120,23 @@ jobs: - 'scripts/ci-cancel-aware.sh' - 'scripts/test-rust-e2e.sh' - 'scripts/test-rust-with-mock.sh' + # Changes here invalidate per-module test scoping → full suite. + rust-core-full: + - '.github/workflows/pr-ci.yml' + - 'scripts/ci/rust-coverage-changed.sh' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'scripts/ci-cancel-aware.sh' + rust-core-src: + - 'src/**' + - 'tests/**' rust-tauri: - '.github/workflows/pr-ci.yml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'app/src-tauri/**' - 'scripts/ci-cancel-aware.sh' - playwright: - - '.github/workflows/pr-ci.yml' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' - - 'package.json' - - 'pnpm-lock.yaml' - - 'src/**' - - 'app/package.json' - - 'app/index.html' - - 'app/public/**' - - 'app/src/**' - - 'app/test/e2e/**' - - 'app/scripts/e2e-web-*.sh' - - 'scripts/ci-cancel-aware.sh' frontend-checks: name: Frontend Checks (quality, i18n, docs, coverage) @@ -172,12 +209,13 @@ jobs: if: needs.changes.outputs.docs == 'true' run: pnpm docs:check - - name: Run Vitest with coverage + - name: Run Vitest with coverage (changed files only) if: needs.changes.outputs.frontend == 'true' - run: pnpm test:coverage - working-directory: app + run: bash scripts/ci/vitest-changed-coverage.sh env: NODE_ENV: test + FULL: ${{ needs.changes.outputs['frontend-full'] }} + CHANGED_FILES: ${{ needs.changes.outputs['frontend-src-files'] }} - name: Normalize lcov source paths to repo root if: needs.changes.outputs.frontend == 'true' @@ -232,105 +270,6 @@ jobs: - name: Run clippy (core crate) run: bash scripts/ci-cancel-aware.sh cargo clippy -p openhuman - build-playwright-e2e-artifact: - name: Build Playwright E2E Artifact - needs: [changes] - if: needs.changes.outputs.playwright == 'true' - runs-on: ubuntu-22.04 - container: - image: ghcr.io/tinyhumansai/openhuman_ci:latest - timeout-minutes: 30 - env: - CARGO_INCREMENTAL: "0" - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 1 - persist-credentials: false - submodules: recursive - - - name: Cache pnpm store - id: pnpm-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: .pnpm-store - key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - pnpm-store-${{ runner.os }}- - - - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@v2 - with: - workspaces: . -> target - cache-on-failure: true - # Shared with rust-e2e (same image + env): both compile the openhuman - # crate's non-instrumented dependency tree, so one cache entry serves - # both instead of two ~1GB copies competing for the repo cache budget. - shared-key: pr-rust-core - - - name: Restore cached Playwright E2E artifact - id: playwright-artifact-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: .ci/artifacts/e2e-playwright-linux.tar.gz - key: e2e-playwright-linux-${{ hashFiles('src/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', 'app/src/**', 'app/public/**', 'app/index.html', 'app/vite.config.*', 'app/tailwind.config.*', 'app/postcss.config.*', 'app/package.json', 'pnpm-lock.yaml', 'app/scripts/e2e-web-build.sh') }} - - - name: Install dependencies - if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' - run: pnpm install --frozen-lockfile - - - name: Save pnpm store cache - if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: .pnpm-store - key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - - - name: Ensure .env exists for E2E build - if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' - run: | - touch .env - touch app/.env - - - name: Build and package Playwright E2E artifact - if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' - run: | - set -euo pipefail - pnpm --filter openhuman-app test:e2e:web:build - - RUST_HOST_TRIPLE="$(rustc -vV | awk '/^host: / { print $2 }')" - WEB_CORE_TARGET_DIR="target/e2e-web-${RUST_HOST_TRIPLE}" - STAGE="$(mktemp -d)" - mkdir -p "$STAGE/repo/app/dist-web" - mkdir -p "$STAGE/repo/$WEB_CORE_TARGET_DIR/debug" - mkdir -p .ci/artifacts - - cp -a app/dist-web/. "$STAGE/repo/app/dist-web/" - cp -a "$WEB_CORE_TARGET_DIR/debug/openhuman-core" "$STAGE/repo/$WEB_CORE_TARGET_DIR/debug/" - tar -czf .ci/artifacts/e2e-playwright-linux.tar.gz -C "$STAGE" repo - ls -lh .ci/artifacts/e2e-playwright-linux.tar.gz - - - name: Verify build artifact exists - run: | - set -euo pipefail - test -f .ci/artifacts/e2e-playwright-linux.tar.gz - - - name: Save Playwright E2E artifact cache - if: always() && steps.playwright-artifact-cache.outputs.cache-hit != 'true' && hashFiles('.ci/artifacts/e2e-playwright-linux.tar.gz') != '' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: .ci/artifacts/e2e-playwright-linux.tar.gz - key: ${{ steps.playwright-artifact-cache.outputs.cache-primary-key }} - - - name: Upload Playwright E2E artifact - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 - with: - name: e2e-playwright-linux-${{ github.run_id }} - path: .ci/artifacts/e2e-playwright-linux.tar.gz - retention-days: 1 - if-no-files-found: error - rust-core-coverage: name: Rust Core Coverage (cargo-llvm-cov) needs: [changes, rust-quality] @@ -399,10 +338,13 @@ jobs: - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - - name: Run cargo llvm-cov for openhuman core - run: bash scripts/ci-cancel-aware.sh cargo llvm-cov --no-fail-fast -p openhuman --lcov --output-path lcov-core.info + - name: Run cargo llvm-cov for openhuman core (changed modules only) + run: bash scripts/ci/rust-coverage-changed.sh env: CARGO_BUILD_JOBS: "1" + FULL: ${{ needs.changes.outputs['rust-core-full'] }} + CHANGED_FILES: ${{ needs.changes.outputs['rust-core-src-files'] }} + OUT: lcov-core.info # The tinyagents harness is the agent engine on every build now # (issue #4249); the suite exercises it by default. The legacy engine # is being removed. @@ -513,168 +455,14 @@ jobs: retention-days: 7 if-no-files-found: error - rust-e2e: - name: Rust E2E (mock backend) - needs: [changes, rust-quality] - if: always() && needs.changes.outputs['rust-core'] == 'true' && needs['rust-quality'].result == 'success' - runs-on: ubuntu-22.04 - container: - image: ghcr.io/tinyhumansai/openhuman_ci:latest - timeout-minutes: 30 - env: - CARGO_INCREMENTAL: "0" - # Trim DWARF debug info so test-binary linking doesn't exhaust runner disk - # (SIGBUS). Tests don't need full debug info; backtraces keep file/line. - CARGO_PROFILE_DEV_DEBUG: line-tables-only - steps: - - name: Free disk space - run: | - # Reclaim space on the build partition before compiling/linking the - # Rust test binaries (host toolcache is bind-mounted at /__t inside the - # container). Mirrors the rust-core-coverage cleanup. - rm -rf /__t/* || true - df -h /__w || true - - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 1 - persist-credentials: false - submodules: recursive - - - name: Cache pnpm store - id: pnpm-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: .pnpm-store - key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - pnpm-store-${{ runner.os }}- - - - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@v2 - with: - workspaces: . -> target - cache-on-failure: true - # Shared with build-playwright (same image + env): one non-instrumented - # core cache serves both lanes instead of two competing copies. - shared-key: pr-rust-core - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Save pnpm store cache - if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: .pnpm-store - key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - - - name: Ensure .env exists for tests - run: | - touch .env - touch app/.env - - - name: Run Rust E2E suite - run: pnpm test:rust:e2e - env: - RUST_BACKTRACE: "1" - - playwright-e2e: - name: E2E (Playwright / web lane) - needs: [changes, build-playwright-e2e-artifact] - if: | - always() && - needs.changes.outputs.playwright == 'true' && - needs['build-playwright-e2e-artifact'].result == 'success' - runs-on: ubuntu-22.04 - container: - image: ghcr.io/tinyhumansai/openhuman_ci:latest - timeout-minutes: 90 - # Run the Playwright suite in one container to reduce PR CI fanout and stay - # under GitHub concurrency limits. This is intentionally slower than the - # previous shard matrix but avoids four separate runner/container slots. - # TODO(ci-flaky, #3615): several specs consistently hit toBeVisible - # timeouts under CI resource contention — chat-tool-error-recovery, - # connector-gmail-composio, connector-session-guard-matrix, - # chat-harness-send-stream, chat-management-functional and others. - # Playwright config bumped to 90s test / 15s expect timeout + 2 retries. - # Run the lane but don't block merges until infra stabilises. - continue-on-error: true - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 1 - persist-credentials: false - submodules: recursive - - - name: Cache pnpm store - id: pnpm-cache - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: .pnpm-store - key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - pnpm-store-${{ runner.os }}- - - - name: Install JS dependencies (test harness) - run: pnpm install --frozen-lockfile - - - name: Save pnpm store cache - if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 - with: - path: .pnpm-store - key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} - - - name: Download Playwright E2E artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 - with: - name: e2e-playwright-linux-${{ github.run_id }} - path: . - - - name: Restore Playwright E2E artifact - run: | - set -euo pipefail - tar -xzf e2e-playwright-linux.tar.gz - cp -a repo/app/dist-web app/ - mkdir -p target - cp -a repo/target/. target/ - rm -rf repo e2e-playwright-linux.tar.gz - chmod +x target/e2e-web-*/debug/openhuman-core - - - name: Install Playwright Chromium headless shell - run: pnpm --filter openhuman-app exec playwright install chromium-headless-shell - - - name: Run Playwright web E2E suite - env: - OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace - run: | - mkdir -p "$OPENHUMAN_WORKSPACE" - bash app/scripts/e2e-web-session.sh - - - name: Upload Playwright E2E failure artifacts - if: failure() - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 - with: - name: e2e-playwright-failure-logs-${{ github.run_id }} - path: | - ${{ runner.temp }}/openhuman-playwright-workspace/** - retention-days: 7 - if-no-files-found: ignore - pr-ci-gate: name: PR CI Gate needs: - changes - frontend-checks - rust-quality - - build-playwright-e2e-artifact - rust-core-coverage - rust-tauri-coverage - - rust-e2e - - playwright-e2e if: always() runs-on: ubuntu-latest timeout-minutes: 15 @@ -686,11 +474,8 @@ jobs: ["Detect Changed Areas"]="${{ needs.changes.result }}" ["Frontend Checks"]="${{ needs['frontend-checks'].result }}" ["Rust Quality"]="${{ needs['rust-quality'].result }}" - ["Build Playwright E2E Artifact"]="${{ needs['build-playwright-e2e-artifact'].result }}" ["Rust Core Coverage"]="${{ needs['rust-core-coverage'].result }}" ["Rust Tauri Coverage"]="${{ needs['rust-tauri-coverage'].result }}" - ["Rust E2E"]="${{ needs['rust-e2e'].result }}" - ["Playwright E2E"]="${{ needs['playwright-e2e'].result }}" ) failed=0 diff --git a/.github/workflows/prepare-release-pr.yml b/.github/workflows/prepare-release-pr.yml new file mode 100644 index 000000000..47716f0db --- /dev/null +++ b/.github/workflows/prepare-release-pr.yml @@ -0,0 +1,128 @@ +--- +# Keeps the main→release PR standing at all times. +# +# Every push to main ensures there is exactly one open PR from main into the +# long-lived `release` branch. GitHub automatically refreshes an open +# branch-to-branch PR as main advances, so if one already exists this is a +# no-op; otherwise it opens one (skipping when main has nothing release +# doesn't already have). Release CI (release-ci.yml) runs the full test + +# E2E matrix on that PR; once it greens up a maintainer merges it and cuts a +# release from `release` via release-staging.yml / release-production.yml. +# +# Two-job split on purpose: `check` runs on every merge to main with only the +# default read token and no environment — the common case (PR already open) +# stops there. `open-pr`, which claims the Release-PR-Automation environment +# and mints a GitHub App token, only runs when a PR actually needs creating. +name: Prepare Release PR + +on: + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: read + pull-requests: read + +concurrency: + group: prepare-release-pr + cancel-in-progress: false + +jobs: + check: + name: Check whether a main→release PR is needed + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + needs-pr: ${{ steps.decide.outputs.needs_pr }} + steps: + - name: Decide (release branch exists, no open PR, main is ahead) + id: decide + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + log() { echo "[ci][prepare-release-pr] $*"; } + decide() { echo "needs_pr=$1" >> "$GITHUB_OUTPUT"; log "needs_pr=$1 ($2)"; } + + if ! gh api "repos/${REPO}/branches/release" --silent 2>/dev/null; then + decide false "release branch does not exist yet" + exit 0 + fi + + existing="$(gh pr list --repo "${REPO}" --base release --head main --state open --json number --jq '.[0].number // empty')" + if [ -n "${existing}" ]; then + decide false "open main→release PR already exists: #${existing} (auto-updates as main advances)" + exit 0 + fi + + # Skip when main has no commits release lacks (e.g. right after a + # release-back-merge) — GitHub rejects empty PRs anyway. + ahead_by="$(gh api "repos/${REPO}/compare/release...main" --jq '.ahead_by')" + log "main is ahead of release by ${ahead_by} commit(s)" + if [ "${ahead_by}" = "0" ]; then + decide false "release already contains main" + exit 0 + fi + + decide true "no open PR and main is ahead by ${ahead_by}" + + open-pr: + name: Open the main→release PR + needs: [check] + if: needs.check.outputs.needs-pr == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + # The XGITHUB_APP_* secrets live in environments, never at repo level. + # This dedicated environment holds ONLY those two secrets and is branch- + # policied to `main`, so this job never sees the Production environment + # (Apple signing, Tauri updater key, …) and no other ref can claim it. + # It is only entered when `check` says a PR must be created. + environment: Release-PR-Automation + steps: + # PRs created with the default GITHUB_TOKEN do not trigger pull_request + # workflows (GitHub's recursion guard), which would leave the standing + # main→release PR without a Release CI run until someone touched it. + # Use the same GitHub App the release workflows push with. + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.XGITHUB_APP_ID }} + private-key: ${{ secrets.XGITHUB_APP_PRIVATE_KEY }} + # Scope the minted installation token to the minimum this job + # needs: opening a PR. Unlike the release workflows' token, this + # one can NOT push commits, so a compromise of this job cannot + # write to protected branches. (This workflow only ever runs + # trusted code — push:main / dispatch, never pull_request — this + # is defense-in-depth.) + permission-contents: read + permission-pull-requests: write + + - name: Open the main→release PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + log() { echo "[ci][prepare-release-pr] $*"; } + + # Re-check under the concurrency group: a parallel run may have + # opened the PR between `check` and this job. + existing="$(gh pr list --repo "${REPO}" --base release --head main --state open --json number --jq '.[0].number // empty')" + if [ -n "${existing}" ]; then + log "open main→release PR appeared meanwhile: #${existing} — nothing to do" + exit 0 + fi + + url="$(gh pr create \ + --repo "${REPO}" \ + --base release \ + --head main \ + --title "Release: merge main into release" \ + --body "$(printf 'Auto-prepared by prepare-release-pr.yml on push to main.\n\nThis PR runs the full Release CI suite (unit + Rust E2E + Playwright + desktop E2E on 3 OSes). Maintainers can push fixes to it; merge once green to advance the release branch, then cut a release with release-staging.yml / release-production.yml.')")" + log "opened ${url}" diff --git a/.github/workflows/release-ci.yml b/.github/workflows/release-ci.yml new file mode 100644 index 000000000..47ad21ec1 --- /dev/null +++ b/.github/workflows/release-ci.yml @@ -0,0 +1,341 @@ +--- +# Release CI — full-suite gate for PRs targeting the long-lived `release` +# branch (normally the auto-prepared main→release PR). +# +# Two-branch model: +# - PRs → main run the fast lane (pr-ci.yml): quality checks + unit tests +# scoped to the changed files only. +# - main → release PRs run EVERYTHING here: full frontend + Rust unit +# suites, Rust mock-backend E2E, Playwright web E2E, and the full desktop +# E2E matrix on Linux/macOS/Windows. Maintainers can push fixups directly +# to the PR until it greens up; releases are then cut from `release`. +# +# Branch protection on `release` requires the "Release CI Gate" check below. +name: Release CI + +on: + pull_request: + branches: [release] + workflow_dispatch: {} + +permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read + contents: read + packages: read + pull-requests: read + +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + NPM_CONFIG_STORE_DIR: .pnpm-store + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.head_ref || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + # Full unit suites: frontend Vitest, i18n coverage, Rust core tests, Rust + # Tauri shell tests — the same reusable recipe the release workflows' + # pretest gate uses. + unit-tests: + name: Full Unit Suites + # No `secrets: inherit` — the called workflow uses no secrets, and this + # workflow is pull_request-triggered; keep its runs secret-free. + uses: ./.github/workflows/test-reusable.yml + with: + run_unit: true + run_rust_core: true + run_rust_tauri: true + + rust-e2e: + name: Rust E2E (mock backend) + runs-on: ubuntu-22.04 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:latest + timeout-minutes: 30 + env: + CARGO_INCREMENTAL: "0" + # Trim DWARF debug info so test-binary linking doesn't exhaust runner disk + # (SIGBUS). Tests don't need full debug info; backtraces keep file/line. + CARGO_PROFILE_DEV_DEBUG: line-tables-only + steps: + - name: Free disk space + run: | + # Reclaim space on the build partition before compiling/linking the + # Rust test binaries (host toolcache is bind-mounted at /__t inside + # the container). + rm -rf /__t/* || true + df -h /__w || true + + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + persist-credentials: false + submodules: recursive + + - name: Cache pnpm store + id: pnpm-cache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .pnpm-store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + cache-on-failure: true + # Shared with pr-ci's former key so warm caches carry over between + # the fast lane and this full lane on the same dependency tree. + shared-key: pr-rust-core + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Save pnpm store cache + if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .pnpm-store + key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} + + - name: Ensure .env exists for tests + run: | + touch .env + touch app/.env + + - name: Run Rust E2E suite + # Wrapped so the cancellation watchdog covers the whole suite (mock + # server + node harness), not just the cargo segments the inner + # script wraps itself. + run: bash scripts/ci-cancel-aware.sh pnpm test:rust:e2e + env: + RUST_BACKTRACE: "1" + + build-playwright-e2e-artifact: + name: Build Playwright E2E Artifact + runs-on: ubuntu-22.04 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:latest + timeout-minutes: 30 + env: + CARGO_INCREMENTAL: "0" + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + persist-credentials: false + submodules: recursive + + - name: Cache pnpm store + id: pnpm-cache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .pnpm-store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + cache-on-failure: true + shared-key: pr-rust-core + + - name: Restore cached Playwright E2E artifact + id: playwright-artifact-cache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .ci/artifacts/e2e-playwright-linux.tar.gz + key: e2e-playwright-linux-${{ hashFiles('src/**', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', 'app/src/**', 'app/public/**', 'app/index.html', 'app/vite.config.*', 'app/tailwind.config.*', 'app/postcss.config.*', 'app/package.json', 'pnpm-lock.yaml', 'app/scripts/e2e-web-build.sh') }} + + - name: Install dependencies + if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' + run: pnpm install --frozen-lockfile + + - name: Save pnpm store cache + if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .pnpm-store + key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} + + - name: Ensure .env exists for E2E build + if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' + run: | + touch .env + touch app/.env + + - name: Build and package Playwright E2E artifact + if: steps.playwright-artifact-cache.outputs.cache-hit != 'true' + run: | + set -euo pipefail + bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:e2e:web:build + + RUST_HOST_TRIPLE="$(rustc -vV | awk '/^host: / { print $2 }')" + WEB_CORE_TARGET_DIR="target/e2e-web-${RUST_HOST_TRIPLE}" + STAGE="$(mktemp -d)" + mkdir -p "$STAGE/repo/app/dist-web" + mkdir -p "$STAGE/repo/$WEB_CORE_TARGET_DIR/debug" + mkdir -p .ci/artifacts + + cp -a app/dist-web/. "$STAGE/repo/app/dist-web/" + cp -a "$WEB_CORE_TARGET_DIR/debug/openhuman-core" "$STAGE/repo/$WEB_CORE_TARGET_DIR/debug/" + tar -czf .ci/artifacts/e2e-playwright-linux.tar.gz -C "$STAGE" repo + ls -lh .ci/artifacts/e2e-playwright-linux.tar.gz + + - name: Verify build artifact exists + run: | + set -euo pipefail + test -f .ci/artifacts/e2e-playwright-linux.tar.gz + + - name: Save Playwright E2E artifact cache + if: always() && steps.playwright-artifact-cache.outputs.cache-hit != 'true' && hashFiles('.ci/artifacts/e2e-playwright-linux.tar.gz') != '' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .ci/artifacts/e2e-playwright-linux.tar.gz + key: ${{ steps.playwright-artifact-cache.outputs.cache-primary-key }} + + - name: Upload Playwright E2E artifact + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: e2e-playwright-linux-${{ github.run_id }} + path: .ci/artifacts/e2e-playwright-linux.tar.gz + retention-days: 1 + if-no-files-found: error + + playwright-e2e: + name: E2E (Playwright / web lane) + needs: [build-playwright-e2e-artifact] + runs-on: ubuntu-22.04 + container: + image: ghcr.io/tinyhumansai/openhuman_ci:latest + timeout-minutes: 90 + # TODO(ci-flaky, #3615): several specs consistently hit toBeVisible + # timeouts under CI resource contention. Run the lane but don't block + # release merges until infra stabilises. + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + persist-credentials: false + submodules: recursive + + - name: Cache pnpm store + id: pnpm-cache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .pnpm-store + key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-store-${{ runner.os }}- + + - name: Install JS dependencies (test harness) + run: pnpm install --frozen-lockfile + + - name: Save pnpm store cache + if: always() && steps.pnpm-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: .pnpm-store + key: ${{ steps.pnpm-cache.outputs.cache-primary-key }} + + - name: Download Playwright E2E artifact + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: e2e-playwright-linux-${{ github.run_id }} + path: . + + - name: Restore Playwright E2E artifact + run: | + set -euo pipefail + tar -xzf e2e-playwright-linux.tar.gz + cp -a repo/app/dist-web app/ + mkdir -p target + cp -a repo/target/. target/ + rm -rf repo e2e-playwright-linux.tar.gz + chmod +x target/e2e-web-*/debug/openhuman-core + + - name: Install Playwright Chromium headless shell + run: pnpm --filter openhuman-app exec playwright install chromium-headless-shell + + - name: Run Playwright web E2E suite + env: + OPENHUMAN_WORKSPACE: ${{ runner.temp }}/openhuman-playwright-workspace + run: | + mkdir -p "$OPENHUMAN_WORKSPACE" + bash scripts/ci-cancel-aware.sh bash app/scripts/e2e-web-session.sh + + - name: Upload Playwright E2E failure artifacts + if: failure() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: e2e-playwright-failure-logs-${{ github.run_id }} + path: | + ${{ runner.temp }}/openhuman-playwright-workspace/** + retention-days: 7 + if-no-files-found: ignore + + # Full desktop E2E suite on all three shipped OSes (build-once-then-fanout + # shards — see e2e-reusable.yml). + e2e-desktop: + name: Desktop E2E (full suite, 3 OS) + # No `secrets: inherit` — the called workflow uses no secrets, and this + # workflow is pull_request-triggered; keep its runs secret-free. + uses: ./.github/workflows/e2e-reusable.yml + with: + run_linux: true + run_macos: true + run_windows: true + full: true + + release-ci-gate: + name: Release CI Gate + needs: + - unit-tests + - rust-e2e + - build-playwright-e2e-artifact + - playwright-e2e + - e2e-desktop + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require all Release CI jobs to pass + run: | + set -euo pipefail + declare -A results=( + ["Full Unit Suites"]="${{ needs['unit-tests'].result }}" + ["Rust E2E"]="${{ needs['rust-e2e'].result }}" + ["Build Playwright E2E Artifact"]="${{ needs['build-playwright-e2e-artifact'].result }}" + ["Playwright E2E"]="${{ needs['playwright-e2e'].result }}" + ["Desktop E2E"]="${{ needs['e2e-desktop'].result }}" + ) + + failed=0 + for name in "${!results[@]}"; do + result="${results[$name]}" + echo "${name}: ${result}" + if [ "${result}" != "success" ] && [ "${result}" != "skipped" ]; then + echo "::error::${name} did not pass (result=${result})." + failed=1 + fi + done + + if [ "${failed}" -ne 0 ]; then + exit 1 + fi diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 9a052b3e1..52942f5b2 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -13,6 +13,10 @@ name: Release Packages on: workflow_dispatch: permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: write pages: write id-token: write @@ -33,6 +37,9 @@ jobs: name: Build Linux arm64 CLI tarball runs-on: ubuntu-24.04-arm timeout-minutes: 30 + env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} steps: - name: Checkout tag uses: actions/checkout@v5 diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index c50157c2b..d6ce8f873 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -11,8 +11,8 @@ on: options: [patch, minor, major] commit_sha: description: - Build from a specific commit SHA instead of main HEAD. The commit - must be reachable from main. Leave empty to use main HEAD. + Build from a specific commit SHA instead of release HEAD. The commit + must be reachable from release. Leave empty to use release HEAD. required: false type: string default: "" @@ -25,6 +25,10 @@ on: type: boolean default: true permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: write packages: write concurrency: @@ -33,6 +37,11 @@ concurrency: group: release-production cancel-in-progress: false # --------------------------------------------------------------------------- +# Branch model: releases are cut from the long-lived `release` branch, not +# `main`. `release` only advances via green main→release PRs that run the +# full test suite; this workflow bumps/commits/tags on `release` and the +# release is built from the tag this workflow creates on `release`. +# # Job dependency graph # # prepare-build @@ -61,10 +70,10 @@ jobs: # ========================================================================= # Phase 0: Manual approval gate. # - # This workflow bumps the version, COMMITS to `main`, and pushes that + # This workflow bumps the version, COMMITS to `release`, and pushes that # commit + tag using a GitHub App token that bypasses branch protection. # If that App private key (secrets.XGITHUB_APP_PRIVATE_KEY) ever leaked, - # an attacker could push arbitrary commits to the default branch — CWE-250. + # an attacker could push arbitrary commits to a protected branch — CWE-250. # This job parks the run on the `Release-Approval` environment (configured # with required reviewers in repo settings) so a human must explicitly # approve before prepare-build is allowed to run its push step. @@ -77,11 +86,11 @@ jobs: steps: - name: Record approval run: | - echo "[release-production] Push to main approved by a required reviewer." + echo "[release-production] Push to release approved by a required reviewer." echo "Proceeding to version bump + commit/tag push on prepare-build." # ========================================================================= - # Phase 1: Bump version on main, commit, push, and optionally tag + # Phase 1: Bump version on release, commit, push, and optionally tag # ========================================================================= prepare-build: name: Prepare build context @@ -103,10 +112,10 @@ jobs: build_ref: ${{ steps.resolve.outputs.build_ref }} base_url: ${{ steps.resolve.outputs.base_url }} steps: - - name: Enforce main branch - if: github.ref != 'refs/heads/main' + - name: Enforce release branch + if: github.ref != 'refs/heads/release' run: | - echo "This workflow can only run from main. Current ref: $GITHUB_REF" + echo "This workflow can only run from release. Current ref: $GITHUB_REF" exit 1 - name: Generate GitHub App token id: app-token @@ -114,10 +123,10 @@ jobs: with: app-id: ${{ secrets.XGITHUB_APP_ID }} private-key: ${{ secrets.XGITHUB_APP_PRIVATE_KEY }} - - name: Checkout main + - name: Checkout release uses: actions/checkout@v5 with: - ref: main + ref: release fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} submodules: recursive @@ -143,14 +152,14 @@ jobs: exit 1 } git rev-parse --verify "${COMMIT_SHA}^{commit}" >/dev/null - git merge-base --is-ancestor "$COMMIT_SHA" origin/main || { - echo "commit_sha $COMMIT_SHA is not reachable from main" + git merge-base --is-ancestor "$COMMIT_SHA" origin/release || { + echo "commit_sha $COMMIT_SHA is not reachable from release" exit 1 } git checkout --detach "$COMMIT_SHA" else - git checkout main - git pull origin main --ff-only + git checkout release + git pull origin release --ff-only fi - name: Compute next version and sync release files id: bump @@ -183,7 +192,7 @@ jobs: run: | git add app/package.json app/src-tauri/tauri.conf.json app/src-tauri/Cargo.toml Cargo.toml app/src-tauri/Cargo.lock Cargo.lock git commit -m "chore(release): v${VERSION}" - git push origin HEAD:main + git push origin HEAD:release if [ "$CREATE_RELEASE" = "true" ]; then git tag -a "$TAG" -m "Release $TAG" git push origin "$TAG" @@ -214,6 +223,34 @@ jobs: echo "build_ref=$BUILD_REF" >> "$GITHUB_OUTPUT" echo "base_url=https://api.tinyhumans.ai/" >> "$GITHUB_OUTPUT" + # Keep main in sync: the version-bump commit (and anything else on + # release) flows back into main right after the cut. continue-on-error + # so a merge conflict never strands a release that is already tagged — + # resolve the merge manually in that case. + - name: Merge release back into main + continue-on-error: true + env: + VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + echo "[release-production] merging release back into main" + RELEASE_SHA="$(git rev-parse HEAD)" + git fetch origin main + git checkout -B main origin/main + # Fast-forward when main hasn't moved since the cut — this leaves + # main == release, so prepare-release-pr sees ahead_by=0 and does + # NOT open a no-op main→release PR for the back-merge commit. + if git merge --ff-only "$RELEASE_SHA" 2>/dev/null; then + git push origin HEAD:main + echo "[release-production] fast-forwarded main to $RELEASE_SHA" + elif git merge --no-ff "$RELEASE_SHA" -m "chore(release): merge release v${VERSION} back into main"; then + git push origin HEAD:main + echo "[release-production] merged $RELEASE_SHA into main (merge commit)" + else + echo "::warning::Automatic release→main back-merge hit conflicts. Merge branch 'release' into 'main' manually." + exit 1 + fi + # ========================================================================= # Phase 2: Create draft GitHub release # ========================================================================= @@ -448,6 +485,9 @@ jobs: target: x86_64-unknown-linux-gnu - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu + env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} steps: - name: Checkout build ref uses: actions/checkout@v5 diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 8fb162fa6..7cf960307 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -5,22 +5,26 @@ on: inputs: commit_sha: description: - Build from a specific commit SHA instead of main HEAD. Leave empty - to use main HEAD (the default). The commit must be reachable from - main. + Build from a specific commit SHA instead of release HEAD. Leave empty + to use release HEAD (the default). The commit must be reachable from + release. required: false type: string default: "" create_tag: description: Create the immutable `v-staging` tag and run the full - build matrix. When false, bump versions and commit to main but + build matrix. When false, bump versions and commit to release but skip tag creation, desktop builds, and Docker — useful for version-bump-only runs. required: false type: boolean default: true permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read # `contents: write` is required for the patch bump commit and the # `v-staging` tag push performed by `prepare-build` below. contents: write @@ -29,6 +33,11 @@ concurrency: group: release-staging cancel-in-progress: false # --------------------------------------------------------------------------- +# Branch model: releases are cut from the long-lived `release` branch, not +# `main`. `release` only advances via green main→release PRs that run the +# full test suite; this workflow bumps/commits/tags on `release` and the +# build matrix is built from the tag this workflow creates on `release`. +# # Job dependency graph # # prepare-build @@ -48,9 +57,9 @@ concurrency: # --------------------------------------------------------------------------- jobs: # ========================================================================= - # Phase 1: Patch-bump on `main` and optionally create the immutable + # Phase 1: Patch-bump on `release` and optionally create the immutable # `v-staging` tag at that commit. The build matrix below then - # checks out the tag (not main HEAD) so reruns reproduce byte-for-byte. + # checks out the tag (not release HEAD) so reruns reproduce byte-for-byte. # ========================================================================= prepare-build: name: Prepare build context @@ -78,10 +87,10 @@ jobs: build_ref: ${{ steps.resolve.outputs.build_ref }} base_url: ${{ steps.resolve.outputs.base_url }} steps: - - name: Enforce main branch - if: github.ref != 'refs/heads/main' + - name: Enforce release branch + if: github.ref != 'refs/heads/release' run: | - echo "This workflow can only run from main. Current ref: $GITHUB_REF" + echo "This workflow can only run from release. Current ref: $GITHUB_REF" exit 1 - name: Generate GitHub App token id: app-token @@ -89,10 +98,10 @@ jobs: with: app-id: ${{ secrets.XGITHUB_APP_ID }} private-key: ${{ secrets.XGITHUB_APP_PRIVATE_KEY }} - - name: Checkout main + - name: Checkout release uses: actions/checkout@v5 with: - ref: main + ref: release fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} submodules: recursive @@ -118,21 +127,21 @@ jobs: exit 1 } git rev-parse --verify "${COMMIT_SHA}^{commit}" >/dev/null - git merge-base --is-ancestor "$COMMIT_SHA" origin/main || { - echo "commit_sha $COMMIT_SHA is not reachable from main" + git merge-base --is-ancestor "$COMMIT_SHA" origin/release || { + echo "commit_sha $COMMIT_SHA is not reachable from release" exit 1 } git checkout --detach "$COMMIT_SHA" else - git checkout main - git pull origin main --ff-only + git checkout release + git pull origin release --ff-only fi # Patch-only bump for staging cuts. Minor/major promotions are owned # by `release-production.yml` and only happen on the production path. - # Bump commit lands on `main` (we don't maintain a separate `staging` + # Bump commit lands on `release` (we don't maintain a separate `staging` # branch) and the immutable `v-staging` tag pinpoints the - # exact main commit QA validated, so production promotion can later - # find the tagged commit reachable from main. + # exact release commit QA validated, so production promotion can later + # find the tagged commit reachable from release. - name: Bump patch version id: bump run: node scripts/release/bump-version.js patch @@ -175,7 +184,7 @@ jobs: run: | git add app/package.json app/src-tauri/tauri.conf.json app/src-tauri/Cargo.toml Cargo.toml app/src-tauri/Cargo.lock Cargo.lock git commit -m "chore(staging): v${VERSION}" - git push origin HEAD:main + git push origin HEAD:release if [ "$CREATE_TAG" = "true" ]; then git tag -a "$TAG" -m "Staging cut $TAG" git push origin "$TAG" @@ -198,8 +207,8 @@ jobs: echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" if [ "$CREATE_TAG" = "true" ]; then - # Build from the immutable staging tag rather than main HEAD so - # reruns of this workflow rebuild the same content even if main + # Build from the immutable staging tag rather than release HEAD so + # reruns of this workflow rebuild the same content even if release # has moved on (e.g. another patch cut, or a hotfix landed). echo "build_ref=$TAG" >> "$GITHUB_OUTPUT" else @@ -336,7 +345,7 @@ jobs: # ========================================================================= # Cleanup: delete the staging tag if the build matrix failed. The version - # bump commit on `main` stays — reverting it would risk a race with + # bump commit on `release` stays — reverting it would risk a race with # concurrent merges. The next staging cut just continues from the new # patch number; the small “gap” in patch numbers is acceptable. # ========================================================================= diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 0ddba2777..9fe05af1e 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -32,9 +32,17 @@ on: default: true permissions: + # `actions: read` lets scripts/ci-cancel-aware.sh poll the run status so + # cancelled builds inside container jobs stop themselves (docker exec + # swallows the runner's signals). + actions: read contents: read packages: read +env: + # Consumed by scripts/ci-cancel-aware.sh's cancellation watchdog. + GH_TOKEN: ${{ github.token }} + jobs: i18n-coverage: if: inputs.run_unit diff --git a/AGENTS.md b/AGENTS.md index ce8a55650..04608cff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,8 @@ GGML_NATIVE=OFF cargo check --manifest-path Cargo.toml **Build speed**: both `Cargo.toml` files set `[profile.dev.package."*"] debug = false` — dependencies compile without DWARF in `dev`/`test` (faster builds + smaller `target/`); our own crates keep full debuginfo so panics/backtraces still resolve to file:line. `release`/`ci` profiles are unchanged. Keep this stanza in sync across the root and `app/src-tauri/Cargo.toml` if you touch profiles. +**Two-branch CI model**: PRs → `main` run the **fast lane** (`pr-ci.yml`): quality checks per changed area plus unit tests **only for the changed files** — `vitest related` for `app/src` changes and domain-scoped `cargo llvm-cov` (libtest filter derived from `src///…`) for Rust — still gated at ≥ 80% diff coverage. Config-level changes (lockfile, Cargo.toml/lock, vitest config, `src/lib.rs`, …) fall back to the full suite (`scripts/ci/vitest-changed-coverage.sh`, `scripts/ci/rust-coverage-changed.sh`). A standing main→release PR (auto-opened by `prepare-release-pr.yml` on every push to main) runs the **full lane** (`release-ci.yml`): complete unit suites, Rust mock-backend E2E, Playwright, and the full desktop E2E matrix on 3 OSes, gated by the `Release CI Gate` check. Releases are cut from the `release` branch (`release-staging.yml` / `release-production.yml` run there and tag on `release`; production takes a `release_type` increment — patch/minor/major — and merges the release branch back into `main` after the cut; staging patch-bumps without a back-merge). Long build/test commands must run through `scripts/ci-cancel-aware.sh`, whose Actions-API watchdog stops cancelled builds inside container jobs (docker exec swallows runner signals). + **CI build topology**: full-suite E2E is **build-once-then-fanout** on all three OSes — `build-{linux,macos,windows}-full` compile/bundle the app once and upload it as a per-run workflow artifact, and the shard jobs (`e2e-*-full`) `needs:` that job and download it instead of each shard rebuilding on a cold cache (`.github/workflows/e2e-reusable.yml`). Linux desktop packaging (`build-desktop.yml`) does a **single** `cargo tauri build`: libcef.so is resolved from the restored CEF cache (or a targeted `cargo build -p cef-dll-sys` prewarm on a cold cache) rather than a throwaway `--no-bundle` full build. The root core crate and the Tauri shell are still **separate Cargo worlds** (two `Cargo.lock`, two `target/`); converging them into one workspace is tracked as follow-up in #3877. **Tests**: `pnpm test` (Vitest) · `pnpm test:coverage` · `pnpm test:rust` (`scripts/test-rust-with-mock.sh`). diff --git a/scripts/ci-cancel-aware.sh b/scripts/ci-cancel-aware.sh index 680b9ac13..339f6e03a 100755 --- a/scripts/ci-cancel-aware.sh +++ b/scripts/ci-cancel-aware.sh @@ -11,6 +11,7 @@ OS_NAME="$(uname -s 2>/dev/null || echo unknown)" CHILD_PID="" RECEIVED_SIGNAL="" CHILD_OWNS_PROCESS_GROUP=0 +WATCHDOG_PID="" is_windows_shell() { case "$OS_NAME" in @@ -82,10 +83,66 @@ forward_cancel() { fi } +# Signal traps alone cannot stop builds inside `container:` jobs: the runner +# delivers SIGINT/SIGTERM to the host-side `docker exec` client, which does +# NOT forward them into the container (actions/runner#1503). Observed on run +# 28692500745: cancelled jobs kept building for 23-28 minutes to natural +# completion. This watchdog polls the Actions API for the run's cancellation +# and delivers the TERM ourselves, from inside the container. +# +# Requires: GH_TOKEN or GITHUB_TOKEN in the environment with `actions: read` +# (workflows set `env: GH_TOKEN: ${{ github.token }}` at the top level). +# Silently disabled outside GitHub Actions or when no token is available. +start_cancel_watchdog() { + [ "${CI_CANCEL_WATCHDOG:-1}" = "1" ] || return 0 + [ -n "${GITHUB_ACTIONS:-}" ] || return 0 + [ -n "${GITHUB_RUN_ID:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] || return 0 + local token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [ -z "$token" ]; then + echo "[ci-cancel-aware] watchdog disabled: no GH_TOKEN/GITHUB_TOKEN in env" >&2 + return 0 + fi + if ! command -v curl >/dev/null 2>&1; then + echo "[ci-cancel-aware] watchdog disabled: curl not available" >&2 + return 0 + fi + + local api="${GITHUB_API_URL:-https://api.github.com}" + local url="${api}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + local interval="${CI_CANCEL_POLL_SECONDS:-20}" + local self=$$ + ( + # The parent's `set -euo pipefail` is inherited; a poll where conclusion + # is still null makes grep exit 1 and must not kill the watchdog loop. + set +e +o pipefail + while :; do + sleep "$interval" + # Extract the run's top-level status/conclusion without jq. + run_json="$(curl -sf --max-time 10 \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/vnd.github+json" \ + "$url" 2>/dev/null | head -c 4096)" || continue + status="$(printf '%s' "$run_json" | tr -d ' \n' | grep -o '"status":"[a-z_]*"' | head -1 | cut -d'"' -f4)" + conclusion="$(printf '%s' "$run_json" | tr -d ' \n' | grep -o '"conclusion":"[a-z_]*"' | head -1 | cut -d'"' -f4)" + if [ "$status" = "cancelled" ] || [ "$status" = "completed" ] || [ "$conclusion" = "cancelled" ]; then + echo "[ci-cancel-aware] watchdog: run status=${status:-?} conclusion=${conclusion:-?} — cancelling build" >&2 + kill -TERM "$self" 2>/dev/null || true + exit 0 + fi + done + ) & + WATCHDOG_PID=$! + echo "[ci-cancel-aware] cancellation watchdog polling every ${interval}s (run ${GITHUB_RUN_ID})" >&2 +} + cleanup() { local status=$? trap - EXIT INT TERM HUP + if [ -n "$WATCHDOG_PID" ]; then + kill "$WATCHDOG_PID" 2>/dev/null || true + fi + if [ -n "$CHILD_PID" ] && kill -0 "$CHILD_PID" 2>/dev/null; then terminate_tree_term "$CHILD_PID" for _ in $(seq 1 10); do @@ -129,6 +186,7 @@ trap 'forward_cancel HUP' HUP trap cleanup EXIT echo "[ci-cancel-aware] exec: $(printf '%q ' "$@")" >&2 +start_cancel_watchdog start_child "$@" set +e diff --git a/scripts/ci/rust-coverage-changed.sh b/scripts/ci/rust-coverage-changed.sh new file mode 100755 index 000000000..00baf809e --- /dev/null +++ b/scripts/ci/rust-coverage-changed.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# PR CI Rust core coverage lane — changed-files-only cargo-llvm-cov. +# +# Fast-lane policy (PRs targeting main): instead of the full ~13k-test +# instrumented suite, run only the unit tests for the modules the PR touched: +# - src///... .rs → libtest filter "::" (domain-level scope, so +# sibling-module tests like store_tests.rs / ops.rs still run) +# - tests/.rs → that integration-test target only (--test ) +# Coverage from all scoped runs is merged (--no-report + report) into a single +# lcov file; the PR CI Gate's diff-cover step enforces >= 80% on changed lines. +# +# NOTE: this means changed lines must be covered by tests in their own domain +# (or a changed integration test) — coverage contributed by unrelated suites +# no longer counts on the fast lane. The full suite still runs on main→release +# PRs (Release CI). +# +# Inputs (env): +# FULL "true" → run the full suite (build-config / lib.rs / script +# changes, detected by paths-filter) +# CHANGED_FILES shell-quoted, space-separated repo-relative paths from +# dorny/paths-filter (list-files: shell) +# OUT lcov output path (default lcov-core.info) +# +# Falls back to the FULL suite whenever scoping is not clearly safe. +set -euo pipefail + +FULL="${FULL:-false}" +CHANGED_FILES="${CHANGED_FILES:-}" +OUT="${OUT:-lcov-core.info}" +MAX_CHANGED_FILES="${MAX_CHANGED_FILES:-200}" + +log() { echo "[ci][rust-cov-changed] $*"; } + +llvm_cov() { + bash scripts/ci-cancel-aware.sh cargo llvm-cov "$@" +} + +run_full() { + log "running FULL instrumented suite (reason: $1)" + llvm_cov --no-fail-fast -p openhuman --lcov --output-path "${OUT}" + exit 0 +} + +if [ "${FULL}" = "true" ]; then + run_full "build-config/workflow-level change detected by paths-filter" +fi + +# Portable across bash 3.2 (macOS) and 5.x (CI containers): no declare -A, +# no mapfile, and no empty-array "${arr[@]}" expansion under set -u. +# +# CHANGED_FILES is the shell-quoted list from dorny/paths-filter +# (list-files: shell). Filenames are PR-controlled, so never eval it — +# xargs unquotes tokens as data without ever invoking a shell. If xargs +# can't parse it (e.g. hostile quoting), we get an empty list and fall +# back to the full suite. +declare -a files=() +while IFS= read -r f; do + [ -n "${f}" ] && files+=("${f}") +done < <(printf '%s\n' "${CHANGED_FILES}" | xargs -n1 printf '%s\n' 2>/dev/null || true) +log "received ${#files[@]} changed rust file(s)" + +if [ "${#files[@]}" -eq 0 ]; then + run_full "empty changed-file list — scoping unsafe" +fi +if [ "${#files[@]}" -gt "${MAX_CHANGED_FILES}" ]; then + run_full "${#files[@]} changed files exceed MAX_CHANGED_FILES=${MAX_CHANGED_FILES}" +fi + +lib_filters_raw="" +test_targets_raw="" +for f in "${files[@]}"; do + case "${f}" in + src/lib.rs | src/main.rs) + run_full "root module ${f} changed — whole-crate scope" + ;; + src/bin/*) + # Standalone backfill binaries (slack-backfill, gmail-backfill-3d) have + # no unit tests; nothing to scope to. + log "ignoring standalone-binary file: ${f}" + ;; + src/*.rs) + p="${f#src/}" + p="${p%.rs}" + IFS='/' read -r -a segs <<<"${p}" + n="${#segs[@]}" + if [ "${segs[n - 1]}" = "mod" ]; then + segs=("${segs[@]:0:n-1}") + n="${#segs[@]}" + fi + if [ "${n}" -ge 2 ]; then + key="${segs[0]}::${segs[1]}" + else + key="${segs[0]}" + fi + lib_filters_raw="${lib_filters_raw}${key} +" + log "${f} → libtest filter '${key}'" + ;; + src/*/*) + # Non-.rs asset embedded in a domain (e.g. agent prompt markdown under + # src/openhuman/agent/prompts/) — scope to that domain's tests. + p="${f#src/}" + IFS='/' read -r -a segs <<<"${p}" + n="${#segs[@]}" + if [ "${n}" -ge 3 ]; then + key="${segs[0]}::${segs[1]}" + else + key="${segs[0]}" + fi + lib_filters_raw="${lib_filters_raw}${key} +" + log "${f} → libtest filter '${key}' (embedded asset)" + ;; + tests/*.rs) + name="${f#tests/}" + name="${name%.rs}" + if [[ "${name}" == */* ]]; then + # Nested support module — can affect any integration target. + run_full "shared integration-test support file ${f} changed" + fi + test_targets_raw="${test_targets_raw}${name} +" + log "${f} → integration target '--test ${name}'" + ;; + *) + run_full "unclassified rust-relevant file ${f} changed" + ;; + esac +done + +declare -a lib_filters=() +while IFS= read -r k; do + [ -n "${k}" ] && lib_filters+=("${k}") +done < <(printf '%s' "${lib_filters_raw}" | sort -u) + +declare -a test_targets=() +while IFS= read -r k; do + [ -n "${k}" ] && test_targets+=("${k}") +done < <(printf '%s' "${test_targets_raw}" | sort -u) + +if [ "${#lib_filters[@]}" -eq 0 ] && [ "${#test_targets[@]}" -eq 0 ]; then + run_full "no scoped test targets derivable from the change set" +fi + +# Drop artifacts from previous coverage runs so merged profdata only reflects +# this run (build cache for dependencies is unaffected). +llvm_cov clean --workspace + +if [ "${#lib_filters[@]}" -gt 0 ]; then + log "running scoped lib unit tests with filters: ${lib_filters[*]}" + # libtest ORs multiple positional filters — one run covers all domains. + llvm_cov --no-report --no-fail-fast -p openhuman --lib -- "${lib_filters[@]}" +fi + +if [ "${#test_targets[@]}" -gt 0 ]; then + for t in "${test_targets[@]}"; do + log "running changed integration-test target: ${t}" + llvm_cov --no-report --no-fail-fast -p openhuman --test "${t}" + done +fi + +log "merging coverage into ${OUT}" +llvm_cov report --lcov --output-path "${OUT}" diff --git a/scripts/ci/vitest-changed-coverage.sh b/scripts/ci/vitest-changed-coverage.sh new file mode 100755 index 000000000..f6cb5d8d0 --- /dev/null +++ b/scripts/ci/vitest-changed-coverage.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# PR CI frontend unit-test lane — changed-files-only Vitest. +# +# Fast-lane policy (PRs targeting main): run only the tests related to the +# files the PR actually changed, via `vitest related` (static import graph — +# reliable here because dynamic imports are banned in app/src). Coverage is +# still written to app/coverage/lcov.info; the PR CI Gate's diff-cover step +# then enforces >= 80% on changed lines. Untested changed files still appear +# in the lcov report at 0% (vitest coverage.include is explicit), so the gate +# cannot be dodged by having no related tests. +# +# Inputs (env): +# FULL "true" → run the entire suite with coverage (config-level +# change: lockfile, vitest/vite/ts config, test setup, etc.) +# CHANGED_FILES shell-quoted, space-separated repo-relative paths from +# dorny/paths-filter (list-files: shell) +# +# Falls back to the FULL suite whenever scoping is not clearly safe. +set -euo pipefail + +FULL="${FULL:-false}" +CHANGED_FILES="${CHANGED_FILES:-}" +# Above this many changed source files a scoped run buys little and the +# argv/related-graph bookkeeping gets silly — just run everything. +MAX_RELATED_FILES="${MAX_RELATED_FILES:-200}" + +log() { echo "[ci][vitest-changed] $*"; } + +run_full() { + log "running FULL Vitest coverage suite (reason: $1)" + exec bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app test:coverage +} + +if [ "${FULL}" = "true" ]; then + run_full "config/workflow-level change detected by paths-filter" +fi + +# CHANGED_FILES is the shell-quoted list from dorny/paths-filter +# (list-files: shell). Filenames are PR-controlled, so never eval it — +# xargs unquotes tokens as data without ever invoking a shell. If xargs +# can't parse it (e.g. hostile quoting), we get an empty list and fall +# back to the full suite. +declare -a files=() +while IFS= read -r f; do + [ -n "${f}" ] && files+=("${f}") +done < <(printf '%s\n' "${CHANGED_FILES}" | xargs -n1 printf '%s\n' 2>/dev/null || true) +log "received ${#files[@]} changed frontend file(s)" + +if [ "${#files[@]}" -eq 0 ]; then + run_full "empty changed-file list — scoping unsafe" +fi + +declare -a related=() +for f in "${files[@]}"; do + case "${f}" in + app/src/*.ts | app/src/*.tsx) ;; + *) + log "ignoring non-source path: ${f}" + continue + ;; + esac + if [ ! -f "${f}" ]; then + log "skipping deleted/renamed file: ${f}" + continue + fi + # vitest runs from app/ (config root) — strip the workspace prefix. + related+=("${f#app/}") +done + +if [ "${#related[@]}" -eq 0 ]; then + run_full "no surviving changed .ts/.tsx files under app/src — scoping unsafe" +fi +if [ "${#related[@]}" -gt "${MAX_RELATED_FILES}" ]; then + run_full "${#related[@]} changed files exceed MAX_RELATED_FILES=${MAX_RELATED_FILES}" +fi + +log "running 'vitest related' with coverage for ${#related[@]} file(s):" +printf '[ci][vitest-changed] %s\n' "${related[@]}" + +cd app +exec bash ../scripts/ci-cancel-aware.sh pnpm exec vitest related --run --coverage --config test/vitest.config.ts "${related[@]}"