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
136 changes: 133 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,144 @@ permissions:
contents: read

jobs:
# Promotion PRs (staging→main etc.) double-run the test suite: every commit
# on staging/main already gets a push-event run of the exact same test-build,
# and the pull_request "synchronize" run for the open release PR re-runs it on
# the same sha seconds later (~39 duplicate runs / 5 days measured Jul 2026).
# This gate skips the PR run's test-build ONLY when it can prove the identical
# work already passed elsewhere:
# 1. the PR base adds no file changes over the merge base with the head sha
# (compare head...base has an empty diff), so the merge result's tree is
# identical to the head tree the push run tested. Plain ancestry is not
# enough of a check here: main's merge-only ruleset leaves merge commits
# on main that staging lacks, so main...staging is permanently
# "diverged" — but those merge commits carry no tree delta. A real
# hotfix landed directly on the base makes the diff non-empty and we
# run tests here;
# 2. the push-event CI run at the same head sha finished its test jobs with
# conclusion success (polled, since push + PR runs start simultaneously).
# Fail-open by construction: any API error, timeout, missing run, or push-run
# failure leaves covered=false and the PR run tests normally, so the PR check
# is green only if tests passed either here or on the identical tree. Not a
# workflow-level filter on purpose — a job-level skip still reports a
# (successful) check context. NOTE: when test-build is skipped, its nested
# "Test and Build / ..." contexts are not created; verified 2026-07-22 that
# no required status checks are configured on main/staging (rulesets contain
# only pull_request/deletion/non_fast_forward). If required checks are ever
# added, require the caller "Test and Build" context, not the nested ones.
# dev is excluded: push runs on dev skip test-build, so dev-headed PRs have
# no push-run coverage to reuse.
dedup-promotion:
name: Dedup Promotion PR
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 15
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
contains(fromJSON('["main", "staging"]'), github.event.pull_request.head.ref)
permissions:
contents: read
actions: read
outputs:
covered: ${{ steps.probe.outputs.covered }}
steps:
- name: Probe for a passing push run at the same sha
id: probe
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
COVERED=false

# (1) Merge-tree equivalence: compare head...base diffs the merge
# base against the base tip. An empty diff means the base contributes
# nothing beyond what head already contains (merge commits only), so
# the PR merge tree equals the head tree the push run tested. Any
# error yields "unknown" and we run the tests.
BASE_DELTA="$(gh api "repos/${REPO}/compare/${HEAD_SHA}...${BASE_SHA}" --jq '.files | length' 2>/dev/null || echo unknown)"
if [ "$BASE_DELTA" != "0" ]; then
echo "Base tip changes ${BASE_DELTA} file(s) over the merge base; merge tree differs from head — running tests in this PR run."
echo "covered=false" >> "$GITHUB_OUTPUT"
exit 0
fi

# (2) Poll the push-event CI run's test jobs (they start seconds after
# this run and take ~4 min). Any conclusion other than success, or
# deadline expiry, falls through to covered=false.
DEADLINE=$((SECONDS + 600))
while [ "$SECONDS" -lt "$DEADLINE" ]; do
RUN_JSON="$(gh api "repos/${REPO}/actions/workflows/ci.yml/runs?event=push&head_sha=${HEAD_SHA}&per_page=5" 2>/dev/null || echo '')"
RUN_ID="$(printf '%s' "$RUN_JSON" | jq -r '.workflow_runs[0].id // empty' 2>/dev/null || echo '')"
if [ -n "$RUN_ID" ]; then
# Jobs from the reusable test-build workflow are prefixed
# "Test and Build /". If that name ever changes, fall back to the
# overall run conclusion (stricter, still correct).
STATE="$(gh api "repos/${REPO}/actions/runs/${RUN_ID}/jobs?per_page=100" --jq '
[.jobs[] | select(.name | startswith("Test and Build /"))] as $t |
if ($t | length) == 0 then "nojobs"
elif all($t[]; .conclusion == "success") then "success"
elif any($t[]; .conclusion != null and .conclusion != "success") then "failed"
else "pending" end' 2>/dev/null || echo pending)"
if [ "$STATE" = "nojobs" ]; then
# Nested reusable-workflow jobs appear only after the caller
# starts, so an in-progress run with no "Test and Build /"
# jobs yet just needs another poll. Once the run has
# COMPLETED without them, fail closed: the job was renamed or
# tests were skipped — never infer coverage from the overall
# run conclusion. Update the prefix here on a rename.
RUN_STATUS="$(printf '%s' "$RUN_JSON" | jq -r '.workflow_runs[0].status // "unknown"' 2>/dev/null || echo unknown)"
if [ "$RUN_STATUS" = "completed" ]; then
echo "Push run ${RUN_ID} completed with no 'Test and Build /' jobs — running tests in this PR run (update the prefix if the job was renamed)."
break
fi
STATE="pending"
fi
if [ "$STATE" = "success" ]; then
# (3) Re-verify merge-tree equivalence against the LIVE base
# tip at decision time: the base branch may have gained real
# commits during the poll, in which case the frozen BASE_SHA
# check from step (1) is stale and the skip would be unsound.
# Any error yields "unknown" and we run the tests.
LIVE_DELTA="$(gh api "repos/${REPO}/compare/${HEAD_SHA}...heads/${BASE_REF}" --jq '.files | length' 2>/dev/null || echo unknown)"
if [ "$LIVE_DELTA" = "0" ]; then
COVERED=true
echo "Push run ${RUN_ID} passed its test jobs for ${HEAD_SHA} and the live base tip still adds no file changes — skipping duplicate test-build."
else
echo "Base branch moved during the poll (live delta: ${LIVE_DELTA}) — running tests in this PR run."
fi
break
Comment thread
waleedlatif1 marked this conversation as resolved.
elif [ "$STATE" = "failed" ]; then
echo "Push run ${RUN_ID} did not pass (state: ${STATE}) — running tests in this PR run."
break
fi
fi
sleep 20
done

