From a78a8434192e0a8ffee2ba4b54b3eea9a3f005da Mon Sep 17 00:00:00 2001 From: martmull Date: Thu, 9 Apr 2026 12:10:16 +0200 Subject: [PATCH] Fix install and deploy commands (#19481) - disable publish with existing version - disable installation of app version already installed --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../developers/extend/apps/publishing.mdx | 17 ++++++- .../application-exception-filter.ts | 2 + .../application-install.service.ts | 32 +++++++++++++ .../application-registration.exception.ts | 3 ++ .../application-tarball.service.ts | 28 +++++++++++ .../application/application.exception.ts | 6 +++ .../app-distribution.integration-spec.ts | 48 ++++++++++++++++++- 7 files changed, 133 insertions(+), 3 deletions(-) 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(