Add twenty version validation (#20227)

as title, server version is checked before app deploy, and app install
commands

### New section in publishing doc
<img width="1344" height="912" alt="image"
src="https://github.com/user-attachments/assets/2a9335e7-0a7a-4973-a2db-f30f03181001"
/>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
martmull
2026-05-04 15:55:31 +02:00
committed by GitHub
parent 0bb345b75f
commit 3ffda0a29e
14 changed files with 214 additions and 12 deletions
+1
View File
@@ -12,6 +12,7 @@
"concurrently": "^8.2.2",
"http-server": "^14.1.1",
"nx": "22.5.4",
"tsx": "^4.17.0",
"verdaccio": "^6.3.1"
},
"engines": {
@@ -77,6 +77,39 @@ Pre-release tags work as expected: bumping `1.0.0-rc.1` → `1.0.0-rc.2` is allo
{/* TODO: add screenshot of the Upgrade button */}
### Server version compatibility
If your app uses a feature introduced in a specific Twenty server version (for example, OAuth providers added in v2.3.0), you should declare the minimum server version your app requires using the `engines.twenty` field in `package.json`:
```json filename="package.json"
{
"name": "twenty-my-app",
"version": "1.0.0",
"engines": {
"node": "^24.5.0",
"twenty": ">=2.3.0"
}
}
```
The value is a standard [semver range](https://github.com/npm/node-semver#ranges). Common patterns:
| Range | Meaning |
|-------|---------|
| `>=2.3.0` | Any server from 2.3.0 onward |
| `>=2.3.0 <3.0.0` | 2.3.0 or later, but below the next major |
| `^2.3.0` | Same as `>=2.3.0 <3.0.0` |
**What happens at deploy and install time:**
- If `engines.twenty` is set and the target server's version does not satisfy the range, the deploy (tarball upload) or install is rejected with a `SERVER_VERSION_INCOMPATIBLE` error and a message indicating both the required range and the actual server version.
- If `engines.twenty` is **not set**, the app is accepted on any server version (backward-compatible with existing apps).
- If the server has no `APP_VERSION` configured, the check is skipped.
<Note>
The server is the authoritative check — it validates `engines.twenty` on both tarball upload and workspace install. If you deploy a tarball out-of-band or install from the marketplace, the server still enforces compatibility.
</Note>
## Automated CI/CD (scaffolded workflows)
Apps generated with `create-twenty-app` ship with two GitHub Actions workflows out of the box, under `.github/workflows/`. They are ready to run as soon as you push the repo to GitHub — no extra setup is needed for CI, and CD only requires a single secret.
@@ -31,12 +31,15 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH:
case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
throw new UserInputError(exception);
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
case ApplicationExceptionCode.POST_INSTALL_ERROR:
case ApplicationExceptionCode.PRE_INSTALL_ERROR:
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
case ApplicationExceptionCode.UPGRADE_FAILED:
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
throw new InternalServerError(exception);
default: {
assertUnreachable(exception.code);
@@ -21,6 +21,10 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import {
ApplicationVersionValidationService,
type VersionValidationFailureReason,
} from 'src/engine/core-modules/application/application-package/application-version-validation.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -39,11 +43,22 @@ import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-func
export class ApplicationInstallService {
private readonly logger = new Logger(ApplicationInstallService.name);
private static readonly VERSION_REASON_TO_EXCEPTION_CODE: Record<
VersionValidationFailureReason,
ApplicationExceptionCode
> = {
INVALID_REQUIRED_VERSION:
ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT,
INVALID_SERVER_VERSION: ApplicationExceptionCode.INVALID_SERVER_VERSION,
INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
};
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly applicationService: ApplicationService,
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly fileStorageService: FileStorageService,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
@@ -118,6 +133,27 @@ export class ApplicationInstallService {
return true;
}
const requiredServerVersion =
resolvedPackage.packageJson.engines?.['twenty'];
const versionValidation =
this.applicationVersionValidationService.validateServerCompatibility(
requiredServerVersion,
);
if (!versionValidation.compatible) {
await this.applicationPackageFetcherService.cleanupExtractedDir(
resolvedPackage.cleanupDir,
);
throw new ApplicationException(
versionValidation.message,
ApplicationInstallService.VERSION_REASON_TO_EXCEPTION_CODE[
versionValidation.reason
],
);
}
const universalIdentifier = appRegistration.universalIdentifier;
const existingApplication =
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -13,7 +14,13 @@ import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty
TwentyConfigModule,
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
],
providers: [ApplicationPackageFetcherService],
exports: [ApplicationPackageFetcherService],
providers: [
ApplicationPackageFetcherService,
ApplicationVersionValidationService,
],
exports: [
ApplicationPackageFetcherService,
ApplicationVersionValidationService,
],
})
export class ApplicationPackageModule {}
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import semver from 'semver';
import { isDefined } from 'twenty-shared/utils';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export type VersionValidationFailureReason =
| 'INVALID_REQUIRED_VERSION'
| 'INVALID_SERVER_VERSION'
| 'INCOMPATIBLE';
export type VersionValidationResult =
| { compatible: true }
| {
compatible: false;
reason: VersionValidationFailureReason;
message: string;
};
@Injectable()
export class ApplicationVersionValidationService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
validateServerCompatibility(
requiredServerVersion: string | undefined,
): VersionValidationResult {
if (!isDefined(requiredServerVersion)) {
return { compatible: true };
}
if (!isDefined(semver.validRange(requiredServerVersion))) {
return {
compatible: false,
reason: 'INVALID_REQUIRED_VERSION',
message: `App manifest declares invalid engines.twenty value "${requiredServerVersion}". Must be a valid semver range.`,
};
}
const serverVersion = this.twentyConfigService.get('APP_VERSION');
if (!isDefined(serverVersion) || !isDefined(semver.valid(serverVersion))) {
return {
compatible: false,
reason: 'INVALID_SERVER_VERSION',
message: `Cannot verify server compatibility: APP_VERSION "${serverVersion ?? 'undefined'}" is not a valid semver version. Self-hosted instances must set a valid APP_VERSION.`,
};
}
if (!semver.satisfies(serverVersion, requiredServerVersion)) {
return {
compatible: false,
reason: 'INCOMPATIBLE',
message: `App requires Twenty server ${requiredServerVersion} but this server is ${serverVersion}.`,
};
}
return { compatible: true };
}
}
@@ -22,6 +22,9 @@ export class ApplicationRegistrationExceptionFilter implements ExceptionFilter {
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH:
case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
case ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
case ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS:
case ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
throw new UserInputError(exception);
default:
throw new InternalServerError(exception);
@@ -13,6 +13,9 @@ export enum ApplicationRegistrationExceptionCode {
SOURCE_CHANNEL_MISMATCH = 'SOURCE_CHANNEL_MISMATCH',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
VERSION_ALREADY_EXISTS = 'VERSION_ALREADY_EXISTS',
SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE',
INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT',
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
}
const getExceptionUserFriendlyMessage = (
@@ -35,6 +38,12 @@ const getExceptionUserFriendlyMessage = (
return msg`Application registration variable not found.`;
case ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS:
return msg`This version is not higher than the currently deployed version. Please bump the version in package.json before deploying again.`;
case ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
return msg`This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.`;
case ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
return msg`The app manifest declares an invalid server version requirement.`;
case ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION:
return msg`The server's APP_VERSION is not a valid semver version. Self-hosted instances must configure a valid APP_VERSION.`;
default:
assertUnreachable(code);
}
@@ -6,6 +6,7 @@ import { ApplicationRegistrationResolver } from 'src/engine/core-modules/applica
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
@@ -25,6 +26,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
]),
ApplicationRegistrationVariableModule,
ApplicationModule,
ApplicationPackageModule,
DomainServerConfigModule,
FeatureFlagModule,
PermissionsModule,
@@ -20,6 +20,10 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util';
import { readJsonFile } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
import {
ApplicationVersionValidationService,
type VersionValidationFailureReason,
} from 'src/engine/core-modules/application/application-package/application-version-validation.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import type { ApplicationManifest } from 'twenty-shared/application';
@@ -31,12 +35,25 @@ export const MAX_TARBALL_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024;
export class ApplicationTarballService {
private readonly logger = new Logger(ApplicationTarballService.name);
private static readonly VERSION_REASON_TO_EXCEPTION_CODE: Record<
VersionValidationFailureReason,
ApplicationRegistrationExceptionCode
> = {
INVALID_REQUIRED_VERSION:
ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT,
INVALID_SERVER_VERSION:
ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION,
INCOMPATIBLE:
ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
};
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
) {}
async uploadTarball(params: {
@@ -66,6 +83,7 @@ export class ApplicationTarballService {
const packageJson = await readJsonFile<{
version: string;
engines?: { twenty?: string };
}>(contentDir, 'package.json');
if (manifest === null) {
@@ -75,6 +93,22 @@ export class ApplicationTarballService {
);
}
const requiredServerVersion = packageJson?.engines?.twenty;
const versionValidation =
this.applicationVersionValidationService.validateServerCompatibility(
requiredServerVersion,
);
if (!versionValidation.compatible) {
throw new ApplicationRegistrationException(
versionValidation.message,
ApplicationTarballService.VERSION_REASON_TO_EXCEPTION_CODE[
versionValidation.reason
],
);
}
const universalIdentifier =
params.universalIdentifier ?? manifest.application?.universalIdentifier;
@@ -22,6 +22,9 @@ export enum ApplicationExceptionCode {
POST_INSTALL_ERROR = 'POST_INSTALL_ERROR',
APP_ALREADY_INSTALLED = 'APP_ALREADY_INSTALLED',
CANNOT_DOWNGRADE_APPLICATION = 'CANNOT_DOWNGRADE_APPLICATION',
SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE',
INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT',
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
}
const getApplicationExceptionUserFriendlyMessage = (
@@ -62,6 +65,12 @@ const getApplicationExceptionUserFriendlyMessage = (
return msg`This version of the application is already installed in this workspace.`;
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
return msg`A higher version of this application is already installed. Downgrading is not allowed.`;
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
return msg`This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.`;
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
return msg`The app manifest declares an invalid server version requirement.`;
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
return msg`The server's APP_VERSION is not a valid semver version. Self-hosted instances must configure a valid APP_VERSION.`;
default:
assertUnreachable(code);
}
@@ -1,3 +1,5 @@
import { getStandardObjectMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-object-metadata-related-entity-ids.util';
let uuidCounter = 0;
jest.mock('uuid', () => ({
@@ -6,8 +8,6 @@ jest.mock('uuid', () => ({
),
}));
import { getStandardObjectMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-object-metadata-related-entity-ids.util';
describe('getStandardObjectMetadataRelatedEntityIds', () => {
beforeEach(() => {
uuidCounter = 0;
@@ -1,16 +1,20 @@
let uuidCounter = 0;
jest.mock('uuid', () => ({
v4: jest.fn(
() => `00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, '0')}`,
),
}));
import { v4 } from 'uuid';
import { getStandardPageLayoutMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-page-layout-metadata-related-entity-ids.util';
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
describe('getStandardPageLayoutMetadataRelatedEntityIds', () => {
let uuidCounter = 0;
beforeEach(() => {
uuidCounter = 0;
(v4 as jest.Mock).mockImplementation(
() =>
`00000000-0000-0000-0000-${String(++uuidCounter).padStart(12, '0')}`,
);
});
afterAll(() => {
+2 -1
View File
@@ -57593,7 +57593,7 @@ __metadata:
languageName: node
linkType: hard
"tsx@npm:^4.19.3, tsx@npm:^4.7.0":
"tsx@npm:^4.17.0, tsx@npm:^4.19.3, tsx@npm:^4.7.0":
version: 4.21.0
resolution: "tsx@npm:4.21.0"
dependencies:
@@ -58490,6 +58490,7 @@ __metadata:
concurrently: "npm:^8.2.2"
http-server: "npm:^14.1.1"
nx: "npm:22.5.4"
tsx: "npm:^4.17.0"
verdaccio: "npm:^6.3.1"
languageName: unknown
linkType: soft