echo "covered=${COVERED}" >> "$GITHUB_OUTPUT"

test-build:
name: Test and Build
if: github.ref != 'refs/heads/dev' || github.event_name == 'pull_request'
needs: [dedup-promotion]
# !cancelled(): dedup-promotion is skipped on every non-promotion event and
# a skipped need would otherwise skip this job too. covered != 'true' is
# fail-open — empty (skipped/failed probe) means run the tests.
if: >-
!cancelled() &&
(github.ref != 'refs/heads/dev' || github.event_name == 'pull_request') &&
needs.dedup-promotion.outputs.covered != 'true'
uses: ./.github/workflows/test-build.yml
secrets: inherit

# Detect if this is a version release commit (e.g., "v0.5.24: ...")
# Smallest runner on purpose: a few seconds of pure shell over the commit
# message, no checkout and no install.
detect-version:
name: Detect Version
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/dev')
outputs:
Expand Down Expand Up @@ -486,9 +614,11 @@ jobs:
fi

# Check if docs changed
# Smallest runner on purpose: a depth-2 checkout plus a path filter, no
# install and no build.
check-docs-changes:
name: Check Docs Changes
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/companion-pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ on:
branches: [staging, main]
workflow_dispatch: {}

# One live run per PR: a newer opened/edited/synchronize event supersedes the
# previous run's work entirely (the sticky comment/label upsert is idempotent
# and only the latest body matters), so cancel in-flight runs instead of
# letting them race the new one. workflow_dispatch bulk scans get a unique
# group via run_id and are never cancelled. No paths filter on purpose: the
# check reads the PR body and cross-repo PR state, not changed files, so a
# paths filter would both be semantically wrong and stop status refreshes.
concurrency:
group: companion-pr-check-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
pull-requests: write
issues: write
Expand Down
27 changes: 10 additions & 17 deletions apps/sim/app/account/settings/billing/credit-usage/page.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,21 @@
/**
* @vitest-environment node
*/
import { authMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockCreditUsageLoading,
mockGetPersonalSubscription,
mockGetSession,
mockIsEnterprise,
mockRedirect,
} = vi.hoisted(() => ({
mockCreditUsageLoading: vi.fn(() => null),
mockGetPersonalSubscription: vi.fn(),
mockGetSession: vi.fn(),
mockIsEnterprise: vi.fn(),
mockRedirect: vi.fn(),
}))
const { mockCreditUsageLoading, mockGetPersonalSubscription, mockIsEnterprise, mockRedirect } =
vi.hoisted(() => ({
mockCreditUsageLoading: vi.fn(() => null),
mockGetPersonalSubscription: vi.fn(),
mockIsEnterprise: vi.fn(),
mockRedirect: vi.fn(),
}))

vi.mock('next/navigation', () => ({
redirect: mockRedirect,
}))

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
}))

