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>
This commit is contained in:
martmull
2026-04-09 12:10:16 +02:00
committed by GitHub
parent 5eaabe95e7
commit a78a843419
7 changed files with 133 additions and 3 deletions
@@ -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
<Note>
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.
</Note>
{/* 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
```
<Note>
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`.
</Note>
@@ -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:
@@ -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,
@@ -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);
}
@@ -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,
@@ -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);
}
@@ -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(