diff --git a/packages/base/src/commands/tag/README.md b/packages/base/src/commands/tag/README.md index a77d558f43..4ac4874d1a 100644 --- a/packages/base/src/commands/tag/README.md +++ b/packages/base/src/commands/tag/README.md @@ -5,7 +5,7 @@ Tag CI Visibility pipeline and job spans. ## Usage ```bash -datadog-ci tag [--no-fail] [--level ] [--tags] +datadog-ci tag [--no-fail] [--level ] [--tags] ``` For example: @@ -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 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. diff --git a/packages/base/src/commands/tag/__tests__/tag.test.ts b/packages/base/src/commands/tag/__tests__/tag.test.ts index 78308757e6..e00cb6669d 100644 --- a/packages/base/src/commands/tag/__tests__/tag.test.ts +++ b/packages/base/src/commands/tag/__tests__/tag.test.ts @@ -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([ { diff --git a/packages/base/src/commands/tag/tag.ts b/packages/base/src/commands/tag/tag.ts index 3421e8e7ef..201e1a6dbd 100644 --- a/packages/base/src/commands/tag/tag.ts +++ b/packages/base/src/commands/tag/tag.ts @@ -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' @@ -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'], ], }) @@ -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 = () => { @@ -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`) diff --git a/packages/base/src/helpers/__tests__/ci.test.ts b/packages/base/src/helpers/__tests__/ci.test.ts index 3459645939..e9f1d2dc96 100644 --- a/packages/base/src/helpers/__tests__/ci.test.ts +++ b/packages/base/src/helpers/__tests__/ci.test.ts @@ -26,6 +26,8 @@ import { githubWellKnownDiagnosticDirsWin, isGithubWindowsRunner, isInteractive, + parseLevels, + processLevels, shouldGetGithubJobDisplayName, } from '../ci' import {globSync} from '../glob' @@ -1494,6 +1496,139 @@ describe('Bitbucket PR pipeline tags', () => { }) }) +describe('parseLevels', () => { + beforeEach(() => { + // Buildkite supports the `pipeline` and `job` levels (no provider restriction on those). + process.env = {BUILDKITE: 'true', BUILDKITE_BUILD_ID: 'id'} + }) + + test('parses a single level', () => { + expect(parseLevels('pipeline')).toEqual({levels: ['pipeline']}) + }) + + test('parses multiple levels, sorted by span depth regardless of input order', () => { + expect(parseLevels('job,pipeline')).toEqual({levels: ['pipeline', 'job']}) + }) + + test('dedupes repeated levels', () => { + expect(parseLevels('pipeline,pipeline')).toEqual({levels: ['pipeline']}) + }) + + test('trims whitespace around each member', () => { + expect(parseLevels(' pipeline , job ')).toEqual({levels: ['pipeline', 'job']}) + }) + + test.each([undefined, '', ',', ' , '])('errors on empty input %p', (input) => { + const {levels, error} = parseLevels(input) + expect(levels).toEqual([]) + expect(error).toBe('Level must be one of [pipeline, job, stage, step]') + }) + + test('errors when a member is unknown', () => { + const {levels, error} = parseLevels('pipeline,bogus') + expect(levels).toEqual([]) + expect(error).toBe('Level must be one of [pipeline, job, stage, step]') + }) + + test('errors when a member is unsupported for the current provider', () => { + const {levels, error} = parseLevels('pipeline,stage') + expect(levels).toEqual([]) + expect(error).toContain("Level 'stage' is only supported for providers") + }) +}) + +describe('processLevels', () => { + const githubEnv = { + GITHUB_ACTIONS: 'true', + GITHUB_SERVER_URL: 'url', + GITHUB_REPOSITORY: 'repo', + GITHUB_RUN_ID: '123', + GITHUB_RUN_ATTEMPT: '1', + GITHUB_JOB: 'build', + GITHUB_ACTION: '__run', + } + + const mockWorkerLog = (orchestrationId?: string) => { + const readdirSpy = 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: '', + }, + ]) + const orchestrationLine = orchestrationId ? `"system.orchestrationId": "${orchestrationId}"\n` : '' + const readFileSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue( + `[2025-09-15 10:14:00Z INFO Worker] ${orchestrationLine}Job message:\n${JSON.stringify({ + jobDisplayName: 'real job name', + steps: [{contextName: '__checkout'}, {contextName: '__run'}], + })}` + ) + + return {readdirSpy, readFileSpy} + } + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('locates and reads the GitHub diagnostic logs once across levels', async () => { + process.env = {...githubEnv} + const {readdirSpy, readFileSpy} = mockWorkerLog() + + const anyFailed = await processLevels(createMockContext() as BaseContext, ['job', 'step'], {}, () => + Promise.resolve(0) + ) + + expect(anyFailed).toBe(false) + // Without the shared cache, each of the job and step levels would re-glob and + // re-read the same Worker log; the per-execution cache collapses that to one. + expect(readdirSpy).toHaveBeenCalledTimes(1) + expect(readFileSpy).toHaveBeenCalledTimes(1) + }) + + test('rethrows a fatal level error (annotated with the level) after sending earlier levels', 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 + }) + process.env = {...githubEnv} + const context = createMockContext() as BaseContext + const sentLevels: number[] = [] + + await expect( + processLevels(context, ['pipeline', 'step'], {}, (_levelEnv, levelNumber) => { + sentLevels.push(levelNumber) + + return Promise.resolve(0) + }) + ).rejects.toThrow("level 'step'") + + expect(sentLevels).toEqual([0]) // pipeline was sent before the step level threw + }) + + test('selects the orchestrationId target log once across levels (cached)', async () => { + process.env = {...githubEnv, ACTIONS_ORCHESTRATION_ID: 'orch-1'} + mockWorkerLog('orch-1') + const context = createMockContext() as BaseContext + + const anyFailed = await processLevels(context, ['job', 'step'], {}, () => Promise.resolve(0)) + + expect(anyFailed).toBe(false) + // The orchestrationId match runs once for the whole command, not once per level. + expect(context.stdout.toString().match(/Found Worker log via system\.orchestrationId/g)).toHaveLength(1) + }) +}) + const getTags = (): SpanTags => { return { ...getCISpanTags(), diff --git a/packages/base/src/helpers/ci.ts b/packages/base/src/helpers/ci.ts index ea7fc391a1..2e8e3f6c47 100644 --- a/packages/base/src/helpers/ci.ts +++ b/packages/base/src/helpers/ci.ts @@ -118,6 +118,34 @@ export const validateLevel = (level: string | undefined): string | undefined => return undefined } +/** + * Parses an additive, comma-separated `--level` value (e.g. "pipeline,stage"). + * Trims each member, drops empties, dedupes, sorts by span depth, and validates + * every member (known value + provider support). Returns the parsed levels or, + * if anything is invalid or empty, a single aggregated error message. + */ +export const parseLevels = (input: string | undefined): {levels: CILevel[]; error?: string} => { + const parts = (input ?? '') + .split(',') + .map((p) => p.trim()) + .filter((p) => p.length > 0) + + const unique = uniq(parts) + + if (unique.length === 0) { + return {levels: [], error: `Level must be one of [${VALID_LEVELS.join(', ')}]`} + } + + const errors = uniq(unique.map((p) => validateLevel(p)).filter((e): e is string => Boolean(e))) + if (errors.length > 0) { + return {levels: [], error: errors.join('; ')} + } + + const levels = (unique as CILevel[]).sort((a, b) => LEVEL_TO_NUMBER[a] - LEVEL_TO_NUMBER[b]) + + return {levels} +} + export const githubWellKnownDiagnosticDirsUnix = [ '/home/runner/actions-runner/_diag', // for self-hosted '/opt/actions-runner/_diag', // self-hosted in some cases @@ -1205,7 +1233,59 @@ export const isGithubWindowsRunner = (): boolean => { return os.toLowerCase() === 'windows' } -const getGithubWorkerLogFiles = (context: BaseContext): [string, string[]] | undefined => { +/** + * Per-execution cache for GitHub diagnostic-log reads. Locating the Worker log + * dir, selecting the target log file(s), and reading the (~26KB) contents is + * shared by the job and step enrichment paths; when several levels are requested + * in a single command (e.g. `--level job,step`) this avoids re-globbing, + * re-selecting and re-reading the same files once per level. The cache is created + * per command execution (see `processLevels`), never module-global, so it cannot + * leak state across commands or tests. + */ +export interface GithubLogCache { + workerLogFilesResolved: boolean + workerLogFiles: [string, string[]] | undefined + targetLogFilesResolved: boolean + targetLogFiles: string[] + fileContents: Map +} + +export const createGithubLogCache = (): GithubLogCache => ({ + workerLogFilesResolved: false, + workerLogFiles: undefined, + targetLogFilesResolved: false, + targetLogFiles: [], + fileContents: new Map(), +}) + +/** Reads a diagnostic log file, serving from (and populating) `cache` when provided. */ +const readLogFile = (filePath: string, cache?: GithubLogCache): string => { + const cached = cache?.fileContents.get(filePath) + if (cached !== undefined) { + return cached + } + + const content = fs.readFileSync(filePath, 'utf-8') + cache?.fileContents.set(filePath, content) + + return content +} + +const getGithubWorkerLogFiles = (context: BaseContext, cache?: GithubLogCache): [string, string[]] | undefined => { + if (cache?.workerLogFilesResolved) { + return cache.workerLogFiles + } + + const result = computeGithubWorkerLogFiles(context) + if (cache) { + cache.workerLogFilesResolved = true + cache.workerLogFiles = result + } + + return result +} + +const computeGithubWorkerLogFiles = (context: BaseContext): [string, string[]] | undefined => { let foundDiagDir = '' let workerLogFiles: string[] = [] @@ -1288,7 +1368,31 @@ const getGithubWorkerLogFiles = (context: BaseContext): [string, string[]] | und * which on a single-job runner is immediate, and on a multi-runner is the current * job's (most recent) log. */ -const getTargetWorkerLogFiles = (context: BaseContext, foundDiagDir: string, workerLogFiles: string[]): string[] => { +const getTargetWorkerLogFiles = ( + context: BaseContext, + foundDiagDir: string, + workerLogFiles: string[], + cache?: GithubLogCache +): string[] => { + if (cache?.targetLogFilesResolved) { + return cache.targetLogFiles + } + + const result = computeTargetWorkerLogFiles(context, foundDiagDir, workerLogFiles, cache) + if (cache) { + cache.targetLogFilesResolved = true + cache.targetLogFiles = result + } + + return result +} + +const computeTargetWorkerLogFiles = ( + context: BaseContext, + foundDiagDir: string, + workerLogFiles: string[], + cache?: GithubLogCache +): string[] => { // Always sort newest-first so the fallback iteration is in the right order. const sortedNewestFirst = [...workerLogFiles].sort().reverse() @@ -1296,7 +1400,7 @@ const getTargetWorkerLogFiles = (context: BaseContext, foundDiagDir: string, wor if (orchestrationId) { for (const logFile of sortedNewestFirst) { const filePath = upath.join(foundDiagDir, logFile) - const content = fs.readFileSync(filePath, 'utf-8') + const content = readLogFile(filePath, cache) if (content.includes('"system.orchestrationId":') && content.includes(`"${orchestrationId}"`)) { context.stdout.write(`Found Worker log via system.orchestrationId for ${orchestrationId}: ${logFile}\n`) @@ -1317,13 +1421,14 @@ const getGithubJobAttributeFromLogFiles = ( context: BaseContext, foundDiagDir: string, workerLogFiles: string[], - jobAttributeRegex: RegExp + jobAttributeRegex: RegExp, + cache?: GithubLogCache ): string | undefined => { - const logsToCheck = getTargetWorkerLogFiles(context, foundDiagDir, workerLogFiles) + const logsToCheck = getTargetWorkerLogFiles(context, foundDiagDir, workerLogFiles, cache) for (const logFile of logsToCheck) { const filePath = upath.join(foundDiagDir, logFile) - const content = fs.readFileSync(filePath, 'utf-8') + const content = readLogFile(filePath, cache) const match = content.match(jobAttributeRegex) @@ -1353,13 +1458,13 @@ const getGithubJobAttributeFromLogFiles = ( * * @returns The job display name, or undefined if not found */ -export const getGithubJobNameFromLogs = (context: BaseContext): string | undefined => { +export const getGithubJobNameFromLogs = (context: BaseContext, cache?: GithubLogCache): string | undefined => { if (!shouldGetGithubJobDisplayName()) { return } context.stdout.write('Determining GitHub job name\n') - const result = getGithubWorkerLogFiles(context) + const result = getGithubWorkerLogFiles(context, cache) if (!result) { return } @@ -1369,7 +1474,8 @@ export const getGithubJobNameFromLogs = (context: BaseContext): string | undefin context, foundDiagDir, workerLogFiles, - githubJobDisplayNameRegex + githubJobDisplayNameRegex, + cache ) if (!jobDisplayName) { @@ -1392,11 +1498,12 @@ export const getGithubJobNameFromLogs = (context: BaseContext): string | undefin export const enrichCIEnvFromGithubLogs = ( context: BaseContext, level: CILevel, - ciEnv: Record + ciEnv: Record, + cache?: GithubLogCache ): void => { switch (level) { case CI_LEVELS.STEP: { - const stepInfo = getGithubStepInfoFromLogs(context) + const stepInfo = getGithubStepInfoFromLogs(context, cache) if (!ciEnv[envDDGithubJobName]) { ciEnv[envDDGithubJobName] = stepInfo.jobDisplayName } @@ -1404,7 +1511,7 @@ export const enrichCIEnvFromGithubLogs = ( break } case CI_LEVELS.JOB: { - const jobName = getGithubJobNameFromLogs(context) + const jobName = getGithubJobNameFromLogs(context, cache) if (jobName) { ciEnv[envDDGithubJobName] = jobName } @@ -1415,6 +1522,60 @@ export const enrichCIEnvFromGithubLogs = ( } } +/** + * Sends the same payload to each requested CI level, one request per level. + * + * Shared by the `tag` and `measure` commands. Each level is enriched on its own + * copy of `ciEnv` (so e.g. the step index never leaks into the job request). + * + * Failure handling mirrors the original single-level behavior: + * - A setup/config error — a missing API key, or GitHub diagnostic logs that + * cannot be located — is thrown and propagates to the caller's outer catch, + * making it fatal regardless of `--no-fail`. The thrown error is annotated + * with the failing level so the report says which one broke. + * - A request that completes but reports a non-zero status sets the returned + * flag and the loop continues; the caller decides whether `--no-fail` turns + * it into a success. + * + * A single `GithubLogCache` lives for the duration of the loop so the GitHub + * diagnostic logs are located and read at most once even when several levels + * need them (e.g. `--level job,step`). + * + * Returns `true` if any level reported a non-zero status. Callers own the + * `--no-fail`/success messaging and exit code based on this flag. + */ +export const processLevels = async ( + context: BaseContext, + levels: CILevel[], + ciEnv: Record, + sendForLevel: (levelEnv: Record, levelNumber: number) => Promise +): Promise => { + const cache = createGithubLogCache() + let anyFailed = false + + for (const level of levels) { + let exitStatus: number + try { + const levelEnv = {...ciEnv} + enrichCIEnvFromGithubLogs(context, level, levelEnv, cache) + + exitStatus = await sendForLevel(levelEnv, LEVEL_TO_NUMBER[level]) + } catch (error) { + // Re-throw annotated with the level. The command's outer catch reports it + // and returns a non-zero exit code even under --no-fail, matching the + // original single-level behavior where setup/config throws were fatal. + throw new Error(`level '${level}': ${(error as Error).message}`) + } + + if (exitStatus !== 0) { + anyFailed = true + context.stderr.write(`${chalk.red.bold('[ERROR]')} failed to send for level '${level}'\n`) + } + } + + return anyFailed +} + /** * Extracts the job display name and step index from GitHub Actions diagnostic logs. * @@ -1423,7 +1584,10 @@ export const enrichCIEnvFromGithubLogs = ( * 2. Extract only the `"steps": [...]` array from the Job message via bracket-depth tracking * 3. Parse just that array and match `contextName` against `GITHUB_ACTION` */ -export const getGithubStepInfoFromLogs = (context: BaseContext): {jobDisplayName: string; stepIndex: number} => { +export const getGithubStepInfoFromLogs = ( + context: BaseContext, + cache?: GithubLogCache +): {jobDisplayName: string; stepIndex: number} => { if (getCIProvider() !== CI_ENGINES.GITHUB) { throw new Error('Step level is only supported for GitHub Actions') } @@ -1435,16 +1599,16 @@ export const getGithubStepInfoFromLogs = (context: BaseContext): {jobDisplayName context.stdout.write('Determining GitHub step info from diagnostic logs\n') - const result = getGithubWorkerLogFiles(context) + const result = getGithubWorkerLogFiles(context, cache) if (!result) { throw new Error('Could not find GitHub diagnostic log files, cannot determine step index') } const [foundDiagDir, workerLogFiles] = result - const logsToCheck = getTargetWorkerLogFiles(context, foundDiagDir, workerLogFiles) + const logsToCheck = getTargetWorkerLogFiles(context, foundDiagDir, workerLogFiles, cache) for (const logFile of logsToCheck) { const filePath = upath.join(foundDiagDir, logFile) - const content = fs.readFileSync(filePath, 'utf-8') + const content = readLogFile(filePath, cache) // Extract jobDisplayName using the existing regex const displayNameMatch = content.match(githubJobDisplayNameRegex)