Compare commits

..

2 Commits

Author SHA1 Message Date
Charles Bochet 914f286a1f Messaging cleaning fixes (#14345) 2025-09-08 00:26:07 +02:00
Charles Bochet 1d9a1c69e4 Improve Messaging Gmail experience (#14342)
In this PR, I'm solving several issues:
1) We were not checking if the currentWorkspaceMember was owning the
message in the thread. It kind of worked before because the case of
shared threads (with shared threads visibility restriction) was not
happening that often. It seems that the bug has always been there
2) Re-implement orphan messages and threads deletion on messageChannel
deletion. We used to brutally look for all orphans, we disabled it last
week because it was too heavy on db. I've re-implemented it more
carefully and "surgically"
3) Gmail sync was not handling folder synced correctly. It was
leveraging labelIds which it shouldn't do (this is a AND AND parameter)
in full sync
4) Added a command to clean orphan message threads manually if needed.
Usually this is done when you remove a messageChannel, or change
blocklist rules but it can be useful to have it to debug
2025-09-07 21:16:28 +02:00
22082 changed files with 465381 additions and 2047702 deletions
-34
View File
@@ -1,34 +0,0 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
curl \
git \
make \
build-essential \
postgresql-client \
docker.io \
&& rm -rf /var/lib/apt/lists/*
# Install nvm (project recommends nvm + .nvmrc for consistent Node versions)
ENV NVM_DIR=/usr/local/nvm
RUN mkdir -p $NVM_DIR \
&& curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
SHELL ["/bin/bash", "-c"]
# Copy .nvmrc so nvm install picks up the right version
COPY .nvmrc /tmp/.nvmrc
# Install Node.js from .nvmrc, enable Corepack, and symlink binaries
# so they're available on PATH without hardcoding a version
RUN . $NVM_DIR/nvm.sh \
&& nvm install $(cat /tmp/.nvmrc) \
&& nvm alias default $(cat /tmp/.nvmrc) \
&& corepack enable \
&& BIN_DIR=$(dirname $(nvm which default)) \
&& ln -sf $BIN_DIR/node /usr/local/bin/node \
&& ln -sf $BIN_DIR/npm /usr/local/bin/npm \
&& ln -sf $BIN_DIR/npx /usr/local/bin/npx \
&& ln -sf $BIN_DIR/corepack /usr/local/bin/corepack
+18
View File
@@ -0,0 +1,18 @@
{
"install": "yarn install && echo 'Setting up Docker Compose environment...' && cd packages/twenty-docker && cp -n docker-compose.yml docker-compose.dev.yml || true && echo 'Dependencies installed and docker-compose prepared'",
"start": "sudo service docker start && echo 'Docker service started' && cd packages/twenty-docker && echo 'Installing yq for YAML processing...' && sudo apt-get update -qq && sudo apt-get install -y wget && wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq && echo 'Patching docker-compose for local development...' && yq eval 'del(.services.server.image)' -i docker-compose.dev.yml && yq eval '.services.server.build.context = \"../../\"' -i docker-compose.dev.yml && yq eval '.services.server.build.dockerfile = \"./packages/twenty-docker/twenty/Dockerfile\"' -i docker-compose.dev.yml && yq eval 'del(.services.worker.image)' -i docker-compose.dev.yml && yq eval '.services.worker.build.context = \"../../\"' -i docker-compose.dev.yml && yq eval '.services.worker.build.dockerfile = \"./packages/twenty-docker/twenty/Dockerfile\"' -i docker-compose.dev.yml && echo 'Setting up .env file with database configuration...' && echo 'SERVER_URL=http://localhost:3000' > .env && echo 'APP_SECRET='$(openssl rand -base64 32) >> .env && echo 'PG_DATABASE_PASSWORD='$(openssl rand -hex 16) >> .env && echo 'PG_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres' >> .env && echo 'SIGN_IN_PREFILLED=true' >> .env && echo 'Building and starting services...' && docker-compose -f docker-compose.dev.yml up -d --build && echo 'Waiting for services to initialize...' && sleep 30 && echo 'Checking service health...' && docker-compose -f docker-compose.dev.yml ps && echo 'Environment setup complete!'",
"terminals": [
{
"name": "Database Setup & Seed",
"command": "sleep 40 && cd packages/twenty-docker && echo 'Waiting for PostgreSQL to be ready...' && until docker-compose -f docker-compose.dev.yml exec -T db pg_isready -U postgres; do echo 'Waiting for PostgreSQL...'; sleep 5; done && echo 'PostgreSQL is ready!' && echo 'Waiting for Twenty server to be healthy...' && until docker-compose -f docker-compose.dev.yml exec -T server curl --fail http://localhost:3000/healthz 2>/dev/null; do echo 'Waiting for server...'; sleep 5; done && echo 'Server is healthy!' && echo 'Running database setup and seeding...' && docker-compose -f docker-compose.dev.yml exec -T server npx nx database:reset twenty-server && echo 'Database seeded successfully!' && bash"
},
{
"name": "Application Logs",
"command": "sleep 35 && cd packages/twenty-docker && echo 'Following application logs...' && docker-compose -f docker-compose.dev.yml logs -f server worker"
},
{
"name": "Service Monitor",
"command": "sleep 15 && cd packages/twenty-docker && echo '=== Service Status Monitor ===' && while true; do clear; echo '=== Service Status at $(date) ===' && docker-compose -f docker-compose.dev.yml ps && echo '\\n=== Health Status ===' && (docker-compose -f docker-compose.dev.yml exec -T server curl -s http://localhost:3000/healthz 2>/dev/null && echo '✅ Twenty Server: Healthy') || echo '❌ Twenty Server: Not Ready' && (docker-compose -f docker-compose.dev.yml exec -T db pg_isready -U postgres 2>/dev/null && echo '✅ PostgreSQL: Ready') || echo '❌ PostgreSQL: Not Ready' && echo '\\n=== Database Connection Test ===' && docker-compose -f docker-compose.dev.yml exec -T server node -e \"const { Client } = require('pg'); const client = new Client({connectionString: process.env.PG_DATABASE_URL}); client.connect().then(() => {console.log('✅ Database Connection: OK'); client.end();}).catch(e => console.log('❌ Database Connection: Failed -', e.message));\" || echo 'Connection test failed' && sleep 45; done"
}
]
}
+12 -4
View File
@@ -1,10 +1,18 @@
{
"install": "yarn install",
"start": "(sudo service docker start || service docker start || true) && bash packages/twenty-utils/setup-dev-env.sh && npx nx database:reset twenty-server",
"install": "yarn install && echo 'Installing dependencies complete'",
"start": "sudo service docker start && echo 'Docker service started' && sleep 3 && echo 'Starting PostgreSQL and Redis containers...' && make postgres-on-docker && make redis-on-docker && echo 'Waiting for containers to initialize...' && sleep 20 && echo 'Checking container status...' && docker ps --filter name=twenty_ && echo 'Waiting for PostgreSQL to be ready...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'PostgreSQL not ready yet, waiting...'; sleep 3; done && echo 'PostgreSQL is ready!' && echo 'Setting up database...' && cd packages/twenty-server && npm run database:init || echo 'Database already initialized' && echo 'Environment setup complete!'",
"terminals": [
{
"name": "Development Server",
"command": "yarn start"
"command": "echo 'Waiting for database to be fully ready...' && sleep 30 && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'Starting Twenty development server...' && export SERVER_URL=http://localhost:3000 && export PG_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres && yarn start"
},
{
"name": "Database Management",
"command": "sleep 25 && echo 'Database management terminal ready' && echo 'Waiting for PostgreSQL to be available...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'PostgreSQL is ready for database operations!' && echo 'You can now run database commands like:' && echo ' npx nx database:reset twenty-server' && echo ' npx nx database:migrate twenty-server' && bash"
},
{
"name": "Container Logs & Status",
"command": "sleep 10 && echo '=== Container Status Monitor ===' && while true; do echo '\\n=== Container Status at $(date) ===' && docker ps --filter name=twenty_ --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}' && echo '\\n=== PostgreSQL Status ===' && (docker exec twenty_pg pg_isready -U postgres -h localhost && echo 'PostgreSQL: ✅ Ready') || echo 'PostgreSQL: ❌ Not Ready' && echo '\\n=== Redis Status ===' && (docker exec twenty_redis redis-cli ping && echo 'Redis: ✅ Ready') || echo 'Redis: ❌ Not Ready' && sleep 30; done"
}
]
}
}
+10 -14
View File
@@ -12,8 +12,6 @@ This directory contains Twenty's development guidelines and best practices in th
### Core Guidelines
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
- **server-migrations.mdc** - Upgrade command guidelines (instance commands and workspace commands) for `twenty-server` (Auto-attached to server entities and upgrade command files)
- **creating-syncable-entity.mdc** - Comprehensive guide for creating new syncable entities (with universalIdentifier and applicationId) in the workspace migration system (Agent-requested for metadata-modules and workspace-migration files)
### Code Quality
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
@@ -22,7 +20,7 @@ This directory contains Twenty's development guidelines and best practices in th
### React Development
- **react-general-guidelines.mdc** - Core React development principles (Auto-attached to React files)
- **react-state-management.mdc** - State management approaches with Jotai (Auto-attached to state files)
- **react-state-management.mdc** - State management approaches with Recoil (Auto-attached to state files)
### Testing & Quality
- **testing-guidelines.mdc** - Testing strategies and best practices (Auto-attached to test files)
@@ -40,10 +38,9 @@ You can manually reference any rule using the `@ruleName` syntax:
- `@nx-rules` - Include Nx-specific guidance
- `@react-general-guidelines` - Load React best practices
- `@testing-guidelines` - Get testing recommendations
- `@creating-syncable-entity` - Guide for creating new syncable entities
### Rule Types Used
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
- **Auto Attached** - Loaded when matching file patterns are referenced
- **Agent Requested** - Available for AI to include when relevant
- **Manual** - Only included when explicitly mentioned
@@ -55,12 +52,10 @@ You can manually reference any rule using the `@ruleName` syntax:
# Testing
npx nx test twenty-front # Run unit tests
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:test # Run Storybook tests
npx nx storybook:serve-and-test:static # Run Storybook tests
# Development
npx nx lint:diff-with-main twenty-front # Lint files changed vs main (fastest)
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
npx nx lint twenty-front # Lint all files (slower)
npx nx lint twenty-front # Run linter
npx nx typecheck twenty-front # Type checking
npx nx run twenty-front:graphql:generate # Generate GraphQL types
```
@@ -74,15 +69,16 @@ npx nx run twenty-server:database:migrate:prod # Run migrations
# Development
npx nx run twenty-server:start # Start the server
npx nx lint:diff-with-main twenty-server # Lint files changed vs main (fastest)
npx nx lint:diff-with-main twenty-server --configuration=fix # Auto-fix changed files
npx nx run twenty-server:lint # Lint all files (slower)
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
npx nx run twenty-server:typecheck # Type checking
npx nx run twenty-server:test # Run unit tests
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
# Upgrade commands (instance + workspace)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
# Migrations
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
# Workspace
npx nx run twenty-server:command workspace:sync-metadata -f # Sync metadata
```
## Usage Guidelines
+1 -1
View File
@@ -7,7 +7,7 @@ alwaysApply: true
# Twenty Architecture
## Tech Stack
- **Frontend**: React 18, TypeScript, Jotai, Styled Components, Vite
- **Frontend**: React 18, TypeScript, Recoil, Styled Components, Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL
- **Monorepo**: Nx workspace with yarn
-326
View File
@@ -1,326 +0,0 @@
---
description: Process and guidelines for creating release changelogs for Twenty CRM
globs: ["**/releases/*.mdx", "**/releases/**"]
alwaysApply: false
---
# Twenty Release Changelog Process
Complete guide for creating release changelogs, including codebase research, file structure, and content guidelines.
## Prerequisites
Before starting, gather the following information:
### Required Information
**Version Number**: `{VERSION}` (e.g., 1.9.0, 2.0.0, 2.1.0)
**Release Date**: Use today's date in format: YYYY-MM-DD
### Changes/Features to Document
List the features and changes to include in this release:
1. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area (workflow, UI, backend, etc.): ______________________________
2. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area: ______________________________
3. **Feature Name**: ______________________________
- Brief description: ______________________________
- Related area: ______________________________
## Codebase Research Guide
If feature descriptions are not provided or need enhancement, research the codebase:
### Where to Look
**For Workflow Features:**
- Frontend: `packages/twenty-front/src/modules/workflow/`
- Backend: `packages/twenty-server/src/modules/workflow/`
- Components: `packages/twenty-front/src/modules/workflow/components/`
**For UI/UX Changes:**
- Components: `packages/twenty-front/src/modules/ui/`
- Layout: `packages/twenty-front/src/modules/layout/`
- Design system: `packages/twenty-ui/src/`
**For Backend/API Features:**
- Server modules: `packages/twenty-server/src/modules/`
- Entities: `packages/twenty-server/src/entities/`
- Services: Look for `*.service.ts` files
**For Database/ORM Changes:**
- Instance commands (fast/slow): `packages/twenty-server/src/database/commands/upgrade-version-command/`
- Legacy TypeORM migrations: `packages/twenty-server/src/database/typeorm/`
- Entities: `packages/twenty-server/src/entities/`
### Research Commands
```bash
# Find recent merged PRs (adjust date as needed)
gh pr list --search "merged:>2025-10-01" --limit 50 --state merged
# View recent commits
git log --since="2 weeks ago" --oneline --no-merges
# View commits between releases (replace with actual release tags)
git log v1.7.0..v1.8.0 --oneline
# Search for specific feature keywords in code
grep -r "iterator" packages/twenty-front/src/modules/workflow/
grep -r "bulk select" packages/twenty-front/src/modules/workflow/
# Find recent changes in specific directory
git log --since="2 weeks ago" --oneline -- packages/twenty-front/src/modules/workflow/
```
### Using Codebase Search
Use the AI codebase search to find:
- "How does the workflow iterator node work?"
- "Where is bulk select implemented for workflows?"
- "What changes were made to the search node limit?"
## Step-by-Step Process
### 1. Setup Git Branch
**IMPORTANT**: Always start from an up-to-date main branch to avoid merge conflicts and ensure the changelog is based on the latest code.
```bash
cd /Users/thomascolasdesfrancs/code/twenty
git checkout main
git pull origin main
git checkout -b {VERSION}
```
Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
⚠️ **Do this first** before making any file changes. This ensures your branch is based on the latest main.
### 2. Create File Structure
**Create changelog file:**
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
**Create image folder:**
- Path: `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
```bash
# Create the image folder
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
```
### 3. Move Illustration Files
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
**Naming Convention:** `{VERSION}-descriptive-name.webp`
Examples:
- `1.9.0-feature-name.webp`
- `1.9.0-another-feature.webp`
```bash
# Move and rename source files, then convert to webp if needed
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
```
### 4. Research Features (if needed)
If descriptions are not provided:
1. Use the research commands above to find recent PRs and commits
2. Search the codebase for feature-related code
3. Read PR descriptions for context
4. Check component comments and documentation
### 5. Write Changelog Content
Create the MDX file with this structure:
```markdown
---
release: {VERSION}
Date: {YYYY-MM-DD}
---
# Feature 1 Name
Short description explaining what the feature does and why it's useful. Keep it user-focused and concise (1-2 sentences).
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp)
# Feature 2 Name
Another short description of the second feature.
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp)
# Feature 3 Name
Description of the third feature.
![](/images/releases/{MINOR_VERSION}/{VERSION}-feature-3.webp)
```
**Style Guidelines:**
- Use H1 (`#`) for feature names
- Keep descriptions to 1-2 sentences
- Focus on user benefits, not technical implementation
- Use active voice
- Start with what the user can now do
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
**Reference Previous Changelogs:**
- Check `packages/twenty-website-new/src/content/releases/` for examples
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
### 6. Review
Open the changelog file for review:
```bash
# Open in Cursor
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
# Open image folder to verify illustrations
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
```
Review checklist:
- [ ] Version number is correct in frontmatter
- [ ] Date is today's date
- [ ] All features are documented
- [ ] Image paths are correct
- [ ] Image files exist in the folder
- [ ] Descriptions are clear and user-focused
- [ ] Spelling and grammar are correct
### 7. Present Changelog for User Approval
**IMPORTANT**: Before committing and creating the PR, always show the complete changelog content to the user and wait for explicit approval.
**What to show:**
1. Display the full MDX content of the changelog file
2. Confirm that illustration files were moved to the correct location
3. List the image file names and paths
**What to say:**
```
I've created the changelog for version {VERSION}. Here's the content for your review:
[Show full MDX content]
Images moved to:
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
Please review the content. Once you approve, I'll commit the changes and create the pull request.
```
**Wait for user approval before proceeding to step 8.**
Possible user responses:
- "Looks good" / "Approve" / "Create the PR" → Proceed to step 8
- Requests changes → Make the requested edits, then show content again
- Asks questions → Answer them, then wait for approval
### 8. Commit Changes
```bash
# Check status
git status
# Add files
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
# Commit
git commit -m "Add {VERSION} release changelog"
# Push branch
git push -u origin {VERSION}
```
### 9. Create Pull Request
```bash
# Create PR using GitHub CLI
gh pr create \
--title "Release {VERSION}" \
--body "## Release {VERSION}
This release includes:
- Feature 1
- Feature 2
- Feature 3
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
Release date: {DATE}" \
--base main \
--head {VERSION}
```
Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
## File Naming Conventions
### Changelog Files
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
- **Convention**: One file per complete version
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
- **Location**: `packages/twenty-website-new/src/content/releases/`
### Image Folders
- **Format**: `{MAJOR}.{MINOR}/`
- **Convention**: One folder per minor version (shared across patches)
- **Examples**: `1.6/`, `1.7/`, `2.0/`
- **Location**: `packages/twenty-website-new/public/images/releases/`
### Image Files
- **Format**: `{VERSION}-descriptive-name.webp`
- **Convention**: Kebab-case descriptive names
- **Examples**:
- `1.8.0-workflow-iterator.webp`
- `1.8.0-bulk-select.webp`
- `1.9.0-new-feature.webp`
## Quick Reference Template
Copy and fill this for each release:
```
VERSION: ___________
DATE: ___________
MINOR_VERSION: ___________
Features to document:
1. ___________________________
2. ___________________________
3. ___________________________
Branch name: {VERSION}
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
```
## Tips
- **Start early**: Begin documenting features as they're developed
- **User perspective**: Write for users, not developers
- **Be concise**: 1-2 sentences per feature
- **Visual first**: Illustrations should showcase the feature clearly
- **Consistent style**: Match tone and structure of previous changelogs
- **Test links**: Verify all image paths work before committing
- **Research thoroughly**: Use codebase search to understand features deeply
+9 -78
View File
@@ -8,7 +8,7 @@ alwaysApply: true
## Formatting Standards
- **Prettier**: 2-space indentation, single quotes, trailing commas, semicolons
- **Print width**: 80 characters
- **Oxlint**: No unused imports, consistent import ordering, prefer const over let
- **ESLint**: No unused imports, consistent import ordering, prefer const over let
## Naming Conventions
```typescript
@@ -30,18 +30,6 @@ type ButtonProps = {}; // Component props suffix with 'Props'
// ✅ Files and directories - kebab-case
// user-profile.component.tsx
// user-profile.styles.ts
// ❌ NEVER use abbreviations in variable names
// Bad
const users = data.map((u) => u.name);
const field = items.find((f) => f.id === id);
// Good
const users = data.map((user) => user.name);
const field = items.find((item) => item.id === id);
const fieldMetadata = inlineFields.find(
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
);
```
## Import Organization
@@ -71,89 +59,32 @@ const processUserData = (
): ProcessedUser => {
const processedUser = transformUserData(user);
applyOptions(processedUser, options);
if (callback) {
callback(processedUser);
}
return processedUser;
};
```
## Comments
```typescript
// ✅ Use short-form comments, NOT JSDoc blocks
// ✅ Explain business logic and non-obvious intentions (WHY, not WHAT)
// ✅ Explain business logic and non-obvious intentions
// Apply 15% discount for premium users with orders > $100
const discount = isPremiumUser && orderTotal > 100 ? 0.15 : 0;
// TODO: Replace with proper authentication service
const isAuthenticated = localStorage.getItem('token') !== null;
// ✅ Multi-line comments use multiple // lines (NOT /** */ blocks)
// Calculates the total price after applying tax and discount
// Returns the final price that should be charged to the customer
/**
* JSDoc for public APIs
* @param basePrice - The base price before modifications
* @returns The final price after tax and discount
*/
const calculateTotalPrice = (basePrice: number): number => {
// Implementation
};
// ❌ AVOID obvious comments that just describe what code does
// Bad: Get all inline fields dynamically
const { inlineFieldMetadataItems } = useFieldListFieldMetadataItems({...});
// Bad: Define standard fields in display order
const standardFieldOrder = ['startsAt', 'endsAt', 'conferenceLink'];
// Bad: Split fields into standard and custom
const standardFields = standardFieldOrder.map(...)
// ✅ GOOD: Only comment if explaining non-obvious business logic
// Calendar events display standard fields first, then custom fields after participants
// to maintain consistency with the legacy UI behavior
const standardFields = standardFieldOrder.map(...)
// ❌ AVOID JSDoc blocks - use short comments instead
/**
* This style is NOT preferred in this codebase
*/
```
**Comment Guidelines:**
- **DO** comment complex business rules or domain-specific logic
- **DO** comment non-obvious algorithmic decisions
- **DO** add TODOs for future improvements
- **DON'T** comment obvious variable declarations or function calls
- **DON'T** comment what is already clear from well-named variables/functions
- **DON'T** add comments that just repeat what the code says
## Utility Helpers
```typescript
// ✅ Use existing utility helpers instead of manual checks
import { isDefined } from 'twenty-shared/utils';
import { isNonEmptyString, isNonEmptyArray } from '@sniptt/guards';
// ❌ Manual type guards
const validItems = items.filter((item): item is Item => item !== undefined);
const hasValue = value !== null && value !== undefined;
// ✅ Use utility helpers
const validItems = items.filter(isDefined);
const hasValue = isDefined(value);
// Other useful helpers:
// - isDefined(value) - checks !== null && !== undefined
// - isNonEmptyString(value) - checks string is defined and not empty
// - isNonEmptyArray(value) - checks array is defined and has items
```
## Security Patterns
```typescript
// ✅ CSV Export: Always apply security first, then formatting
const safeValue = formatValueForCSV(sanitizeValueForCSVExport(userInput));
// ✅ Input validation before processing
const sanitizedInput = validateAndSanitize(userInput);
const result = processData(sanitizedInput);
```
## Error Handling
-219
View File
@@ -1,219 +0,0 @@
---
description: Main guide for creating syncable entities in Twenty's workspace migration system
globs: ["**/metadata-modules/**", "**/workspace-migration/**"]
alwaysApply: false
---
# Creating a New Syncable Entity - Main Guide
This is the main guide for creating **syncable entities** in Twenty's workspace migration architecture.
## Documentation Structure
This main guide provides a high-level overview and navigation hub.
**⚡ Skills** (`.cursor/skills/syncable-entity-*/SKILL.md`) - Concise, action-oriented implementation guides for each step. Reference these when creating a new syncable entity.
**When to use:**
- Start here for architecture overview and workflow
- Reference specific skills (`@syncable-entity-types-and-constants`) when implementing each step
## What is a Syncable Entity?
A syncable entity is a metadata entity that:
- Has a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications
- Has an **`applicationId`**: Links the entity to an application (Standard or Custom applications)
- Participates in the **workspace migration system**: Can be created, updated, and deleted through the migration pipeline
- Is **cached as a flat entity**: Denormalized representation for efficient validation and change detection
Examples: `skill`, `agent`, `view`, `viewField`, `role`, `pageLayout`, etc.
## Architecture Overview
```
Input DTO → Transform → Universal Flat Entity → Builder/Validator → Runner → Database
Cache Service
```
**Key Components:**
- **TypeORM Entity**: Database model extending `SyncableEntity`
- **Flat Entity**: Denormalized type (no relations, dates as strings) - for caching
- **Universal Flat Entity**: Flat entity with foreign keys mapped to universal identifiers - for migrations
- **Transform Utils**: Convert DTOs to universal flat entities
- **Builder/Validator**: Validate and create migration actions
- **Runner**: Execute actions against the database
## Implementation Steps
Follow these skills in order:
### 1️⃣ **Foundation: Types & Constants** → `@syncable-entity-types-and-constants`
**What:** Define all types, entities, and register in central constants
**Tasks:**
- Create TypeORM entity (extends `SyncableEntity`)
- Define flat entity types
- Define action types (universal + flat)
- Register in 5 central constants
**Why first:** Everything else depends on these types
---
### 2️⃣ **Data Layer: Cache & Transform** → `@syncable-entity-cache-and-transform`
**What:** Handle conversion between different representations
**Tasks:**
- Create cache service
- Create entity-to-flat conversion
- Create input transform utils
- Handle foreign key resolution
**Dependencies:** Requires Step 1
---
### 3️⃣ **Business Logic: Builder & Validation** → `@syncable-entity-builder-and-validation`
**What:** Validate business rules and create actions
**Tasks:**
- Create validator service (never throws, never mutates)
- Create builder service
- Wire into orchestrator (⚠️ critical!)
**Dependencies:** Requires Steps 1-2
---
### 4️⃣ **Execution: Runner & Actions** → `@syncable-entity-runner-and-actions`
**What:** Execute migration actions against the database
**Tasks:**
- Create action handlers (create/update/delete)
- Implement transpilation methods
- Create universal-to-flat conversion utilities
**Dependencies:** Requires Steps 1-3
---
### 5️⃣ **Assembly: Integration** → `@syncable-entity-integration`
**What:** Wire everything together
**Tasks:**
- Register in 3 NestJS modules
- Create service and resolver layers
- Use exception interceptor
**Dependencies:** Requires Steps 1-4
---
### 6️⃣ **Testing: Integration Tests** (**MANDATORY**) → `@syncable-entity-testing`
**What:** Comprehensive test suite
**Tasks:**
- Create test utilities
- Write failing tests (all validator exceptions)
- Write successful tests (all CRUD operations)
- Use snapshot testing
**Dependencies:** Requires all previous steps
---
## Quick Reference
### Multi-Agent Workflow
For parallel development:
1. **Agent 1** (Foundation): Complete Step 1 first - unblocks everyone
2. **Agent 2** (Cache): Can start immediately after Step 1
3. **Agent 3** (Builder): Can work in parallel with Agent 4 after Step 1
4. **Agent 4** (Runner): Can work in parallel with Agent 3 after Step 1
5. **Agent 5** (Integration): Assembles everything after Steps 2-4
### Key Design Principles
| Layer | Responsibility | Can Throw? | Can Mutate? |
|-------|---------------|------------|-------------|
| Transform Utils | Data transformation | Yes (input validation) | N/A (creates new) |
| Validator | Business rule validation | **No** (returns errors) | **No** |
| Builder | Action creation | **No** (returns errors) | **No** |
| Runner | Database operations | Yes (DB errors) | Yes (via TypeORM) |
### Common Pitfalls
⚠️ **Most Commonly Forgotten:**
1. Wiring builder in orchestrator service
2. Registering in all 3 modules (builder, validators, action handlers)
3. Setting `universalIdentifier` correctly in entity-to-flat conversion
⚠️ **Common Mistakes:**
1. Using regular IDs instead of universal identifiers in transform utils
2. Throwing exceptions in validators/builders
3. Mutating entity maps in validators/builders
4. Forgetting to handle JSONB properties with `SerializedRelation`
### File Locations
```
packages/twenty-shared/src/metadata/
└── all-metadata-name.constant.ts
packages/twenty-server/src/engine/metadata-modules/
├── my-entity/ # Step 1
│ └── entities/
├── flat-my-entity/ # Steps 1-2
│ ├── types/
│ ├── constants/
│ ├── services/
│ └── utils/
└── flat-entity/constant/ # Step 1 (central registries)
├── all-entity-properties-configuration-by-metadata-name.constant.ts
├── all-one-to-many-metadata-relations.constant.ts
├── all-many-to-one-metadata-foreign-key.constant.ts
└── all-many-to-one-metadata-relations.constant.ts
packages/twenty-server/src/engine/workspace-manager/workspace-migration/
├── workspace-migration-builder/ # Step 3
│ ├── builders/my-entity/
│ └── validators/services/
└── workspace-migration-runner/ # Step 4
└── action-handlers/my-entity/
```
### Complete Checklist
Before considering complete:
- [ ] All 6 guides completed
- [ ] TypeORM entity extends `SyncableEntity`
- [ ] All constants registered (5 central registries)
- [ ] Cache service with correct decorator
- [ ] Transform utils return universal flat entities
- [ ] Validator never throws/mutates
- [ ] Builder wired in orchestrator (⚠️ critical!)
- [ ] All 3 action handlers implemented
- [ ] All 3 modules updated
- [ ] **Integration tests written (MANDATORY)**
- [ ] **All failing scenarios covered**
- [ ] **All successful use cases tested**
---
## Need Help?
Reference the appropriate skill for step-by-step guidance:
- `@syncable-entity-types-and-constants` - Types, entities, constants
- `@syncable-entity-cache-and-transform` - Cache & transform
- `@syncable-entity-builder-and-validation` - Builder & validation
- `@syncable-entity-runner-and-actions` - Runner & actions
- `@syncable-entity-integration` - Integration & wiring
- `@syncable-entity-testing` - Testing patterns
-100
View File
@@ -1,100 +0,0 @@
---
description: Guidelines for incorporating user feedback and improving cursor rules
globs: []
alwaysApply: true
---
# Feedback Incorporation Guidelines
## Post-Interaction Reflection
After each coding session or significant interaction, the AI should:
### 1. Reflect on User Feedback
- **Identify patterns** in user corrections or suggestions
- **Note recurring issues** that could be prevented with better rules
- **Recognize gaps** in current cursor rules or guidelines
### 2. Suggest Rule Improvements
When user provides feedback that reveals a pattern or preference:
```typescript
// Example feedback patterns to watch for:
// - "We don't use useEffect, handle state changes in event callbacks"
// - "We don't use JSDoc blocks, prefer // comments"
// - "Always use named exports, never default exports"
// - "We prefer functional components over class components"
// - "Use event handlers over useEffect for state updates"
```
### 3. Proactive Rule Suggestions
At the end of interactions, suggest:
```markdown
## 💡 Suggested Cursor Rule Updates
Based on your feedback today, I recommend adding/updating these rules:
**Code Style Rule Update:**
- Add preference for // comments over JSDoc blocks
- Enforce named exports only (no default exports)
**React Guidelines Update:**
- Document preference for event handlers over useEffect
- Add functional components only rule
Would you like me to help incorporate these into your cursor rules?
```
## Implementation Process
### When to Suggest Updates
- User corrects the same type of mistake multiple times
- User explains a codebase-specific preference
- User points out missing functionality or incomplete implementations
- User provides context about existing patterns not captured in rules
### How to Present Suggestions
1. **Summarize the pattern** observed from feedback
2. **Propose specific rule language** that would prevent the issue
3. **Explain the benefit** of codifying this knowledge
4. **Ask for confirmation** before implementing
### Rule Categories to Consider
- **Code Style**: Formatting, naming, comment styles, export patterns
- **React Patterns**: Hook usage, component structure, state management
- **Architecture**: File organization, import patterns, component composition
- **Testing**: Test structure, naming, coverage expectations
- **Performance**: Optimization patterns, anti-patterns to avoid
## Example Feedback Integration
```markdown
## Today's Learning: React State Management Patterns
**User Feedback Received:**
- "We don't use useEffect, handle state changes in event callbacks"
- "We don't use JSDoc blocks, prefer // comments"
- "Always use named exports, never default exports"
**Proposed Rule Additions:**
```typescript
// ✅ React State Updates - Use event handlers, not useEffect
const handleButtonClick = () => {
setData(newData); // Direct state update in event handler
};
// ❌ Avoid useEffect for state updates
// useEffect(() => { setData(newData); }, [trigger]);
// ✅ Named exports only
export const UserComponent = () => {};
export const useUserData = () => {};
```
**Benefits:**
- Prevents useEffect overuse and related bugs
- Ensures consistent export patterns across codebase
- Documents preferred React patterns for the team
```
This approach helps the AI learn from each interaction and continuously improve the development experience.
+2 -13
View File
@@ -12,12 +12,7 @@ alwaysApply: true
npx nx run twenty-front:build
npx nx run twenty-server:test
# Lint diff with main (recommended - much faster!)
npx nx lint:diff-with-main twenty-front # Lint only files changed vs main
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix changed files
# Run target for all projects (slower)
# Run target for all projects
npx nx run-many --target=build --all
npx nx run-many --target=test --projects=twenty-front,twenty-server
@@ -48,18 +43,12 @@ npx nx g @nx/react:component my-component
}
```
## Linting Strategy
For faster development, always prefer linting only changed files:
- Use `npx nx lint:diff-with-main <project>` to lint only files changed vs main branch
- Use `--configuration=fix` to auto-fix issues in changed files
- Only use `npx nx lint <project>` when you need to lint the entire project
## Dependency Graph
```bash
# View project dependencies
npx nx graph
# Check what's affected by changes (runs target on affected projects)
# Check what's affected by changes
npx nx affected --target=test
npx nx affected --target=build --base=main
```
+12 -33
View File
@@ -4,20 +4,16 @@ alwaysApply: false
---
# React State Management
## Jotai Patterns
## Recoil Patterns
```typescript
// ✅ Atoms for primitive state (use createAtomState for keyed state with optional persistence)
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const currentUserState = createAtomState<User | null>({
// ✅ Atoms for primitive state
export const currentUserState = atom<User | null>({
key: 'currentUserState',
defaultValue: null,
default: null,
});
// ✅ Derived atoms for computed state (use createAtomSelector)
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const userDisplayNameSelector = createAtomSelector({
// ✅ Selectors for derived state
export const userDisplayNameSelector = selector({
key: 'userDisplayNameSelector',
get: ({ get }) => {
const user = get(currentUserState);
@@ -25,30 +21,13 @@ export const userDisplayNameSelector = createAtomSelector({
},
});
// ✅ Atom factory pattern for dynamic atoms (use createAtomFamilyState)
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export const userByIdState = createAtomFamilyState<User | null, string>({
// ✅ Atom families for dynamic atoms
export const userByIdState = atomFamily<User | null, string>({
key: 'userByIdState',
defaultValue: null,
default: null,
});
```
## Jotai Hooks
```typescript
// useAtomState - read and write
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
// useAtomStateValue - read only
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
// useSetAtomState - write only
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
```
## Provider
Jotai works without a Provider by default. For scoped stores or testing, use `Provider` from `jotai`.
## Local State Guidelines
```typescript
// ✅ Multiple useState for unrelated state
@@ -95,7 +74,7 @@ const increment = useCallback(() => {
```
## Performance Tips
- Use atom factory pattern (createAtomFamilyState) for dynamic data collections
- Derived atoms (createAtomSelector) are automatically memoized by Jotai
- Avoid heavy computations in derived atoms
- Use atom families for dynamic data collections
- Implement proper selector caching
- Avoid heavy computations in selectors
- Batch state updates when possible
-53
View File
@@ -1,53 +0,0 @@
---
description: ESM dependency guidelines for twenty-sdk and create-twenty-app packages
globs: ["packages/twenty-sdk/**", "packages/create-twenty-app/**"]
alwaysApply: false
---
# ESM Dependency Guidelines
## Context
`twenty-sdk` and `create-twenty-app` are published as dual-format npm packages (ESM `.mjs` + CJS `.cjs`). Dependencies listed in `dependencies` are **externalized** by the Vite/Rollup build — they are not bundled, and consumers resolve them from `node_modules` at runtime.
This means **CJS-only dependencies break the ESM output**. When Rollup emits `import { foo } from 'cjs-package'`, Node.js ESM cannot resolve named exports from CommonJS modules, causing `SyntaxError: Named export 'foo' not found`.
## Rules
### Only add ESM-compatible dependencies
Before adding a new dependency to `package.json`, verify it supports ESM:
- Check for `"type": "module"` in its `package.json`
- Or check for an `"exports"` map with ESM entries
- Or check for a `"module"` field pointing to an ESM build
### Use native `node:fs/promises` for standard fs operations
```typescript
// ✅ Import native fs functions directly
import { readFile, writeFile, mkdir, rm, cp } from 'node:fs/promises';
import { createWriteStream, existsSync } from 'node:fs';
// ✅ Import only custom helpers from fs-utils (no native re-exports)
import { pathExists, ensureDir, emptyDir, copy, move, remove, readJson, writeJson, ensureFile } from '@/cli/utilities/file/fs-utils';
// ❌ Don't use fs-extra (CJS-only, breaks ESM bundle)
import * as fs from 'fs-extra';
// ❌ Don't use import * as fs from fs-utils (it doesn't re-export native fs)
import * as fs from '@/cli/utilities/file/fs-utils';
```
### Use `@/cli/utilities/string/kebab-case` instead of lodash
```typescript
// ✅ Use internal utility
import { kebabCase } from '@/cli/utilities/string/kebab-case';
// ❌ Don't use lodash single-function packages (CJS-only, unmaintained)
import kebabCase from 'lodash.kebabcase';
```
### When no ESM alternative exists
If a CJS-only package has no ESM replacement (e.g. `archiver`), add it to the `cjsOnlyPackages` list in `vite.config.node.ts` so it gets inlined into the bundle instead of externalized.
-46
View File
@@ -1,46 +0,0 @@
---
description: Guidelines for generating and managing upgrade commands (instance commands and workspace commands) in twenty-server
globs: [
"packages/twenty-server/src/**/*.entity.ts",
"packages/twenty-server/src/database/commands/upgrade-version-command/**/*.ts"
]
alwaysApply: false
---
## Upgrade Commands (twenty-server)
The upgrade system uses two types of commands instead of raw TypeORM migrations:
- **Instance commands** — schema and data migrations that run once at the instance level.
- **Workspace commands** — commands that iterate over all active/suspended workspaces.
See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation.
### Instance Commands
- **When changing a `*.entity.ts` file**, generate an instance command:
```bash
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
```
- **Fast commands** (`--type fast`, default) are for schema-only changes that must run immediately. They implement `FastInstanceCommand` with `up`/`down` methods and use the `@RegisteredInstanceCommand` decorator.
- **Slow commands** (`--type slow`) add a `runDataMigration` method for potentially long-running data backfills that execute before `up`. They only run when `--include-slow` is passed. Use the decorator with `{ type: 'slow' }`.
- The generator auto-registers the command in `instance-commands.constant.ts` — do not edit that file manually.
- **Keep commands consistent and reversible**: include both `up` and `down` logic. Do not delete or rewrite existing, committed commands unless on a pre-release branch.
### Workspace Commands
- Use the `@RegisteredWorkspaceCommand` decorator alongside nest-commander's `@Command` decorator.
- Extend `ActiveOrSuspendedWorkspaceCommandRunner` and implement `runOnWorkspace`.
- The base class provides `--dry-run`, `--verbose`, and workspace filter options automatically.
### Execution Order
Within a given version, commands run in this order (timestamp-sorted within each group):
1. Instance fast commands
2. Instance slow commands (only with `--include-slow`)
3. Workspace commands
-39
View File
@@ -69,45 +69,6 @@ beforeEach(() => {
- **Keep tests isolated** - Independent and repeatable
- **70% unit, 20% integration, 10% E2E** - Test pyramid
## Running Tests
### Single Test File Execution
```bash
# ✅ Run a specific test file (PREFERRED - Fast & Efficient)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Key Benefits:
# - Only runs the specific test file (fast)
# - No dependency resolution overhead
# - Immediate feedback for test development
# ✅ Examples:
# Frontend tests (use .test.ts extension)
npx jest packages/twenty-front/src/modules/localization/utils/detection/detectNumberFormat.test.ts --config=packages/twenty-front/jest.config.mjs
# Server tests (use .spec.ts extension)
npx jest packages/twenty-server/src/utils/__test__/is-work-email.spec.ts --config=packages/twenty-server/jest.config.mjs
# ❌ AVOID - This runs ALL tests (slow):
npx nx test twenty-front --testPathPattern=detectNumberFormat.test.ts
# ✅ Run tests in watch mode for development:
npx jest path/to/test.test.ts --config=packages/twenty-front/jest.config.mjs --watch
# ✅ Run with coverage for single file:
npx jest path/to/test.test.ts --config=packages/twenty-front/jest.config.mjs --coverage
```
### Test Suite Execution
```bash
# Run all tests for a project (use sparingly)
npx nx test twenty-front
npx nx test twenty-server
# Run tests matching a pattern
npx jest --testNamePattern="UserService" --config=packages/twenty-front/jest.config.mjs
```
## Common Patterns
```typescript
// Async testing
@@ -1,393 +0,0 @@
---
name: syncable-entity-builder-and-validation
description: Create validation logic and migration action builders for syncable entities in Twenty. Use when implementing business rule validation, uniqueness checks, foreign key validation, or building workspace migration actions for syncable entities. Validators never throw and never mutate.
---
# Syncable Entity: Builder & Validation (Step 3/6)
**Purpose**: Implement business rule validation and create migration action builders.
**When to use**: After completing Steps 1-2 (Types, Cache, Transform). Required before implementing action handlers.
---
## Quick Start
This step creates:
1. Validator service (business logic validation)
2. Builder service (action creation)
3. Orchestrator wiring (**CRITICAL** - often forgotten!)
**Key principles**:
- Validators **never throw** - return error arrays
- Validators **never mutate** - pass optimistic entity maps
- Use indexed lookups (O(1)) not `Object.values().find()` (O(n))
---
## Step 1: Create Validator Service
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { t, msg } from '@lingui/macro';
import { isDefined } from 'twenty-shared/utils';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type';
import { WorkspaceMigrationValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/types/workspace-migration-validation-error.type';
import { MyEntityExceptionCode } from 'src/engine/metadata-modules/my-entity/exceptions/my-entity-exception-code.enum';
@Injectable()
export class FlatMyEntityValidatorService {
validateMyEntityForCreate(
flatMyEntity: FlatMyEntity,
optimisticFlatMyEntityMaps: FlatMyEntityMaps,
): WorkspaceMigrationValidationError[] {
const errors: WorkspaceMigrationValidationError[] = [];
// Pattern 1: Required field validation
if (!isDefined(flatMyEntity.name) || flatMyEntity.name.trim() === '') {
errors.push({
code: MyEntityExceptionCode.NAME_REQUIRED,
message: t`Name is required`,
userFriendlyMessage: msg`Please provide a name for this entity`,
});
}
// Pattern 2: Uniqueness check - use indexed map (O(1))
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[flatMyEntity.name];
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
errors.push({
code: MyEntityExceptionCode.MY_ENTITY_ALREADY_EXISTS,
message: t`Entity with name ${flatMyEntity.name} already exists`,
userFriendlyMessage: msg`An entity with this name already exists`,
});
}
// Pattern 3: Foreign key validation
if (isDefined(flatMyEntity.parentEntityId)) {
const parentEntity = optimisticFlatParentEntityMaps.byId[flatMyEntity.parentEntityId];
if (!isDefined(parentEntity)) {
errors.push({
code: MyEntityExceptionCode.PARENT_ENTITY_NOT_FOUND,
message: t`Parent entity with ID ${flatMyEntity.parentEntityId} not found`,
userFriendlyMessage: msg`The specified parent entity does not exist`,
});
} else if (isDefined(parentEntity.deletedAt)) {
errors.push({
code: MyEntityExceptionCode.PARENT_ENTITY_DELETED,
message: t`Parent entity is deleted`,
userFriendlyMessage: msg`Cannot reference a deleted parent entity`,
});
}
}
// Pattern 4: Standard entity protection
if (flatMyEntity.isCustom === false) {
errors.push({
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_CREATED,
message: t`Cannot create standard entity`,
userFriendlyMessage: msg`Standard entities can only be created by the system`,
});
}
return errors;
}
validateMyEntityForUpdate(
flatMyEntity: FlatMyEntity,
updates: Partial<FlatMyEntity>,
optimisticFlatMyEntityMaps: FlatMyEntityMaps,
): WorkspaceMigrationValidationError[] {
const errors: WorkspaceMigrationValidationError[] = [];
// Standard entity protection
if (flatMyEntity.isCustom === false) {
errors.push({
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_UPDATED,
message: t`Cannot update standard entity`,
userFriendlyMessage: msg`Standard entities cannot be modified`,
});
return errors; // Early return if standard
}
// Uniqueness check for name changes
if (isDefined(updates.name) && updates.name !== flatMyEntity.name) {
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[updates.name];
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
errors.push({
code: MyEntityExceptionCode.MY_ENTITY_ALREADY_EXISTS,
message: t`Entity with name ${updates.name} already exists`,
userFriendlyMessage: msg`An entity with this name already exists`,
});
}
}
return errors;
}
validateMyEntityForDelete(
flatMyEntity: FlatMyEntity,
): WorkspaceMigrationValidationError[] {
const errors: WorkspaceMigrationValidationError[] = [];
// Standard entity protection
if (flatMyEntity.isCustom === false) {
errors.push({
code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_DELETED,
message: t`Cannot delete standard entity`,
userFriendlyMessage: msg`Standard entities cannot be deleted`,
});
}
return errors;
}
}
```
**Performance warning**: Avoid `Object.values().find()` - use indexed maps instead!
```typescript
// ❌ BAD: O(n) - slow for large datasets
const duplicate = Object.values(optimisticFlatMyEntityMaps.byId).find(
(entity) => entity.name === flatMyEntity.name && entity.id !== flatMyEntity.id
);
// ✅ GOOD: O(1) - use indexed map
const existingEntityWithName = optimisticFlatMyEntityMaps.byName[flatMyEntity.name];
if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) {
// Handle duplicate
}
```
---
## Step 2: Create Builder Service
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-entity-migration-builder.service';
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
import {
type UniversalCreateMyEntityAction,
type UniversalUpdateMyEntityAction,
type UniversalDeleteMyEntityAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
@Injectable()
export class WorkspaceMigrationMyEntityActionsBuilderService extends WorkspaceEntityMigrationBuilderService<
'myEntity',
UniversalFlatMyEntity,
UniversalCreateMyEntityAction,
UniversalUpdateMyEntityAction,
UniversalDeleteMyEntityAction
> {
constructor(
private readonly flatMyEntityValidatorService: FlatMyEntityValidatorService,
) {
super();
}
protected buildCreateAction(
universalFlatMyEntity: UniversalFlatMyEntity,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): BuildWorkspaceMigrationActionReturnType<UniversalCreateMyEntityAction> {
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForCreate(
universalFlatMyEntity,
flatEntityMaps.flatMyEntityMaps,
);
if (validationResult.length > 0) {
return {
status: 'failed',
errors: validationResult,
};
}
return {
status: 'success',
action: {
type: 'create',
metadataName: 'myEntity',
universalFlatEntity: universalFlatMyEntity,
},
};
}
protected buildUpdateAction(
universalFlatMyEntity: UniversalFlatMyEntity,
universalUpdates: Partial<UniversalFlatMyEntity>,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): BuildWorkspaceMigrationActionReturnType<UniversalUpdateMyEntityAction> {
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForUpdate(
universalFlatMyEntity,
universalUpdates,
flatEntityMaps.flatMyEntityMaps,
);
if (validationResult.length > 0) {
return {
status: 'failed',
errors: validationResult,
};
}
return {
status: 'success',
action: {
type: 'update',
metadataName: 'myEntity',
universalFlatEntity: universalFlatMyEntity,
universalUpdates,
},
};
}
protected buildDeleteAction(
universalFlatMyEntity: UniversalFlatMyEntity,
): BuildWorkspaceMigrationActionReturnType<UniversalDeleteMyEntityAction> {
const validationResult = this.flatMyEntityValidatorService.validateMyEntityForDelete(
universalFlatMyEntity,
);
if (validationResult.length > 0) {
return {
status: 'failed',
errors: validationResult,
};
}
return {
status: 'success',
action: {
type: 'delete',
metadataName: 'myEntity',
universalFlatEntity: universalFlatMyEntity,
},
};
}
}
```
---
## Step 3: Wire into Orchestrator (**CRITICAL**)
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-build-orchestrator.service.ts`
```typescript
@Injectable()
export class WorkspaceMigrationBuildOrchestratorService {
constructor(
// ... existing builders
private readonly workspaceMigrationMyEntityActionsBuilderService: WorkspaceMigrationMyEntityActionsBuilderService,
) {}
async buildWorkspaceMigration({
allFlatEntityOperationByMetadataName,
flatEntityMaps,
isSystemBuild,
}: BuildWorkspaceMigrationInput): Promise<BuildWorkspaceMigrationOutput> {
// ... existing code
// Add your entity builder
const myEntityResult = await this.workspaceMigrationMyEntityActionsBuilderService.build({
flatEntitiesToCreate: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToCreate ?? [],
flatEntitiesToUpdate: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToUpdate ?? [],
flatEntitiesToDelete: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToDelete ?? [],
flatEntityMaps,
isSystemBuild,
});
// ... aggregate errors
return {
status: aggregatedErrors.length > 0 ? 'failed' : 'success',
errors: aggregatedErrors,
actions: [
...existingActions,
...myEntityResult.actions,
],
};
}
}
```
**⚠️ This step is the most commonly forgotten!** Your entity won't sync without orchestrator wiring.
---
## Validation Patterns
### Pattern 1: Required Field
```typescript
if (!isDefined(field) || field.trim() === '') {
errors.push({ code: ..., message: ..., userFriendlyMessage: ... });
}
```
### Pattern 2: Uniqueness (O(1) lookup)
```typescript
const existing = optimisticMaps.byName[entity.name];
if (isDefined(existing) && existing.id !== entity.id) {
errors.push({ ... });
}
```
### Pattern 3: Foreign Key Validation
```typescript
if (isDefined(entity.parentId)) {
const parent = parentMaps.byId[entity.parentId];
if (!isDefined(parent)) {
errors.push({ code: NOT_FOUND, ... });
} else if (isDefined(parent.deletedAt)) {
errors.push({ code: DELETED, ... });
}
}
```
### Pattern 4: Standard Entity Protection
```typescript
if (entity.isCustom === false) {
errors.push({ code: STANDARD_ENTITY_PROTECTED, ... });
return errors; // Early return
}
```
---
## Checklist
Before moving to Step 4:
- [ ] Validator service created
- [ ] Validator **never throws** (returns error arrays)
- [ ] Validator **never mutates** (uses optimistic maps)
- [ ] All uniqueness checks use indexed maps (O(1))
- [ ] Required field validation implemented
- [ ] Foreign key validation implemented
- [ ] Standard entity protection implemented
- [ ] Builder service extends `WorkspaceEntityMigrationBuilderService`
- [ ] Builder creates actions with universal entities
- [ ] **Builder wired into orchestrator** (**CRITICAL**)
- [ ] **Builder injected in orchestrator constructor**
- [ ] **Builder called in `buildWorkspaceMigration`**
- [ ] **Actions added to orchestrator return statement**
---
## Next Step
Once builder and validation are complete, proceed to:
**[Syncable Entity: Runner & Actions (Step 4/6)](../syncable-entity-runner-and-actions/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,303 +0,0 @@
---
name: syncable-entity-cache-and-transform
description: Create cache services and transformation utilities for syncable entities in Twenty. Use when implementing entity-to-flat conversions, input DTO transpilation to universal flat entities, or cache recomputation for syncable entities.
---
# Syncable Entity: Cache & Transform (Step 2/6)
**Purpose**: Create cache layer and transformation utilities to convert between different entity representations.
**When to use**: After completing Step 1 (Types & Constants). Required before building validators and action handlers.
---
## Quick Start
This step creates:
1. Cache service for flat entity maps
2. Entity-to-flat conversion utility
3. Input transform utils (DTO → Universal Flat Entity)
**Key principle**: Input transform utils must output **universal flat entities** (with `universalIdentifier` and foreign keys mapped to universal identifiers).
---
## Step 1: Create Cache Service
**File**: `src/engine/metadata-modules/flat-my-entity/services/flat-my-entity-cache.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { WorkspaceCache } from 'src/engine/twenty-orm/decorators/workspace-cache.decorator';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type';
import { fromMyEntityEntityToFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util';
@Injectable()
export class FlatMyEntityCacheService {
constructor(
@InjectRepository(MyEntityEntity, 'metadata')
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {}
@WorkspaceCache({ flatMapsKey: 'flatMyEntityMaps' })
async getFlatMyEntityMaps(): Promise<FlatMyEntityMaps> {
const myEntities = await this.myEntityRepository.find({
withDeleted: true, // CRITICAL: Include soft-deleted entities
});
const flatMyEntities = myEntities.map((entity) =>
fromMyEntityEntityToFlatMyEntity(entity),
);
return {
byId: Object.fromEntries(flatMyEntities.map((e) => [e.id, e])),
byName: Object.fromEntries(flatMyEntities.map((e) => [e.name, e])),
};
}
}
```
**Critical rules**:
- Use `@WorkspaceCache` decorator with unique `flatMapsKey`
- **Always** use `withDeleted: true` to include soft-deleted entities
- Cache key pattern: `flat{EntityName}Maps` (camelCase)
---
## Step 2: Entity-to-Flat Conversion
**File**: `src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util.ts`
```typescript
import { v4 } from 'uuid';
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
export const fromMyEntityEntityToFlatMyEntity = (
entity: MyEntityEntity,
): FlatMyEntity => {
return {
id: entity.id,
// Critical: generate a new UUID for universalIdentifier
universalIdentifier: v4(),
workspaceId: entity.workspaceId,
applicationId: entity.applicationId,
name: entity.name,
label: entity.label,
description: entity.description,
isCustom: entity.isCustom,
parentEntityId: entity.parentEntityId,
settings: entity.settings,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
deletedAt: entity.deletedAt?.toISOString() ?? null,
};
};
```
**Critical**: `universalIdentifier` must be a new UUID generated with `v4()` (not `entity.id`)
---
## Step 3: Input Transform Utils (DTO → Universal Flat Entity)
**File**: `src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.util.ts`
```typescript
import { v4 } from 'uuid';
import { sanitizeString } from 'twenty-shared/string';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type';
export const fromCreateMyEntityInputToUniversalFlatMyEntity = ({
input,
workspaceId,
flatEntityMaps,
}: {
input: CreateMyEntityInput;
workspaceId: string;
flatEntityMaps?: AllFlatEntityMapsByMetadataName;
}): UniversalFlatMyEntity => {
const id = v4();
const universalIdentifier = v4();
// 1. Extract foreign key IDs BEFORE sanitization
const parentEntityId = input.parentEntityId ?? null;
// 2. Sanitize string properties
const name = sanitizeString(input.name);
const label = sanitizeString(input.label);
const description = input.description ? sanitizeString(input.description) : null;
// 3. Build base flat entity
const baseFlatEntity = {
id,
universalIdentifier,
workspaceId,
applicationId: null,
name,
label,
description,
isCustom: true,
parentEntityId,
settings: input.settings ?? null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
// 4. Resolve foreign keys to universal identifiers (if flatEntityMaps provided)
if (flatEntityMaps) {
return resolveEntityRelationUniversalIdentifiers({
metadataName: 'myEntity',
flatEntity: baseFlatEntity,
flatEntityMaps,
});
}
// 5. Return with null universal foreign keys if no maps
return {
...baseFlatEntity,
parentEntityUniversalIdentifier: null,
};
};
```
**Key steps**:
1. Generate IDs (`id` and `universalIdentifier` with `v4()`)
2. Extract foreign keys **before** sanitization
3. Sanitize all string properties
4. Build base flat entity
5. Resolve foreign keys → universal identifiers
---
## Step 4: Create Flat Entity Module
**File**: `src/engine/metadata-modules/flat-my-entity/flat-my-entity.module.ts`
```typescript
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { FlatMyEntityCacheService } from 'src/engine/metadata-modules/flat-my-entity/services/flat-my-entity-cache.service';
@Module({
imports: [TypeOrmModule.forFeature([MyEntityEntity], 'metadata')],
providers: [FlatMyEntityCacheService],
exports: [FlatMyEntityCacheService],
})
export class FlatMyEntityModule {}
```
**Rules**:
- Import entity with `'metadata'` datasource
- Export cache service for use in other modules
---
## Common Patterns
### Pattern: Foreign Key Resolution
```typescript
// Extract foreign keys BEFORE sanitization
const parentEntityId = input.parentEntityId ?? null;
// After building base entity, resolve to universal identifiers
const universalFlatEntity = resolveEntityRelationUniversalIdentifiers({
metadataName: 'myEntity',
flatEntity: baseFlatEntity,
flatEntityMaps,
});
```
### Pattern: JSONB with SerializedRelation
```typescript
// For JSONB properties containing foreign keys
const settings = input.settings
? {
...input.settings,
fieldMetadataId: input.settings.fieldMetadataId,
}
: null;
// After resolution, JSONB foreign keys become universal identifiers
return resolveEntityRelationUniversalIdentifiers({
metadataName: 'myEntity',
flatEntity: { ...baseFlatEntity, settings },
flatEntityMaps,
});
```
### Pattern: Update Transform
```typescript
// from-update-my-entity-input-to-universal-flat-my-entity-updates.util.ts
export const fromUpdateMyEntityInputToUniversalFlatMyEntityUpdates = ({
input,
flatEntityMaps,
}: {
input: UpdateMyEntityInput;
flatEntityMaps?: AllFlatEntityMapsByMetadataName;
}): Partial<UniversalFlatMyEntity> => {
const updates: Partial<UniversalFlatMyEntity> = {};
if (input.name !== undefined) {
updates.name = sanitizeString(input.name);
}
if (input.parentEntityId !== undefined) {
updates.parentEntityId = input.parentEntityId;
}
updates.updatedAt = new Date().toISOString();
// Resolve foreign keys if maps provided
if (flatEntityMaps) {
return resolveEntityRelationUniversalIdentifiers({
metadataName: 'myEntity',
flatEntity: updates as any,
flatEntityMaps,
});
}
return updates;
};
```
---
## Checklist
Before moving to Step 3:
- [ ] Cache service created with `@WorkspaceCache` decorator
- [ ] Cache uses `withDeleted: true`
- [ ] Cache key follows `flat{EntityName}Maps` pattern
- [ ] Entity-to-flat conversion implemented
- [ ] `universalIdentifier` set correctly (generated with `v4()`)
- [ ] Create input transform implemented
- [ ] Update input transform implemented (if needed)
- [ ] Foreign keys extracted before sanitization
- [ ] String properties sanitized
- [ ] Foreign keys resolved to universal identifiers
- [ ] Flat entity module created and exports cache service
---
## Next Step
Once cache and transform utilities are complete, proceed to:
**[Syncable Entity: Builder & Validation (Step 3/6)](../syncable-entity-builder-and-validation/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,326 +0,0 @@
---
name: syncable-entity-integration
description: Wire syncable entity services into NestJS modules, create service layer and resolvers for Twenty entities. Use when registering builders, validators, and action handlers in modules, creating business services, or exposing entities via GraphQL API with proper exception handling.
---
# Syncable Entity: Integration (Step 5/6)
**Purpose**: Wire everything together, register in modules, create services and resolvers.
**When to use**: After completing Steps 1-4 (all previous steps). Required before testing.
---
## Quick Start
This step:
1. Registers services in 3 NestJS modules
2. Creates service layer (returns flat entities)
3. Creates resolver layer (converts flat → DTO)
4. Uses exception interceptor for GraphQL
**Key principle**: Services return flat entities, resolvers transpile flat → DTO.
---
## Step 1: Register in Builder Module
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts`
```typescript
import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
WorkspaceMigrationMyEntityActionsBuilderService,
],
exports: [
// ... existing exports
WorkspaceMigrationMyEntityActionsBuilderService,
],
})
export class WorkspaceMigrationBuilderModule {}
```
**Important**: Add to both `providers` AND `exports` (builder needs to be exported for orchestrator).
---
## Step 2: Register in Validators Module
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts`
```typescript
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
FlatMyEntityValidatorService,
],
exports: [
// ... existing exports
FlatMyEntityValidatorService,
],
})
export class WorkspaceMigrationBuilderValidatorsModule {}
```
---
## Step 3: Register Action Handlers
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts`
```typescript
import { CreateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service';
import { UpdateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service';
import { DeleteMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service';
@Module({
imports: [
// ... existing imports
],
providers: [
// ... existing providers
CreateMyEntityActionHandlerService,
UpdateMyEntityActionHandlerService,
DeleteMyEntityActionHandlerService,
],
exports: [
// ... existing exports (action handlers typically not exported)
],
})
export class WorkspaceSchemaMigrationRunnerActionHandlersModule {}
```
**Note**: Action handlers are typically only in `providers`, not `exports`.
---
## Step 4: Create Service Layer
**File**: `src/engine/metadata-modules/my-entity/my-entity.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateMyEntityInputToUniversalFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class MyEntityService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async create(input: CreateMyEntityInput, workspaceId: string): Promise<FlatMyEntity> {
// 1. Transform input to universal flat entity
const universalFlatMyEntityToCreate = fromCreateMyEntityInputToUniversalFlatMyEntity({
input,
workspaceId,
});
// 2. Validate, build, and run
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
myEntity: {
flatEntityToCreate: [universalFlatMyEntityToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
// 3. Throw if validation failed
if (isDefined(result)) {
throw new WorkspaceMigrationBuilderException(
result,
'Validation errors occurred while creating entity',
);
}
// 4. Return freshly cached flat entity
const { flatMyEntityMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatMyEntityMaps'],
},
);
return findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: universalFlatMyEntityToCreate.id,
flatEntityMaps: flatMyEntityMaps,
});
}
}
```
**Service pattern**:
1. Transform input → universal flat entity
2. Call `validateBuildAndRunWorkspaceMigration`
3. Throw if validation errors
4. **Return flat entity** (not DTO)
---
## Step 5: Create Resolver Layer
**File**: `src/engine/metadata-modules/my-entity/my-entity.resolver.ts`
```typescript
import { UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { MyEntityService } from 'src/engine/metadata-modules/my-entity/my-entity.service';
import { fromFlatMyEntityToMyEntityDto } from 'src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util';
@Resolver(() => MyEntityDto)
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
export class MyEntityResolver {
constructor(private readonly myEntityService: MyEntityService) {}
@Mutation(() => MyEntityDto)
async createMyEntity(
@Args('input') input: CreateMyEntityInput,
@Workspace() { id: workspaceId }: Workspace,
): Promise<MyEntityDto> {
// Service returns flat entity
const flatMyEntity = await this.myEntityService.create(input, workspaceId);
// Resolver converts flat entity to DTO
return fromFlatMyEntityToMyEntityDto(flatMyEntity);
}
@Mutation(() => MyEntityDto)
async updateMyEntity(
@Args('id') id: string,
@Args('input') input: UpdateMyEntityInput,
@Workspace() { id: workspaceId }: Workspace,
): Promise<MyEntityDto> {
const flatMyEntity = await this.myEntityService.update(id, input, workspaceId);
return fromFlatMyEntityToMyEntityDto(flatMyEntity);
}
@Mutation(() => Boolean)
async deleteMyEntity(
@Args('id') id: string,
@Workspace() { id: workspaceId }: Workspace,
) {
await this.myEntityService.delete(id, workspaceId);
return true;
}
}
```
**Resolver responsibilities**:
- Receives flat entities from service
- **Converts flat → DTO** using conversion utility
- Returns DTOs to GraphQL API
- Uses exception interceptor for error formatting
---
## Step 6: Flat-to-DTO Conversion
**File**: `src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util.ts`
```typescript
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto';
export const fromFlatMyEntityToMyEntityDto = (
flatMyEntity: FlatMyEntity,
): MyEntityDto => {
return {
id: flatMyEntity.id,
name: flatMyEntity.name,
label: flatMyEntity.label,
description: flatMyEntity.description,
isCustom: flatMyEntity.isCustom,
createdAt: flatMyEntity.createdAt,
updatedAt: flatMyEntity.updatedAt,
// Convert foreign key IDs to relation objects if needed
// parentEntity: flatMyEntity.parentEntityId ? { id: flatMyEntity.parentEntityId } : null,
};
};
```
---
## Layer Responsibilities
| Layer | Input | Output | Responsibility |
|-------|-------|--------|----------------|
| **Service** | Input DTO | Flat Entity | Business logic, validation orchestration |
| **Resolver** | Service result | DTO | Flat → DTO conversion, GraphQL exposure |
**Service Layer**:
- Works with flat entities internally
- Returns `FlatMyEntity` type
- No knowledge of DTOs or GraphQL types
**Resolver Layer**:
- Receives flat entities from service
- Converts flat entities to DTOs
- Returns DTOs to GraphQL API
---
## Exception Interceptor
The `WorkspaceMigrationGraphqlApiExceptionInterceptor` automatically handles:
1. `FlatEntityMapsException` → Converts to GraphQL errors (NotFoundError, etc.)
2. `WorkspaceMigrationBuilderException` → Formats validation errors with i18n
3. `WorkspaceMigrationRunnerException` → Formats runner errors
**What it does**:
- Catches exceptions and formats for API responses
- Translates error messages based on user locale
- Ensures consistent error structure for frontend
---
## Checklist
Before moving to Step 6 (Testing):
- [ ] Builder registered in builder module (providers + exports)
- [ ] Validator registered in validators module (providers + exports)
- [ ] All 3 action handlers registered in action handlers module (providers)
- [ ] Service layer created
- [ ] Service returns flat entities (not DTOs)
- [ ] Resolver layer created
- [ ] Resolver uses exception interceptor
- [ ] Resolver converts flat → DTO
- [ ] Flat-to-DTO conversion utility created
---
## Next Step
Once integration is complete, proceed to (**MANDATORY**):
**[Syncable Entity: Integration Testing (Step 6/6)](../syncable-entity-testing/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,355 +0,0 @@
---
name: syncable-entity-runner-and-actions
description: Implement action handlers for executing workspace migrations in Twenty. Use when creating database operations for syncable entities, implementing universal-to-flat entity transpilation, or handling create/update/delete actions in the runner layer.
---
# Syncable Entity: Runner & Actions (Step 4/6)
**Purpose**: Execute migration actions against the database with proper transpilation from universal to flat entities.
**When to use**: After completing Steps 1-3 (Types, Cache, Builder). Required before integration.
---
## Quick Start
This step creates:
1. Create action handler
2. Update action handler
3. Delete action handler
4. Universal-to-flat conversion utilities
**Key pattern**: Each handler has two phases:
1. **Transpilation**: Universal action → Flat action
2. **Execution**: Flat action → Database operation
---
## Step 1: Create Action Handler
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCreateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-create-action-handler.service';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
import {
type UniversalCreateMyEntityAction,
type FlatCreateMyEntityAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
@Injectable()
export class CreateMyEntityActionHandlerService extends WorkspaceCreateActionHandlerService<
'myEntity',
UniversalCreateMyEntityAction,
FlatCreateMyEntityAction
> {
constructor(
@InjectRepository(MyEntityEntity, 'metadata')
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {
super();
}
// Phase 1: Transpile universal action to flat action
protected transpileUniversalActionToFlatAction(
universalAction: UniversalCreateMyEntityAction,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): FlatCreateMyEntityAction {
return {
type: 'create',
metadataName: 'myEntity',
flatEntity: fromUniversalFlatMyEntityToFlatMyEntity(
universalAction.universalFlatEntity,
flatEntityMaps,
),
};
}
// Phase 2: Execute flat action against database
protected async executeForMetadata(
flatActions: FlatCreateMyEntityAction[],
): Promise<void> {
const flatEntities = flatActions.map((action) => action.flatEntity);
await this.insertFlatEntitiesInRepository({
repository: this.myEntityRepository,
flatEntities,
});
}
protected async executeForWorkspaceSchema(): Promise<void> {
// No workspace schema changes needed for metadata-only entity
return;
}
}
```
**Key helper methods**:
- `transpileUniversalActionToFlatAction`: Converts universal → flat
- `insertFlatEntitiesInRepository`: Base class helper for inserts
- `executeForMetadata`: Metadata database operations
- `executeForWorkspaceSchema`: Workspace schema changes (if needed)
---
## Step 2: Update Action Handler
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceUpdateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-update-action-handler.service';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util';
@Injectable()
export class UpdateMyEntityActionHandlerService extends WorkspaceUpdateActionHandlerService<
'myEntity',
UniversalUpdateMyEntityAction,
FlatUpdateMyEntityAction
> {
constructor(
@InjectRepository(MyEntityEntity, 'metadata')
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {
super();
}
protected transpileUniversalActionToFlatAction(
universalAction: UniversalUpdateMyEntityAction,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): FlatUpdateMyEntityAction {
const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity(
universalAction.universalFlatEntity,
flatEntityMaps,
);
// Resolve universal foreign keys in updates to regular IDs
const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'myEntity',
universalUpdates: universalAction.universalUpdates,
flatEntityMaps,
});
return {
type: 'update',
metadataName: 'myEntity',
flatEntity,
updates: flatUpdates,
};
}
protected async executeForMetadata(
flatActions: FlatUpdateMyEntityAction[],
): Promise<void> {
for (const action of flatActions) {
await this.myEntityRepository.update(
{ id: action.flatEntity.id },
action.updates,
);
}
}
protected async executeForWorkspaceSchema(): Promise<void> {
return;
}
}
```
**Update-specific helper**:
- `resolveUniversalUpdateRelationIdentifiersToIds`: Maps universal identifiers back to regular IDs in the updates object
---
## Step 3: Delete Action Handler
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service.ts`
```typescript
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceDeleteActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-delete-action-handler.service';
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util';
@Injectable()
export class DeleteMyEntityActionHandlerService extends WorkspaceDeleteActionHandlerService<
'myEntity',
UniversalDeleteMyEntityAction,
FlatDeleteMyEntityAction
> {
constructor(
@InjectRepository(MyEntityEntity, 'metadata')
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {
super();
}
protected transpileUniversalActionToFlatAction(
universalAction: UniversalDeleteMyEntityAction,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): FlatDeleteMyEntityAction {
// Use base class helper for delete transpilation
return this.transpileUniversalDeleteActionToFlatDeleteAction({
universalAction,
flatEntityMaps,
fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity,
});
}
protected async executeForMetadata(
flatActions: FlatDeleteMyEntityAction[],
): Promise<void> {
const ids = flatActions.map((action) => action.flatEntity.id);
await this.myEntityRepository.delete(ids);
}
protected async executeForWorkspaceSchema(): Promise<void> {
return;
}
}
```
**Delete-specific helper**:
- `transpileUniversalDeleteActionToFlatDeleteAction`: Base class helper that handles standard delete transpilation
---
## Step 4: Universal-to-Flat Conversion
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util.ts`
```typescript
import { resolveUniversalRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util';
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type';
export const fromUniversalFlatMyEntityToFlatMyEntity = (
universalFlatMyEntity: UniversalFlatMyEntity,
flatEntityMaps: AllFlatEntityMapsByMetadataName,
): FlatMyEntity => {
// Resolve universal foreign keys back to regular IDs
return resolveUniversalRelationIdentifiersToIds({
metadataName: 'myEntity',
universalFlatEntity: universalFlatMyEntity,
flatEntityMaps,
}) as FlatMyEntity;
};
```
**Key utility**:
- `resolveUniversalRelationIdentifiersToIds`: Maps universal identifiers → regular IDs (reverse of `resolveEntityRelationUniversalIdentifiers`)
---
## Action Handler Patterns
### Pattern: Create Handler
```typescript
// 1. Transpile: Universal → Flat
protected transpileUniversalActionToFlatAction(
universalAction,
flatEntityMaps,
) {
return {
type: 'create',
metadataName: 'myEntity',
flatEntity: fromUniversalFlatMyEntityToFlatMyEntity(
universalAction.universalFlatEntity,
flatEntityMaps,
),
};
}
// 2. Execute: Flat → Database
protected async executeForMetadata(flatActions) {
await this.insertFlatEntitiesInRepository({
repository: this.myEntityRepository,
flatEntities: flatActions.map(a => a.flatEntity),
});
}
```
### Pattern: Update Handler
```typescript
// Transpile with update-specific resolution
protected transpileUniversalActionToFlatAction(
universalAction,
flatEntityMaps,
) {
const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity(
universalAction.universalFlatEntity,
flatEntityMaps,
);
const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'myEntity',
universalUpdates: universalAction.universalUpdates,
flatEntityMaps,
});
return { type: 'update', metadataName: 'myEntity', flatEntity, updates: flatUpdates };
}
```
### Pattern: Delete Handler
```typescript
// Use base class helper
protected transpileUniversalActionToFlatAction(
universalAction,
flatEntityMaps,
) {
return this.transpileUniversalDeleteActionToFlatDeleteAction({
universalAction,
flatEntityMaps,
fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity,
});
}
// Delete
protected async executeForMetadata(flatActions) {
const ids = flatActions.map(a => a.flatEntity.id);
await this.myEntityRepository.delete(ids);
}
```
---
## Checklist
Before moving to Step 5:
- [ ] Create action handler implemented
- [ ] Update action handler implemented
- [ ] Delete action handler implemented
- [ ] All handlers extend appropriate base class
- [ ] `transpileUniversalActionToFlatAction` implemented in all handlers
- [ ] `executeForMetadata` implemented in all handlers
- [ ] `executeForWorkspaceSchema` implemented (or returns empty)
- [ ] Universal-to-flat conversion utility created
- [ ] Create handler uses `insertFlatEntitiesInRepository`
- [ ] Update handler uses `resolveUniversalUpdateRelationIdentifiersToIds`
- [ ] Delete handler uses `transpileUniversalDeleteActionToFlatDeleteAction`
- [ ] Delete handler uses hard delete (`delete()`)
---
## Next Step
Once action handlers are complete, proceed to:
**[Syncable Entity: Integration (Step 5/6)](../syncable-entity-integration/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,494 +0,0 @@
---
name: syncable-entity-testing
description: Create comprehensive integration tests for syncable entities in Twenty. Use when writing integration tests for metadata entities, covering validator exceptions, input transpilation errors, and CRUD operations. Tests are MANDATORY for all syncable entities.
---
# Syncable Entity: Integration Testing (Step 6/6 - MANDATORY)
**Purpose**: Create comprehensive test suite covering all validation scenarios, input transpilation exceptions, and successful use cases.
**When to use**: After completing Steps 1-5. Integration tests are **REQUIRED** for all syncable entities.
---
## Quick Start
Tests must cover:
1. **Failing scenarios** - All validator exceptions and input transpilation errors
2. **Successful scenarios** - All CRUD operations and edge cases
3. **Test utilities** - Reusable query factories and helper functions
**Test pattern**: Two-file pattern (query factory + wrapper) for each operation.
---
## Step 1: Create Test Utilities
### Pattern: Query Factory
**File**: `test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util.ts`
```typescript
import gql from 'graphql-tag';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
export type CreateMyEntityFactoryInput = CreateMyEntityInput;
const DEFAULT_MY_ENTITY_GQL_FIELDS = `
id
name
label
description
isCustom
createdAt
updatedAt
`;
export const createMyEntityQueryFactory = ({
input,
gqlFields = DEFAULT_MY_ENTITY_GQL_FIELDS,
}: PerformMetadataQueryParams<CreateMyEntityFactoryInput>) => ({
query: gql`
mutation CreateMyEntity($input: CreateMyEntityInput!) {
createMyEntity(input: $input) {
${gqlFields}
}
}
`,
variables: {
input,
},
});
```
### Pattern: Wrapper Utility
**File**: `test/integration/metadata/suites/my-entity/utils/create-my-entity.util.ts`
```typescript
import {
type CreateMyEntityFactoryInput,
createMyEntityQueryFactory,
} from 'test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto';
export const createMyEntity = async ({
input,
gqlFields,
expectToFail = false,
token,
}: PerformMetadataQueryParams<CreateMyEntityFactoryInput>): CommonResponseBody<{
createMyEntity: MyEntityDto;
}> => {
const graphqlOperation = createMyEntityQueryFactory({
input,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage: 'My entity creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage: 'My entity creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
```
**Required utilities** (follow same pattern):
- `update-my-entity-query-factory.util.ts` + `update-my-entity.util.ts`
- `delete-my-entity-query-factory.util.ts` + `delete-my-entity.util.ts`
---
## Step 2: Failing Creation Tests
**File**: `test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts`
```typescript
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util';
import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { isDefined } from 'twenty-shared/utils';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
type TestContext = {
input: CreateMyEntityInput;
};
type GlobalTestContext = {
existingEntityLabel: string;
existingEntityName: string;
};
const globalTestContext: GlobalTestContext = {
existingEntityLabel: 'Existing Test Entity',
existingEntityName: 'existingTestEntity',
};
type CreateMyEntityTestingContext = EachTestingContext<TestContext>[];
describe('My entity creation should fail', () => {
let existingEntityId: string | undefined;
beforeAll(async () => {
// Setup: Create entity for uniqueness tests
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: globalTestContext.existingEntityName,
label: globalTestContext.existingEntityLabel,
},
});
existingEntityId = data.createMyEntity.id;
});
afterAll(async () => {
// Cleanup
if (isDefined(existingEntityId)) {
await deleteMyEntity({
expectToFail: false,
input: { id: existingEntityId },
});
}
});
const failingMyEntityCreationTestCases: CreateMyEntityTestingContext = [
// Input transpilation validation
{
title: 'when name is missing',
context: {
input: {
label: 'Entity Missing Name',
} as CreateMyEntityInput,
},
},
{
title: 'when label is missing',
context: {
input: {
name: 'entityMissingLabel',
} as CreateMyEntityInput,
},
},
{
title: 'when name is empty string',
context: {
input: {
name: '',
label: 'Empty Name Entity',
},
},
},
// Validator business logic
{
title: 'when name already exists (uniqueness)',
context: {
input: {
name: globalTestContext.existingEntityName,
label: 'Duplicate Name Entity',
},
},
},
{
title: 'when trying to create standard entity',
context: {
input: {
name: 'myEntity',
label: 'Standard Entity',
isCustom: false,
} as CreateMyEntityInput,
},
},
// Foreign key validation
{
title: 'when parentEntityId does not exist',
context: {
input: {
name: 'invalidParentEntity',
label: 'Invalid Parent Entity',
parentEntityId: '00000000-0000-0000-0000-000000000000',
},
},
},
];
it.each(eachTestingContextFilter(failingMyEntityCreationTestCases))(
'$title',
async ({ context }) => {
const { errors } = await createMyEntity({
expectToFail: true,
input: context.input,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
});
```
**Test coverage requirements**:
- ✅ Missing required fields
- ✅ Empty strings
- ✅ Invalid format
- ✅ Uniqueness violations
- ✅ Standard entity protection
- ✅ Foreign key validation
---
## Step 3: Successful Creation Tests
**File**: `test/integration/metadata/suites/my-entity/successful-my-entity-creation.integration-spec.ts`
```typescript
import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util';
import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util';
import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input';
describe('My entity creation should succeed', () => {
let createdEntityId: string;
afterEach(async () => {
if (createdEntityId) {
await deleteMyEntity({
expectToFail: false,
input: { id: createdEntityId },
});
}
});
it('should create entity with minimal required input', async () => {
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: 'minimalEntity',
label: 'Minimal Entity',
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'minimalEntity',
label: 'Minimal Entity',
description: null,
isCustom: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
it('should create entity with all optional fields', async () => {
const input = {
name: 'fullEntity',
label: 'Full Entity',
description: 'Entity with all fields specified',
} as const satisfies CreateMyEntityInput;
const { data } = await createMyEntity({
expectToFail: false,
input,
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'fullEntity',
label: 'Full Entity',
description: 'Entity with all fields specified',
isCustom: true,
});
});
it('should sanitize input by trimming whitespace', async () => {
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: ' entityWithSpaces ',
label: ' Entity With Spaces ',
description: ' Description with spaces ',
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
name: 'entityWithSpaces',
label: 'Entity With Spaces',
description: 'Description with spaces',
});
});
it('should handle long text content', async () => {
const longDescription = 'A'.repeat(1000);
const { data } = await createMyEntity({
expectToFail: false,
input: {
name: 'longDescEntity',
label: 'Long Description Entity',
description: longDescription,
},
});
createdEntityId = data?.createMyEntity?.id;
expect(data.createMyEntity).toMatchObject({
id: expect.any(String),
description: longDescription,
});
});
});
```
**Test coverage requirements**:
- ✅ Minimal required input
- ✅ All optional fields
- ✅ Input sanitization
- ✅ Long text content
- ✅ Special characters
---
## Step 4: Update and Delete Tests
Create similar test files for update and delete operations:
**Required files**:
- `failing-my-entity-update.integration-spec.ts`
- `successful-my-entity-update.integration-spec.ts`
- `failing-my-entity-deletion.integration-spec.ts`
- `successful-my-entity-deletion.integration-spec.ts`
---
## Testing Best Practices
### Pattern: Cleanup
```typescript
afterEach(async () => {
if (createdEntityId) {
await deleteMyEntity({
expectToFail: false,
input: { id: createdEntityId },
});
}
});
```
### Pattern: Type-Safe Inputs
```typescript
const input = {
name: 'myEntity',
label: 'My Entity',
} as const satisfies CreateMyEntityInput;
```
### Pattern: Snapshot Testing
```typescript
expectOneNotInternalServerErrorSnapshot({
errors,
});
```
---
## Running Tests
```bash
# Run all entity tests
npx jest test/integration/metadata/suites/my-entity --config=packages/twenty-server/jest.config.mjs
# Run specific test file
npx jest test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts --config=packages/twenty-server/jest.config.mjs
# Update snapshots
npx jest test/integration/metadata/suites/my-entity --updateSnapshot --config=packages/twenty-server/jest.config.mjs
```
---
## Complete Test Checklist
### Test Utilities
- [ ] `create-my-entity-query-factory.util.ts` created
- [ ] `create-my-entity.util.ts` created
- [ ] `update-my-entity-query-factory.util.ts` created
- [ ] `update-my-entity.util.ts` created
- [ ] `delete-my-entity-query-factory.util.ts` created
- [ ] `delete-my-entity.util.ts` created
### Failing Tests Coverage
- [ ] Missing required fields
- [ ] Empty string validation
- [ ] Uniqueness violations
- [ ] Standard entity protection
- [ ] Foreign key validation
- [ ] JSONB property validation (if applicable)
### Successful Tests Coverage
- [ ] Create with minimal input
- [ ] Create with all optional fields
- [ ] Input sanitization (whitespace)
- [ ] Long text content
- [ ] Update single field
- [ ] Update multiple fields
- [ ] Successful deletion
### Snapshot Tests
- [ ] All failing tests use `expectOneNotInternalServerErrorSnapshot`
- [ ] Snapshots committed to `__snapshots__/` directory
---
## Success Criteria
Your integration tests are complete when:
✅ All test utilities created (minimum 6 files)
✅ Failing creation tests cover all validators
✅ Failing update tests cover business rules
✅ Failing deletion tests cover protection rules
✅ Successful tests cover all use cases
✅ All snapshots generated and committed
✅ All tests pass consistently
✅ Test coverage meets requirements (>80%)
---
## Final Step
**Step 6 Complete!** → Your syncable entity is fully tested and production-ready!
**Congratulations!** You've successfully created a new syncable entity in Twenty's workspace migration system.
For complete workflow, see `@creating-syncable-entity` rule.
@@ -1,340 +0,0 @@
---
name: syncable-entity-types-and-constants
description: Define types, entities, and central constant registrations for syncable entities in Twenty's workspace migration system. Use when creating new syncable entities, defining TypeORM entities, flat entity types, or registering in central constants (ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME, ALL_ONE_TO_MANY_METADATA_RELATIONS, ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY, ALL_MANY_TO_ONE_METADATA_RELATIONS).
---
# Syncable Entity: Types & Constants (Step 1/6)
**Purpose**: Define all types, entities, and register in central constants. This is the foundation - everything else depends on these types being correct.
**When to use**: First step when creating any new syncable entity. Must be completed before other steps.
---
## Quick Start
This step creates:
1. Metadata name constant (twenty-shared)
2. TypeORM entity (extends `SyncableEntity`)
3. Flat entity types
4. Action types (universal + flat)
5. Central constant registrations (5 constants)
---
## Step 1: Add Metadata Name
**File**: `packages/twenty-shared/src/metadata/all-metadata-name.constant.ts`
```typescript
export const ALL_METADATA_NAME = {
// ... existing entries
myEntity: 'myEntity',
} as const;
```
---
## Step 2: Create TypeORM Entity
**File**: `src/engine/metadata-modules/my-entity/entities/my-entity.entity.ts`
```typescript
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'myEntity' })
export class MyEntityEntity extends SyncableEntity {
@Column({ type: 'varchar' })
name: string;
@Column({ type: 'varchar' })
label: string;
@Column({ type: 'boolean', default: true })
isCustom: boolean;
// Foreign key example (optional)
@Column({ type: 'uuid', nullable: true })
parentEntityId: string | null;
@ManyToOne(() => ParentEntityEntity, { nullable: true })
@JoinColumn({ name: 'parentEntityId' })
parentEntity: ParentEntityEntity | null;
// JSONB column example (optional)
@Column({ type: 'jsonb', nullable: true })
settings: Record<string, any> | null;
}
```
**Key rules**:
- Must extend `SyncableEntity` (provides `id`, `universalIdentifier`, `applicationId`, etc.)
- Must have `isCustom` boolean column
- Use `@Column({ type: 'jsonb' })` for JSON data
---
## Step 3: Define Flat Entity Types
**File**: `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type.ts`
```typescript
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
export type FlatMyEntity = FlatEntityFrom<MyEntityEntity>;
```
**Maps file** (if entity has indexed lookups):
```typescript
// flat-my-entity-maps.type.ts
export type FlatMyEntityMaps = {
byId: Record<string, FlatMyEntity>;
byName: Record<string, FlatMyEntity>;
// Add other indexes as needed
};
```
---
## Step 4: Define Editable Properties
**File**: `src/engine/metadata-modules/flat-my-entity/constants/editable-flat-my-entity-properties.constant.ts`
```typescript
export const EDITABLE_FLAT_MY_ENTITY_PROPERTIES = [
'name',
'label',
'description',
'parentEntityId',
'settings',
] as const satisfies ReadonlyArray<keyof FlatMyEntity>;
```
**Rule**: Only include properties that can be updated (exclude `id`, `createdAt`, `universalIdentifier`, etc.)
---
## Step 5: Define Action Types
**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type.ts`
```typescript
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type';
// Universal actions (used by builder/runner)
export type UniversalCreateMyEntityAction = {
type: 'create';
metadataName: 'myEntity';
universalFlatEntity: UniversalFlatMyEntity;
};
export type UniversalUpdateMyEntityAction = {
type: 'update';
metadataName: 'myEntity';
universalFlatEntity: UniversalFlatMyEntity;
universalUpdates: Partial<UniversalFlatMyEntity>;
};
export type UniversalDeleteMyEntityAction = {
type: 'delete';
metadataName: 'myEntity';
universalFlatEntity: UniversalFlatMyEntity;
};
// Flat actions (internal to runner)
export type FlatCreateMyEntityAction = {
type: 'create';
metadataName: 'myEntity';
flatEntity: FlatMyEntity;
};
export type FlatUpdateMyEntityAction = {
type: 'update';
metadataName: 'myEntity';
flatEntity: FlatMyEntity;
updates: Partial<FlatMyEntity>;
};
export type FlatDeleteMyEntityAction = {
type: 'delete';
metadataName: 'myEntity';
flatEntity: FlatMyEntity;
};
```
---
## Step 6: Register in Central Constants
### 6a. AllFlatEntityTypesByMetadataName
**File**: `src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts`
```typescript
export type AllFlatEntityTypesByMetadataName = {
// ... existing entries
myEntity: {
flatEntityMaps: FlatMyEntityMaps;
universalActions: {
create: UniversalCreateMyEntityAction;
update: UniversalUpdateMyEntityAction;
delete: UniversalDeleteMyEntityAction;
};
flatActions: {
create: FlatCreateMyEntityAction;
update: FlatUpdateMyEntityAction;
delete: FlatDeleteMyEntityAction;
};
flatEntity: FlatMyEntity;
universalFlatEntity: UniversalFlatMyEntity;
entity: MyEntityEntity;
};
};
```
### 6b. ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME
**File**: `src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts`
```typescript
export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
// ... existing entries
myEntity: {
name: { toCompare: true },
label: { toCompare: true },
description: { toCompare: true },
parentEntityId: {
toCompare: true,
universalProperty: 'parentEntityUniversalIdentifier',
},
settings: {
toCompare: true,
toStringify: true,
universalProperty: 'universalSettings',
},
},
} as const;
```
**Rules**:
- `toCompare: true` → Editable property (checked for changes)
- `toStringify: true` → JSONB/object property (needs JSON serialization)
- `universalProperty` → Maps to universal version (for foreign keys & JSONB with `SerializedRelation`)
### 6c. ALL_ONE_TO_MANY_METADATA_RELATIONS
**File**: `src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts`
This constant is **type-checked** — values for `metadataName`, `flatEntityForeignKeyAggregator`, and `universalFlatEntityForeignKeyAggregator` are derived from entity type definitions. The aggregator names follow the pattern: remove trailing `'s'` from the relation property name, then append `Ids` or `UniversalIdentifiers`.
```typescript
export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
// ... existing entries
myEntity: {
// If myEntity has a `childEntities: ChildEntityEntity[]` property:
childEntities: {
metadataName: 'childEntity',
flatEntityForeignKeyAggregator: 'childEntityIds',
universalFlatEntityForeignKeyAggregator: 'childEntityUniversalIdentifiers',
},
// null for relations to non-syncable entities
someNonSyncableRelation: null,
},
} as const;
```
### 6d. ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY
**File**: `src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts`
Low-level primitive constant. Only contains `foreignKey` — the column name ending in `Id` that stores the foreign key. Type-checked against entity properties.
```typescript
export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
// ... existing entries
myEntity: {
workspace: null,
application: null,
parentEntity: {
foreignKey: 'parentEntityId',
},
},
} as const;
```
### 6e. ALL_MANY_TO_ONE_METADATA_RELATIONS
**File**: `src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts`
Derived from both `ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY` (for `foreignKey` type and `universalForeignKey` derivation) and `ALL_ONE_TO_MANY_METADATA_RELATIONS` (for `inverseOneToManyProperty` key constraint). This is the main constant consumed by utils and optimistic tooling.
```typescript
export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
// ... existing entries
myEntity: {
workspace: null,
application: null,
parentEntity: {
metadataName: 'parentEntity',
foreignKey: 'parentEntityId',
inverseOneToManyProperty: 'myEntities', // key in ALL_ONE_TO_MANY_METADATA_RELATIONS['parentEntity'], or null if no inverse
isNullable: false,
universalForeignKey: 'parentEntityUniversalIdentifier',
},
},
} as const;
```
**Derivation dependency graph**:
```
ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY ALL_ONE_TO_MANY_METADATA_RELATIONS
(foreignKey only) (metadataName, aggregators)
│ │
│ FK type + universalFK derivation │ inverseOneToManyProperty keys
│ │
└────────────────┬───────────────────────┘
ALL_MANY_TO_ONE_METADATA_RELATIONS
(metadataName, foreignKey, inverseOneToManyProperty,
isNullable, universalForeignKey)
```
**Rules**:
- `workspace: null`, `application: null` — always present, always null (non-syncable relations)
- `inverseOneToManyProperty` — must be a key in `ALL_ONE_TO_MANY_METADATA_RELATIONS[targetMetadataName]`, or `null` if the target entity doesn't expose an inverse one-to-many relation
- `universalForeignKey` — derived from `foreignKey` by replacing the `Id` suffix with `UniversalIdentifier`
- Optimistic utils resolve `flatEntityForeignKeyAggregator` / `universalFlatEntityForeignKeyAggregator` at runtime by looking up `inverseOneToManyProperty` in `ALL_ONE_TO_MANY_METADATA_RELATIONS`
---
## Checklist
Before moving to Step 2:
- [ ] Metadata name added to `ALL_METADATA_NAME`
- [ ] TypeORM entity created (extends `SyncableEntity`)
- [ ] `isCustom` column added
- [ ] Flat entity type defined
- [ ] Flat entity maps type defined (if needed)
- [ ] Editable properties constant defined
- [ ] Universal and flat action types defined
- [ ] Registered in `AllFlatEntityTypesByMetadataName`
- [ ] Registered in `ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME`
- [ ] Registered in `ALL_ONE_TO_MANY_METADATA_RELATIONS` (if entity has one-to-many relations)
- [ ] Registered in `ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY`
- [ ] Registered in `ALL_MANY_TO_ONE_METADATA_RELATIONS`
- [ ] TypeScript compiles without errors
---
## Next Step
Once all types and constants are defined, proceed to:
**[Syncable Entity: Cache & Transform (Step 2/6)](../syncable-entity-cache-and-transform/SKILL.md)**
For complete workflow, see `@creating-syncable-entity` rule.
-8
View File
@@ -1,8 +0,0 @@
{
"setup-worktree": [
"nvm use",
"yarn",
"cp $ROOT_WORKTREE_PATH/packages/twenty-server/.env packages/twenty-server/.env || true",
"cp $ROOT_WORKTREE_PATH/packages/twenty-front/.env packages/twenty-front/.env || true"
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
.git
.env
**/node_modules
node_modules
.nx/cache
packages/twenty-server/.env
-28
View File
@@ -1,28 +0,0 @@
* text=auto eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.js text eol=lf
*.jsx text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.sh text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
# Patch files may have mixed line endings by design
*.patch -text
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
+5 -5
View File
@@ -1,21 +1,21 @@
# Contributing to Twenty
Thanks for considering contributing to Twenty!
Thanks for considering contributing to Twenty!
Please make sure to go through the [documentation](https://docs.twenty.com) before.
Please make sure to go through the [documentation](https://docs.twenty.com) before.
<br>
## Good first issues
Good first issues are a great way to start contributing and get familiar with the codebase. You can find them on by filtering on the [good first issue](https://github.com/twentyhq/twenty/labels/good%20first%20issue) label.
Good first issues are a great way to start contributing and get familiar with the codebase. You can find them on by filtering on the [good first issue](https://github.com/twentyhq/twenty/labels/good%20first%20issue) label.
## Issue assignment
To avoid conflicts, we follow these guidelines:
1. For `Good First Issue` and `Experienced Contributor` issues without `size: long` labels, we'll merge the first PRs that meet our [code quality standards](https://docs.twenty.com/developers). **We don't assign contributors to these issues**. For `priority: high` issues, our core team will step in within days if no adequate contributions are received.
1. For `Good First Issue` and `Experienced Contributor` issues without `size: long` labels, we'll merge the first PRs that meet our [code quality standards](https://twenty.com/developers). **We don't assign contributors to these issues**. For `priority: high` issues, our core team will step in within days if no adequate contributions are received.
2. For `size: long` Issues, assigned contributors have one week to submit their first draft PR.
## How to Contribute
@@ -63,4 +63,4 @@ git push origin your-branch-name
## Reporting Issues
If you face any issues or have suggestions, please feel free to [create an issue on Twenty's GitHub repository](https://github.com/twentyhq/twenty/issues/new). Please provide as much detail as possible.
If you face any issues or have suggestions, please feel free to (create an issue on Twenty's GitHub repository)[https://github.com/twentyhq/twenty/issues/new]. Please provide as much detail as possible.
@@ -1,53 +0,0 @@
name: Deploy Twenty App
description: Build and deploy a Twenty app to a remote instance
inputs:
api-url:
description: Base URL of the target Twenty instance (e.g. https://my.twenty.instance)
required: true
api-key:
description: API key or access token for the target instance
required: true
app-path:
description: Path to the app directory (relative to repo root). Defaults to repo root.
required: false
default: '.'
runs:
using: composite
steps:
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
cache: yarn
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn install --immutable
- name: Configure remote
shell: bash
run: |
mkdir -p ~/.twenty
node -e "
const fs = require('fs'), path = require('path'), os = require('os');
fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({
version: 1,
remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } }
}, null, 2));
"
env:
API_URL: ${{ inputs.api-url }}
API_KEY: ${{ inputs.api-key }}
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty deploy --remote target
@@ -1,53 +0,0 @@
name: Install Twenty App
description: Install (or upgrade) a Twenty app on a specific workspace
inputs:
api-url:
description: Base URL of the target Twenty instance (e.g. https://my.twenty.instance)
required: true
api-key:
description: API key or access token for the target workspace
required: true
app-path:
description: Path to the app directory (relative to repo root). Defaults to repo root.
required: false
default: '.'
runs:
using: composite
steps:
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
cache: yarn
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn install --immutable
- name: Configure remote
shell: bash
run: |
mkdir -p ~/.twenty
node -e "
const fs = require('fs'), path = require('path'), os = require('os');
fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({
version: 1,
remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } }
}, null, 2));
"
env:
API_URL: ${{ inputs.api-url }}
API_KEY: ${{ inputs.api-key }}
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty install --remote target
-36
View File
@@ -1,36 +0,0 @@
name: Nx Affected CI
inputs:
parallel:
required: false
default: '3'
tag:
required: false
tasks:
required: true
configuration:
required: false
default: 'ci'
args:
required: false
runs:
using: "composite"
steps:
- name: Fetch main branch for diff
shell: bash
run: git fetch origin main --depth=1
- name: Get last successful commit
if: env.NX_BASE == ''
uses: nrwl/nx-set-shas@v4
- name: Fallback to origin/main if no base found
if: env.NX_BASE == ''
shell: bash
run: echo "NX_BASE=$(git rev-parse origin/main)" >> $GITHUB_ENV
- name: Run affected command
shell: bash
env:
NX_CONFIGURATION: ${{ inputs.configuration }}
NX_TASKS: ${{ inputs.tasks }}
NX_PARALLEL: ${{ inputs.parallel }}
NX_TAG: ${{ inputs.tag }}
NX_ARGS: ${{ inputs.args }}
run: npx nx affected --nxBail --configuration="$NX_CONFIGURATION" -t="$NX_TASKS" --parallel="$NX_PARALLEL" --exclude="*,!tag:$NX_TAG" $NX_ARGS
-38
View File
@@ -1,38 +0,0 @@
name: Restore cache
inputs:
key:
required: true
description: Prefix to the cache key
additional-paths:
required: false
outputs:
cache-primary-key:
description: actions/cache/restore cache-primary-key outputs proxy
value: ${{ steps.restore-cache.outputs.cache-primary-key }}
cache-hit:
description: String bool indicating whether cache has been directly or indirectly hit
value: ${{ steps.restore-cache.outputs.cache-hit == 'true' || steps.restore-cache.outputs.cache-matched-key != '' }}
runs:
using: composite
steps:
- name: Cache primary key builder
id: cache-primary-key-builder
shell: bash
env:
CACHE_KEY: ${{ inputs.key }}
REF_NAME: ${{ github.ref_name }}
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${CACHE_KEY}-${REF_NAME}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@v4
id: restore-cache
with:
key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }}
restore-keys: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-
path: |
.cache
.nx
node_modules/.cache
packages/*/node_modules/.cache
${{ inputs.additional-paths }}
@@ -1,47 +0,0 @@
name: Spawn Twenty App Dev Test
description: >
Starts a Twenty all-in-one test instance (server, worker, database, redis)
using the twentycrm/twenty-app-dev Docker image on port 2021.
The server is available at http://localhost:2021 with seeded demo data.
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag for twenty-app-dev (e.g., "latest" or "v1.20.0").'
required: false
default: 'latest'
outputs:
server-url:
description: 'URL where the Twenty test server can be reached'
value: http://localhost:2021
api-key:
description: 'API key (type: API_KEY) for the seeded Twenty dev workspace'
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc
runs:
using: 'composite'
steps:
- name: Start twenty-app-dev-test container
shell: bash
run: |
docker run -d \
--name twenty-app-dev-test \
-p 2021:2021 \
-e NODE_PORT=2021 \
-e SERVER_URL=http://localhost:2021 \
twentycrm/twenty-app-dev:${{ inputs.twenty-version }}
echo "Waiting for Twenty test instance to become healthy…"
TIMEOUT=180
ELAPSED=0
until curl -sf http://localhost:2021/healthz > /dev/null 2>&1; do
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
echo "::error::Twenty did not become healthy within ${TIMEOUT}s"
docker logs twenty-app-dev-test 2>&1 | tail -80
exit 1
fi
sleep 3
ELAPSED=$((ELAPSED + 3))
echo " … waited ${ELAPSED}s"
done
echo "Twenty test instance is ready at http://localhost:2021 (took ~${ELAPSED}s)"
@@ -1,88 +0,0 @@
name: Spawn Twenty Docker Image
description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Accepts "latest" (pulls the latest Docker Hub image, checks out main) or a
semver tag (e.g., v0.40.0).
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag — either "latest" or a semver tag (e.g., v0.40.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
required: false
default: 'twentyhq/twenty'
github-token:
description: 'GitHub token for cross-repo checkout. Required when calling from an external repository.'
required: false
default: ${{ github.token }}
outputs:
server-url:
description: 'URL where the Twenty server can be reached'
value: http://localhost:3000
access-token:
description: 'Admin access token for the Twenty instance'
value: ${{ steps.admin-token.outputs.access-token }}
runs:
using: 'composite'
steps:
- name: Resolve version
id: resolve
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if [ "$VERSION" = "latest" ]; then
echo "docker-tag=latest" >> "$GITHUB_OUTPUT"
echo "git-ref=main" >> "$GITHUB_OUTPUT"
elif echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "docker-tag=$VERSION" >> "$GITHUB_OUTPUT"
echo "git-ref=$VERSION" >> "$GITHUB_OUTPUT"
else
echo "::error::twenty-version must be \"latest\" or a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
- name: Checkout docker compose files
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ steps.resolve.outputs.git-ref }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
sparse-checkout-cone-mode: false
path: .twenty-spawn
- name: Prepare environment
shell: bash
working-directory: ./.twenty-spawn/packages/twenty-docker
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ steps.resolve.outputs.docker-tag }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env
- name: Start Twenty instance
shell: bash
working-directory: ./.twenty-spawn/packages/twenty-docker
run: |
docker compose up -d --wait || {
echo "::error::Docker compose failed to start or health checks timed out"
docker compose logs
exit 1
}
echo "Twenty instance is ready at http://localhost:3000"
- name: Set admin access token
id: admin-token
shell: bash
run: |
ACCESS_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik"
echo "::add-mask::$ACCESS_TOKEN"
echo "access-token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"
-53
View File
@@ -1,53 +0,0 @@
name: Yarn Install
inputs:
node-version:
required: false
default: '24'
runs:
using: 'composite'
steps:
- name: Free disk space for install
if: runner.os == 'Linux'
shell: bash
run: |
# Default GitHub images ship large SDKs this repo does not use; removing
# them avoids ENOSPC when restoring or linking a full Yarn node_modules.
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
df -h
- name: Cache primary key builder
id: globals
shell: bash
run: |
echo "ACTION_SHELL=bash" >> "${GITHUB_OUTPUT}"
echo "CACHE_KEY_PREFIX=node_modules-cache-node-${{ inputs.node-version }}-${{ hashFiles('yarn.lock') }}" >> "${GITHUB_OUTPUT}"
echo 'PATH_TO_CACHE<<EOF' >> $GITHUB_OUTPUT
echo "node_modules" >> $GITHUB_OUTPUT
echo "packages/*/node_modules" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
- name: Setup Node.js and get yarn cache
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Restore node_modules
id: cache-node-modules
uses: actions/cache/restore@v4
with:
key: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
restore-keys: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
- name: Install Dependencies
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
shell: ${{ steps.globals.outputs.ACTION_SHELL }}
run: |
yarn config set enableHardenedMode true
yarn --immutable --check-cache
- name: Save cache
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
uses: actions/cache/save@v4
with:
key: ${{ steps.cache-node-modules.outputs.cache-primary-key }}
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
-22
View File
@@ -1,22 +0,0 @@
#
# Crowdin CLI configuration for App translations (twenty-front, twenty-server, twenty-emails)
# Project ID: 1
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
preserve_hierarchy: true
base_path: ..
files:
#
# Source files filter - PO files for Lingui
#
- source: packages/twenty-front/src/locales/en.po
#
# Translation files path
#
translation: '%original_path%/%locale%.po'
- source: packages/twenty-server/src/engine/core-modules/i18n/locales/en.po
translation: '%original_path%/%locale%.po'
- source: packages/twenty-emails/src/locales/en.po
translation: '%original_path%/%locale%.po'
-45
View File
@@ -1,45 +0,0 @@
#
# Crowdin CLI configuration for Documentation translations
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
"project_id": 2
"preserve_hierarchy": true
"base_url": "https://twenty.api.crowdin.com"
"base_path": ".."
files: [
{
#
# MDX documentation files - user-guide
# Using md type to preserve JSX component structure
# This prevents Crowdin from reformatting <Warning>, <Accordion>, etc.
#
"source": "packages/twenty-docs/user-guide/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/user-guide/**/%original_file_name%",
},
{
#
# MDX documentation files - developers
# Using md type to preserve JSX component structure
#
"source": "packages/twenty-docs/developers/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/developers/**/%original_file_name%",
},
{
#
# MDX documentation files - twenty-ui
# Using md type to preserve JSX component structure
#
"source": "packages/twenty-docs/twenty-ui/**/*.mdx",
"translation": "packages/twenty-docs/l/%two_letters_code%/twenty-ui/**/%original_file_name%",
},
{
#
# Navigation labels template - translated into per-locale navigation.json
#
"source": "packages/twenty-docs/navigation/navigation.template.json",
"translation": "packages/twenty-docs/l/%two_letters_code%/navigation.json",
}
]
-23
View File
@@ -1,23 +0,0 @@
#
# Crowdin CLI configuration for Website translations (twenty-website-new)
# Project ID: 4
# See https://crowdin.github.io/crowdin-cli/configuration for more information
#
project_id: 4
preserve_hierarchy: true
base_url: 'https://twenty.api.crowdin.com'
base_path: ..
languages_mapping:
locale:
fr: fr-FR
files:
#
# Source file - PO file for Lingui
#
- source: packages/twenty-website-new/src/locales/en.po
#
# Translation files path
#
translation: '%original_path%/%locale%.po'
-17
View File
@@ -1,17 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
exclude-paths:
- "packages/twenty-apps/community/**"
schedule:
interval: "weekly"
open-pull-requests-limit: 3
versioning-strategy: "lockfile-only"
assignees:
- "mabdullahabaid"
ignore:
- dependency-name: "@graphql-yoga/nestjs"
- dependency-name: "@nestjs/graphql"
- dependency-name: "@ptc-org/nestjs-query-graphql"
- dependency-name: "typeorm"
-22
View File
@@ -1,22 +0,0 @@
storage: /tmp/verdaccio-storage
auth:
htpasswd:
file: /tmp/verdaccio-htpasswd
max_users: 100
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'twenty-sdk':
access: $all
publish: $all
'twenty-client-sdk':
access: $all
publish: $all
'create-twenty-app':
access: $all
publish: $all
'**':
access: $all
proxy: npmjs
log: { type: stdout, format: pretty, level: warn }
@@ -0,0 +1,22 @@
name: Nx Affected CI
inputs:
parallel:
required: false
default: '3'
tag:
required: false
tasks:
required: true
configuration:
required: false
default: 'ci'
args:
required: false
runs:
using: "composite"
steps:
- name: Get last successful commit
uses: nrwl/nx-set-shas@v4
- name: Run affected command
shell: bash
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}' ${{ inputs.args }}
@@ -0,0 +1,35 @@
name: Restore cache
inputs:
key:
required: true
description: Prefix to the cache key
additional-paths:
required: false
outputs:
cache-primary-key:
description: actions/cache/restore cache-primary-key outputs proxy
value: ${{ steps.restore-cache.outputs.cache-primary-key }}
cache-hit:
description: String bool indicating whether cache has been directly or indirectly hit
value: ${{ steps.restore-cache.outputs.cache-hit == 'true' || steps.restore-cache.outputs.cache-matched-key != '' }}
runs:
using: composite
steps:
- name: Cache primary key builder
id: cache-primary-key-builder
shell: bash
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v3-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@v4
id: restore-cache
with:
key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }}
restore-keys: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-
path: |
.cache
.nx
node_modules/.cache
packages/*/node_modules/.cache
${{ inputs.additional-paths }}
@@ -0,0 +1,42 @@
name: Yarn Install
inputs:
node-version:
required: false
default: '24'
runs:
using: 'composite'
steps:
- name: Cache primary key builder
id: globals
shell: bash
run: |
echo "ACTION_SHELL=bash" >> "${GITHUB_OUTPUT}"
echo "CACHE_KEY_PREFIX=node_modules-cache-node-${{ inputs.node-version }}-${{ hashFiles('yarn.lock') }}" >> "${GITHUB_OUTPUT}"
echo 'PATH_TO_CACHE<<EOF' >> $GITHUB_OUTPUT
echo "node_modules" >> $GITHUB_OUTPUT
echo "packages/*/node_modules" >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
- name: Setup Node.js and get yarn cache
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Restore node_modules
id: cache-node-modules
uses: actions/cache/restore@v4
with:
key: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
restore-keys: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
- name: Install Dependencies
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
shell: ${{ steps.globals.outputs.ACTION_SHELL }}
run: |
yarn config set enableHardenedMode true
yarn --immutable --check-cache
- name: Save cache
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
uses: actions/cache/save@v4
with:
key: ${{ steps.cache-node-modules.outputs.cache-primary-key }}
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
-5
View File
@@ -1,13 +1,8 @@
name: CD deploy main
permissions:
contents: read
on:
push:
branches:
- main
jobs:
deploy-main:
timeout-minutes: 3
-5
View File
@@ -1,13 +1,8 @@
name: CD deploy tag
permissions:
contents: read
on:
push:
tags:
- 'v*'
jobs:
deploy-tag:
timeout-minutes: 3
+1 -5
View File
@@ -1,5 +1,4 @@
name: Changed files reusable workflow
on:
workflow_call:
inputs:
@@ -10,9 +9,6 @@ on:
any_changed:
value: ${{ jobs.changed-files.outputs.any_changed }}
permissions:
contents: read
jobs:
changed-files:
timeout-minutes: 5
@@ -23,7 +19,7 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Check for changed files
id: changed-files
uses: tj-actions/changed-files@v45
-68
View File
@@ -1,68 +0,0 @@
name: AI Catalog Sync
on:
schedule:
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
pull-requests: write
jobs:
sync-catalog:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Run catalog sync
run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/ai-sync-models-dev.ts
- name: Check for changes
id: changes
run: |
if git diff --quiet packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-providers.json; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Create pull request
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: sync AI model catalog from models.dev'
title: 'chore: sync AI model catalog from models.dev'
body: |
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based on the latest data.
New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same model family.
**Please review before merging** — verify no critical models were incorrectly deprecated.
branch: chore/ai-catalog-sync
base: main
labels: ai, automated
delete-branch: true
- name: Trigger automerge
if: steps.changes.outputs.changed == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: automated-pr-ready
+276 -129
View File
@@ -8,7 +8,7 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
MAIN_SERVER_PORT: 3000
@@ -16,6 +16,8 @@ env:
permissions:
contents: read
pull-requests: write
checks: write
jobs:
changed-files-check:
@@ -32,13 +34,15 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 45
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
@@ -51,7 +55,7 @@ jobs:
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:25.8.8
image: clickhouse/clickhouse-server:latest
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
@@ -59,29 +63,27 @@ jobs:
- 8123:8123
- 9000:9000
options: >-
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout current branch
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Try to merge main into current branch
id: merge_attempt
run: |
echo "Attempting to merge main into current branch..."
git config user.email "ci@twenty.com"
git config user.name "CI"
git fetch origin main
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "Current branch: $CURRENT_BRANCH"
if git merge origin/main --no-edit; then
echo "✅ Successfully merged main into current branch"
echo "merged=true" >> $GITHUB_OUTPUT
@@ -89,16 +91,16 @@ jobs:
else
echo "❌ Merge failed due to conflicts"
echo "⚠️ Falling back to comparing current branch against main without merge"
# Abort the failed merge (may not exist if merge never started)
git merge --abort 2>/dev/null || true
# Abort the failed merge
git merge --abort
echo "merged=false" >> $GITHUB_OUTPUT
echo "BRANCH_STATE=conflicts" >> $GITHUB_ENV
fi
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build shared dependencies
run: |
@@ -122,27 +124,27 @@ jobs:
- name: Setup current branch database
run: |
npx nx reset:env twenty-server
# Function to set or update environment variable
set_env_var() {
local var_name="$1"
local var_value="$2"
local env_file="packages/twenty-server/.env"
echo "" >> "$env_file"
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
if grep -q "^${var_name}=" "$env_file"; then
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
else
echo "${var_name}=${var_value}" >> "$env_file"
fi
}
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
set_env_var "REDIS_URL" "redis://localhost:6379"
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed current branch database with test data
run: |
@@ -163,19 +165,19 @@ jobs:
timeout=300
interval=5
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Current branch server is ready!"
break
fi
echo "Current branch server not ready yet, waiting ${interval}s..."
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for current branch server to start"
echo "Current server log:"
@@ -185,15 +187,15 @@ jobs:
- name: Download GraphQL and REST responses from current branch
run: |
# Read admin token from shared test tokens file (single source of truth)
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
# Admin token from jest-integration.config.ts
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
# Load introspection query from file
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
# Prepare the query payload
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
echo "Downloading GraphQL schema from current server..."
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
@@ -202,7 +204,7 @@ jobs:
-o current-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
echo "Downloading GraphQL metadata schema from current server..."
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
-H "Content-Type: application/json" \
@@ -211,32 +213,32 @@ jobs:
-o current-metadata-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
# Download current branch OpenAPI specs
echo "Downloading OpenAPI specifications from current server..."
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o current-rest-api.json \
-w "HTTP Status: %{http_code}\n"
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o current-rest-metadata-api.json \
-w "HTTP Status: %{http_code}\n"
# Verify the downloads
echo "Current branch files downloaded:"
ls -la current-*
- name: Preserve current branch files
run: |
# Create a temp directory to store current branch files
mkdir -p /tmp/current-branch-files
# Move current branch files to temp directory
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
echo "Preserved current branch files for later restoration"
- name: Stop current branch server
@@ -251,28 +253,17 @@ jobs:
rm -f /tmp/current-server.pid
fi
- name: Flush Redis between server runs
run: |
# Clear all Redis caches to prevent stale data from the current branch
# server contaminating the main branch server. Both servers share the
# same Redis instance, and CoreEntityCacheService/WorkspaceCacheService
# persist cached entities across process restarts.
redis-cli -h localhost -p 6379 FLUSHALL || echo "::warning::Failed to flush Redis"
- name: Checkout main branch
run: |
git stash
git checkout origin/main
git reset --hard
git clean -xfd -ff
rm -rf node_modules packages/*/node_modules packages/*/dist dist .nx/cache
git clean -fd
- name: Install dependencies for main branch
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build main branch dependencies
run: |
npx nx reset
npx nx build twenty-shared
npx nx build twenty-emails
@@ -281,28 +272,24 @@ jobs:
- name: Setup main branch database
run: |
npx nx reset:env twenty-server
# Function to set or update environment variable
set_env_var() {
local var_name="$1"
local var_value="$2"
local env_file="packages/twenty-server/.env"
echo "" >> "$env_file"
if grep -q "^${var_name}=" "$env_file" 2>/dev/null; then
if grep -q "^${var_name}=" "$env_file"; then
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
else
echo "${var_name}=${var_value}" >> "$env_file"
fi
}
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
set_env_var "REDIS_URL" "redis://localhost:6379"
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed main branch database with test data
run: |
@@ -323,19 +310,19 @@ jobs:
timeout=300
interval=5
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Main branch server is ready!"
break
fi
echo "Main branch server not ready yet, waiting ${interval}s..."
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for main branch server to start"
echo "Main server log:"
@@ -345,15 +332,15 @@ jobs:
- name: Download GraphQL and REST responses from main branch
run: |
# Read admin token from shared test tokens file (single source of truth)
ADMIN_TOKEN=$(jq -r '.APPLE_JANE_ADMIN_ACCESS_TOKEN' packages/twenty-server/test/integration/constants/test-tokens.json)
# Admin token from jest-integration.config.ts
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
# Load introspection query from file
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
# Prepare the query payload
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
echo "Downloading GraphQL schema from main server..."
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
@@ -362,7 +349,7 @@ jobs:
-o main-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
echo "Downloading GraphQL metadata schema from main server..."
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
-H "Content-Type: application/json" \
@@ -371,70 +358,53 @@ jobs:
-o main-metadata-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
# Download main branch OpenAPI specs
echo "Downloading OpenAPI specifications from main server..."
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o main-rest-api.json \
-w "HTTP Status: %{http_code}\n"
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o main-rest-metadata-api.json \
-w "HTTP Status: %{http_code}\n"
# Verify the downloads
echo "Main branch files downloaded:"
ls -la main-*
- name: Restore current branch files
run: |
# Move current branch files back to working directory
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
# Verify all files are present
echo "All API files restored:"
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
# Clean up temp directory
rm -rf /tmp/current-branch-files
- name: Validate downloaded schema files
id: validate-schemas
run: |
valid=true
for file in main-schema-introspection.json current-schema-introspection.json \
main-metadata-schema-introspection.json current-metadata-schema-introspection.json \
main-rest-api.json current-rest-api.json \
main-rest-metadata-api.json current-rest-metadata-api.json; do
if [ ! -f "$file" ] || ! jq empty "$file" 2>/dev/null; then
echo "::warning::Invalid or missing schema file: $file"
valid=false
fi
done
echo "valid=$valid" >> $GITHUB_OUTPUT
- name: Install OpenAPI Diff Tool
run: |
# Using the Java-based OpenAPITools/openapi-diff via Docker
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
npm install -g @graphql-inspector/cli
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
# Check if GraphQL schema has changes
echo "Checking GraphQL schema for changes..."
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
@@ -447,11 +417,12 @@ jobs:
echo "\`\`\`" >> graphql-schema-diff.md
}
fi
# Check if GraphQL metadata schema has changes
echo "Checking GraphQL metadata schema for changes..."
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL metadata schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
@@ -464,41 +435,40 @@ jobs:
echo "\`\`\`" >> graphql-metadata-diff.md
}
fi
# Show summary
echo "Generated diff files:"
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
# Use the Java-based openapi-diff via Docker
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
--json /specs/rest-api-diff.json \
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
# Check if the output file was created and is valid JSON
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
# Check for breaking changes using Java openapi-diff JSON structure
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
different=$(jq -r '.different // false' rest-api-diff.json)
# Count changes
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST API"
# Generate breaking changes report
echo "# REST API Breaking Changes" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
echo "" >> rest-api-diff.md
# Parse and format the changes from Java openapi-diff
jq -r '
if (.missingEndpoints | length) > 0 then
@@ -514,7 +484,7 @@ jobs:
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end
' rest-api-diff.json >> rest-api-diff.md
elif [ "$different" = "true" ]; then
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed) - no PR comment will be posted"
# Don't create markdown file for non-breaking changes to avoid PR comments
@@ -524,7 +494,7 @@ jobs:
fi
else
echo "⚠️ OpenAPI diff tool could not process the files"
echo "# REST API Analysis Error" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
@@ -533,42 +503,41 @@ jobs:
echo "\`\`\`" >> rest-api-diff.md
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
echo "\`\`\`" >> rest-api-diff.md
# Don't fail the workflow for tool errors
echo "::warning::REST API analysis tool error - continuing workflow"
fi
- name: Check REST Metadata API Breaking Changes
if: steps.validate-schemas.outputs.valid == 'true'
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
# Use the Java-based openapi-diff for metadata API as well
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
--json /specs/rest-metadata-api-diff.json \
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
# Check if the output file was created and is valid JSON
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
# Check for breaking changes using Java openapi-diff JSON structure
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
# Count changes
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST Metadata API"
# Generate breaking changes report (only for breaking changes)
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
# Parse and format the changes from Java openapi-diff
# Parse and format the changes from Java openapi-diff
jq -r '
if (.missingEndpoints | length) > 0 then
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
@@ -592,7 +561,7 @@ jobs:
fi
else
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
@@ -601,21 +570,187 @@ jobs:
echo "\`\`\`" >> rest-metadata-api-diff.md
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
echo "\`\`\`" >> rest-metadata-api-diff.md
# Don't fail the workflow for tool errors
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Upload breaking changes report
- name: Comment API Changes on PR
if: always()
uses: actions/upload-artifact@v4
uses: actions/github-script@v7
with:
name: breaking-changes-report
path: |
*-diff.md
*-diff.json
if-no-files-found: ignore
retention-days: 3
script: |
const fs = require('fs');
let hasChanges = false;
let comment = '';
try {
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
}
}
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += restDiff + '\n\n';
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += metadataDiff + '\n\n';
}
}
// Only post comment if there are changes
if (hasChanges) {
// Add branch state information only if there were conflicts
const branchState = process.env.BRANCH_STATE || 'unknown';
let branchStateNote = '';
if (branchState === 'conflicts') {
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
}
// Check if there are any breaking changes detected
let hasBreakingChanges = false;
let breakingChangeNote = '';
// Check for breaking changes in any of the diff files
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
// Also check GraphQL changes for breaking changes indicators
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.includes('Breaking changes') || graphqlDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
// Check PR title for "breaking"
const prTitle = ${{ toJSON(github.event.pull_request.title) }};
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
if (hasBreakingChanges) {
if (titleContainsBreaking) {
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
'**This PR title contains "breaking" and breaking changes were detected - the CI will fail as expected.**\n\n' +
'📝 **Action Required**: Please add `BREAKING CHANGE:` to your commit message to trigger a major version bump.\n\n' +
'Example:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
} else {
breakingChangeNote = '\n\n## ⚠️ Breaking Change Protocol\n\n' +
'**Breaking changes detected but PR title does not contain "breaking" - CI will pass but action needed.**\n\n' +
'🔄 **Options**:\n' +
'1. **If this IS a breaking change**: Add "breaking" to your PR title and add `BREAKING CHANGE:` to your commit message\n' +
'2. **If this is NOT a breaking change**: The API diff tool may have false positives - please review carefully\n\n' +
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
}
}
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const commentBody = COMMENT_MARKER + '\n' + comment + branchStateNote + '\n⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
// Get all comments to find existing API changes comment
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
// Find our existing comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing API changes comment');
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
console.log('Created new API changes comment');
}
} else {
console.log('No API changes detected - skipping PR comment');
// Check if there's an existing comment to remove
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted existing API changes comment (no changes detected)');
}
}
} catch (error) {
console.log('Could not post comment:', error);
}
- name: Cleanup servers
if: always()
@@ -627,4 +762,16 @@ jobs:
kill $(cat /tmp/main-server.pid) || true
fi
- name: Upload API specifications and diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: api-specifications-and-diffs
path: |
/tmp/main-server.log
/tmp/current-server.log
*-api.json
*-schema-introspection.json
*-diff.md
*-diff.json
@@ -1,201 +0,0 @@
name: CI Hello world App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx run $pkg:set-local-version --releaseVersion=$CI_VERSION
done
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example hello-world --display-name "Test hello-world app" --description "E2E test hello-world app" --skip-local-instance
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute hello-world logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName hello-world-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-hello-world-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-hello-world-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-hello-world-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-hello-world]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -1,171 +0,0 @@
name: CI Create App E2E minimal
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-minimal:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --display-name "Test scaffolded app" --description "E2E test scaffolded app" --skip-local-instance
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-minimal-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-minimal]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -1,199 +0,0 @@
name: CI Postcard App E2E
on:
# Temporarily disabled — will be re-enabled when example apps are published.
# push:
# branches:
# - main
#
# pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/create-twenty-app/package.json
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
create-app-e2e-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-4-cores
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Set CI version and prepare packages for publish
run: |
CI_VERSION="0.0.0-ci.$(date +%s)"
echo "CI_VERSION=$CI_VERSION" >> $GITHUB_ENV
npx nx run-many -t set-local-version -p $PUBLISHABLE_PACKAGES --releaseVersion=$CI_VERSION
- name: Build packages
run: |
for pkg in $PUBLISHABLE_PACKAGES; do
npx nx build $pkg
done
- name: Install and start Verdaccio
run: |
npx verdaccio --config .github/verdaccio-config.yaml &
for i in $(seq 1 30); do
if curl -s http://localhost:4873 > /dev/null 2>&1; then
echo "Verdaccio is ready"
break
fi
echo "Waiting for Verdaccio... ($i/30)"
sleep 1
done
- name: Publish packages to local registry
run: |
yarn config set npmRegistryServer http://localhost:4873
yarn config set unsafeHttpWhitelist --json '["localhost"]'
yarn config set npmAuthToken ci-auth-token
for pkg in $PUBLISHABLE_PACKAGES; do
cd packages/$pkg
yarn npm publish --tag ci
cd ../..
done
- name: Scaffold app using published create-twenty-app
run: |
npm install -g create-twenty-app@$CI_VERSION --registry http://localhost:4873
create-twenty-app --version
mkdir -p /tmp/e2e-test-workspace
cd /tmp/e2e-test-workspace
create-twenty-app test-app --example postcard --display-name "Test postcard app" --description "E2E test postcard app" --skip-local-instance
- name: Install scaffolded app dependencies
run: |
cd /tmp/e2e-test-workspace/test-app
echo 'npmRegistryServer: "http://localhost:4873"' >> .yarnrc.yml
echo 'unsafeHttpWhitelist: ["localhost"]' >> .yarnrc.yml
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install --no-immutable
echo "--- Installing last SDK versions ---"
YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn add twenty-sdk twenty-client-sdk
- name: Verify installed app versions
run: |
cd /tmp/e2e-test-workspace/test-app
echo "--- Checking package.json references correct SDK version ---"
node -e "
const pkg = require('./package.json');
const sdkVersion = pkg.dependencies['twenty-sdk'];
if (!sdkVersion.startsWith('0.0.0-ci.')) {
console.error('Expected twenty-sdk version to start with 0.0.0-ci., got:', sdkVersion);
process.exit(1);
}
console.log('SDK version in scaffolded app:', sdkVersion);
"
- name: Verify SDK CLI is available
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty --version
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Authenticate with twenty-server
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
- name: Deploy scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty deploy
- name: Install scaffolded app
run: |
cd /tmp/e2e-test-workspace/test-app
npx --no-install twenty install
- name: Execute postcard logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName postcard-logic-function)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q "Hello, World!"
- name: Execute create-postcard-company logic function
run: |
cd /tmp/e2e-test-workspace/test-app
EXEC_OUTPUT=$(npx --no-install twenty exec --functionName create-postcard-company)
echo "$EXEC_OUTPUT"
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
- name: Run scaffolded app integration test
run: |
cd /tmp/e2e-test-workspace/test-app
yarn test
ci-create-app-e2e-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-e2e-postcard]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-58
View File
@@ -1,58 +0,0 @@
name: CI Create App
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/create-twenty-app/**
!packages/create-twenty-app/package.json
create-app-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build create-twenty-app
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:create-app
tasks: ${{ matrix.task }}
ci-create-app-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-46
View File
@@ -1,46 +0,0 @@
name: CI Docs
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-docs/**
docs-lint:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Docs / Lint
run: npx nx lint twenty-docs
+132
View File
@@ -0,0 +1,132 @@
name: CI E2E Playwright Tests
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, labeled]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/**
playwright.config.ts
.github/workflows/ci-e2e.yaml
test:
runs-on: ubuntu-latest
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true' && ( github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
timeout-minutes: 30
env:
# https://github.com/actions/runner-images/issues/70#issuecomment-589562148
NODE_OPTIONS: "--max-old-space-size=10240"
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: "true"
SPILO_PROVIDER: "local"
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Check system resources
run: |
echo "Available memory:"
free -h
echo "Available disk space:"
df -h
echo "CPU info:"
lscpu
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Install Playwright Browsers
run: npx nx setup twenty-e2e-testing
- name: Setup environment files
run: |
cp packages/twenty-front/.env.example packages/twenty-front/.env
npx nx reset:env twenty-server
- name: Build frontend
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build server
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start frontend
run: |
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
echo "Waiting for frontend to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
- name: Start worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Run Playwright tests
run: npx nx test twenty-e2e-testing
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: packages/twenty-e2e-testing/run_results/
retention-days: 30
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: packages/twenty-e2e-testing/playwright-report/
retention-days: 30
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+9 -13
View File
@@ -1,8 +1,4 @@
name: CI Emails
permissions:
contents: read
on:
push:
branches:
@@ -12,7 +8,7 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
changed-files-check:
@@ -25,14 +21,14 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build twenty-emails
run: npx nx build twenty-emails
- name: Run email tests
@@ -40,25 +36,25 @@ jobs:
# Start the email server in the background
npx nx run twenty-emails:start &
SERVER_PID=$!
# Wait for server to start
sleep 20
# Check if server is running
if ! curl -s http://localhost:4001/preview/test.email > /dev/null; then
echo "Email server failed to start"
kill $SERVER_PID
exit 1
fi
# Kill the server
kill $SERVER_PID
ci-emails-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, emails-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
run: exit 1
@@ -1,94 +0,0 @@
name: CI Example App Hello World
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-apps/examples/hello-world/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
example-app-hello-world:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK packages
run: npx nx build twenty-sdk
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Run integration tests
working-directory: packages/twenty-apps/examples/hello-world
run: npx vitest run
ci-example-app-hello-world-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, example-app-hello-world]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -1,94 +0,0 @@
name: CI Example App Postcard
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-apps/examples/postcard/**
packages/twenty-sdk/**
packages/twenty-client-sdk/**
packages/twenty-shared/**
!packages/twenty-sdk/package.json
!packages/twenty-client-sdk/package.json
!packages/twenty-shared/package.json
example-app-postcard:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK packages
run: npx nx build twenty-sdk
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: Run integration tests
working-directory: packages/twenty-apps/examples/postcard
run: npx vitest run
ci-example-app-postcard-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, example-app-postcard]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
@@ -1,114 +0,0 @@
name: CI Front Component Renderer
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-front-component-renderer/**
packages/twenty-sdk/**
packages/twenty-shared/**
!packages/twenty-sdk/package.json
renderer-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [build, typecheck, lint]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }}
run: npx nx ${{ matrix.task }} twenty-front-component-renderer
renderer-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build storybook
run: npx nx storybook:build twenty-front-component-renderer
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
retention-days: 1
renderer-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: renderer-sb-build
env:
STORYBOOK_URL: http://localhost:6008
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-sdk
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-front-component-renderer
path: packages/twenty-front-component-renderer/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-front-component-renderer
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-front-component-renderer/storybook-static --port 6008 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6008 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-front-component-renderer
ci-front-component-renderer-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
renderer-task,
renderer-sb-build,
renderer-sb-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+87 -131
View File
@@ -1,40 +1,33 @@
name: CI Front
on:
pull_request:
merge_group:
push:
branches:
- main
permissions:
contents: read
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-ubuntu-latest-8-cores-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v3-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-front/**
packages/twenty-front-component-renderer/**
packages/twenty-ui/**
packages/twenty-shared/**
packages/twenty-sdk/**
!packages/twenty-sdk/package.json
front-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
steps:
@@ -45,28 +38,22 @@ jobs:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Diagnostic disk space issue
run: df -h
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Front / Build storybook
run: npx nx storybook:build twenty-front
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
retention-days: 1
- name: Save storybook build cache
uses: ./.github/actions/save-cache
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
front-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
needs: front-sb-build
strategy:
fail-fast: false
@@ -76,89 +63,88 @@ jobs:
env:
SHARD_COUNTER: 4
REACT_APP_SERVER_BASE_URL: http://localhost:3000
STORYBOOK_URL: http://localhost:6006
steps:
- name: Fetch local actions
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Install Playwright
run: cd packages/twenty-front && npx playwright install
- name: Restore storybook build cache
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
- name: Clean stale storybook vitest cache
run: rm -rf packages/twenty-front/node_modules/.cache/storybook
- name: Build dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-ui
npx nx build twenty-front-component-renderer
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-static
path: packages/twenty-front/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-front
npx playwright install
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Serve storybook & run tests
- name: Run storybook tests
run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }} --checkCoverage=false
- name: Rename coverage file
run: mv packages/twenty-front/coverage/storybook/coverage-storybook.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
retention-days: 1
name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
merge-reports-and-check-coverage:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: front-sb-test
env:
PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
strategy:
matrix:
storybook_scope: [modules, pages, performance]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- uses: actions/download-artifact@v4
with:
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
merge-multiple: true
path: coverage-artifacts
- name: Merge coverage reports
run: |
npx http-server packages/twenty-front/storybook-static --port 6006 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6006 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
# - name: Rename coverage file
# run: |
# if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
# mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# else
# echo "Error: coverage-final.json not found"
# ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
# exit 1
# fi
# - name: Upload coverage artifact
# uses: actions/upload-artifact@v4
# with:
# retention-days: 1
# name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
# path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# merge-reports-and-check-coverage:
# timeout-minutes: 30
# runs-on: ubuntu-latest
# needs: front-sb-test
# env:
# PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
# strategy:
# matrix:
# storybook_scope: [modules, pages, performance]
# steps:
# - uses: actions/checkout@v4
# with:
# fetch-depth: 10
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@v4
# with:
# pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
# merge-multiple: true
# path: coverage-artifacts
# - name: Merge coverage reports
# run: |
# mkdir -p ${{ env.PATH_TO_COVERAGE }}
# npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
# - name: Checking coverage
# run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
mkdir -p ${{ env.PATH_TO_COVERAGE }}
npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
- name: Checking coverage
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
front-chromatic-deployment:
timeout-minutes: 30
if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push'
needs: front-sb-build
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Restore storybook build cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
- name: Front / Write .env
run: |
cd packages/twenty-front
touch .env
echo "REACT_APP_SERVER_BASE_URL: $REACT_APP_SERVER_BASE_URL" >> .env
- name: Publish to Chromatic
run: npx nx run twenty-front:chromatic:ci
front-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
env:
NODE_OPTIONS: '--max-old-space-size=6144'
TASK_CACHE_KEY: front-task-${{ matrix.task }}
strategy:
matrix:
@@ -171,68 +157,38 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Restore ${{ matrix.task }} cache
id: restore-task-cache
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.TASK_CACHE_KEY }}
- name: Reset .env
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:frontend
tasks: reset:env
- name: Run ${{ matrix.task }} task
id: run-task
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:frontend
tasks: ${{ matrix.task }}
- name: Save ${{ matrix.task }} cache
uses: ./.github/actions/save-cache
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
front-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest-8-cores
env:
NODE_OPTIONS: "--max-old-space-size=10240"
ANALYZE: "true"
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Build frontend
run: npx nx build twenty-front
# - name: Upload frontend build artifact
# uses: actions/upload-artifact@v4
# with:
# name: frontend-build
# path: packages/twenty-front/build
# retention-days: 1
ci-front-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
needs:
[
changed-files-check,
front-task,
front-build,
# merge-reports-and-check-coverage,
front-chromatic-deployment,
merge-reports-and-check-coverage,
front-sb-test,
front-sb-build,
]
-138
View File
@@ -1,138 +0,0 @@
name: CI Merge Queue
on:
merge_group:
pull_request:
types: [labeled, synchronize, opened, reopened]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
e2e-test:
if: >
github.event_name == 'merge_group' ||
(github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'run-merge-queue'))
runs-on: ubuntu-latest-8-cores
timeout-minutes: 30
env:
NODE_OPTIONS: "--max-old-space-size=10240"
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 10
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore Nx build cache
uses: actions/cache/restore@v4
with:
key: v4-e2e-build-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
v4-e2e-build-${{ github.ref_name }}-
v4-e2e-build-main-
path: |
.nx
node_modules/.cache
packages/*/node_modules/.cache
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Install Playwright Browsers
run: npx nx setup twenty-e2e-testing
- name: Setup environment files
run: |
cp packages/twenty-front/.env.example packages/twenty-front/.env
npx nx reset:env:e2e-testing-server twenty-server
- name: Build frontend
run: NODE_ENV=production npx nx build twenty-front
- name: Build server
run: npx nx build twenty-server
- name: Save Nx build cache
if: always()
uses: actions/cache/save@v4
with:
key: v4-e2e-build-${{ github.ref_name }}-${{ github.sha }}
path: |
.nx
node_modules/.cache
packages/*/node_modules/.cache
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Start server
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start frontend
run: |
npm_config_yes=true npx serve -s packages/twenty-front/build -l 3001 &
echo "Waiting for frontend to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3001; do sleep 2; done'
- name: Start worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Run Playwright tests
run: npx nx test twenty-e2e-testing
- name: Upload Playwright results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: |
packages/twenty-e2e-testing/run_results/
packages/twenty-e2e-testing/test-results/
retention-days: 7
ci-merge-queue-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+11 -25
View File
@@ -1,9 +1,4 @@
name: "Release: create"
permissions:
contents: read
pull-requests: write
on:
workflow_dispatch:
inputs:
@@ -18,42 +13,33 @@ on:
default: true
description: Create a release after merging the PR
defaults:
run:
shell: bash --noprofile --norc -euo pipefail {0}
jobs:
create_pr:
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
ref: ${{ github.event.inputs.ref }}
- name: Sanitize version
id: sanitize
env:
RAW_VERSION: ${{ github.event.inputs.version }}
run: |
VERSION="${RAW_VERSION#v}"
printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT"
echo version=$(echo ${{ github.event.inputs.version }} | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Update versions
env:
VERSION: ${{ steps.sanitize.outputs.version }}
run: |
printf '%s\n' "$VERSION" > version.txt
echo ${{ steps.sanitize.outputs.version }} > version.txt
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
branch: release/${{ steps.sanitize.outputs.version }}
commit-message: "chore: release v${{ steps.sanitize.outputs.version }}"
committer: Github Action Deploy <github-action-deploy@twenty.com>
author: Github Action Deploy <github-action-deploy@twenty.com>
title: Release v${{ steps.sanitize.outputs.version }}
labels: |
release
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
branch: release/${{ steps.sanitize.outputs.version }}
commit-message: "chore: release v${{ steps.sanitize.outputs.version }}"
committer: Github Action Deploy <github-action-deploy@twenty.com>
author: Github Action Deploy <github-action-deploy@twenty.com>
title: Release v${{ steps.sanitize.outputs.version }}
labels: |
release
${{ github.event.inputs.create_release == true && 'create_release' || '' }}
+7 -22
View File
@@ -1,17 +1,9 @@
name: "Release: on merge"
permissions:
contents: write
on:
pull_request:
types:
- closed
defaults:
run:
shell: bash --noprofile --norc -euo pipefail {0}
jobs:
tag_and_release:
timeout-minutes: 10
@@ -20,40 +12,33 @@ jobs:
steps:
- name: Check PR Author
id: check_author
env:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
set -euo pipefail
if [[ "$PR_AUTHOR" != "github-actions[bot]" ]]; then
echo "PR author ($PR_AUTHOR) is not trusted. Exiting."
if [[ "${{ github.event.pull_request.user.login }}" != "github-actions[bot]" ]]; then
echo "PR author (${AUTHOR}) is not trusted. Exiting."
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
ref: main
- name: Get version from PR title
id: extract_version
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
set -euo pipefail
VERSION=$(printf '%s' "$PR_TITLE" | sed -n 's/.*Release v\([0-9][0-9.]*\).*/\1/p')
VERSION=$(echo "${{ github.event.pull_request.title }}" | sed -n 's/.*Release v\([0-9.]*\).*/\1/p')
if [ -z "$VERSION" ]; then
echo "No valid version found in PR title. Exiting."
exit 1
fi
printf 'VERSION=%s\n' "$VERSION" >> "$GITHUB_ENV"
echo "VERSION=$VERSION" >> $GITHUB_ENV
- name: Push new tag
run: |
set -euo pipefail
git config --global user.name 'Github Action Deploy'
git config --global user.email 'github-action-deploy@twenty.com'
git tag "v${{ env.VERSION }}"
git push origin "v${{ env.VERSION }}"
git tag v${{ env.VERSION }}
git push origin v${{ env.VERSION }}
- uses: release-drafter/release-drafter@v5
if: contains(github.event.pull_request.labels.*.name, 'create_release')
-110
View File
@@ -1,110 +0,0 @@
name: CI SDK
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-sdk/**
packages/twenty-server/**
.github/workflows/ci-sdk.yaml
!packages/twenty-sdk/package.json
sdk-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, test:integration]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-sdk
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
env:
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK
run: npx nx build twenty-sdk
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server > /tmp/twenty-server.log 2>&1 &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: SDK / Run e2e Tests
run: NODE_ENV=test npx vitest run --config ./vitest.e2e.config.ts
working-directory: packages/twenty-sdk
- name: Server / Dump logs on failure
if: failure()
run: tail -100 /tmp/twenty-server.log || echo "No server log file found"
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+83 -228
View File
@@ -1,170 +1,42 @@
name: CI Server
on:
pull_request:
merge_group:
push:
branches:
- main
permissions:
contents: read
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
env:
SERVER_BUILD_CACHE_KEY: server-build
SERVER_SETUP_CACHE_KEY: server-setup
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-front/src/generated-admin/**
packages/twenty-client-sdk/**
packages/twenty-emails/**
packages/twenty-shared/**
server-build:
server-setup:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore server build cache
id: restore-server-build-cache
uses: ./.github/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Build
run: npx nx build twenty-server
- name: Save server build cache
uses: ./.github/actions/save-cache
with:
key: ${{ steps.restore-server-build-cache.outputs.cache-primary-key }}
server-lint-typecheck:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Run lint & typecheck
uses: ./.github/actions/nx-affected
with:
tag: scope:backend
tasks: lint,typecheck
server-previous-version-upgrade-mutation-guard:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Get changed upgrade-version-command files
id: changed-files
uses: tj-actions/changed-files@v45
with:
files: |
packages/twenty-server/src/database/commands/upgrade-version-command/**
- name: Check upgrade version commands are in current version only
if: >
steps.changed-files.outputs.any_changed == 'true' &&
!contains(github.event.pull_request.labels.*.name, 'ci:allow-previous-version-upgrade-mutation')
run: |
VERSION_CONSTANT_FILE="packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts"
CURRENT_VERSION=$(sed -n "s/.*TWENTY_CURRENT_VERSION = '\([0-9.]*\)'.*/\1/p" "$VERSION_CONSTANT_FILE")
if [ -z "$CURRENT_VERSION" ]; then
echo "::error::Could not extract TWENTY_CURRENT_VERSION from $VERSION_CONSTANT_FILE"
exit 1
fi
CURRENT_DIR=$(echo "$CURRENT_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+)\..*/\1-\2/')
echo "Current version: $CURRENT_VERSION (directory: $CURRENT_DIR)"
ADDED_OFFENDERS=""
MODIFIED_OFFENDERS=""
check_files() {
local category="$1"
shift
for file in "$@"; do
VERSION_DIR=$(echo "$file" | sed -n 's|.*upgrade-version-command/\([0-9]*-[0-9]*\)/.*|\1|p')
if [ -n "$VERSION_DIR" ] && [ "$VERSION_DIR" != "$CURRENT_DIR" ]; then
if [ "$category" = "added" ]; then
ADDED_OFFENDERS="$ADDED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
else
MODIFIED_OFFENDERS="$MODIFIED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
fi
fi
done
}
check_files "added" ${{ steps.changed-files.outputs.added_files }}
check_files "modified" ${{ steps.changed-files.outputs.modified_files }}
if [ -n "$ADDED_OFFENDERS" ] || [ -n "$MODIFIED_OFFENDERS" ]; then
echo "This PR touches upgrade command files outside the current version directory ($CURRENT_DIR / $CURRENT_VERSION)."
if [ -n "$ADDED_OFFENDERS" ]; then
echo ""
echo "New files added to non-current version directories:"
echo -e "$ADDED_OFFENDERS"
fi
if [ -n "$MODIFIED_OFFENDERS" ]; then
echo ""
echo "Existing files modified in non-current version directories:"
echo -e "$MODIFIED_OFFENDERS"
fi
echo ""
echo "If this is intentional, add the label 'ci:allow-previous-version-upgrade-mutation' to this PR and re-run CI."
echo "Otherwise, please move your changes to the current version directory ($CURRENT_DIR)."
echo "::error::Upgrade commands were added or modified in non-current version directories."
exit 1
fi
server-validation:
needs: server-build
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
@@ -180,15 +52,21 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore server build cache
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/yarn-install
- name: Restore server setup
id: restore-server-setup-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Run lint & typecheck
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: lint,typecheck
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Build
@@ -198,12 +76,15 @@ jobs:
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Worker / Run
run: |
timeout 30s npx nx run twenty-server:worker || exit_code=$?
if [ $exit_code -eq 124 ]; then
# If timeout was reached (exit code 124), consider it a success
exit 0
elif [ $exit_code -ne 0 ]; then
# If worker failed for other reasons, fail the build
exit $exit_code
fi
- name: Server / Start
@@ -222,93 +103,78 @@ jobs:
exit 1
- name: Server / Check for Pending Migrations
run: |
npx nx database:migrate:generate twenty-server -- --name pending-migration-check || true
CORE_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate core-migration-check -d src/database/typeorm/core/core.datasource.ts || true)
CORE_MIGRATION_FILE=$(ls packages/twenty-server/*core-migration-check.ts 2>/dev/null || echo "")
if [ -n "$CORE_MIGRATION_FILE" ]; then
echo "::error::Unexpected migration files were generated. Please create a proper migration manually."
echo "$CORE_MIGRATION_OUTPUT"
rm -f packages/twenty-server/*core-migration-check.ts
exit 1
fi
- name: GraphQL / Check for Pending Generation
run: |
# Run GraphQL generation commands
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
# Check if any files were modified
if ! git diff --quiet; then
echo "::error::Unexpected migration files were generated. Please run 'npx nx database:migrate:generate twenty-server -- --name <migration-name>' and commit the result."
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
echo "The following migration changes were detected:"
echo "The following GraphQL schema changes were detected:"
echo "==================================================="
git diff
echo "==================================================="
echo ""
git checkout -- .
exit 1
fi
- name: Check for Pending Code Generation
run: |
HAS_ERRORS=false
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
npx nx run twenty-front:graphql:generate --configuration=admin
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata packages/twenty-front/src/generated-admin; then
echo "::error::GraphQL schema changes detected. Please run the three graphql:generate configurations ('data', 'metadata', 'admin') and commit the changes."
echo ""
echo "The following GraphQL schema changes were detected:"
echo "==================================================="
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata packages/twenty-front/src/generated-admin
echo "==================================================="
echo ""
HAS_ERRORS=true
fi
npx nx run twenty-client-sdk:generate-metadata-client
if ! git diff --quiet -- packages/twenty-client-sdk/src/metadata/generated; then
echo "::error::SDK metadata client changes detected. Please run 'npx nx run twenty-client-sdk:generate-metadata-client' and commit the changes."
echo ""
echo "The following SDK metadata client changes were detected:"
echo "==================================================="
git diff -- packages/twenty-client-sdk/src/metadata/generated
echo "==================================================="
echo ""
HAS_ERRORS=true
fi
if [ "$HAS_ERRORS" = true ]; then
echo "Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
exit 1
fi
- name: Save server setup
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
needs: server-build
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-8
needs: server-setup
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Restore server build cache
uses: ./.github/actions/restore-cache
uses: ./.github/workflows/actions/yarn-install
- name: Restore server setup
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
- name: Build twenty-shared
run: npx nx build twenty-shared
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Server / Run Tests
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: test
server-integration-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: server-build
runs-on: depot-ubuntu-24.04-8
needs: server-setup
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
shard: [1, 2, 3, 4]
services:
postgres:
image: postgres:18
image: twentycrm/twenty-postgres-spilo
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
@@ -321,7 +187,7 @@ jobs:
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:25.8.8
image: clickhouse/clickhouse-server:latest
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
@@ -329,35 +195,34 @@ jobs:
- 8123:8123
- 9000:9000
options: >-
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
NODE_ENV: test
ANALYTICS_ENABLED: true
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
CLICKHOUSE_PASSWORD: clickhousePassword
SHARD_COUNTER: 10
SHARD_COUNTER: 4
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Update .env.test for integrations tests
run: |
echo "" >> .env.test
echo "IS_BILLING_ENABLED=true" >> .env.test
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
- name: Restore server build cache
uses: ./.github/actions/restore-cache
- name: Restore server setup
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.SERVER_BUILD_CACHE_KEY }}
key: ${{ env.SERVER_SETUP_CACHE_KEY }}
- name: Server / Build
run: npx nx build twenty-server
- name: Build dependencies
@@ -372,28 +237,18 @@ jobs:
- name: Run ClickHouse seeds
run: npx nx clickhouse:seed twenty-server
- name: Server / Run Integration Tests
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:backend
tasks: 'test:integration'
configuration: 'with-db-reset'
args: --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
server-build,
server-lint-typecheck,
server-previous-version-upgrade-mutation-guard,
server-validation,
server-test,
server-integration-test,
]
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, server-setup, server-test, server-integration-test]
steps:
- name: Fail job if any needs failed
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+9 -13
View File
@@ -1,19 +1,17 @@
name: CI Shared
on:
pull_request:
merge_group:
push:
branches:
- main
permissions:
contents: read
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
@@ -23,8 +21,6 @@ jobs:
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=4096'
strategy:
matrix:
task: [lint, typecheck, test]
@@ -36,13 +32,13 @@ jobs:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:shared
tag: scope:frontend
tasks: ${{ matrix.task }}
ci-shared-status-check:
if: always() && !cancelled()
+6 -76
View File
@@ -1,25 +1,19 @@
name: CI Docker
permissions:
contents: read
name: 'Test Docker Compose'
on:
pull_request:
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-docker/**
docker-compose.yml
test-compose:
test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
@@ -27,24 +21,18 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i docker-compose.yml
yq eval '.services.server.restart = "no"' -i docker-compose.yml
echo "Setting up .env file..."
cp .env.example .env
echo "Generating secrets..."
echo "" >> .env
echo "# === Randomly generated secrets ===" >>.env
echo "APP_SECRET=$(openssl rand -base64 32)" >>.env
echo "PGPASSWORD_SUPERUSER=$(openssl rand -hex 16)" >>.env
@@ -94,69 +82,11 @@ jobs:
echo "Still waiting for server... (${count}/300s)"
done
working-directory: ./packages/twenty-docker/
test-app-dev:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Create frontend placeholder
run: |
mkdir -p packages/twenty-front/build
echo '<html><body>CI placeholder</body></html>' > packages/twenty-front/build/index.html
- name: Build app-dev image
run: |
docker build \
--target twenty-app-dev \
-f packages/twenty-docker/twenty/Dockerfile \
-t twenty-app-dev-ci \
.
- name: Start container
run: |
docker run -d --name twenty-app-dev \
-p 2020:2020 \
twenty-app-dev-ci
docker logs twenty-app-dev -f &
- name: Wait for server health
run: |
echo "Waiting for twenty-app-dev to become healthy..."
count=0
while true; do
status=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:2020/healthz 2>/dev/null || echo "000")
if [ "$status" = "200" ]; then
echo "Server is healthy!"
curl -s http://localhost:2020/healthz
break
fi
container_status=$(docker inspect --format='{{.State.Status}}' twenty-app-dev 2>/dev/null || echo "unknown")
if [ "$container_status" = "exited" ]; then
echo "Container exited unexpectedly"
docker logs twenty-app-dev
exit 1
fi
count=$((count+1))
if [ $count -gt 300 ]; then
echo "Server did not become healthy within 5 minutes"
docker logs twenty-app-dev
exit 1
fi
echo "Still waiting... (${count}/300s) [HTTP ${status}]"
sleep 1
done
ci-test-docker-status-check:
ci-test-docker-compose-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, test-compose, test-app-dev]
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
-112
View File
@@ -1,112 +0,0 @@
name: CI UI
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-ui/**
packages/twenty-shared/**
ui-task:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }}
run: npx nx ${{ matrix.task }} twenty-ui
ui-sb-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build storybook
run: npx nx storybook:build twenty-ui
- name: Upload storybook build
uses: actions/upload-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
retention-days: 1
ui-sb-test:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: ui-sb-build
env:
STORYBOOK_URL: http://localhost:6007
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Download storybook build
uses: actions/download-artifact@v4
with:
name: storybook-twenty-ui
path: packages/twenty-ui/storybook-static
- name: Install Playwright
run: |
cd packages/twenty-ui
npx playwright install
- name: Serve storybook & run tests
run: |
npx http-server packages/twenty-ui/storybook-static --port 6007 --silent &
timeout 30 bash -c 'until curl -sf http://localhost:6007 > /dev/null 2>&1; do sleep 1; done'
npx nx storybook:test twenty-ui
ci-ui-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs:
[
changed-files-check,
ui-task,
ui-sb-build,
ui-sb-test,
]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+6 -6
View File
@@ -1,5 +1,4 @@
name: CI Utils
on:
# it's usually not recommended to use pull_request_target
# but we consider it's safe here if we keep the same steps
@@ -7,12 +6,13 @@ on:
# and: https://github.com/facebook/react-native/pull/34370/files
pull_request_target:
types: [opened, synchronize, reopened, closed]
permissions:
actions: write
checks: write
contents: write
issues: write
pull-requests: write
statuses: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# We don't cancel in-progress because this workflow is triggered on
@@ -27,12 +27,12 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Utils / Run Danger.js
run: cd packages/twenty-utils && npx nx danger:ci
env:
DANGER_GITHUB_API_TOKEN: ${{ github.token }}
congratulate:
timeout-minutes: 3
runs-on: ubuntu-latest
@@ -40,7 +40,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Run congratulate-dangerfile.js
run: cd packages/twenty-utils && npx nx danger:congratulate
env:
+44 -31
View File
@@ -1,57 +1,70 @@
name: CI Website
on:
pull_request:
merge_group:
push:
branches:
- main
permissions:
contents: read
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
changed-files-check:
if: github.event_name != 'merge_group'
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
yarn.lock
packages/twenty-website-new/**
packages/twenty-shared/**
website-task:
packages/twenty-website/**
website-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
timeout-minutes: 10
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=6144'
strategy:
matrix:
task: [lint, typecheck, test]
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
- uses: actions/checkout@v4
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:website
tasks: ${{ matrix.task }}
uses: ./.github/workflows/actions/yarn-install
- name: Server / Create DB
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
- name: Website / Run migrations
run: npx nx database:migrate twenty-website
env:
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
- name: Website / Build Website
run: npx nx build twenty-website
env:
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
KEYSTATIC_GITHUB_CLIENT_ID: xxx
KEYSTATIC_GITHUB_CLIENT_SECRET: xxx
KEYSTATIC_SECRET: xxx
NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG: xxx
ci-website-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, website-task]
needs: [changed-files-check, website-build]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
-125
View File
@@ -1,125 +0,0 @@
name: CI Zapier
on:
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
SERVER_SETUP_CACHE_KEY: server-setup
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-zapier/**
packages/twenty-server/**
!packages/twenty-zapier/package.json
!packages/twenty-zapier/CHANGELOG.md
server-setup:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Write .env
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Server / Build
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Server / Start
run: |
npx nx run twenty-server:start:ci &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -sf http://localhost:3000/healthz; do sleep 2; done'
- name: Start worker
working-directory: packages/twenty-server
run: |
NODE_ENV=development node dist/queue-worker/queue-worker.js &
echo "Worker started"
- name: Zapier / Build
run: npx nx build twenty-zapier
- name: Zapier / Run Tests
uses: ./.github/actions/nx-affected
with:
tag: scope:zapier
tasks: test
zapier-test:
needs: server-setup
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, validate]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-zapier
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:zapier
tasks: ${{ matrix.task }}
ci-zapier-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, zapier-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-167
View File
@@ -1,167 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request_review:
types: [submitted]
issues:
types: [opened, assigned]
repository_dispatch:
types: [claude-core-team-issues]
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.client_payload.issue_number }}
cancel-in-progress: false
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Run Claude Code
id: claude-code
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: |
actions: read
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
settings: |
{
"env": {
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Post Create-PR link if Claude ran out of turns
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH=$(git branch --show-current)
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "" ]; then
exit 0
fi
AHEAD=$(git rev-list --count main.."$BRANCH" 2>/dev/null || echo "0")
if [ "$AHEAD" = "0" ]; then
exit 0
fi
EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
exit 0
fi
ISSUE_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
ENCODED_BRANCH=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$BRANCH")
PR_URL="https://github.com/${{ github.repository }}/compare/main...${ENCODED_BRANCH}?quick_pull=1"
BODY="⚠️ Claude ran out of turns before creating a PR. Work has been pushed to [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$ENCODED_BRANCH).\n\n[**Create PR →**]($PR_URL)"
if [ -n "$ISSUE_NUMBER" ]; then
gh issue comment "$ISSUE_NUMBER" --body "$(echo -e "$BODY")"
fi
claude-cross-repo:
if: github.event_name == 'repository_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build prompt from dispatch payload
id: prompt
uses: actions/github-script@v7
with:
script: |
const p = context.payload.client_payload;
let prompt;
if (p.comment_body) {
prompt = `You are responding to a comment on issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository.\n\nThe comment by @${p.sender} says:\n\n${p.comment_body}\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
} else {
prompt = `You are responding to issue #${p.issue_number} ("${p.issue_title}") in the ${p.repo_full_name} repository, opened by @${p.sender}.\n\nIssue body:\n\n${p.issue_body}\n\nPlease help with this request. The code you are working with is the twenty codebase (this repository).`;
}
core.setOutput('prompt', prompt);
core.setOutput('repo', p.repo_full_name);
core.setOutput('issue_number', p.issue_number);
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ steps.prompt.outputs.prompt }}
additional_permissions: |
actions: read
claude_args: '--max-turns 200 --model opus --allowedTools "Edit,Write,WebFetch,Bash(bash packages/twenty-utils/setup-dev-env.sh),Bash(npx nx *),Bash(npx jest *),Bash(yarn *),Bash(git *),Bash(gh *),Bash(sed *),Bash(python3 *),Bash(rm *),Bash(find *),Bash(grep *),Bash(cat *),Bash(ls *),Bash(head *),Bash(tail *),Bash(wc *),Bash(sort *),Bash(uniq *),Bash(mkdir *),Bash(cp *),Bash(mv *),Bash(touch *),Bash(chmod *),Bash(echo *),Bash(curl *),Bash(cd *),Bash(pwd *),Bash(diff *),Bash(xargs *),Bash(awk *),Bash(cut *),Bash(tee *),Bash(tr *)"'
settings: |
{
"env": {
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Dispatch response to ci-privileged
if: always()
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: claude-cross-repo-response
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
-160
View File
@@ -1,160 +0,0 @@
name: 'Pull docs translations from Crowdin'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 */2 * * *' # Every two hours
workflow_dispatch:
inputs:
force_pull:
description: 'Force pull translations regardless of status'
required: false
type: boolean
default: false
workflow_call:
inputs:
force_pull:
description: 'Force pull translations regardless of status'
required: false
type: boolean
default: false
pull_request:
paths:
- 'packages/twenty-docs/**'
- '.github/crowdin-docs.yml'
- '.github/workflows/docs-i18n-pull.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
pull_docs_translations:
name: Pull docs translations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Setup i18n-docs branch
if: github.event_name != 'pull_request'
run: |
git fetch origin i18n-docs || true
git checkout -B i18n-docs origin/i18n-docs || git checkout -b i18n-docs
- name: Configure git
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
- name: Stash any changes before pulling translations
if: github.event_name != 'pull_request'
run: |
git add .
git stash || true
# Install Crowdin CLI for downloading translations
- name: Install Crowdin CLI
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
run: npm install -g @crowdin/cli
# Pull docs translations from Crowdin one language at a time
# This avoids build timeout issues when processing all languages at once
- name: Pull translated docs from Crowdin
if: github.event_name != 'pull_request' && (inputs.force_pull == true || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
run: |
# Languages supported by Mintlify (see packages/twenty-docs/src/shared/supported-languages.ts)
LANGUAGES="fr ar cs de es it ja ko pt ro ru tr zh-CN"
for lang in $LANGUAGES; do
echo "=== Pulling translations for $lang ==="
crowdin download \
--config .github/crowdin-docs.yml \
--token "$CROWDIN_PERSONAL_TOKEN" \
--base-url "https://twenty.api.crowdin.com" \
--language "$lang" \
--skip-untranslated-strings=false \
--skip-untranslated-files=false \
--export-only-approved=false \
--verbose || echo "Warning: Failed to pull $lang, continuing with other languages..."
echo ""
done
echo "=== Download complete ==="
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Fix file permissions
if: github.event_name != 'pull_request'
run: sudo chown -R runner:docker . || true
- name: Fix translated documentation links
run: bash packages/twenty-docs/scripts/fix-translated-links.sh
- name: Regenerate navigation template
if: github.event_name == 'pull_request'
run: yarn docs:generate-navigation-template
- name: Regenerate docs.json
run: yarn docs:generate
- name: Regenerate documentation paths constants
run: yarn docs:generate-paths
- name: Commit artifacts to pull request branch
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
run: |
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json packages/twenty-shared/src/constants/DocumentationPaths.ts
if git diff --staged --quiet --exit-code; then
echo "No navigation/doc changes to commit."
exit 0
fi
git commit -m "chore: sync docs artifacts"
git push origin "HEAD:$HEAD_REF"
env:
HEAD_REF: ${{ github.head_ref }}
- name: Check for changes and commit
if: github.event_name != 'pull_request'
id: check_changes
run: |
git add .
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: update docs translations from Crowdin and fix internal links"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Push changes
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n-docs
- name: Create pull request
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n-docs --title 'i18n - docs translations' --body 'Created by Github action' || true
else
echo "No file differences between branches, skipping PR creation"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: github.event_name != 'pull_request' && steps.check_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-51
View File
@@ -1,51 +0,0 @@
name: 'Push docs to Crowdin'
permissions:
contents: write
on:
workflow_dispatch:
workflow_call:
push:
branches: ['main']
paths:
- 'packages/twenty-docs/**/*.mdx'
- '!packages/twenty-docs/l/**'
- 'packages/twenty-docs/navigation/navigation.template.json'
- '.github/crowdin-docs.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
push_docs:
name: Push documentation to Crowdin
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.ref }}
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Generate navigation template for Crowdin
run: yarn docs:generate-navigation-template
- name: Upload docs to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
localization_branch_name: i18n-docs
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-docs.yml'
env:
# Docs translations project
CROWDIN_PROJECT_ID: '2'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+11 -25
View File
@@ -3,10 +3,6 @@
name: 'Pull translations from Crowdin'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 */2 * * *' # Every two hours.
@@ -27,12 +23,16 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
pull_translations:
name: Pull translations
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -46,7 +46,7 @@ jobs:
git checkout -B i18n origin/i18n || git checkout -b i18n
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
@@ -83,37 +83,31 @@ jobs:
push_sources: false
skip_untranslated_strings: false
skip_untranslated_files: false
push_translations: false
push_translations: true
create_pull_request: false
skip_ref_checkout: true
dryrun_action: false
config: '.github/crowdin-app.yml'
env:
GITHUB_TOKEN: ${{ github.token }}
# App translations project
CROWDIN_PROJECT_ID: '1'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# As the files are extracted from a Docker container, they belong to root:root
# We need to fix this before the next steps
- name: Fix file permissions
run: sudo chown -R runner:docker .
# Fix encoding issues (escaped Unicode like \u62db -> 招) and push fixes back to Crowdin
- name: Fix translation encoding and sync to Crowdin
run: npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Compile translations
id: compile_translations
# Because we have set English as a fallback locale, this condition does not work anymore
# if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
run: |
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
git status
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add .
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile translations"
@@ -136,11 +130,3 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+9 -17
View File
@@ -1,9 +1,5 @@
name: 'Push translations to Crowdin'
permissions:
contents: write
pull-requests: write
on:
workflow_dispatch:
workflow_call:
@@ -12,12 +8,15 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
cancel-in-progress: true
jobs:
extract_translations:
name: Extract and upload translations
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -31,7 +30,7 @@ jobs:
git checkout -B i18n origin/i18n || git checkout -b i18n
- name: Install dependencies
uses: ./.github/actions/yarn-install
uses: ./.github/workflows/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
@@ -87,10 +86,11 @@ jobs:
download_translations: false
localization_branch_name: i18n
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-app.yml'
env:
# App translations project
CROWDIN_PROJECT_ID: '1'
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
CROWDIN_PROJECT_ID: 1
# Visit https://crowdin.com/settings#api-key to create this token
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Create a pull request
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
@@ -102,11 +102,3 @@ jobs:
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-71
View File
@@ -1,71 +0,0 @@
name: Post CI Comments
on:
workflow_run:
workflows: ['GraphQL and OpenAPI Breaking Changes Detection']
types: [completed]
permissions:
actions: read
jobs:
dispatch-breaking-changes:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Get PR number from workflow run
id: pr-info
uses: actions/github-script@v7
with:
script: |
const runId = context.payload.workflow_run.id;
const headSha = context.payload.workflow_run.head_sha;
const headBranch = context.payload.workflow_run.head_branch;
const headRepo = context.payload.workflow_run.head_repository;
// workflow_run.pull_requests is empty for fork PRs,
// so fall back to searching by head SHA
let pullRequests = context.payload.workflow_run.pull_requests;
let prNumber;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
} else {
core.info(`pull_requests is empty (likely a fork PR), searching by SHA ${headSha}`);
const owner = context.repo.owner;
const repo = context.repo.repo;
const headLabel = `${headRepo.owner.login}:${headBranch}`;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
head: headLabel,
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
core.info('No pull request found for this workflow run');
core.setOutput('has_pr', 'false');
return;
}
core.setOutput('pr_number', prNumber);
core.setOutput('run_id', runId);
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}, Run ID: ${runId}`);
- name: Dispatch to ci-privileged
if: steps.pr-info.outputs.has_pr == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: breaking-changes-report
client-payload: '{"pr_number": ${{ toJSON(steps.pr-info.outputs.pr_number) }}, "run_id": ${{ toJSON(steps.pr-info.outputs.run_id) }}, "repo": ${{ toJSON(github.repository) }}, "branch_state": ${{ toJSON(github.event.workflow_run.head_branch) }}}'
+8 -24
View File
@@ -1,9 +1,9 @@
name: 'Preview Environment Dispatch'
permissions:
contents: write
on:
# Using pull_request_target instead of pull_request to have access to secrets for external contributors
# Security note: This is safe because we're only using the repository-dispatch action with limited scope
# and not checking out or running any code from the external contributor's PR
pull_request_target:
types: [opened, synchronize, reopened, labeled]
paths:
@@ -19,19 +19,11 @@ concurrency:
jobs:
trigger-preview:
if: |
(github.event.action == 'labeled' && github.event.label.name == 'preview-app') ||
(
(
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
) && (
github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened'
)
)
permissions:
contents: write
actions: write
pull-requests: read
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
@@ -42,11 +34,3 @@ jobs:
repository: ${{ github.repository }}
event-type: preview-environment
client-payload: '{"pr_number": "${{ github.event.pull_request.number }}", "pr_head_sha": "${{ github.event.pull_request.head.sha }}", "repo_full_name": "${{ github.repository }}"}'
- name: Dispatch to ci-privileged for PR comment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: preview-env-url
client-payload: '{"pr_number": ${{ toJSON(github.event.pull_request.number) }}, "keepalive_dispatch_time": ${{ toJSON(github.event.pull_request.updated_at) }}, "repo": ${{ toJSON(github.repository) }}}'
+61 -47
View File
@@ -1,8 +1,5 @@
name: 'Preview Environment Keep Alive'
permissions:
contents: read
on:
repository_dispatch:
types: [preview-environment]
@@ -16,13 +13,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
@@ -30,21 +21,18 @@ jobs:
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "" >> packages/twenty-docker/.env
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
@@ -53,25 +41,23 @@ jobs:
cd packages/twenty-docker/
docker compose build
working-directory: ./
- name: Create Tunnel
id: expose-tunnel
uses: codetalkio/expose-tunnel@v1.5.0
with:
service: bore.pub
port: 3000
- name: Start services with correct SERVER_URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
cd packages/twenty-docker/
echo "Setting SERVER_URL to $TUNNEL_URL"
# Update the SERVER_URL with the tunnel URL
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=$TUNNEL_URL" >> .env
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
# Start the services
echo "Docker compose up..."
docker compose up -d || {
@@ -79,7 +65,7 @@ jobs:
docker compose logs
exit 1
}
echo "Waiting for services to be ready..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
@@ -92,7 +78,7 @@ jobs:
fi
echo "Still waiting for services... ($count/60)"
done
echo "All services are up and running!"
working-directory: ./
@@ -107,33 +93,61 @@ jobs:
fi
working-directory: ./
- name: Output tunnel URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
- name: Output tunnel URL to logs
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: $TUNNEL_URL"
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
echo "⏱️ This environment will be available for 5 hours"
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@v4
- name: Post comment on PR
uses: actions/github-script@v6
with:
name: tunnel-url
path: tunnel-url.txt
retention-days: 1
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const COMMENT_MARKER = '<!-- PR_PREVIEW_ENV -->';
const commentBody = `${COMMENT_MARKER}
🚀 **Preview Environment Ready!**
Your preview environment is available at: ${{ steps.expose-tunnel.outputs.tunnel-url }}
This environment will automatically shut down when the PR is closed or after 5 hours.`;
// Get all comments
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
});
// Find our comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing comment');
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
body: commentBody
});
console.log('Created new comment');
}
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
- name: Cleanup
if: always()
run: |
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
working-directory: ./
@@ -1,144 +0,0 @@
name: Visual Regression Dispatch
# Uses workflow_run to dispatch visual regression to ci-privileged.
# This runs in the context of the base repo (not the fork), so it has
# access to secrets — making it work for external contributor PRs.
on:
workflow_run:
workflows: ['CI Front', 'CI UI']
types: [completed]
permissions:
actions: read
pull-requests: read
jobs:
dispatch:
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Determine project and artifact name
id: project
uses: actions/github-script@v7
with:
script: |
const workflowName = context.payload.workflow_run.name;
if (workflowName === 'CI Front') {
core.setOutput('project', 'twenty-front');
core.setOutput('artifact_name', 'storybook-static');
core.setOutput('tarball_name', 'storybook-twenty-front-tarball');
core.setOutput('tarball_file', 'storybook-twenty-front.tar.gz');
} else if (workflowName === 'CI UI') {
core.setOutput('project', 'twenty-ui');
core.setOutput('artifact_name', 'storybook-twenty-ui');
core.setOutput('tarball_name', 'storybook-twenty-ui-tarball');
core.setOutput('tarball_file', 'storybook-twenty-ui.tar.gz');
} else {
core.setFailed(`Unexpected workflow: ${workflowName}`);
}
- name: Check if storybook artifact exists
id: check-artifact
uses: actions/github-script@v7
with:
script: |
const artifactName = '${{ steps.project.outputs.artifact_name }}';
const runId = context.payload.workflow_run.id;
const { data: artifacts } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: runId,
});
const found = artifacts.artifacts.some(a => a.name === artifactName);
core.setOutput('exists', found ? 'true' : 'false');
if (!found) {
core.info(`Artifact "${artifactName}" not found in run ${runId} — storybook build was likely skipped`);
}
- name: Get PR number
if: steps.check-artifact.outputs.exists == 'true'
id: pr-info
uses: actions/github-script@v7
with:
script: |
const headBranch = context.payload.workflow_run.head_branch;
const headRepo = context.payload.workflow_run.head_repository;
// workflow_run.pull_requests is empty for fork PRs,
// so fall back to searching by head label (owner:branch)
let pullRequests = context.payload.workflow_run.pull_requests;
let prNumber;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
} else {
const headLabel = `${headRepo.owner.login}:${headBranch}`;
core.info(`pull_requests is empty (likely a fork PR), searching by head label: ${headLabel}`);
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: headLabel,
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
core.info('No pull request found for this workflow run — skipping');
core.setOutput('has_pr', 'false');
return;
}
core.setOutput('pr_number', prNumber);
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}`);
- name: Download storybook artifact from triggering run
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/download-artifact@v4
with:
name: ${{ steps.project.outputs.artifact_name }}
path: storybook-static
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Package storybook
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
run: tar -czf /tmp/${{ steps.project.outputs.tarball_file }} -C storybook-static .
- name: Upload storybook tarball
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: actions/upload-artifact@v4
with:
name: ${{ steps.project.outputs.tarball_name }}
path: /tmp/${{ steps.project.outputs.tarball_file }}
retention-days: 1
- name: Dispatch to ci-privileged
if: steps.check-artifact.outputs.exists == 'true' && steps.pr-info.outputs.has_pr == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: visual-regression
client-payload: >-
{
"pr_number": "${{ steps.pr-info.outputs.pr_number }}",
"run_id": "${{ github.run_id }}",
"repo": "${{ github.repository }}",
"project": "${{ steps.project.outputs.project }}",
"branch": "${{ github.event.workflow_run.head_branch }}",
"commit": "${{ github.event.workflow_run.head_sha }}"
}
-135
View File
@@ -1,135 +0,0 @@
# Pull down website translations from Crowdin every two hours or when triggered manually.
# When force_pull input is true, translations will be pulled regardless of compilation status.
name: 'Pull website translations from Crowdin'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 */2 * * *' # Every two hours.
workflow_dispatch:
inputs:
force_pull:
description: 'Force pull translations regardless of compilation status'
required: false
type: boolean
default: false
workflow_call:
inputs:
force_pull:
description: 'Force pull translations regardless of compilation status'
required: false
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
pull_website_translations:
name: Pull website translations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: ${{ github.head_ref || github.ref_name }}
- name: Setup website i18n branch
run: |
git fetch origin i18n-website || true
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
# Strict mode fails if there are missing website translations.
- name: Compile website translations
id: compile_translations_strict
run: npx nx run twenty-website-new:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add .
git stash
- name: Pull website translations from Crowdin
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
source: 'packages/twenty-website-new/src/locales/en.po'
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
export_only_approved: false
localization_branch_name: i18n-website
base_url: 'https://twenty.api.crowdin.com'
auto_approve_imported: false
import_eq_suggestions: false
download_sources: false
push_sources: false
skip_untranslated_strings: false
skip_untranslated_files: false
push_translations: false
create_pull_request: false
skip_ref_checkout: true
dryrun_action: false
config: '.github/crowdin-website.yml'
env:
GITHUB_TOKEN: ${{ github.token }}
# Website translations project
CROWDIN_PROJECT_ID: '4'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# As the files are extracted from a Docker container, they belong to root:root.
# We need to fix this before the next steps.
- name: Fix file permissions
run: sudo chown -R runner:docker .
- name: Compile website translations
id: compile_translations
run: |
npx nx run twenty-website-new:lingui:compile
git status
git add packages/twenty-website-new/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Push changes
if: steps.compile_translations.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n-website
- name: Create pull request
if: steps.compile_translations.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
else
echo "No file differences between branches, skipping PR creation"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.compile_translations.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
-111
View File
@@ -1,111 +0,0 @@
name: 'Push website translations to Crowdin'
permissions:
contents: write
pull-requests: write
on:
workflow_dispatch:
workflow_call:
push:
branches: ['main']
paths:
- 'packages/twenty-website-new/**'
- '.github/crowdin-website.yml'
- '.github/workflows/website-i18n-push.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
extract_website_translations:
name: Extract and upload website translations
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ github.token }}
ref: main
- name: Setup website i18n branch
run: |
git fetch origin i18n-website || true
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build dependencies
run: npx nx build twenty-shared
- name: Extract website translations
run: npx nx run twenty-website-new:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website-new/src/locales
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: extract website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Compile website translations
run: npx nx run twenty-website-new:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
git add packages/twenty-website-new/src/locales/generated
if ! git diff --staged --quiet --exit-code; then
git commit -m "chore: compile website translations"
echo "changes_detected=true" >> $GITHUB_OUTPUT
else
echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
- name: Push changes and create remote branch if needed
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
run: git push origin HEAD:i18n-website
- name: Upload missing website translations
if: steps.check_extract_changes.outputs.changes_detected == 'true'
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: true
download_translations: false
localization_branch_name: i18n-website
base_url: 'https://twenty.api.crowdin.com'
config: '.github/crowdin-website.yml'
env:
# Website translations project
CROWDIN_PROJECT_ID: '4'
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Create a pull request
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
run: |
if git diff --name-only origin/main..HEAD | grep -q .; then
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
else
echo "No file differences between branches, skipping PR creation"
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Trigger i18n automerge
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
repository: twentyhq/twenty-infra
event-type: i18n-pr-ready
+1 -8
View File
@@ -1,8 +1,6 @@
**/**/.env
.DS_Store
/.idea
.claude/
.cursor/debug-*.log
**/**/node_modules/
.cache
@@ -12,7 +10,6 @@
.nx/installation
.nx/cache
.nx/workspace-data
.nx/nxw.js
.pnp.*
.yarn/*
@@ -30,7 +27,7 @@ coverage
dist
storybook-static
*.tsbuildinfo
.oxlintcache
.eslintcache
.nyc_output
test-results/
dump.rdb
@@ -51,7 +48,3 @@ dump.rdb
mcp.json
/.junie/
TRANSLATION_QA_REPORT.md
.playwright-mcp/
.playwright-cli/
output/playwright/
-22
View File
@@ -1,22 +0,0 @@
{
"mcpServers": {
"postgres": {
"type": "stdio",
"command": "bash",
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
"env": {}
},
"playwright": {
"type": "stdio",
"command": "npx",
"args": ["@playwright/mcp@latest", "--no-sandbox", "--headless"],
"env": {}
},
"context7": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {}
}
}
}
+115
View File
@@ -0,0 +1,115 @@
"use strict";
// This file should be committed to your repository! It wraps Nx and ensures
// that your local installation matches nx.json.
// See: https://nx.dev/recipes/installation/install-non-javascript for more info.
Object.defineProperty(exports, "__esModule", { value: true });
const fs = require('fs');
const path = require('path');
const cp = require('child_process');
const installationPath = path.join(__dirname, 'installation', 'package.json');
function matchesCurrentNxInstall(currentInstallation, nxJsonInstallation) {
if (!currentInstallation.devDependencies ||
!Object.keys(currentInstallation.devDependencies).length) {
return false;
}
try {
if (currentInstallation.devDependencies['nx'] !==
nxJsonInstallation.version ||
require(path.join(path.dirname(installationPath), 'node_modules', 'nx', 'package.json')).version !== nxJsonInstallation.version) {
return false;
}
for (const [plugin, desiredVersion] of Object.entries(nxJsonInstallation.plugins || {})) {
if (currentInstallation.devDependencies[plugin] !== desiredVersion) {
return false;
}
}
return true;
}
catch {
return false;
}
}
function ensureDir(p) {
if (!fs.existsSync(p)) {
fs.mkdirSync(p, { recursive: true });
}
}
function getCurrentInstallation() {
try {
return require(installationPath);
}
catch {
return {
name: 'nx-installation',
version: '0.0.0',
devDependencies: {},
};
}
}
function performInstallation(currentInstallation, nxJson) {
fs.writeFileSync(installationPath, JSON.stringify({
name: 'nx-installation',
devDependencies: {
nx: nxJson.installation.version,
...nxJson.installation.plugins,
},
}));
try {
cp.execSync('npm i', {
cwd: path.dirname(installationPath),
stdio: 'inherit',
});
}
catch (e) {
// revert possible changes to the current installation
fs.writeFileSync(installationPath, JSON.stringify(currentInstallation));
// rethrow
throw e;
}
}
function ensureUpToDateInstallation() {
const nxJsonPath = path.join(__dirname, '..', 'nx.json');
let nxJson;
try {
nxJson = require(nxJsonPath);
if (!nxJson.installation) {
console.error('[NX]: The "installation" entry in the "nx.json" file is required when running the nx wrapper. See https://nx.dev/recipes/installation/install-non-javascript');
process.exit(1);
}
}
catch {
console.error('[NX]: The "nx.json" file is required when running the nx wrapper. See https://nx.dev/recipes/installation/install-non-javascript');
process.exit(1);
}
try {
ensureDir(path.join(__dirname, 'installation'));
const currentInstallation = getCurrentInstallation();
if (!matchesCurrentNxInstall(currentInstallation, nxJson.installation)) {
performInstallation(currentInstallation, nxJson);
}
}
catch (e) {
const messageLines = [
'[NX]: Nx wrapper failed to synchronize installation.',
];
if (e instanceof Error) {
messageLines.push('');
messageLines.push(e.message);
messageLines.push(e.stack);
}
else {
messageLines.push(e.toString());
}
console.error(messageLines.join('\n'));
process.exit(1);
}
}
if (!process.env.NX_WRAPPER_SKIP_INSTALL) {
ensureUpToDateInstallation();
}
require('./installation/node_modules/nx/bin/nx');
+4
View File
@@ -0,0 +1,4 @@
# Add files here to ignore them from prettier formatting
/dist
/coverage
/.nx/cache
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "auto"
}
View File
+1 -1
View File
@@ -1,7 +1,7 @@
{
"recommendations": [
"arcanis.vscode-zipfs",
"oxc.oxc-vscode",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"figma.figma-vscode-extension",
"firsttris.vscode-jest-runner",
+15 -19
View File
@@ -8,7 +8,11 @@
"runtimeVersion": "24.5",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": ["nx", "run", "twenty-server:start"],
"runtimeArgs": [
"nx",
"run",
"twenty-server:start"
],
"outputCapture": "std",
"internalConsoleOptions": "openOnSessionStart",
"console": "internalConsole",
@@ -19,7 +23,11 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": ["nx", "run", "twenty-server:worker"],
"runtimeArgs": [
"nx",
"run",
"twenty-server:worker"
],
"outputCapture": "std",
"internalConsoleOptions": "openOnSessionStart",
"console": "internalConsole",
@@ -66,17 +74,14 @@
"--",
"--config",
"./jest-integration.config.ts",
"${relativeFile}",
"--silent=false",
"${input:updateSnapshot}"
"${relativeFile}"
],
"cwd": "${workspaceFolder}/packages/twenty-server",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
}
"NODE_ENV": "test"
},
},
{
"type": "node",
@@ -97,16 +102,7 @@
"internalConsoleOptions": "neverOpen",
"env": {
"NODE_ENV": "test"
}
}
],
"inputs": [
{
"id": "updateSnapshot",
"type": "pickString",
"description": "Update snapshots?",
"options": ["", "--updateSnapshot"],
"default": ""
},
}
]
}
}
+9 -16
View File
@@ -1,31 +1,26 @@
{
"editor.formatOnSave": false,
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true,
"files.eol": "auto",
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
},
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always",
"source.organizeImports": "always"
}
@@ -34,7 +29,6 @@
"editor.formatOnSave": true
},
"javascript.format.enable": false,
"javascript.preferences.importModuleSpecifier": "non-relative",
"typescript.format.enable": false,
"cSpell.enableFiletypes": [
"!javascript",
@@ -51,11 +45,10 @@
"search.exclude": {
"**/.yarn": true
},
"oxc.lint.enable": true,
"eslint.debug": true,
"files.associations": {
".cursorrules": "markdown"
},
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}",
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.experimental.useTsgo": false
"typescript.tsdk": "node_modules/typescript/lib"
}
+7 -59
View File
@@ -2,64 +2,12 @@
"version": "2.0.0",
"tasks": [
{
"label": "twenty-server - run integration test file",
"type": "shell",
"command": "npx nx run twenty-server:jest -- --config ./jest-integration.config.ts ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
"options": {
"cwd": "${workspaceFolder}/packages/twenty-server",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
},
"shell": {
"executable": "/bin/zsh",
"args": ["-l", "-c"]
}
},
"presentation": {
"reveal": "always",
"panel": "new",
"close": false
},
"problemMatcher": []
},
{
"label": "twenty-server - run unit test file",
"type": "shell",
"command": "npx nx run twenty-server:jest -- --config ./jest.config.mjs ${relativeFile} --silent=false ${input:watchMode} ${input:updateSnapshot}",
"options": {
"cwd": "${workspaceFolder}/packages/twenty-server",
"env": {
"NODE_ENV": "test",
"NODE_OPTIONS": "--max-old-space-size=12288 --import tsx/esm"
},
"shell": {
"executable": "/bin/zsh",
"args": ["-l", "-c"]
}
},
"presentation": {
"reveal": "always",
"panel": "new",
"close": false
},
"problemMatcher": []
}
],
"inputs": [
{
"id": "watchMode",
"type": "pickString",
"description": "Enable watch mode?",
"options": ["", "--watch"],
"default": ""
},
{
"id": "updateSnapshot",
"type": "pickString",
"description": "Update snapshots?",
"options": ["", "--updateSnapshot"],
"default": ""
"type": "npm",
"script": "start",
"path": "server",
"problemMatcher": [],
"label": "yarn: start - server",
"detail": "yarn start"
}
]
}
}
+9 -13
View File
@@ -37,8 +37,8 @@
"path": "../packages/twenty-zapier"
},
{
"name": "packages/twenty-oxlint-rules",
"path": "../packages/twenty-oxlint-rules"
"name": "tools/eslint-rules",
"path": "../tools/eslint-rules"
},
{
"name": "packages/twenty-e2e-testing",
@@ -49,26 +49,23 @@
"editor.formatOnSave": false,
"files.eol": "auto",
"[typescript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
}
},
"[javascript]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
}
},
"[typescriptreact]": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
}
},
@@ -91,7 +88,7 @@
"typescript.preferences.importModuleSpecifier": "non-relative",
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "explicit",
"source.fixAll.eslint": "explicit",
"source.addMissingImports": "always"
}
},
@@ -101,7 +98,6 @@
"files.exclude": {
"packages/": true
},
"oxc.lint.enable": true,
"jest.runMode": "on-demand",
"jest.disabledWorkspaceFolders": [
"ROOT",
@@ -1,58 +0,0 @@
diff --git a/esm/cache.js b/esm/cache.js
index 07cf6d7dd99effb9c3464b620ba67a7f445224f5..248bb527923499a6be8065ee7a3613b55819c58c 100644
--- a/esm/cache.js
+++ b/esm/cache.js
@@ -69,17 +69,20 @@ export class TransformCacheCollection {
this.invalidate(cacheName, filename);
});
}
- invalidateIfChanged(filename, content) {
+ invalidateIfChanged(filename, content, _visited) {
+ const visited = _visited || new Set();
+ if (visited.has(filename)) {
+ return false;
+ }
+ visited.add(filename);
const fileEntrypoint = this.get('entrypoints', filename);
- // We need to check all dependencies of the file
- // because they might have changed as well.
if (fileEntrypoint) {
for (const [, dependency] of fileEntrypoint.dependencies) {
const dependencyFilename = dependency.resolved;
if (dependencyFilename) {
const dependencyContent = fs.readFileSync(dependencyFilename, 'utf8');
- this.invalidateIfChanged(dependencyFilename, dependencyContent);
+ this.invalidateIfChanged(dependencyFilename, dependencyContent, visited);
}
}
}
diff --git a/lib/cache.js b/lib/cache.js
index 0762ed7d3c39b31000f7aa7d8156da15403c8e64..6955410cd3c9ec53cf7a01c8346abc4c47fff791 100644
--- a/lib/cache.js
+++ b/lib/cache.js
@@ -77,17 +77,20 @@ class TransformCacheCollection {
this.invalidate(cacheName, filename);
});
}
- invalidateIfChanged(filename, content) {
+ invalidateIfChanged(filename, content, _visited) {
+ const visited = _visited || new Set();
+ if (visited.has(filename)) {
+ return false;
+ }
+ visited.add(filename);
const fileEntrypoint = this.get('entrypoints', filename);
- // We need to check all dependencies of the file
- // because they might have changed as well.
if (fileEntrypoint) {
for (const [, dependency] of fileEntrypoint.dependencies) {
const dependencyFilename = dependency.resolved;
if (dependencyFilename) {
const dependencyContent = _nodeFs.default.readFileSync(dependencyFilename, 'utf8');
- this.invalidateIfChanged(dependencyFilename, dependencyContent);
+ this.invalidateIfChanged(dependencyFilename, dependencyContent, visited);
}
}
}
-940
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,4 +6,4 @@ enableInlineHunks: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.13.0.cjs
yarnPath: .yarn/releases/yarn-4.9.2.cjs
+32 -109
View File
@@ -21,33 +21,22 @@ npx nx run twenty-server:worker # Start background worker
### Testing
```bash
# Preferred: run a single test file (fast)
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
# Run all tests for a package
# Run tests
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
npx nx storybook:test twenty-front
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:serve-and-test:static twenty-front # Run Storybook tests
```
### Code Quality
```bash
# Linting (diff with main - fastest, always prefer this)
npx nx lint:diff-with-main twenty-front
npx nx lint:diff-with-main twenty-server
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
# Linting (full project - slower, use only when needed)
npx nx lint twenty-front
npx nx lint twenty-server
# Linting
npx nx lint twenty-front # Frontend linting
npx nx lint twenty-server # Backend linting
npx nx lint twenty-front --fix # Auto-fix linting issues
# Type checking
npx nx typecheck twenty-front
@@ -60,8 +49,7 @@ npx nx fmt twenty-server
### Build
```bash
# Build packages (twenty-shared must be built first)
npx nx build twenty-shared
# Build packages
npx nx build twenty-front
npx nx build twenty-server
```
@@ -71,34 +59,25 @@ npx nx build twenty-server
# Database management
npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
npx nx run twenty-server:database:migrate:prod # Run migrations
# Generate an instance command (fast or slow)
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
# Generate migration
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
# Sync metadata
npx nx run twenty-server:command workspace:sync-metadata -f
```
### Database Inspection (Postgres MCP)
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
- Inspect workspace data, metadata, and object definitions while developing
- Verify migration results (columns, types, constraints) after running migrations
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
- Inspect metadata tables to debug GraphQL schema generation issues
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
### GraphQL
```bash
# Generate GraphQL types (run after schema changes)
# Generate GraphQL types
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
```
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
- **Frontend**: React 18, TypeScript, Recoil (state management), Emotion (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
@@ -110,8 +89,7 @@ packages/
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website-new/ # Next.js marketing website
├── twenty-docs/ # Documentation website
├── twenty-website/ # Next.js documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
```
@@ -121,36 +99,13 @@ packages/
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed** — strict TypeScript enforced
- **No 'any' type allowed**
- **Event handlers preferred over useEffect** for state updates
- **Props down, events up** — unidirectional data flow
- **Composition over inheritance**
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
### Naming Conventions
- **Variables/functions**: camelCase
- **Constants**: SCREAMING_SNAKE_CASE
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
- **TypeScript generics**: descriptive names (`TData` not `T`)
### File Structure
- Components under 300 lines, services under 500 lines
- Components in their own directories with tests and stories
- Use `index.ts` barrel exports for clean imports
- Import order: external libraries first, then internal (`@/`), then relative
### Comments
- Use short-form comments (`//`), not JSDoc blocks
- Explain WHY (business logic), not WHAT
- Do not comment obvious code
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- **Recoil** for global state management
- Component-specific state with React hooks
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
### Backend Architecture
- **NestJS modules** for feature organization
@@ -159,66 +114,34 @@ packages/
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database & Upgrade Commands
### Database
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **TypeORM migrations** for schema management
- **ClickHouse** for analytics (when enabled)
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
- Include both `up` and `down` logic in instance commands
- Never delete or rewrite committed instance command `up`/`down` logic
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
### Utility Helpers
Use existing helpers from `twenty-shared` instead of manual type guards:
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
## Development Workflow
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
### Before Making Changes
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
2. Test changes with relevant test suites (prefer single-file test runs)
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
1. Always run linting and type checking after code changes
2. Test changes with relevant test suites
3. Ensure database migrations are properly structured
4. Check that GraphQL schema changes are backward compatible
5. Run `graphql:generate` after any GraphQL schema changes
### Code Style Notes
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
- Use **Emotion** for styling with styled-components pattern
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Apply security first, then formatting (sanitize before format)
- Components should be in their own directories with tests and stories
### Testing Strategy
- **Test behavior, not implementation** — focus on user perspective
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
- Query by user-visible elements (text, roles, labels) over test IDs
- Use `@testing-library/user-event` for realistic interactions
- Descriptive test names: "should [behavior] when [condition]"
- Clear mocks between tests with `jest.clearAllMocks()`
## Dev Environment Setup
All dev environments (Claude Code web, Cursor, local) use one script:
```bash
bash packages/twenty-utils/setup-dev-env.sh
```
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times.
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
- `--down` — stop services
- `--reset` — wipe data and restart fresh
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script.
- **Unit tests** with Jest for both frontend and backend
- **Integration tests** for critical backend workflows
- **Storybook** for component development and testing
- **E2E tests** with Playwright for critical user flows
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Detailed development guidelines and best practices
- `.cursor/rules/` - Development guidelines and best practices
+53
View File
@@ -0,0 +1,53 @@
DOCKER_NETWORK=twenty_network
ensure-docker-network:
docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK)
postgres-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_pg \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e ALLOW_NOSSL=true \
-v twenty_db_data:/var/lib/postgresql/data \
-p 5432:5432 \
postgres:16
@echo "Waiting for PostgreSQL to be ready..."
@until docker exec twenty_pg psql -U postgres -d postgres \
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
sleep 1; \
done
docker exec twenty_pg psql -U postgres -d postgres \
-c "CREATE DATABASE \"default\" WITH OWNER postgres;" \
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
redis-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
clickhouse-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \
grafana-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_grafana \
-p 4000:3000 \
-e GF_SECURITY_ADMIN_USER=admin \
-e GF_SECURITY_ADMIN_PASSWORD=admin \
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
-v $(PWD)/packages/twenty-docker/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources \
grafana/grafana-oss:latest
opentelemetry-collector-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_otlp_collector \
-p 4317:4317 \
-p 4318:4318 \
-p 13133:13133 \
-v $(PWD)/packages/twenty-docker/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
otel/opentelemetry-collector-contrib:latest \
--config /etc/otel-collector-config.yaml
+85 -117
View File
@@ -1,164 +1,127 @@
<br />
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<h2 align="center" >The #1 Open-Source CRM </h2>
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://twenty.com/developers">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<br />
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.png" alt="Twenty banner" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Cover" />
</picture>
</a>
</p>
<br />
# Why Twenty
# Installation
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
See:
🚀 [Self-hosting](https://twenty.com/developers/section/self-hosting)
🖥️ [Local Setup](https://twenty.com/developers/local-setup)
<a href="https://twenty.com/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
# Does the world need another CRM?
We built Twenty for three reasons:
**CRMs are too expensive, and users are trapped.** Companies use locked-in customer data to hike prices. It shouldn't be that way.
**A fresh start is required to build a better experience.** We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
**We believe in Open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
<br />
# Installation
# What You Can Do With Twenty
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
Please feel free to flag any specific needs you have by creating an issue.
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
Below are a few features we have implemented to date:
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
+ [Personalize layouts with filters, sort, group by, kanban and table views](#personalize-layouts-with-filters-sort-group-by-kanban-and-table-views)
+ [Customize your objects and fields](#customize-your-objects-and-fields)
+ [Create and manage permissions with custom roles](#create-and-manage-permissions-with-custom-roles)
+ [Automate workflow with triggers and actions](#automate-workflow-with-triggers-and-actions)
+ [Emails, calendar events, files, and more](#emails-calendar-events-files-and-more)
Scaffold a new app with the Twenty CLI:
```bash
npx create-twenty-app my-app
```
## Personalize layouts with filters, sort, group by, kanban and table views
Define objects, fields, and views as code:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-light.png" />
<img src="./packages/twenty-website/public/images/readme/views-light.png" alt="Companies Kanban Views" />
</picture>
</p>
```ts
import { defineObject, FieldType } from 'twenty-sdk/define';
## Customize your objects and fields
export default defineObject({
nameSingular: 'deal',
namePlural: 'deals',
labelSingular: 'Deal',
labelPlural: 'Deals',
fields: [
{ name: 'name', label: 'Name', type: FieldType.TEXT },
{ name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
{ name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
],
});
```
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-light.png" />
<img src="./packages/twenty-website/public/images/readme/data-model-light.png" alt="Setting Custom Objects" />
</picture>
</p>
Then ship it to your workspace:
## Create and manage permissions with custom roles
```bash
npx twenty deploy
```
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-light.png" />
<img src="./packages/twenty-website/public/images/readme/permissions-light.png" alt="Permissions" />
</picture>
</p>
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
## Automate workflow with triggers and actions
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-light.png" />
<img src="./packages/twenty-website/public/images/readme/workflows-light.png" alt="Workflows" />
</picture>
</p>
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
## Emails, calendar events, files, and more
<br />
<br />
# Everything you need
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
<table align="center">
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.png" alt="Stay on top with version control" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.png" alt="All the tools you need to build anything" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png" />
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png" alt="Plus all the tools of a good CRM" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
</td>
</tr>
</table>
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-light.png" />
<img src="./packages/twenty-website/public/images/readme/plus-other-features-light.png" alt="Other Features" />
</picture>
</p>
<br />
# Stack
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- [TypeScript](https://www.typescriptlang.org/)
- [Nx](https://nx.dev/)
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.png" height="28" alt="Crowdin" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="30" alt="Chromatic" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
@@ -166,4 +129,9 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Join the Community
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
- Star the repo
- Subscribe to releases (watch -> custom -> releases)
- Follow us on [Twitter](https://twitter.com/twentycrm) or [LinkedIn](https://www.linkedin.com/company/twenty/)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
- Improve translations on [Crowdin](https://twenty.crowdin.com/twenty)
- [Contributions](https://github.com/twentyhq/twenty/contribute) are, of course, most welcome!
+31
View File
@@ -0,0 +1,31 @@
#
# Basic Crowdin CLI configuration
# See https://crowdin.github.io/crowdin-cli/configuration for more information
# See https://support.crowdin.com/developer/configuration-file/ for all available options
#
#
# Defines whether to preserve the original directory structure in the Crowdin project
# Recommended to set to true
#
"preserve_hierarchy": true
#
# Files configuration.
# See https://support.crowdin.com/developer/configuration-file/ for all available options
#
files: [
{
#
# Source files filter
# e.g. "/resources/en/*.json"
#
"source": "**/en.po",
#
# Translation files filter
# e.g. "/resources/%two_letters_code%/%original_file_name%"
#
"translation": "%original_path%/%locale%.po",
}
]
+185
View File
@@ -0,0 +1,185 @@
import js from '@eslint/js';
import nxPlugin from '@nx/eslint-plugin';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import linguiPlugin from 'eslint-plugin-lingui';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
export default [
// Base JavaScript configuration
js.configs.recommended,
// Lingui recommended rules
linguiPlugin.configs['flat/recommended'],
// Global ignores
{
ignores: [
'**/node_modules/**',
],
},
// Base configuration for all files
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'prettier': prettierPlugin,
'lingui': linguiPlugin,
'@nx': nxPlugin,
'prefer-arrow': preferArrowPlugin,
'import': importPlugin,
'unused-imports': unusedImportsPlugin,
'unicorn': unicornPlugin,
},
rules: {
// General rules
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
// Nx rules
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
],
},
],
// Import rules
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
// Prefer arrow functions
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
// Unused imports
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
// TypeScript specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// TypeScript rules
'no-redeclare': 'off', // Turn off base rule for TypeScript
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports'
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
// JavaScript specific configuration
{
files: ['*.{js,jsx}'],
rules: {
// JavaScript-specific rules if needed
},
},
// Test files
{
files: [
'*.spec.@(ts|tsx|js|jsx)',
'*.integration-spec.@(ts|tsx|js|jsx)',
'*.test.@(ts|tsx|js|jsx)',
],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// JSON files
{
files: ['**/*.json'],
languageOptions: {
parser: jsoncParser,
},
},
];
+302
View File
@@ -0,0 +1,302 @@
import js from '@eslint/js';
import nxPlugin from '@nx/eslint-plugin';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import linguiPlugin from 'eslint-plugin-lingui';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
export default [
// Base JavaScript configuration
js.configs.recommended,
// Lingui recommended rules
linguiPlugin.configs['flat/recommended'],
// Base configuration for all files
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'react': reactPlugin,
'react-hooks': reactHooksPlugin,
'react-refresh': reactRefreshPlugin,
'prettier': prettierPlugin,
'lingui': linguiPlugin,
'@nx': nxPlugin,
'prefer-arrow': preferArrowPlugin,
'import': importPlugin,
'unused-imports': unusedImportsPlugin,
'unicorn': unicornPlugin,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
// General rules
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
'prettier/prettier': 'error',
// Nx rules
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
],
},
],
// Import rules
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
// Prefer arrow functions
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
// Unused imports
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
// React rules
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react/jsx-key': 'off',
'react/display-name': 'off',
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/jsx-props-no-spreading': [
'error',
{
explicitSpread: 'ignore',
},
],
// React hooks rules
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: 'useRecoilCallback',
},
],
},
},
// TypeScript specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
// Note: project path should be specified by each package individually
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// Import restrictions
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@tabler/icons-react'],
message: 'Please import icons from `twenty-ui`',
},
{
group: ['react-hotkeys-web-hook'],
importNames: ['useHotkeys'],
message: 'Please use the custom wrapper: `useScopedHotkeys` from `twenty-ui`',
},
{
group: ['lodash'],
message: "Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)",
},
],
},
],
// TypeScript rules
'no-redeclare': 'off', // Turn off base rule for TypeScript
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports'
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
// Custom workspace rules
'@nx/workspace-effect-components': 'error',
'@nx/workspace-no-hardcoded-colors': 'error',
'@nx/workspace-matching-state-variable': 'error',
'@nx/workspace-sort-css-properties-alphabetically': 'error',
'@nx/workspace-styled-components-prefixed-with-styled': 'error',
'@nx/workspace-no-state-useref': 'error',
'@nx/workspace-component-props-naming': 'error',
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
'@nx/workspace-no-navigate-prefer-link': 'error',
},
},
// Storybook files
{
files: ['*.stories.@(ts|tsx|js|jsx)'],
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// JavaScript specific configuration
{
files: ['*.{js,jsx}'],
rules: {
// JavaScript-specific rules if needed
},
},
// Constants files
{
files: ['**/constants/*.ts', '**/*.constants.ts'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
format: ['UPPER_CASE'],
},
],
'unicorn/filename-case': [
'warn',
{
cases: {
pascalCase: true,
},
},
],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
// Test files
{
files: [
'*.test.@(ts|tsx|js|jsx)',
],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// Constants files
{
files: ['**/*.constants.ts'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
format: ['UPPER_CASE'],
},
],
'unicorn/filename-case': [
'warn',
{
cases: {
pascalCase: true,
},
},
],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
// JSON files
{
files: ['**/*.json'],
languageOptions: {
parser: jsoncParser,
},
},
];
+5
View File
@@ -0,0 +1,5 @@
import { getJestProjects } from '@nx/jest';
export default {
projects: getJestProjects(),
};

Some files were not shown because too many files have changed in this diff Show More