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>
This commit is contained in:
Cursor Agent 2026-09-23 11:43:41 +00:00
parent 15379e8fec
commit 467d94b5b7
4 changed files with 245 additions and 3 deletions

66
.github/MERGE_GATES.md vendored Normal file
View File

@ -0,0 +1,66 @@
# CI merge gates
This repository splits continuous integration into **product** checks that should block merge and **infra** checks that provide signal without blocking Autopilot merges.
## Required for merge (product)
Configure branch protection on `main` to require exactly one status check from the `ci` workflow:
| Check name | Workflow | What it covers |
| --- | --- | --- |
| **Product CI** | `ci.yml` | Lint, Typecheck, Production builds, Unit tests, Postgres journeys, Web E2E |
The aggregate `Product CI` job fails when any of those product jobs fail. Individual job names remain visible on the pull request for debugging.
Optional but recommended repository settings:
- Enable **Allow auto-merge**
- Enable **Automatically delete head branches** after merge
## Infra-only (never required)
These workflows may fail without blocking product delivery. Do **not** add them to required status checks.
| Check / workflow | Why infra |
| --- | --- |
| `macOS setup screenshot (infra)` (`desktop-macos-screenshot.yml`) | Native screenshot capture; skips when the runner has no display |
| Playwright / desktop artifact uploads | Soft-fail (`continue-on-error`) when GitHub artifact quota is exhausted |
| `publish-server-image.yml` (PR paths) | Docker image validation for infra changes |
| `mobile Android screenshots` | Manual `workflow_dispatch` only |
| `nightly verification` | Scheduled visual regression |
| `manual topology verification` | Manual smoke |
| `release-desktop.yml` | Tag-driven release pipeline |
| `publish-playwright-report.yml` | Report publishing helper |
## Autopilot auto-merge
Workflow: `.github/workflows/autopilot-auto-merge.yml`
1. Open a pull request from an Autopilot/cloud agent.
2. Add label **`autopilot`** or **`automerge`**.
3. Wait until **Product CI** is green on the head commit.
4. The workflow runs `gh pr merge --auto --squash`. GitHub merges when branch protection is satisfied (required checks green, reviews if configured).
### Dry-run mode
Set repository variable `AUTOPILOT_MERGE_DRY_RUN` to `true` to log the merge command without enabling auto-merge.
### Optional secret: `AUTOPILOT_MERGE_TOKEN`
Most repositories can rely on the built-in `GITHUB_TOKEN` (`pull-requests: write`).
Add a fine-grained PAT or GitHub App installation token as **`AUTOPILOT_MERGE_TOKEN`** when auto-merge is rejected because branch protection requires a human actor or bypass permission the default token lacks.
Minimum scopes:
- Pull requests: read and write
- Contents: read (if merge commits need it under your rules)
After adding the secret, remove `AUTOPILOT_MERGE_DRY_RUN` or set it to `false`.
## One-time branch protection checklist (maintainer)
1. Settings → Branches → `main` → Require status checks before merging.
2. Search for **Product CI** and select only that check.
3. Remove **macOS setup screenshot**, artifact-only jobs, and other infra workflows from required checks if they were added earlier.
4. Enable **Allow auto-merge** if Autopilot should merge without manual clicking.

View File

