diff --git a/packages/twenty-docs/developers/extend/apps/publishing.mdx b/packages/twenty-docs/developers/extend/apps/publishing.mdx
index 4adb676cc5..f2b0d76e29 100644
--- a/packages/twenty-docs/developers/extend/apps/publishing.mdx
+++ b/packages/twenty-docs/developers/extend/apps/publishing.mdx
@@ -66,12 +66,18 @@ The share link uses the server's base URL (without any workspace subdomain) so i
### Version management
+When updating an already deployed tarball app, the server requires the `version` in `package.json` to be **strictly higher** (per [semver](https://semver.org) ordering) than the currently deployed version. Re-deploying the same version, or pushing a lower one, is rejected before the tarball is stored — you'll see a `VERSION_ALREADY_EXISTS` error from the CLI.
+
To release an update:
-1. Bump the `version` field in your `package.json`
+1. Bump the `version` field in your `package.json` (e.g. `1.2.3` → `1.2.4`, `1.3.0`, or `2.0.0`)
2. Run `yarn twenty deploy` (or `yarn twenty deploy --remote production`)
3. Workspaces that have the app installed will see the upgrade available in their settings
+
+Pre-release tags work as expected: bumping `1.0.0-rc.1` → `1.0.0-rc.2` is allowed, and a final release like `1.0.0` is correctly recognized as higher than `1.0.0-rc.5`. The version in `package.json` must itself be a valid semver string.
+
+
{/* TODO: add screenshot of the Upgrade button */}
## Publishing to npm
@@ -189,3 +195,12 @@ You can also install apps from the command line:
```bash filename="Terminal"
yarn twenty install
```
+
+
+The server enforces semver versioning on install, mirroring the rules on deploy:
+
+- Installing the same version that is already installed in your workspace is rejected with an `APP_ALREADY_INSTALLED` error.
+- Installing a lower version than the one currently installed is rejected with a `CANNOT_DOWNGRADE_APPLICATION` error.
+
+To install a newer version, deploy or publish it first, then re-run `yarn twenty install`.
+
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts
index 841d70e854..e6524453b6 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-exception-filter.ts
@@ -29,6 +29,8 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
throw new ForbiddenError(exception);
case ApplicationExceptionCode.INVALID_INPUT:
case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH:
+ case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
+ case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
throw new UserInputError(exception);
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts
index 0d151de489..231cd5b8dd 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { promises as fs } from 'fs';
import { resolve } from 'path';
+import semver from 'semver';
import { Manifest } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -117,6 +118,37 @@ export class ApplicationInstallService {
sourceType: appRegistration.sourceType,
});
+ const incomingVersion = resolvedPackage.packageJson.version;
+
+ if (
+ !wasCreated &&
+ isDefined(application.version) &&
+ isDefined(incomingVersion)
+ ) {
+ if (!isDefined(semver.valid(incomingVersion))) {
+ throw new ApplicationException(
+ `Invalid version "${incomingVersion}" in package.json. Must be a valid semver version.`,
+ ApplicationExceptionCode.INVALID_INPUT,
+ );
+ }
+
+ if (isDefined(semver.valid(application.version))) {
+ if (semver.eq(incomingVersion, application.version)) {
+ throw new ApplicationException(
+ `${universalIdentifier}@${incomingVersion} is already installed in this workspace.`,
+ ApplicationExceptionCode.APP_ALREADY_INSTALLED,
+ );
+ }
+
+ if (semver.lt(incomingVersion, application.version)) {
+ throw new ApplicationException(
+ `Cannot install ${universalIdentifier}@${incomingVersion}: version ${application.version} is already installed and downgrading is not allowed.`,
+ ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION,
+ );
+ }
+ }
+ }
+
await this.writeFilesToStorage(
resolvedPackage.extractedDir,
resolvedPackage.manifest,
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.exception.ts
index 981dc93a3b..bbe24fdcaf 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.exception.ts
@@ -12,6 +12,7 @@ export enum ApplicationRegistrationExceptionCode {
INVALID_INPUT = 'INVALID_INPUT',
SOURCE_CHANNEL_MISMATCH = 'SOURCE_CHANNEL_MISMATCH',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
+ VERSION_ALREADY_EXISTS = 'VERSION_ALREADY_EXISTS',
}
const getExceptionUserFriendlyMessage = (
@@ -32,6 +33,8 @@ const getExceptionUserFriendlyMessage = (
return msg`The app source channel does not match the expected type.`;
case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND:
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.`;
default:
assertUnreachable(code);
}
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts
index 2696054354..0289530d3c 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts
@@ -5,6 +5,7 @@ import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
+import semver from 'semver';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -103,6 +104,33 @@ export class ApplicationTarballService {
ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH,
);
}
+
+ if (
+ appRegistration.sourceType ===
+ ApplicationRegistrationSourceType.TARBALL &&
+ isDefined(appRegistration.latestAvailableVersion) &&
+ isDefined(packageJson?.version)
+ ) {
+ const incomingVersion = packageJson.version;
+ const currentVersion = appRegistration.latestAvailableVersion;
+
+ if (!isDefined(semver.valid(incomingVersion))) {
+ throw new ApplicationRegistrationException(
+ `Invalid version "${incomingVersion}" in package.json. Must be a valid semver version.`,
+ ApplicationRegistrationExceptionCode.INVALID_INPUT,
+ );
+ }
+
+ if (
+ isDefined(semver.valid(currentVersion)) &&
+ semver.lte(incomingVersion, currentVersion)
+ ) {
+ throw new ApplicationRegistrationException(
+ `Cannot deploy ${universalIdentifier}@${incomingVersion}: version must be higher than the currently deployed version ${currentVersion}. Please bump the version in package.json.`,
+ ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS,
+ );
+ }
+ }
} else {
appRegistration = this.appRegistrationRepository.create({
universalIdentifier,
diff --git a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts
index 862463b3f5..75119855ab 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application.exception.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application.exception.ts
@@ -18,6 +18,8 @@ export enum ApplicationExceptionCode {
PACKAGE_RESOLUTION_FAILED = 'PACKAGE_RESOLUTION_FAILED',
TARBALL_EXTRACTION_FAILED = 'TARBALL_EXTRACTION_FAILED',
UPGRADE_FAILED = 'UPGRADE_FAILED',
+ APP_ALREADY_INSTALLED = 'APP_ALREADY_INSTALLED',
+ CANNOT_DOWNGRADE_APPLICATION = 'CANNOT_DOWNGRADE_APPLICATION',
}
const getApplicationExceptionUserFriendlyMessage = (
@@ -50,6 +52,10 @@ const getApplicationExceptionUserFriendlyMessage = (
return msg`Failed to extract tarball.`;
case ApplicationExceptionCode.UPGRADE_FAILED:
return msg`Application upgrade failed.`;
+ case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
+ 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.`;
default:
assertUnreachable(code);
}
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/app-distribution.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/app-distribution.integration-spec.ts
index f3972e6707..842a58d225 100644
--- a/packages/twenty-server/test/integration/metadata/suites/application/app-distribution.integration-spec.ts
+++ b/packages/twenty-server/test/integration/metadata/suites/application/app-distribution.integration-spec.ts
@@ -173,7 +173,7 @@ describe('App Distribution (integration)', () => {
expect(rows[0].sourceType).toBe('tarball');
});
- it('should update existing tarball registration on re-upload', async () => {
+ it('should fail to update existing version', async () => {
const uid = crypto.randomUUID();
const manifest = createValidManifest(uid);
@@ -192,9 +192,53 @@ describe('App Distribution (integration)', () => {
createdRegistrationIds.push(firstResult.data!.uploadAppTarball.id);
- const secondResult = await uploadAppTarball({
+ const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: uid,
+ expectToFail: true,
+ });
+
+ expect(errors).toBeDefined();
+
+ expect(
+ errors?.some((error: { message: string }) =>
+ error.message.includes(
+ 'version must be higher than the currently deployed version',
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ it('should update existing tarball registration on re-upload', async () => {
+ const uid = crypto.randomUUID();
+ const manifest = createValidManifest(uid);
+
+ const firstTarball = await createTestTarball({
+ 'manifest.json': manifest,
+ 'package.json': JSON.stringify({
+ name: 'test-app',
+ version: '1.1.0',
+ }),
+ });
+
+ const firstResult = await uploadAppTarball({
+ tarballBuffer: firstTarball,
+ universalIdentifier: uid,
+ });
+
+ createdRegistrationIds.push(firstResult.data!.uploadAppTarball.id);
+
+ const secondTarball = await createTestTarball({
+ 'manifest.json': manifest,
+ 'package.json': JSON.stringify({
+ name: 'test-app',
+ version: '1.2.0',
+ }),
+ });
+
+ const secondResult = await uploadAppTarball({
+ tarballBuffer: secondTarball,
+ universalIdentifier: uid,
});
expect(secondResult.data?.uploadAppTarball.id).toBe(