mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(ci): two-branch CI — changed-files-only PR lane, full-suite release lane, release-branch cuts, cancellation watchdog (#4486)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+62
-277
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<version>-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<version>-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<version>-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<version>-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.
|
||||
# =========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<a>/<b>/…`) 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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+163
@@ -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/<a>/<b>/... .rs → libtest filter "<a>::<b>" (domain-level scope, so
|
||||
# sibling-module tests like store_tests.rs / ops.rs still run)
|
||||
# - tests/<name>.rs → that integration-test target only (--test <name>)
|
||||
# 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}"
|
||||
Executable
+81
@@ -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[@]}"
|
||||
Reference in New Issue
Block a user