Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/scripts/ci-changes.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Prints which checks a pull request's changes can affect, as `<flag>=true|false` lines for
# $GITHUB_OUTPUT. A flag is `false` only when every changed file is on an allowlist whose jobs
# fully cover it. A file on no allowlist sets every flag but `desktop_live` (which keeps its own
# skip list) to `true`; a change under .github/, an empty diff, or any failure to work out the
# diff sets all of them.
#
# full integration and e2e: any file not allowlisted below
# test the Vitest shards: content, docs app, and desktop changes, plus `full`
# build the app's `next build`: content changes, plus `full`
# desktop_live the live Electron suite: any file outside its own skip list
#
# lint always runs and is not gated.
#
# Usage: ci-changes.sh <base-sha>
set -u

# Posts, author profiles, and the public assets only posts reference. The test shards read them
# (lib/content/og-image.test.ts) and `next build` prerenders every post with its MDX components,
# which check:library-content compiles but never renders. Components under content/ are code.
content='^apps/sim/content/([^/]+/)*[^/]+\.mdx?$|^apps/sim/content/authors/[^/]+\.json$|^apps/sim/public/(authors|blog|changelog|library)/'
# Its own workspace tests, its type-check and docs-manifest audit in lint, and apps/sim tests
# that read its pages (tools/netsuite/netsuite.test.ts).
docs='^apps/docs/'
# Nothing outside the Electron app imports it; the desktop bridge and protocol packages are
# consumed by apps/sim and stay unlisted.
desktop='^apps/desktop/'
# Agent guidance, read only by the lint job's audits.
guidance='^(\.agents|\.claude|\.cursor)/|^([^/]+/)*(AGENTS|CLAUDE)\.md$|^README\.md$'
# The live desktop suite drives apps/desktop against the app, so it skips more than the rest.
desktop_live_skip="$guidance"'|^(apps/docs/|apps/pii/|apps/sim/content/|apps/sim/public/(authors|blog|changelog|library)/|packages/(python-sdk|ts-sdk)/)|\.mdx?$|(^|/)LICENSE$'

emit() {
printf 'full=%s\ntest=%s\nbuild=%s\ndesktop_live=%s\n' "$1" "$2" "$3" "$4"
}
run_all() {
emit true true true true
echo "Running every check: $1" >&2
exit 0
}
matches() { printf '%s\n' "$names" | grep -qE "$1"; }
outside() { printf '%s\n' "$names" | grep -qvE "$1"; }

base=${1:-}
[ -n "$base" ] || run_all 'no base commit'
git fetch --quiet --depth=1 origin "$base" || run_all "could not fetch $base"
names=$(git diff --no-renames --name-only "$base" HEAD) || run_all "could not diff against $base"
[ -n "$names" ] || run_all 'no changed files listed'
if matches '^\.github/'; then run_all 'a CI file changed'; fi

desktop_live=false
if outside "$desktop_live_skip"; then desktop_live=true; fi
if outside "$content|$docs|$desktop|$guidance"; then
emit true true true "$desktop_live"
unlisted=$(printf '%s\n' "$names" | grep -vE "$content|$docs|$desktop|$guidance" | head -n 1)
echo "Running every check (desktop_live=$desktop_live): $unlisted is not on an allowlist" >&2
exit 0
fi

test=false build=false
if matches "$content|$docs|$desktop"; then test=true; fi
if matches "$content"; then build=true; fi
emit false "$test" "$build" "$desktop_live"
echo "Scoped checks: test=$test build=$build desktop_live=$desktop_live" >&2
39 changes: 39 additions & 0 deletions .github/scripts/ci-gate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# The `ci` job's verdict: the single required check, so it must never pass a run whose checks did
# not all pass. Reads the job's `needs` context (`toJSON(needs)`) from $NEEDS and exits non-zero,
# naming each offending job, unless every job succeeded or was skipped as `changes` intended.
#
# A skip passes only for a gated job whose `changes` flag is exactly `false`:
#
# integration, e2e full
# test test
# build build
# desktop-live desktop_live
#
# Every other skip fails: an ungated job (lint, `changes` itself), a gated job whose flag is
# `true`, missing, or anything else (a failed or skipped `changes` leaves no outputs). A failure
# or a cancellation always fails.
#
# Usage: NEEDS='<toJSON(needs)>' ci-gate.sh
set -euo pipefail

needs=${NEEDS:-}
if ! jq -e 'type == "object"' <<< "$needs" > /dev/null 2>&1; then
echo "::error::The needs context is not a JSON object: '$needs'"
exit 1
fi

