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:
@@ -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.
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+36
@@ -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 =
|
||||
|
||||
+9
-2
@@ -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 {}
|
||||
|
||||
+60
@@ -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 };
|
||||
}
|
||||
}
|
||||
+3
@@ -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);
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+34
@@ -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);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+11
-7
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user