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
@@ -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);
}