* ci: add Product CI merge gate and Autopilot auto-merge path
Aggregate lint, typecheck, builds, unit, Postgres journeys, and Web E2E
into a single Product CI check for branch protection. Soft-fail artifact
uploads and skip headless macOS screenshots so infra noise does not block
merge. Add autopilot/auto-merge workflow and MERGE_GATES.md.
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(ci): keep autopilot auto-merge inert without label
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(ci): run autopilot auto-merge on self-hosted runner
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(ci): free stale E2E ports before Playwright bind on Linux
Self-hosted evoo-ci runners can retain listeners on harness ports
(3110 API, 5180 web, 4321 marketing, 3111 device control) after a
failed run, causing EADDRINUSE before the next job. Add a guarded
free-e2e-ports.sh that uses fuser on Linux only and never touches
live dev ports 3100/5173. Wire it into the Playwright workflow and
the E2E harness with clear logging.
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(web): stabilize message More menu Escape in hover E2E
The desktop hover spec assumed Escape always closed the first page-level
menu within one keypress. On self-hosted Chromium the Copy menu could stay
open while the rail remained visible, failing getByRole('menu') count.
Wait for the Copy item to prove the menu opened, then retry Escape until
that item hides while asserting the More control stays on the hover rail.
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
* fix(web): retry sign-up navigation in Playwright helpers
Late-suite specs can miss the sign-up heading when Vite is still
warming on self-hosted runners. Retry goto until the form renders
instead of failing once at the default timeout.
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
460 lines
16 KiB
YAML
460 lines
16 KiB
YAML
# Product merge gate: the `Product CI` job aggregates lint, typecheck, builds, unit,
|
|
# Postgres journeys, and Web E2E. Branch protection should require only that check.
|
|
# Deploy, OTA, and artifact uploads here are post-merge or soft-fail infra helpers.
|
|
name: ci
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ci-${{ github.event.pull_request.number || github.ref }}
|
|
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
|
|
jobs:
|
|
lint:
|
|
name: Lint
|
|
runs-on: [self-hosted, evoo-ci, Linux]
|
|
steps:
|
|
- uses: actions/checkout@v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: pnpm/action-setup@v4
|
|
id: pnpm-setup
|
|
with:
|
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
|
- name: Isolate pnpm store and PATH
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
|
echo "pnpm setup did not report install paths" >&2
|
|
exit 1
|
|
fi
|
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
|
echo "${bin}" >> "$GITHUB_PATH"
|
|
echo "${dest}" >> "$GITHUB_PATH"
|
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
|
mkdir -p "$store"
|
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
cache: pnpm
|
|
- run: pnpm install --frozen-lockfile
|
|
- run: pnpm lint
|
|
|
|
check:
|
|
name: Typecheck
|
|
runs-on: [self-hosted, evoo-ci, Linux]
|
|
steps:
|
|
- uses: actions/checkout@v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: pnpm/action-setup@v4
|
|
id: pnpm-setup
|
|
with:
|
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
|
- name: Isolate pnpm store and PATH
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
|
echo "pnpm setup did not report install paths" >&2
|
|
exit 1
|
|
fi
|
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
|
echo "${bin}" >> "$GITHUB_PATH"
|
|
echo "${dest}" >> "$GITHUB_PATH"
|
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
|
mkdir -p "$store"
|
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
cache: pnpm
|
|
- run: pnpm install --frozen-lockfile
|
|
- run: pnpm db:generate
|
|
env:
|
|
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
|
|
- run: pnpm check
|
|
|
|
build:
|
|
name: Production builds
|
|
runs-on: [self-hosted, evoo-ci, Linux]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- uses: actions/checkout@v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: pnpm/action-setup@v4
|
|
id: pnpm-setup
|
|
with:
|
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
|
- name: Isolate pnpm store and PATH
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
|
echo "pnpm setup did not report install paths" >&2
|
|
exit 1
|
|
fi
|
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
|
echo "${bin}" >> "$GITHUB_PATH"
|
|
echo "${dest}" >> "$GITHUB_PATH"
|
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
|
mkdir -p "$store"
|
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
cache: pnpm
|
|
- run: pnpm install --frozen-lockfile
|
|
- run: pnpm build
|
|
env:
|
|
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
|
|
BETTER_AUTH_SECRET: ci-build-auth-secret-at-least-32-characters
|
|
ENCRYPTION_KEY: ci-build-encryption-key-at-least-32-characters
|
|
- name: Electron smoke
|
|
run: xvfb-run --auto-servernum pnpm --filter @rakazo/desktop exec playwright test --config e2e/playwright.config.ts
|
|
- name: Upload desktop setup screenshots
|
|
if: always()
|
|
continue-on-error: true
|
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
|
with:
|
|
name: desktop-e2e-screenshots
|
|
path: apps/desktop/e2e/screenshots
|
|
if-no-files-found: ignore
|
|
|
|
test:
|
|
name: Unit tests
|
|
runs-on: [self-hosted, evoo-ci, Linux]
|
|
steps:
|
|
- uses: actions/checkout@v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: pnpm/action-setup@v4
|
|
id: pnpm-setup
|
|
with:
|
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
|
- name: Isolate pnpm store and PATH
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
|
echo "pnpm setup did not report install paths" >&2
|
|
exit 1
|
|
fi
|
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
|
echo "${bin}" >> "$GITHUB_PATH"
|
|
echo "${dest}" >> "$GITHUB_PATH"
|
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
|
mkdir -p "$store"
|
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
cache: pnpm
|
|
- run: pnpm install --frozen-lockfile
|
|
- run: pnpm db:generate
|
|
env:
|
|
DATABASE_URL: postgres://rakazo:rakazo@127.0.0.1:5433/rakazo
|
|
- run: pnpm test
|
|
|
|
test-integration:
|
|
name: Postgres journeys
|
|
runs-on: [self-hosted, evoo-ci, Linux]
|
|
timeout-minutes: 10
|
|
steps:
|
|
- uses: actions/checkout@v5
|
|
with:
|
|
persist-credentials: false
|
|
- uses: pnpm/action-setup@v4
|
|
id: pnpm-setup
|
|
with:
|
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
|
- name: Isolate pnpm store and PATH
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
|
echo "pnpm setup did not report install paths" >&2
|
|
exit 1
|
|
fi
|
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
|
echo "${bin}" >> "$GITHUB_PATH"
|
|
echo "${dest}" >> "$GITHUB_PATH"
|
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
|
mkdir -p "$store"
|
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 22
|
|
cache: pnpm
|
|
- run: pnpm install --frozen-lockfile
|
|
- run: pnpm test:integration
|
|
|
|
test-e2e:
|
|
name: Web E2E
|
|
uses: ./.github/workflows/playwright.yml
|
|
with:
|
|
upload_artifacts: true
|
|
|
|
product-gate:
|
|
name: Product CI
|
|
if: always()
|
|
needs: [lint, check, build, test, test-integration, test-e2e]
|
|
runs-on: [self-hosted, evoo-ci, Linux]
|
|
steps:
|
|
- name: Verify product checks
|
|
env:
|
|
LINT: ${{ needs.lint.result }}
|
|
CHECK: ${{ needs.check.result }}
|
|
BUILD: ${{ needs.build.result }}
|
|
TEST: ${{ needs.test.result }}
|
|
TEST_INTEGRATION: ${{ needs.test-integration.result }}
|
|
TEST_E2E: ${{ needs.test-e2e.result }}
|
|
run: |
|
|
set -euo pipefail
|
|
failed=0
|
|
for name in LINT CHECK BUILD TEST TEST_INTEGRATION TEST_E2E; do
|
|
result="${!name}"
|
|
printf '%s=%s\n' "$name" "$result"
|
|
if [[ "$result" != "success" ]]; then
|
|
failed=1
|
|
fi
|
|
done
|
|
if [[ "$failed" -ne 0 ]]; then
|
|
echo "One or more product checks failed." >&2
|
|
exit 1
|
|
fi
|
|
echo "All product checks passed."
|
|
|
|
publish-mobile-update:
|
|
name: Publish compatible mobile update
|
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
needs: [lint, check, build, test, test-integration, test-e2e]
|
|
runs-on: [self-hosted, evoo-ci, Linux]
|
|
timeout-minutes: 20
|
|
concurrency:
|
|
group: mobile-production-update
|
|
cancel-in-progress: false
|
|
steps:
|
|
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- name: Check whether the change is safe for OTA
|
|
id: ota
|
|
shell: bash
|
|
env:
|
|
BEFORE_SHA: ${{ github.event.before }}
|
|
run: |
|
|
set -euo pipefail
|
|
base="$BEFORE_SHA"
|
|
if [[ -z "$base" || "$base" =~ ^0+$ ]] || ! git cat-file -e "${base}^{commit}"; then
|
|
{
|
|
echo "### Mobile OTA skipped"
|
|
echo
|
|
echo "Could not resolve \`github.event.before\` for the full push range, so this job will not publish a partial-range update."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
echo "publish=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
mapfile -t files < <(
|
|
git diff --name-only "$base" "$GITHUB_SHA" -- \
|
|
.github/workflows/ci.yml \
|
|
apps/mobile \
|
|
package.json \
|
|
packages/chat-ui \
|
|
packages/contracts \
|
|
packages/core \
|
|
pnpm-lock.yaml
|
|
)
|
|
|
|
publish=false
|
|
blocked=false
|
|
for file in "${files[@]}"; do
|
|
case "$file" in
|
|
*.test.ts|*.test.tsx|*.d.ts)
|
|
continue
|
|
;;
|
|
esac
|
|
|
|
eligible=false
|
|
case "$file" in
|
|
apps/mobile/app/*|apps/mobile/components/*|apps/mobile/lib/*)
|
|
case "$file" in
|
|
*.ts|*.tsx) eligible=true ;;
|
|
esac
|
|
;;
|
|
packages/chat-ui/src/*)
|
|
case "$file" in
|
|
*.ts|*.tsx|*.css) eligible=true ;;
|
|
esac
|
|
;;
|
|
packages/contracts/src/*|packages/core/src/*)
|
|
case "$file" in
|
|
*.ts|*.tsx) eligible=true ;;
|
|
esac
|
|
;;
|
|
esac
|
|
|
|
if [[ "$eligible" == true ]]; then
|
|
publish=true
|
|
else
|
|
blocked=true
|
|
fi
|
|
done
|
|
|
|
if [[ "$blocked" == true ]]; then
|
|
publish=false
|
|
{
|
|
echo "### Mobile OTA skipped"
|
|
echo
|
|
echo "This revision changes native-sensitive mobile configuration, dependencies, modules, assets, or workflow files. Create new iOS and Android builds instead of publishing it to an older native runtime."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
elif [[ "$publish" == true ]]; then
|
|
{
|
|
echo "### Mobile OTA eligible"
|
|
echo
|
|
echo "The revision only changes JavaScript, TypeScript, or bundled CSS used by the mobile client."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
else
|
|
{
|
|
echo "### No mobile OTA needed"
|
|
echo
|
|
echo "This revision does not change the shipped mobile bundle."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
fi
|
|
echo "publish=$publish" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Require Expo automation token
|
|
if: steps.ota.outputs.publish == 'true'
|
|
env:
|
|
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
|
|
run: |
|
|
if [[ -z "$EXPO_TOKEN" ]]; then
|
|
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
|
|
if: steps.ota.outputs.publish == 'true'
|
|
id: pnpm-setup
|
|
with:
|
|
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
|
|
- name: Isolate pnpm store and PATH
|
|
if: steps.ota.outputs.publish == 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
bin="${{ steps.pnpm-setup.outputs.bin_dest }}"
|
|
dest="${{ steps.pnpm-setup.outputs.dest }}"
|
|
if [[ -z "$bin" || -z "$dest" ]]; then
|
|
echo "pnpm setup did not report install paths" >&2
|
|
exit 1
|
|
fi
|
|
echo "PNPM_HOME=${dest}" >> "$GITHUB_ENV"
|
|
echo "${bin}" >> "$GITHUB_PATH"
|
|
echo "${dest}" >> "$GITHUB_PATH"
|
|
store="${RUNNER_TOOL_CACHE}/pnpm-store"
|
|
mkdir -p "$store"
|
|
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
|
|
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
|
if: steps.ota.outputs.publish == 'true'
|
|
with:
|
|
node-version: 24
|
|
cache: pnpm
|
|
- name: Set up Expo
|
|
if: steps.ota.outputs.publish == 'true'
|
|
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
|
|
with:
|
|
eas-version: latest
|
|
packager: pnpm
|
|
token: ${{ secrets.EXPO_TOKEN }}
|
|
- name: Install dependencies
|
|
if: steps.ota.outputs.publish == 'true'
|
|
run: pnpm install --frozen-lockfile
|
|
- name: Verify mobile project
|
|
if: steps.ota.outputs.publish == 'true'
|
|
run: pnpm --filter @rakazo/mobile check
|
|
- name: Publish production update
|
|
if: steps.ota.outputs.publish == 'true'
|
|
working-directory: apps/mobile
|
|
run: |
|
|
set -euo pipefail
|
|
head="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
|
|
if [[ -z "$head" ]]; then
|
|
echo "Could not resolve the current main head before publishing." >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$GITHUB_SHA" != "$head" ]]; then
|
|
{
|
|
echo "### Mobile OTA skipped"
|
|
echo
|
|
echo "This run is for \`$GITHUB_SHA\`, but \`main\` is now \`$head\`. Skipping so an older revision cannot overwrite a newer production update."
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
exit 0
|
|
fi
|
|
eas update --platform all --channel production --environment production --message "$GITHUB_SHA" --non-interactive
|
|
|
|
deploy-production:
|
|
if: >-
|
|
github.event_name == 'push' &&
|
|
github.ref == 'refs/heads/main' &&
|
|
vars.PRODUCTION_DEPLOY_ENABLED == 'true'
|
|
needs: [lint, check, build, test, test-integration, test-e2e]
|
|
runs-on: [self-hosted, evoo-ci, Linux]
|
|
timeout-minutes: 30
|
|
concurrency:
|
|
group: production
|
|
cancel-in-progress: false
|
|
environment:
|
|
name: production
|
|
url: https://app.rakazo.com
|
|
steps:
|
|
- name: Configure production SSH identity
|
|
env:
|
|
SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }}
|
|
SSH_KNOWN_HOSTS: ${{ secrets.PRODUCTION_SSH_KNOWN_HOSTS }}
|
|
run: |
|
|
test -n "$SSH_PRIVATE_KEY"
|
|
test -n "$SSH_KNOWN_HOSTS"
|
|
install -d -m 700 "$HOME/.ssh"
|
|
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/rakazo-production"
|
|
printf '%s\n' "$SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
|
chmod 600 "$HOME/.ssh/rakazo-production" "$HOME/.ssh/known_hosts"
|
|
ssh-keygen -y -f "$HOME/.ssh/rakazo-production" >/dev/null
|
|
|
|
- name: Deploy successful main revision
|
|
env:
|
|
SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
|
SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
|
run: |
|
|
test -n "$SSH_HOST"
|
|
test -n "$SSH_USER"
|
|
ssh -F /dev/null \
|
|
-i "$HOME/.ssh/rakazo-production" \
|
|
-o BatchMode=yes \
|
|
-o ConnectTimeout=15 \
|
|
-o IdentitiesOnly=yes \
|
|
-o StrictHostKeyChecking=yes \
|
|
-o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
|
|
"$SSH_USER@$SSH_HOST" deploy-main
|