vi.mock('@/lib/billing/core/plan', () => ({
getHighestPriorityPersonalSubscription: mockGetPersonalSubscription,
}))
Expand All @@ -44,6 +35,8 @@ vi.mock('@/app/workspace/[workspaceId]/settings/billing/credit-usage/loading', (

import AccountCreditUsagePage from '@/app/account/settings/billing/credit-usage/page'

const mockGetSession = authMockFns.mockGetSession

describe('AccountCreditUsagePage', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
11 changes: 3 additions & 8 deletions apps/sim/app/api/audit-logs/export/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,23 @@
/**
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockGetSession,
mockValidateEnterpriseAuditAccess,
mockBuildOrgScopeCondition,
mockGetOrgWorkspaceIds,
mockQueryAuditLogs,
mockBuildFilterConditions,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockValidateEnterpriseAuditAccess: vi.fn(),
mockBuildOrgScopeCondition: vi.fn(),
mockGetOrgWorkspaceIds: vi.fn(),
mockQueryAuditLogs: vi.fn(),
mockBuildFilterConditions: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))

vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))
Expand All @@ -38,6 +31,8 @@ vi.mock('@/app/api/v1/audit-logs/query', () => ({

import { GET } from '@/app/api/audit-logs/export/route'

const mockGetSession = authMockFns.mockGetSession

const ORG_ID = 'org-1'
const MEMBER_IDS = ['admin-1']
const SCOPE_SENTINEL = { type: 'org-scope-sentinel' }
Expand Down
12 changes: 7 additions & 5 deletions apps/sim/app/api/auth/forget-password/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
*
* @vitest-environment node
*/
import { createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest, resetEnvMock, setEnv } from '@sim/testing'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => {
const logger = {
Expand All @@ -22,9 +22,6 @@ const { mockRequestPasswordReset, mockLogger } = vi.hoisted(() => {
}
})

vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn(() => 'https://app.example.com'),
}))
vi.mock('@/lib/auth', () => ({
auth: {
api: {
Expand All @@ -43,9 +40,14 @@ import { POST } from '@/app/api/auth/forget-password/route'
describe('Forget Password API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnv({ NEXT_PUBLIC_APP_URL: 'https://app.example.com' })
mockRequestPasswordReset.mockResolvedValue(undefined)
})

afterAll(() => {
resetEnvMock()
})

afterEach(() => {
vi.clearAllMocks()
})
Expand Down
4 changes: 0 additions & 4 deletions apps/sim/app/api/auth/oauth/connections/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ vi.mock('@sim/db', () => ({
eq: mockEq,
}))

vi.mock('drizzle-orm', () => ({
eq: mockEq,
}))

vi.mock('jose', () => ({
decodeJwt: mockDecodeJwt,
}))
Expand Down
3 changes: 0 additions & 3 deletions apps/sim/app/api/auth/oauth/disconnect/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,11 @@ import {
auditMock,
authMockFns,
createMockRequest,
dbChainMock,
dbChainMockFns,
resetDbChainMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@sim/db', () => dbChainMock)

vi.mock('@sim/audit', () => auditMock)

import { POST } from '@/app/api/auth/oauth/disconnect/route'
Expand Down
4 changes: 1 addition & 3 deletions apps/sim/app/api/auth/oauth/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@
* @vitest-environment node
*/

import { redisConfigMock, redisConfigMockFns } from '@sim/testing'
import { redisConfigMockFns } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/oauth/oauth', () => ({
refreshOAuthToken: vi.fn(),
OAUTH_PROVIDERS: {},
}))

vi.mock('@/lib/core/config/redis', () => redisConfigMock)

const { mockDecryptSecret } = vi.hoisted(() => ({ mockDecryptSecret: vi.fn() }))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
Expand Down
18 changes: 13 additions & 5 deletions apps/sim/app/api/auth/oauth2/authorize/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/**
* @vitest-environment node
*/
import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createMockRequest,
dbChainMockFns,
resetDbChainMock,
resetEnvMock,
setEnv,
} from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockGetSession,
Expand All @@ -16,8 +22,6 @@ const {
mockGetCredentialActorContext: vi.fn(),
}))

vi.mock('@sim/db', () => dbChainMock)

vi.mock('@/lib/auth/auth', () => ({
auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } },
getSession: mockGetSession,
Expand Down Expand Up @@ -70,10 +74,14 @@ function oauthCredentialActor(overrides: Record<string, unknown> = {}) {
}

describe('OAuth2 authorize route', () => {
afterAll(() => {
resetEnvMock()
})

beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
setEnv({ NEXT_PUBLIC_APP_URL: BASE_URL })
mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
mockCheckWorkspaceAccess.mockResolvedValue({
hasAccess: true,
Expand Down
Loading
Loading