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:
Steven Enamakel
2026-07-03 21:43:06 -07:00
committed by GitHub
parent c4701a9373
commit 5b15bbc34c
19 changed files with 996 additions and 315 deletions
+8
View File
@@ -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
+3
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
+128
View File
@@ -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}"
+341
View File
@@ -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
+7
View File
@@ -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
+56 -16
View File
@@ -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
+31 -22
View File
@@ -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.
# =========================================================================
+8
View File
@@ -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
+2
View File
@@ -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`).
+58
View File
@@ -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
+163
View File
@@ -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}"
+81
View File
@@ -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[@]}"