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
14 changes: 12 additions & 2 deletions packages/base/src/commands/tag/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Tag CI Visibility pipeline and job spans.
## Usage

```bash
datadog-ci tag [--no-fail] [--level <pipeline|job|stage|step>] [--tags]
datadog-ci tag [--no-fail] [--level <levels>] [--tags]
```

For example:
Expand All @@ -14,10 +14,20 @@ For example:
datadog-ci tag --level job --tags "go.version:`go version`"
```

- `--level` Has to be one of `[pipeline, job, stage, step]`. It will determine in what span the tags will be added.
- `--level` is a comma-separated list of `{pipeline,job,stage,step}`. It determines in what span(s) the tags will be added.

If `pipeline` is selected then the tags will be added to the pipeline trace span. If `job` is selected it will be added
Comment thread
Drarig29 marked this conversation as resolved.
to the span for the currently running job. If `stage` is selected it will be added to the span for the currently
running stage. If `step` is selected it will be added to the span for the currently running step.

Pass a comma-separated list to add the same tags to multiple levels at once, e.g. `--level pipeline,job`.
This is useful for tags that apply at every level (e.g. `team`, which does not propagate between spans) or that should
"flow down" to a few levels (e.g. `job_maintainer` on `--level job,step`). If any level fails the command exits non-zero (unless `--no-fail` is set),
and the spans that did succeed remain tagged.

```bash
datadog-ci tag --level pipeline,job --tags team:backend
```
- `--no-fail` (default: `false`) will prevent the tag command from failing if there are issues submitting the data.
- `--tags` is an array of key value pairs of the shape `key:value`. This will be the tags added to the pipeline or job span.
The resulting dictionary will be merged with whatever is in the `DD_TAGS` environment variable and in the `--tags-file` argument.
Expand Down
159 changes: 159 additions & 0 deletions packages/base/src/commands/tag/__tests__/tag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,165 @@ describe('execute', () => {
expect(result.context.stdout.toString()).toContain('[DRYRUN] Tag request')
})

test('sends one request per level for additive --level', async () => {
const result = await runCLI(
'pipeline,job',
['key:value'],
{
BUILDKITE: 'true',
BUILDKITE_BUILD_ID: 'id',
BUILDKITE_JOB_ID: 'id',
},
['--dry-run']
)
expect(result.code).toBe(0)
const out = result.context.stdout.toString()
expect(out).toContain('"ci_level": 0')
expect(out).toContain('"ci_level": 1')
// one request per level
expect(out.match(/\[DRYRUN\] Tag request/g)).toHaveLength(2)
// the same tag set is included in each request
expect(out.match(/"key": "value"/g)).toHaveLength(2)
})

test('dedupes repeated levels into a single request', async () => {
const result = await runCLI(
'pipeline,pipeline',
['key:value'],
{
BUILDKITE: 'true',
BUILDKITE_BUILD_ID: 'id',
BUILDKITE_JOB_ID: 'id',
},
['--dry-run']
)
expect(result.code).toBe(0)
expect(result.context.stdout.toString().match(/\[DRYRUN\] Tag request/g)).toHaveLength(1)
})

test('fails fast if any level is invalid', async () => {
const {context, code} = await runCLI('pipeline,bogus', ['key:value'], {
BUILDKITE: 'true',
BUILDKITE_BUILD_ID: 'id',
})
expect(code).toBe(1)
expect(context.stderr.toString()).toContain('Level must be one of [pipeline, job, stage, step]')
})

test('fails fast if any level is unsupported for the provider and sends nothing', async () => {
const {context, code} = await runCLI(
'pipeline,stage',
['key:value'],
{
BUILDKITE: 'true',
BUILDKITE_BUILD_ID: 'id',
BUILDKITE_JOB_ID: 'id',
},
['--dry-run']
)
expect(code).toBe(1)
expect(context.stderr.toString()).toContain("Level 'stage' is only supported for providers")
expect(context.stdout.toString()).not.toContain('[DRYRUN] Tag request')
})

test('enriches each level independently for github (job,step)', async () => {
jest.spyOn(fs, 'readdirSync').mockReturnValue([
{
name: 'Worker_1.log' as any,
isFile: () => true,
isDirectory: () => false,
isBlockDevice: () => false,
isCharacterDevice: () => false,
isSymbolicLink: () => false,
isFIFO: () => false,
isSocket: () => false,
parentPath: '',
path: '',
},
])
jest.spyOn(fs, 'readFileSync').mockReturnValue(
`[2025-09-15 10:14:00Z INFO Worker] Job message:\n${JSON.stringify({
jobDisplayName: 'real job name',
steps: [{contextName: '__checkout'}, {contextName: '__run'}],
})}`
)
const result = await runCLI(
'job,step',
['key:value'],
{
GITHUB_ACTIONS: 'true',
GITHUB_SERVER_URL: 'url',
GITHUB_REPOSITORY: 'repo',
GITHUB_RUN_ID: '123',
GITHUB_RUN_ATTEMPT: '1',
GITHUB_JOB: 'build',
GITHUB_ACTION: '__run',
},
['--dry-run']
)
expect(result.code).toBe(0)
const out = result.context.stdout.toString()
expect(out).toContain('"ci_level": 1')
expect(out).toContain('"ci_level": 3')
// step index only belongs on the step request; both carry the resolved job name
expect(out).toContain('"DD_GITHUB_STEP_INDEX": "1"')
expect(out).toContain('"DD_GITHUB_JOB_NAME": "real job name"')
jest.restoreAllMocks()
})

test('earlier levels are still sent before a fatal level aborts the rest', async () => {
// Force `step` enrichment to fail regardless of the host machine's filesystem layout
// (real GitHub Actions runners may have Worker_*.log files under the well-known fallback dirs).
jest.spyOn(fs, 'readdirSync').mockImplementation(() => {
const error = new Error('ENOENT') as NodeJS.ErrnoException
error.code = 'ENOENT'
throw error
})
const result = await runCLI(
'pipeline,step',
['key:value'],
{
GITHUB_ACTIONS: 'true',
GITHUB_SERVER_URL: 'url',
GITHUB_REPOSITORY: 'repo',
GITHUB_RUN_ID: '123',
GITHUB_RUN_ATTEMPT: '1',
GITHUB_JOB: 'build',
GITHUB_ACTION: '__run',
},
['--dry-run']
)
expect(result.code).toBe(1)
expect(result.context.stdout.toString()).toContain('"ci_level": 0')
expect(result.context.stderr.toString()).toContain("level 'step'")
})

test('a fatal setup error exits non-zero even with --no-fail', async () => {
// --no-fail only tolerates a failed submission, not a setup error (missing GitHub logs here),
// matching the original single-level behavior.
jest.spyOn(fs, 'readdirSync').mockImplementation(() => {
const error = new Error('ENOENT') as NodeJS.ErrnoException
error.code = 'ENOENT'
throw error
})
const result = await runCLI(
'pipeline,step',
['key:value'],
{
GITHUB_ACTIONS: 'true',
GITHUB_SERVER_URL: 'url',
GITHUB_REPOSITORY: 'repo',
GITHUB_RUN_ID: '123',
GITHUB_RUN_ATTEMPT: '1',
GITHUB_JOB: 'build',
GITHUB_ACTION: '__run',
},
['--dry-run', '--no-fail']
)
expect(result.code).toBe(1)
expect(result.context.stderr.toString()).toContain("level 'step'")
})

test('should try to determine github job display name', async () => {
jest.spyOn(fs, 'readdirSync').mockReturnValue([
{
Expand Down
19 changes: 10 additions & 9 deletions packages/base/src/commands/tag/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import {Command, Option} from 'clipanion'

import {FIPS_ENV_VAR, FIPS_IGNORE_ERROR_ENV_VAR} from '../../constants'
import {getDatadogSite} from '../../helpers/api'
import type {CILevel} from '../../helpers/ci'
import {LEVEL_TO_NUMBER, enrichCIEnvFromGithubLogs, getCIEnv, validateLevel} from '../../helpers/ci'
import {getCIEnv, parseLevels, processLevels} from '../../helpers/ci'
import {toBoolean} from '../../helpers/env'
import {enableFips} from '../../helpers/fips'
import type {RequestError} from '../../helpers/request'
Expand All @@ -28,6 +27,7 @@ export class TagCommand extends BaseCommand {
examples: [
['Add a team tag to the current pipeline', 'datadog-ci tag --level pipeline --tags team:backend'],
['Tag the current CI job with the go version', 'datadog-ci tag --level job --tags "go.version:`go version`"'],
['Add the same tags to multiple levels at once', 'datadog-ci tag --level pipeline,job --tags team:backend'],
['Add tags in bulk using a JSON file', 'datadog-ci tag --level job --tags-file my_tags.json'],
],
})
Expand Down Expand Up @@ -76,13 +76,12 @@ export class TagCommand extends BaseCommand {
public async execute() {
enableFips(this.fips || this.config.fips, this.fipsIgnoreError || this.config.fipsIgnoreError)

const levelError = validateLevel(this.level)
const {levels, error: levelError} = parseLevels(this.level)
if (levelError) {
this.context.stderr.write(`${chalk.red.bold('[ERROR]')} ${levelError}\n`)

return 1
}
const level = this.level as CILevel

if (this.silent) {
this.context.stdout.write = () => {
Expand Down Expand Up @@ -118,20 +117,22 @@ export class TagCommand extends BaseCommand {
try {
const {provider, ciEnv} = getCIEnv()

enrichCIEnvFromGithubLogs(this.context, level, ciEnv)
// The same tag set is sent to each requested level (one request per level).
const anyFailed = await processLevels(this.context, levels, ciEnv, (levelEnv, levelNumber) =>
this.sendTags(levelEnv, levelNumber, provider, tags)
)

const exitStatus = await this.sendTags(ciEnv, LEVEL_TO_NUMBER[level], provider, tags)
if (exitStatus !== 0 && this.noFail) {
if (anyFailed && this.noFail) {
this.context.stderr.write(
`${chalk.yellow.bold('[WARNING]')} sending tags failed but continuing due to --no-fail\n`
)

return 0
} else if (exitStatus === 0 && !this.dryRun) {
} else if (!anyFailed && !this.dryRun) {
this.context.stdout.write('Tags sent\n')
}

return exitStatus
return anyFailed ? 1 : 0
} catch (error) {
this.context.stderr.write(`${chalk.red.bold('[ERROR]')} ${error.message}\n`)

Expand Down
Loading
Loading