failed="$(jq -r '
{"integration": "full", "e2e": "full", "test": "test", "build": "build", "desktop-live": "desktop_live"} as $gate
| (.changes.outputs // {}) as $flags
| to_entries[]
| select(.value.result != "success")
| select(.value.result != "skipped" or $gate[.key] == null or $flags[$gate[.key]] != "false")
| "\(.key): \(.value.result)"
' <<< "$needs")"
if [ -n "$failed" ]; then
echo "::error::Checks did not pass:"
echo "$failed"
exit 1
fi
echo "All checks passed."
24 changes: 0 additions & 24 deletions .github/scripts/desktop-live-changes.sh

This file was deleted.

91 changes: 50 additions & 41 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ permissions:
contents: read

jobs:
# Which checks a pull request's changes can affect (.github/scripts/ci-changes.sh). A job is
# skipped only when the script prints `false` for its flag; an unset flag, and every push or
# dispatch (the deploy chain's checks), runs it. Every gated job needs this one, so its failure
# skips them and fails `ci` below.
changes:
name: changes
runs-on: &runner-2vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
outputs:
full: ${{ github.event_name != 'pull_request' || steps.classify.outputs.full != 'false' }}
test: ${{ github.event_name != 'pull_request' || steps.classify.outputs.test != 'false' }}
build: ${{ github.event_name != 'pull_request' || steps.classify.outputs.build != 'false' }}
desktop_live: ${{ github.event_name != 'pull_request' || steps.classify.outputs.desktop_live != 'false' }}
steps:
- name: Checkout code (mirror)
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1
with:
fetch-depth: 2
- name: Checkout code
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
fetch-depth: 2
- name: Classify the pull request's changes
id: classify
if: github.event_name == 'pull_request'
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: bash .github/scripts/ci-changes.sh "$BASE" >> "$GITHUB_OUTPUT"

integration:
# Runs the real-infrastructure test layer: every `*.integration.ts` in packages/db and
# apps/sim, discovered by glob (`vitest run --mode integration`), against the database each
Expand All @@ -19,6 +50,8 @@ jobs:
# shard runs its files one at a time against its own database. Files run serially, so a shard
# barely uses more than one core: 4 vCPU is enough.
name: integration (${{ matrix.provision }}, ${{ matrix.shard }}/4)
needs: changes
if: needs.changes.outputs.full == 'true'
runs-on: &runner-4vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 20
strategy:
Expand Down Expand Up @@ -143,6 +176,8 @@ jobs:
# self-hosted suites fit on 4.
e2e:
name: e2e (${{ matrix.group }})
needs: changes
if: needs.changes.outputs.full == 'true'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.runner || 'ubuntu-latest' }}
timeout-minutes: ${{ matrix.group == 'mobile' && 40 || 20 }}
strategy:
Expand Down Expand Up @@ -217,39 +252,12 @@ jobs:
if-no-files-found: ignore
retention-days: 7

# Pull requests skip the live desktop suite only when every change is clearly unrelated to the
# app it drives (docs, other apps, published content). Anything else, and any failure to work
# out the diff, runs it: a pull request that skipped it wrongly would first fail on staging.
desktop-changes:
name: desktop-changes
runs-on: &runner-2vcpu ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
outputs:
changed: ${{ github.event_name != 'pull_request' || steps.diff.outputs.changed != 'false' }}
steps:
- name: Checkout code (mirror)
if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork
uses: useblacksmith/checkout@25227e61ff9dafe400e22fa487b673eac4e4409a # v1.8.1
with:
fetch-depth: 2
- name: Checkout code
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
fetch-depth: 2
- name: Diff against the pull request's base
id: diff
if: github.event_name == 'pull_request'
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: bash .github/scripts/desktop-live-changes.sh "$BASE" >> "$GITHUB_OUTPUT"

# Desktop tools in the real Electron app against a local app, on its own runner: the
# Electron app, the dev app and its realtime server together outgrow the e2e runner.
desktop-live:
name: desktop-live
needs: desktop-changes
if: needs.desktop-changes.outputs.changed == 'true'
needs: changes
if: needs.changes.outputs.desktop_live == 'true'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 30
services:
Expand Down Expand Up @@ -592,6 +600,8 @@ jobs:
# task hash, so a shard only ever replays its own result.
test:
name: test (${{ matrix.shard }}/2)
needs: changes
if: needs.changes.outputs.test == 'true'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
strategy:
Expand Down Expand Up @@ -660,6 +670,8 @@ jobs:
# not the native Turbopack workers that dominate, so it cannot prevent this.
build:
name: build
needs: changes
if: needs.changes.outputs.build == 'true'
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }}
# Build durations crossed 15 minutes as the app grew (10m02 on Jul 29 AM,
# 14m44 after the folders/desktop/library merges, then two straight
Expand Down Expand Up @@ -713,25 +725,22 @@ jobs:
run: bunx turbo run build --filter=@sim/app

# One status for every check above: the single check to require on a branch ruleset, so adding,
# sharding or renaming a job never means editing the ruleset. Skipped is a pass (the desktop live
# suite skips on pull requests that cannot affect it); a failure or a cancellation is not.
# sharding or renaming a job never means editing the ruleset. .github/scripts/ci-gate.sh decides:
# a skip passes only for a gated job whose `changes` flag is exactly `false`; any other skip (a
# failed or skipped `changes`, a lost output, an ungated job) fails, as does a failure or a
# cancellation. A failed checkout fails the gate too.
# `always()`, not `!cancelled()`: a skipped job satisfies a required check, so a gate that skips
# on a cancelled run would report a cancelled head commit as passing.
ci:
name: ci
needs: [integration, e2e, desktop-changes, desktop-live, lint, test, build]
needs: [changes, integration, e2e, desktop-live, lint, test, build]
if: ${{ always() }}
runs-on: *runner-2vcpu
timeout-minutes: 5
steps:
- *checkout-mirror
- *checkout-plain
- name: Require every check to pass
env:
RESULTS: ${{ toJSON(needs) }}
run: |
failed="$(jq -r 'to_entries[] | select(.value.result != "success" and .value.result != "skipped") | "\(.key): \(.value.result)"' <<< "$RESULTS")"
if [ -n "$failed" ]; then
echo "::error::Checks did not pass:"
echo "$failed"
exit 1
fi
echo "All checks passed."
NEEDS: ${{ toJSON(needs) }}
run: bash .github/scripts/ci-gate.sh
5 changes: 2 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ name: ci
on:
push:
branches: [main, staging, dev]
# No path filter: `checks / ci` is the required status check, and a pull request whose run
# never starts never reports it, so it could not merge. Docs-only pull requests are rare enough
# that running CI on them costs nothing measurable.
# No path filter: a pull request whose run never starts never reports `checks / ci`. The
# `changes` job in checks.yml scopes the jobs to what the diff can affect instead.
pull_request:
branches: [main, staging, dev]

Expand Down
Loading
Loading