diff --git a/packages/core/src/helpers/env.ts b/packages/core/src/helpers/env.ts index 88e8fa10f..29aa49665 100644 --- a/packages/core/src/helpers/env.ts +++ b/packages/core/src/helpers/env.ts @@ -18,6 +18,8 @@ const yellow = chalk.bold.yellow; // - DATADOG_SOURCEMAP_INTAKE_URL // - DD_APPS_INTAKE_URL // - DATADOG_APPS_INTAKE_URL +// - DD_APPS_PUBLISH +// - DATADOG_APPS_PUBLISH // - DD_APPS_UPLOAD_ASSETS // - DATADOG_APPS_UPLOAD_ASSETS // - DD_APPS_VERSION_NAME @@ -29,6 +31,7 @@ const OVERRIDE_VARIABLES = [ 'APP_KEY', 'SOURCEMAP_INTAKE_URL', 'APPS_INTAKE_URL', + 'APPS_PUBLISH', 'APPS_UPLOAD_ASSETS', 'APPS_VERSION_NAME', 'SITE', diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 4490ca1ad..49b67bc9f 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -20,6 +20,7 @@ A plugin to upload assets to Datadog's storage - [apps.include](#appsinclude) - [apps.identifier](#appsidentifier) - [apps.name](#appsname) + - [apps.publish](#appspublish) ## Configuration @@ -31,6 +32,7 @@ apps?: { include?: string[]; identifier?: string; name?: string; + publish?: boolean; } ``` @@ -81,3 +83,22 @@ Can be useful to enforce a static identifier instead of relying on possibly chan Override the app's name used in the assets upload API request. Can be useful to enforce a static name instead of relying on the package.json name field. + +### apps.publish + +> default: `true` + +When `true` (the default), the plugin publishes the uploaded version to live immediately after upload. Set to `false` to upload a draft without publishing it — useful for staging environments or CI pipelines where a separate approval step controls promotion. + +You can also disable publishing via the `DATADOG_APPS_PUBLISH=false` (or `DD_APPS_PUBLISH=false`) environment variable. The explicit `apps.publish` config takes precedence over the environment variable. + +To add a dedicated upload-without-publish command to your project, add this script to your `package.json`: + +```json +{ + "scripts": { + "upload": "DD_APPS_UPLOAD_ASSETS=1 vite build", + "upload-no-publish": "DD_APPS_UPLOAD_ASSETS=1 DD_APPS_PUBLISH=false vite build" + } +} +``` diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 0ef4bc662..4f5f3fe58 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -217,6 +217,7 @@ describe('Apps Plugin - getPlugins', () => { dryRun: true, identifier: 'repo:app', name: 'test-app', + publish: true, site: DEFAULT_SITE, version: 'FAKE_VERSION', }, diff --git a/packages/plugins/apps/src/types.ts b/packages/plugins/apps/src/types.ts index c640c6e2c..16e19e835 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -10,6 +10,10 @@ export type AppsOptions = { dryRun?: boolean; identifier?: string; name?: string; + // When false, skips the release/live call after upload so the app is saved + // as a draft without being published. Defaults to true. Can also be set via + // the DD_APPS_PUBLISH=false environment variable. + publish?: boolean; }; export type AppsManifest = { @@ -26,6 +30,6 @@ export type AppsManifest = { // We don't enforce identifier, as it needs to be dynamically computed if absent. export type AppsOptionsWithDefaults = Omit< - WithRequired, + WithRequired, 'enable' >; diff --git a/packages/plugins/apps/src/upload.test.ts b/packages/plugins/apps/src/upload.test.ts index 58be236c7..4d57d8188 100644 --- a/packages/plugins/apps/src/upload.test.ts +++ b/packages/plugins/apps/src/upload.test.ts @@ -55,6 +55,7 @@ describe('Apps Plugin - upload', () => { dryRun: false, identifier: 'repo:app', name: 'test-app', + publish: true, site: 'datadoghq.com', version: '1.0.0', }; @@ -286,6 +287,30 @@ describe('Apps Plugin - upload', () => { ); }); + test('Should skip release/live call and log draft message when publish is false', async () => { + doRequestMock.mockResolvedValueOnce({ + version_id: 'v123', + application_id: 'app123', + app_builder_id: 'builder123', + }); + + const { errors, warnings } = await uploadArchive( + archive, + { ...context, publish: false }, + logger, + ); + + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + // Only the upload POST — no release PUT. + expect(doRequestMock).toHaveBeenCalledTimes(1); + expect(doRequestMock).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST' })); + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('draft (publish skipped)'), + 'info', + ); + }); + test('Should collect warnings on retries', async () => { doRequestMock.mockImplementation(async (opts) => { opts.onRetry?.(new Error('network'), 2); diff --git a/packages/plugins/apps/src/upload.ts b/packages/plugins/apps/src/upload.ts index f0ca4e32a..37f776113 100644 --- a/packages/plugins/apps/src/upload.ts +++ b/packages/plugins/apps/src/upload.ts @@ -28,6 +28,7 @@ export type UploadContext = { dryRun: boolean; identifier: string; name: string; + publish: boolean; site: string; version: string; }; @@ -137,7 +138,7 @@ Would have uploaded ${summary}`, log.info(`Your application is available at:\n ${cyan(appBuilderUrl)}`); } - if (response.version_id) { + if (response.version_id && context.publish) { const releaseUrl = getReleaseUrl(context.site, context.identifier); await doRequest({ auth: { apiKey: context.apiKey, appKey: context.appKey }, @@ -160,6 +161,8 @@ Would have uploaded ${summary}`, }, }); log.info(`Published uploaded version ${bold(response.version_id)} to live.`); + } else if (response.version_id && !context.publish) { + log.info(`Uploaded version ${bold(response.version_id)} as a draft (publish skipped).`); } } catch (error: unknown) { const err = error instanceof Error ? error : new Error(String(error)); diff --git a/packages/plugins/apps/src/validate.test.ts b/packages/plugins/apps/src/validate.test.ts index a660f1662..b90fc4292 100644 --- a/packages/plugins/apps/src/validate.test.ts +++ b/packages/plugins/apps/src/validate.test.ts @@ -13,6 +13,7 @@ describe('Apps Plugin - validateOptions', () => { include: [], identifier: undefined, name: undefined, + publish: true, }); }); @@ -45,6 +46,41 @@ describe('Apps Plugin - validateOptions', () => { delete process.env.DATADOG_APPS_UPLOAD_ASSETS; } }); + + test('Should set publish to false when DD_APPS_PUBLISH=false', () => { + process.env.DD_APPS_PUBLISH = 'false'; + try { + const result = validateOptions({ apps: {} }); + expect(result.publish).toBe(false); + } finally { + delete process.env.DD_APPS_PUBLISH; + } + }); + + test('Should set publish to false when DATADOG_APPS_PUBLISH=false', () => { + process.env.DATADOG_APPS_PUBLISH = 'false'; + try { + const result = validateOptions({ apps: {} }); + expect(result.publish).toBe(false); + } finally { + delete process.env.DATADOG_APPS_PUBLISH; + } + }); + + test('Should default publish to true when DD_APPS_PUBLISH is not set', () => { + const result = validateOptions({ apps: {} }); + expect(result.publish).toBe(true); + }); + + test('Should respect explicit publish: false option over env var', () => { + process.env.DATADOG_APPS_PUBLISH = 'true'; + try { + const result = validateOptions({ apps: { publish: false } }); + expect(result.publish).toBe(false); + } finally { + delete process.env.DATADOG_APPS_PUBLISH; + } + }); }); describe('overrides', () => { @@ -63,6 +99,7 @@ describe('Apps Plugin - validateOptions', () => { include: ['public/**/*', 'dist/**/*'], identifier: 'my-app', name: undefined, + publish: true, }); }); }); diff --git a/packages/plugins/apps/src/validate.ts b/packages/plugins/apps/src/validate.ts index 38e75a61e..bcfaed975 100644 --- a/packages/plugins/apps/src/validate.ts +++ b/packages/plugins/apps/src/validate.ts @@ -11,10 +11,15 @@ import type { AppsOptions, AppsOptionsWithDefaults } from './types'; export const validateOptions = (options: Options): AppsOptionsWithDefaults => { const resolvedOptions = (options[CONFIG_KEY] || {}) as AppsOptions; + const envPublish = getDDEnvValue('APPS_PUBLISH'); + return { include: resolvedOptions.include || [], dryRun: resolvedOptions.dryRun ?? !getDDEnvValue('APPS_UPLOAD_ASSETS'), identifier: resolvedOptions.identifier?.trim(), name: resolvedOptions.name?.trim() || options.metadata?.name?.trim(), + // Default to true (publish after upload). Set DD_APPS_PUBLISH=false or + // options.apps.publish=false to upload without publishing. + publish: resolvedOptions.publish ?? envPublish !== 'false', }; }; diff --git a/packages/plugins/apps/src/vite/handle-upload.ts b/packages/plugins/apps/src/vite/handle-upload.ts index 9519f363a..33e7df0c1 100644 --- a/packages/plugins/apps/src/vite/handle-upload.ts +++ b/packages/plugins/apps/src/vite/handle-upload.ts @@ -148,6 +148,7 @@ Either: dryRun: options.dryRun, identifier, name, + publish: options.publish, site: auth.site, version, }, diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index d53ed20c1..d0fec371a 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -92,6 +92,7 @@ const defaultOptions = { enable: true, include: [], dryRun: true, + publish: true, }, };