@ -0,0 +1,122 @@
# Enables GitHub auto-merge for Autopilot PRs once the Product CI gate is green.
# Label a pull request with `autopilot` or `automerge` to opt in.
name: autopilot auto-merge
on:
pull_request:
types: [labeled, unlabeled, opened, synchronize, reopened, ready_for_review]
check_suite:
types: [completed]
permissions:
contents: read
pull-requests: write
checks: read
concurrency:
group: autopilot-auto-merge-${{ github.event.pull_request.number || github.event.check_suite.pull_requests[0].number || github.run_id }}
cancel-in-progress: false
jobs:
auto-merge:
name: Autopilot auto-merge
runs-on: ubuntu-latest
if: >-
github.event_name == 'pull_request' ||
(github.event_name == 'check_suite' &&
github.event.check_suite.pull_requests[0] != null)
steps:
- name: Resolve pull request
id: pr
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
CHECK_SUITE_PR: ${{ github.event.check_suite.pull_requests[0].number }}
run: |
set -euo pipefail
number="$PR_NUMBER"
if [[ "$EVENT_NAME" == "check_suite" ]]; then
number="$CHECK_SUITE_PR"
fi
if [[ -z "$number" ]]; then
echo "No pull request in event; skipping." >&2
exit 0
fi
pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${number}")"
labels="$(jq -r '[.labels[].name] | join(",")' <<<"$pr_json")"
enabled=false
if [[ ",${labels}," == *",autopilot,"* || ",${labels}," == *",automerge,"* ]]; then
enabled=true
fi
{
echo "number=$number"
echo "head_sha=$(jq -r .head.sha <<<"$pr_json")"
echo "draft=$(jq -r .draft <<<"$pr_json")"
echo "labels=$labels"
echo "enabled=$enabled"
} >> "$GITHUB_OUTPUT"
- name: Skip unless Autopilot label is present
if: steps.pr.outputs.enabled != 'true'
run: echo "No autopilot/automerge label; skipping."
- name: Skip draft pull requests
if: steps.pr.outputs.enabled == 'true' && steps.pr.outputs.draft == 'true'
run: |
echo "Draft pull request; auto-merge waits for ready_for_review."
- name: Verify Product CI on head commit
id: gate
if: steps.pr.outputs.enabled == 'true' && steps.pr.outputs.draft != 'true'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
set -euo pipefail
mapfile -t conclusions < <(
gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/check-runs" --paginate \
--jq '.check_runs[] | select(.name == "Product CI") | .conclusion'
)
if [[ "${#conclusions[@]}" -eq 0 ]]; then
echo "Product CI has not finished yet."
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
for conclusion in "${conclusions[@]}"; do
case "$conclusion" in
success)
echo "Product CI succeeded."
echo "ready=true" >> "$GITHUB_OUTPUT"
exit 0
;;
failure|cancelled|timed_out|action_required)
echo "Product CI failed ($conclusion); not enabling auto-merge." >&2
exit 1
;;
esac
done
echo "Product CI still pending."
echo "ready=false" >> "$GITHUB_OUTPUT"
- name: Enable auto-merge
if: steps.pr.outputs.enabled == 'true' && steps.pr.outputs.draft != 'true' && steps.gate.outputs.ready == 'true'
env:
AUTOPILOT_MERGE_TOKEN: ${{ secrets.AUTOPILOT_MERGE_TOKEN }}
DRY_RUN: ${{ vars.AUTOPILOT_MERGE_DRY_RUN }}
PR_NUMBER: ${{ steps.pr.outputs.number }}
run: |
set -euo pipefail
if [[ "$DRY_RUN" == 'true' ]]; then
echo "AUTOPILOT_MERGE_DRY_RUN=true — would run: gh pr merge $PR_NUMBER --auto --squash"
exit 0
fi
if [[ -n "$AUTOPILOT_MERGE_TOKEN" ]]; then
export GH_TOKEN="$AUTOPILOT_MERGE_TOKEN"
echo "Using AUTOPILOT_MERGE_TOKEN."
else
export GH_TOKEN="${{ github.token }}"
echo "Using GITHUB_TOKEN. Add AUTOPILOT_MERGE_TOKEN if branch protection requires a PAT."
fi
gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --auto --squash
echo "Auto-merge enabled (squash). GitHub merges when branch protection allows."

View File

@ -1,3 +1,6 @@
# 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:
@ -210,6 +213,36 @@ jobs:
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'

View File

@ -1,3 +1,5 @@
# Infra-only: native macOS screenshots for PR review. Never add to branch protection.
# Skips cleanly on headless runners (no display). Artifact quota failures are soft.
name: desktop-macos-screenshot
on:
push:
@ -24,21 +26,37 @@ concurrency:
jobs:
screenshot:
name: macOS setup screenshot
name: macOS setup screenshot (infra)
runs-on: [self-hosted, evoo-ci, macOS]
timeout-minutes: 20
steps:
- name: Detect interactive display
id: display
run: |
set -euo pipefail
if system_profiler SPDisplaysDataType 2>/dev/null | grep -q "Resolution:"; then
echo "available=true" >> "$GITHUB_OUTPUT"
else
echo "available=false" >> "$GITHUB_OUTPUT"
echo "::notice::No display attached on this macOS runner. Skipping infra screenshot; merge is not blocked."
fi
- uses: actions/checkout@v5
if: steps.display.outputs.available == 'true'
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
if: steps.display.outputs.available == 'true'
- uses: actions/setup-node@v4
if: steps.display.outputs.available == 'true'
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
if: steps.display.outputs.available == 'true'
- run: pnpm --filter @rakazo/desktop build
if: steps.display.outputs.available == 'true'
- name: Verify packaged Tahoe icon and legacy fallback
if: steps.display.outputs.available == 'true'
run: |
pnpm --filter @rakazo/web build
CSC_IDENTITY_AUTO_DISCOVERY=false pnpm --filter @rakazo/desktop exec electron-builder --mac --dir --publish never -c.mac.identity=null -c.mac.notarize=false
@ -51,6 +69,8 @@ jobs:
env:
RAKAZO_ALLOW_DEV_SECRETS: "1"
- name: Capture packaged macOS setup window and Dock
if: steps.display.outputs.available == 'true'
continue-on-error: true
env:
RAKAZO_E2E_EXECUTABLE: out/mac-arm64/Rakazo.app/Contents/MacOS/Rakazo
run: >-
@ -58,12 +78,13 @@ jobs:
--config e2e/playwright.config.ts setup.spec.ts
--grep "first run asks whether to use a local or existing instance"
- name: Upload native macOS setup screenshot
if: always()
if: always() && steps.display.outputs.available == 'true'
continue-on-error: true
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: desktop-e2e-macos-screenshot
path: |
apps/desktop/e2e/screenshots/08-setup-macos-native-controls.png
apps/desktop/e2e/screenshots/09-macos-dock.png
if-no-files-found: error
if-no-files-found: warn
retention-days: 1