Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,33 @@ jobs:
app-directory: path/to/your/app
```

## How It Works

The action installs your app's dependencies, then builds, uploads, and publishes
the app with the
[Datadog Apps CLI](https://www.npmjs.com/package/@datadog/apps-cli) by running
`datadog-apps deploy` in your app's directory, always through `npx`. When the
CLI is already installed in your app's `node_modules` (for example, as a
dependency installed by your install command), that version runs. Otherwise,
`npx` fetches the version from the `cli-version` input into the runner user's
npx cache — there is no global install, so the action needs no write access to
npm's global prefix and mutates no shared runner state. The CLI builds the app
by running the project's `build` script with the project's own package manager,
then uploads and publishes the built app to Datadog. Every option the action
supplies — the site and the version name (the commit SHA, `GITHUB_SHA`) — is
passed to the CLI as a command-line flag. Only the API and app keys are passed
through the environment, which is where the CLI reads them from.

## Inputs

| Input | Description | Required | Default |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | --------------- |
| `datadog-api-key` | Your Datadog API key. This key is [created in your Datadog organization](https://docs.datadoghq.com/account_management/api-app-keys/) and should be stored as a [secret](https://docs.github.com/en/actions/reference/encrypted-secrets) | Yes | |
| `datadog-app-key` | Your Datadog application key. This key is [created in your Datadog organization](https://docs.datadoghq.com/account_management/api-app-keys/) and should be stored as a [secret](https://docs.github.com/en/actions/reference/encrypted-secrets) | Yes | |
| `app-directory` | The path to your Datadog App's root directory | No | `.` |
| `install-command` | Command to install dependencies before building | No | `npm ci` |
| `build-command` | Command to build the Vite app | No | `npm run build` |
| Input | Description | Required | Default |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------- |
| `datadog-api-key` | Your Datadog API key. This key is [created in your Datadog organization](https://docs.datadoghq.com/account_management/api-app-keys/) and should be stored as a [secret](https://github.com/en/actions/reference/encrypted-secrets) | Yes | |
| `datadog-app-key` | Your Datadog application key. This key is [created in your Datadog organization](https://docs.datadoghq.com/account_management/api-app-keys/) and should be stored as a [secret](https://github.com/en/actions/reference/encrypted-secrets) | Yes | |
| `app-directory` | The path to your Datadog App's root directory | No | `.` |
| `install-command` | Command to install dependencies before deploying | No | `npm ci` |
| `datadog-site` | Datadog site to deploy to (for example, `datadoghq.eu`). When not set, the CLI resolves the site from the `DD_SITE` or `DATADOG_SITE` environment variable, or the `datadogSite` field of the app's `datadog-app.config.json` | No | |
Comment thread
oliverli marked this conversation as resolved.
| `cli-version` | Version of `@datadog/apps-cli` to run when the CLI is not installed in the app's `node_modules`; `npx` fetches that version at runtime. Ignored otherwise | No | `latest` |

## Contributing

Expand Down
181 changes: 138 additions & 43 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
import { jest } from '@jest/globals';
import * as core from '../__fixtures__/core.js';
import * as execModule from '../__fixtures__/exec.js';
import type * as fs from 'fs';

const mockExistsSync = jest.fn<typeof import('fs').existsSync>();
const mockExistsSync = jest.fn<typeof fs.existsSync>();

// Mocks should be declared before the module being tested is imported.
jest.unstable_mockModule('fs', () => ({
Expand All @@ -34,8 +35,8 @@ jest.unstable_mockModule('fs', () => ({
jest.unstable_mockModule('@actions/core', () => core);
jest.unstable_mockModule('@actions/exec', () => execModule);

// The module being tested should be imported dynamically. This ensures that the
// mocks are used in place of any actual dependencies.
// The module being tested should be imported dynamically. This ensures
// that the mocks are used in place of any actual dependencies.
const { run } = await import('../src/main.js');

describe('run()', () => {
Expand All @@ -45,7 +46,12 @@ describe('run()', () => {
if (name === 'datadog-app-key') return 'test-app-key';
return '';
});
mockExistsSync.mockReturnValue(true);
// By default nothing exists inside node_modules, so the local CLI
// binary is absent and tests exercise the pinned-package npx path;
// tests for the installed-CLI path override this.
mockExistsSync.mockImplementation(
(p: fs.PathLike) => !String(p).includes('node_modules')
);
execModule.exec.mockResolvedValue(0);
process.env.GITHUB_SHA = 'abc123sha';
});
Expand Down Expand Up @@ -73,60 +79,65 @@ describe('run()', () => {
expect(core.setSecret).toHaveBeenCalledWith('test-app-key');
});

it('runs the install command before the build command', async () => {
it('runs the install command, then deploys through npx', async () => {
await run();

expect(execModule.exec).toHaveBeenCalledTimes(2);
const [firstCall, secondCall] = execModule.exec.mock.calls;
expect(firstCall[0]).toBe('npm');
expect(firstCall[1]).toEqual(['ci']);
expect(secondCall[0]).toBe('npm');
expect(secondCall[1]).toEqual(['run', 'build']);
const [installCall, deployCall] = execModule.exec.mock.calls;
expect(installCall[0]).toBe('npm');
expect(installCall[1]).toEqual(['ci']);
expect(deployCall[0]).toBe('npx');
expect(deployCall[1]).toEqual([
'--yes',
'--package',
'@datadog/apps-cli@latest',
'datadog-apps',
'deploy',
'--version-name',
'abc123sha'
]);
});

it('passes Datadog credentials and metadata to the build command', async () => {
it('passes only the Datadog credentials through the environment', async () => {
await run();

expect(execModule.exec).toHaveBeenCalledWith(
'npm',
['run', 'build'],
'npx',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
DATADOG_API_KEY: 'test-api-key',
DATADOG_APP_KEY: 'test-app-key',
DATADOG_APPS_VERSION_NAME: 'abc123sha',
DATADOG_APPS_UPLOAD_ASSETS: '1'
DATADOG_APP_KEY: 'test-app-key'
})
})
);
const deployEnv = execModule.exec.mock.calls[1][2]?.env ?? {};
expect(deployEnv).not.toHaveProperty('DATADOG_APPS_VERSION_NAME');
});

it('uses GITHUB_SHA as the app version name', async () => {
it('passes GITHUB_SHA as --version-name to the deploy command', async () => {
process.env.GITHUB_SHA = 'deadbeef';

await run();

expect(execModule.exec).toHaveBeenCalledWith(
'npm',
['run', 'build'],
expect.objectContaining({
env: expect.objectContaining({ DATADOG_APPS_VERSION_NAME: 'deadbeef' })
})
);
const deployCall = execModule.exec.mock.calls[1];
expect(deployCall[1]).toContain('--version-name');
expect(deployCall[1]).toContain('deadbeef');
});

it('defaults version name to empty string when GITHUB_SHA is unset', async () => {
it('omits --version-name when GITHUB_SHA is unset', async () => {
delete process.env.GITHUB_SHA;

await run();

expect(execModule.exec).toHaveBeenCalledWith(
'npm',
['run', 'build'],
expect.objectContaining({
env: expect.objectContaining({ DATADOG_APPS_VERSION_NAME: '' })
})
);
const deployCall = execModule.exec.mock.calls[1];
expect(deployCall[1]).toEqual([
'--yes',
'--package',
'@datadog/apps-cli@latest',
'datadog-apps',
'deploy'
]);
});

it('uses a custom install command', async () => {
Expand All @@ -146,24 +157,108 @@ describe('run()', () => {
);
});

it('uses a custom build command', async () => {
it('runs a pinned CLI version when cli-version is set', async () => {
core.getInput.mockImplementation((name: string) => {
if (name === 'datadog-api-key') return 'test-api-key';
if (name === 'datadog-app-key') return 'test-app-key';
if (name === 'build-command') return 'pnpm build --mode production';
if (name === 'cli-version') return '0.0.1';
return '';
});

await run();

expect(execModule.exec).toHaveBeenCalledWith(
'pnpm',
['build', '--mode', 'production'],
expect.any(Object)
const deployCall = execModule.exec.mock.calls[1];
expect(deployCall[1]).toContain('--package');
expect(deployCall[1]).toContain('@datadog/apps-cli@0.0.1');
});

it('runs the installed project CLI via npx without --package', async () => {
mockExistsSync.mockImplementation(() => true);

await run();

expect(execModule.exec).toHaveBeenCalledTimes(2);
const [installCall, deployCall] = execModule.exec.mock.calls;
expect(installCall[0]).toBe('npm');
expect(installCall[1]).toEqual(['ci']);
expect(deployCall[0]).toBe('npx');
expect(deployCall[1]).toEqual([
'--yes',
'datadog-apps',
'deploy',
'--version-name',
'abc123sha'
]);
});

it('uses a CLI installed above the app directory (monorepo)', async () => {
core.getInput.mockImplementation((name: string) => {
if (name === 'datadog-api-key') return 'test-api-key';
if (name === 'datadog-app-key') return 'test-app-key';
if (name === 'app-directory') return '/repo/packages/app';
return '';
});
mockExistsSync.mockImplementation(
(p: fs.PathLike) =>
p === '/repo/packages/app' ||
p === '/repo/node_modules/.bin/datadog-apps'
);

await run();

const deployCall = execModule.exec.mock.calls[1];
expect(deployCall[0]).toBe('npx');
expect(deployCall[1]).toEqual([
'--yes',
'datadog-apps',
'deploy',
'--version-name',
'abc123sha'
]);
expect(deployCall[2]).toEqual(
expect.objectContaining({ cwd: '/repo/packages/app' })
);
});

it('ignores the cli-version input when the project CLI is installed', async () => {
core.getInput.mockImplementation((name: string) => {
if (name === 'datadog-api-key') return 'test-api-key';
if (name === 'datadog-app-key') return 'test-app-key';
if (name === 'cli-version') return '0.0.1';
return '';
});
mockExistsSync.mockImplementation(() => true);

await run();

const deployCall = execModule.exec.mock.calls[1];
expect(deployCall[1]).not.toContain('--package');
expect(deployCall[1]).not.toContain('@datadog/apps-cli@0.0.1');
});

it('passes the datadog-site input as --site to the deploy command', async () => {
core.getInput.mockImplementation((name: string) => {
if (name === 'datadog-api-key') return 'test-api-key';
if (name === 'datadog-app-key') return 'test-app-key';
if (name === 'datadog-site') return 'datadoghq.eu';
return '';
});

await run();

const deployCall = execModule.exec.mock.calls[1];
expect(deployCall[1]).toContain('--site');
expect(deployCall[1]).toContain('datadoghq.eu');
});

it('omits --site when the datadog-site input is not set', async () => {
await run();

const deployCall = execModule.exec.mock.calls[1];
expect(deployCall[1]).not.toContain('--site');
});

it('runs commands in the specified app directory', async () => {
it('runs the deploy command in the specified app directory', async () => {
core.getInput.mockImplementation((name: string) => {
if (name === 'datadog-api-key') return 'test-api-key';
if (name === 'datadog-app-key') return 'test-app-key';
Expand All @@ -174,7 +269,7 @@ describe('run()', () => {
await run();

expect(execModule.exec).toHaveBeenCalledWith(
expect.any(String),
'npx',
expect.any(Array),
expect.objectContaining({ cwd: '/path/to/app' })
);
Expand Down Expand Up @@ -205,14 +300,14 @@ describe('run()', () => {
expect(core.setFailed).toHaveBeenCalledWith('npm ci failed');
});

it('fails when the build command exits with an error', async () => {
it('fails when the deploy command exits with an error', async () => {
execModule.exec
.mockResolvedValueOnce(0)
.mockRejectedValueOnce(new Error('build script failed'));
.mockRejectedValueOnce(new Error('deploy failed'));

await run();

expect(core.setFailed).toHaveBeenCalledWith('build script failed');
expect(core.setFailed).toHaveBeenCalledWith('deploy failed');
});

it('does not call setFailed on a successful run', async () => {
Expand Down
18 changes: 14 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,23 @@ inputs:
required: false
default: '.'
install-command:
description: Command to install dependencies before building
description: Command to install dependencies before deploying
required: false
default: npm ci
build-command:
description: Command to build the Vite app
datadog-site:
description: >-
Datadog site to deploy to (for example, datadoghq.eu). When not set, the
CLI resolves the site from the DD_SITE or DATADOG_SITE environment
variable, or the datadogSite field of the app's datadog-app.config.json.
required: false
default: npm run build
default: ''
cli-version:
description: >-
Version of @datadog/apps-cli to run when the CLI is not installed in the
app's node_modules; npx fetches that version at runtime. Ignored when the
CLI is already installed in the app's node_modules.
required: false
default: latest
runs:
using: node24
main: dist/index.js
Loading
Loading