Skip to content

Commit c13168b

Browse files
committed
ci(all-green): fix junit workflow attribution via per-test ci.* tags
datadog-ci junit upload reads GITHUB_WORKFLOW/GITHUB_RUN_ID/GITHUB_RUN_NUMBER from its own process env to set each test's Pipeline/Job facets. Uploading every sibling workflow's junit results from one batched call inside All Green attributed every test to the "All Green" workflow instead of the one that produced it. Revert the per-run-upload workaround from a previous commit (extra datadog-ci invocations, one per sibling workflow) in favor of stamping each job's own CI metadata as junit XML properties at mocha-run time (while its own GITHUB_* env vars are still correct), then lifting them into real per-test ci.pipeline.*/ci.job.name tags via --xpath-tag at upload time - mirroring the existing node_version tagging pattern, restoring the single batched upload. Also drop upload-junit.mjs's custom XML merging: --auto-discovery already recursively finds and uploads every matching file in one CLI invocation, so merging reports into one document first added regex-based XML surgery for no benefit. Generated by Claude Code.
1 parent 29f10b8 commit c13168b

5 files changed

Lines changed: 67 additions & 221 deletions

File tree

.mochamultireporterrc.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,34 @@
11
'use strict'
22

3+
// eslint-disable-next-line eslint-rules/eslint-process-env
4+
const isCI = Boolean(process.env.CI)
5+
36
const reporterEnabled = ['spec']
7+
if (isCI) reporterEnabled.push('./scripts/junit-reporter.js')
48

5-
// eslint-disable-next-line eslint-rules/eslint-process-env
6-
if (process.env.CI) {
7-
reporterEnabled.push('./scripts/junit-reporter.js')
8-
}
9+
// datadog-ci junit upload derives the Pipeline/Job UI facets from GITHUB_* env vars in its own
10+
// process at upload time, which are All Green's own since it uploads every sibling workflow's
11+
// results in a single call. Stamping them here instead, while this job's own GITHUB_* values are
12+
// still correct, lets `--xpath-tag` (see scripts/upload-junit.mjs) remap them onto the real
13+
// ci.pipeline.*/ci.job.* tags per test instead.
14+
const GITHUB_ENV = process.env // eslint-disable-line eslint-rules/eslint-process-env
15+
const {
16+
GITHUB_JOB, GITHUB_RUN_ID, GITHUB_WORKFLOW, GITHUB_RUN_NUMBER, GITHUB_SERVER_URL, GITHUB_REPOSITORY,
17+
} = GITHUB_ENV
918

1019
module.exports = {
1120
reporterEnabled,
1221
scriptsJunitReporterJsReporterOptions: {
1322
mochaFile: `./node-${process.versions.node}-junit.xml`,
1423
properties: {
1524
node_version: process.versions.node,
25+
...(isCI && {
26+
'ci.pipeline.name': GITHUB_WORKFLOW,
27+
'ci.pipeline.id': GITHUB_RUN_ID,
28+
'ci.pipeline.number': GITHUB_RUN_NUMBER,
29+
'ci.pipeline.url': `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}`,
30+
'ci.job.name': GITHUB_JOB,
31+
}),
1632
},
1733
},
1834
}

scripts/all-green.mjs

Lines changed: 17 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { summary } from '@actions/core'
44
import { context } from '@actions/github'
55
import { downloadArtifacts } from './download-artifacts.mjs'
66
import { logUploads } from './run-upload.mjs'
7-
import { uploadJunitForRun } from './upload-junit.mjs'
7+
import { uploadAllJunit } from './upload-junit.mjs'
88
import {
99
uploadAllCoverageToDatadog, uploadCoverage, sendCodecovNotifications, hasCodecovCommit,
1010
} from './upload-coverage.mjs'
@@ -105,42 +105,33 @@ async function getRuns () {
105105
}
106106

107107
// Runs whose reports have already been downloaded/merged, and the resulting promises — each
108-
// sibling workflow's reports are downloaded and its junit/Codecov uploads go out as soon as that
108+
// sibling workflow's reports are downloaded and its Codecov upload goes out as soon as that
109109
// workflow reaches a final state, instead of waiting for every workflow to finish, so a fast
110-
// workflow's results land while slower ones are still running. junit needs this same per-run
111-
// granularity even though it has no per-workflow flag constraint like Codecov: `datadog-ci junit
112-
// upload` tags every uploaded test with the pipeline name/id/number it reads from
113-
// GITHUB_WORKFLOW/GITHUB_RUN_ID/GITHUB_RUN_NUMBER in the process environment, and since the upload
114-
// always runs from inside the All Green job, a single merged upload across every run would
115-
// attribute every test to the "All Green" workflow instead of the one that produced it — see
116-
// `uploadJunitForRun`. Datadog coverage has no comparable per-test attribution to lose, so it stays
117-
// batched into one call after every run is done instead — see `uploadAllCoverageToDatadog`.
110+
// workflow's Codecov coverage lands while slower ones are still running. The junit and Datadog
111+
// coverage uploads don't have Codecov's per-workflow flag constraint, so they're batched into one
112+
// call each after every run is done instead — see `uploadAllJunit`/`uploadAllCoverageToDatadog`.
118113
const processedRunIds = new Set()
119114
const processingPromises = []
120115

121116
/**
122117
* Download a single finished workflow run's junit and coverage artifacts, merge them, and upload
123-
* the junit merge to Datadog (tagged with this run's own CI metadata) and the coverage merge to
124-
* Codecov.
118+
* the coverage merge to Codecov.
125119
*
126-
* @param {{ id: number, name: string, run_number: number, run_attempt?: number }} run
120+
* @param {{ id: number, name: string }} run
127121
* @returns {Promise<void>}
128122
*/
129123
async function processRun (run) {
130124
const { downloaded, failed } = await downloadArtifacts(octokit, { owner, repo, token: GITHUB_TOKEN, runs: [run] })
131125

132-
const [junitResults, coverageResults] = await Promise.all([
133-
uploadJunitForRun(run),
134-
uploadCoverage(run, {
135-
sha: HEAD_SHA,
136-
branch: HEAD_BRANCH,
137-
prNumber: PR_NUMBER,
138-
eventName: GITHUB_EVENT_NAME,
139-
baseRef: BASE_REF,
140-
}),
141-
])
126+
const coverageResults = await uploadCoverage(run, {
127+
sha: HEAD_SHA,
128+
branch: HEAD_BRANCH,
129+
prNumber: PR_NUMBER,
130+
eventName: GITHUB_EVENT_NAME,
131+
baseRef: BASE_REF,
132+
})
142133
const downloadSummary = failed > 0 ? `${downloaded} artifact(s), ${failed} failed` : `${downloaded} artifact(s)`
143-
logUploads(`${run.name} (${downloadSummary})`, [...junitResults, ...coverageResults])
134+
logUploads(`${run.name} (${downloadSummary})`, coverageResults)
144135
}
145136

146137
/**
@@ -279,8 +270,8 @@ async function checkAllGreen () {
279270
console.log(`Waiting for ${processingPromises.length} workflow run report upload(s) to finish.`)
280271
await Promise.all(processingPromises)
281272

282-
const coverageResults = await uploadAllCoverageToDatadog()
283-
logUploads('coverage (every run)', coverageResults)
273+
const [junitResults, coverageResults] = await Promise.all([uploadAllJunit(), uploadAllCoverageToDatadog()])
274+
logUploads('junit + coverage (every run)', [...junitResults, ...coverageResults])
284275

285276
if (!done) {
286277
console.log(`State is still pending after ${RETRIES} retries.`)

scripts/run-upload.mjs

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,12 @@ import { setTimeout as sleep } from 'node:timers/promises'
1717
*
1818
* @param {string} command
1919
* @param {string[]} args
20-
* @param {Record<string, string>} [env] Overrides merged on top of `process.env` for this call only.
2120
* @returns {Promise<UploadResult>}
2221
*/
23-
function spawnUpload (command, args, env) {
22+
function spawnUpload (command, args) {
2423
return new Promise(resolve => {
2524
const start = Date.now()
26-
const child = spawn(command, args, {
27-
stdio: ['ignore', 'pipe', 'pipe'],
28-
env: env ? { ...process.env, ...env } : process.env,
29-
})
25+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
3026
let output = ''
3127
child.stdout.on('data', chunk => { output += chunk })
3228
child.stderr.on('data', chunk => { output += chunk })
@@ -44,11 +40,10 @@ function spawnUpload (command, args, env) {
4440
*
4541
* @param {string} command
4642
* @param {string[]} args
47-
* @param {Record<string, string>} [env] Overrides merged on top of `process.env` for this call only.
4843
* @returns {Promise<UploadResult>}
4944
*/
50-
export async function runUpload (command, args, env) {
51-
const result = await spawnUpload(command, args, env)
45+
export async function runUpload (command, args) {
46+
const result = await spawnUpload(command, args)
5247
if (result.code !== 0) process.exitCode = 1
5348
return result
5449
}

scripts/upload-junit.mjs

Lines changed: 26 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,39 @@
1-
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2-
import { join } from 'node:path'
1+
import { existsSync } from 'node:fs'
32
import { runUpload } from './run-upload.mjs'
43

54
const INPUT_DIR = 'junit-results'
6-
const OUTPUT_DIR = 'junit-upload'
75

8-
// One sibling workflow's upload bundles every matrix cell's (e.g. per-Node-version) junit XML
9-
// together, so a per-test tag is the only way to tell which cell a result came from — the CI job
10-
// name/ID tags datadog-ci attaches are the same for every file in one upload call.
11-
// `.mochamultireporterrc.js` stamps each testsuite with a `node_version` property; this lifts it
12-
// into a real tag using the xpath pattern datadog-ci documents for `<property>` extraction.
13-
const NODE_VERSION_XPATH_TAG = "test.node_version=/testcase/..//property[@name='node_version']/@value"
6+
// Every sibling workflow's upload is merged into one call, so datadog-ci's own GITHUB_*-derived
7+
// Pipeline/Job facets would attribute every test to the All Green workflow instead of the one that
8+
// produced it. `.mochamultireporterrc.js` stamps each testsuite with its own job's CI metadata (plus
9+
// `node_version`) as XML properties at mocha-run time, while that job's own GITHUB_* env vars are
10+
// still correct; these xpath-tag mappings lift those properties into real per-test tags at upload
11+
// time, using the same reserved `ci.*` tag names datadog-ci's own GitHub Actions detection populates.
12+
const XPATH_TAGS = [
13+
"test.node_version=/testcase/..//property[@name='node_version']/@value",
14+
"ci.pipeline.name=/testcase/..//property[@name='ci.pipeline.name']/@value",
15+
"ci.pipeline.id=/testcase/..//property[@name='ci.pipeline.id']/@value",
16+
"ci.pipeline.number=/testcase/..//property[@name='ci.pipeline.number']/@value",
17+
"ci.pipeline.url=/testcase/..//property[@name='ci.pipeline.url']/@value",
18+
"ci.job.name=/testcase/..//property[@name='ci.job.name']/@value",
19+
]
1420

1521
/**
16-
* Recursively collect every junit XML file beneath a directory. `download-artifacts.mjs` lays
17-
* files out as `junit-results/<run-id>/<artifact-name>/*.xml`, one artifact per matrix cell across
18-
* every sibling workflow run.
22+
* Upload every sibling workflow's downloaded junit reports to Datadog in a single call.
23+
* `--auto-discovery` already walks `junit-results/<run-id>/<artifact-name>/*.xml` recursively and
24+
* uploads every matching file itself (with its own internal concurrency), so there's no need to
25+
* merge the reports into one document first — each testcase's `node_version` and `ci.*` properties
26+
* (see `XPATH_TAGS`) keep matrix cells and originating workflows distinguishable regardless of which
27+
* file they came from.
1928
*
20-
* @param {string} dir
21-
* @param {string[]} out
22-
* @returns {string[]}
23-
*/
24-
function collectJunitFiles (dir, out = []) {
25-
let entries
26-
try {
27-
entries = readdirSync(dir, { withFileTypes: true })
28-
} catch {
29-
return out
30-
}
31-
for (const entry of entries) {
32-
const full = join(dir, entry.name)
33-
if (entry.isDirectory()) {
34-
collectJunitFiles(full, out)
35-
} else if (entry.name.endsWith('.xml')) {
36-
out.push(full)
37-
}
38-
}
39-
return out
40-
}
41-
42-
/**
43-
* Parse a `<testsuites ...>` opening tag's attributes into a plain object.
44-
*
45-
* @param {string} attrsString
46-
* @returns {Record<string, string>}
47-
*/
48-
function parseRootAttrs (attrsString) {
49-
const attrs = {}
50-
for (const match of attrsString.matchAll(/(\w+)="([^"]*)"/g)) attrs[match[1]] = match[2]
51-
return attrs
52-
}
53-
54-
/**
55-
* Merge every matrix cell's junit report into a single document, concatenating each report's
56-
* `<testsuite>` children under one `<testsuites>` root instead of uploading one file per cell —
57-
* every testcase already carries its own `node_version` property (see `NODE_VERSION_XPATH_TAG`),
58-
* so cells stay distinguishable after merging. The root's `time`/`tests`/`failures`/`skipped`
59-
* totals are recomputed across every report; unlike those, the root `name` isn't run-specific, so
60-
* the first report's value is kept as-is.
61-
*
62-
* @param {string[]} reportPaths
63-
* @returns {string}
64-
*/
65-
function mergeJunit (reportPaths) {
66-
let name
67-
let inner = ''
68-
const totals = { time: 0, tests: 0, failures: 0, skipped: 0 }
69-
70-
for (const reportPath of reportPaths) {
71-
const contents = readFileSync(reportPath, 'utf8')
72-
const match = contents.match(/<testsuites([^>]*)>([\s\S]*)<\/testsuites>\s*$/)
73-
if (!match) continue
74-
75-
const [, attrsString, reportInner] = match
76-
const attrs = parseRootAttrs(attrsString)
77-
if (name === undefined && attrs.name) name = attrs.name
78-
for (const key of Object.keys(totals)) totals[key] += Number(attrs[key]) || 0
79-
inner += reportInner
80-
}
81-
name ??= 'Mocha Tests'
82-
83-
const skippedAttr = totals.skipped > 0 ? ` skipped="${totals.skipped}"` : ''
84-
return '<?xml version="1.0" encoding="UTF-8"?>\n' +
85-
`<testsuites name="${name}" time="${totals.time.toFixed(3)}" tests="${totals.tests}" ` +
86-
`failures="${totals.failures}"${skippedAttr}>${inner}</testsuites>\n`
87-
}
88-
89-
/**
90-
* Merge one sibling workflow run's downloaded junit reports into one file and upload it to Datadog,
91-
* tagged with that run's own GitHub Actions metadata instead of All Green's. `datadog-ci junit
92-
* upload` reads `GITHUB_WORKFLOW`/`GITHUB_RUN_ID`/`GITHUB_RUN_NUMBER`/`GITHUB_RUN_ATTEMPT` from the
93-
* process environment to set each uploaded test's pipeline name/id/number in Test Optimization; since
94-
* the upload always runs from inside the All Green job, every test would otherwise be attributed to
95-
* the "All Green" workflow instead of the one that actually produced it. Every matrix cell within the
96-
* run is still merged down to one document — each testcase's `node_version` property (see
97-
* `NODE_VERSION_XPATH_TAG`) is what keeps cells distinguishable afterward, not which file they
98-
* came from.
99-
*
100-
* @param {{ id: number, name: string, run_number: number, run_attempt?: number }} run
10129
* @returns {Promise<import('./run-upload.mjs').UploadResult[]>}
10230
*/
103-
export async function uploadJunitForRun (run) {
104-
const reportPaths = collectJunitFiles(join(INPUT_DIR, String(run.id)))
105-
if (reportPaths.length === 0) return []
106-
107-
const outputDir = join(OUTPUT_DIR, String(run.id))
108-
mkdirSync(outputDir, { recursive: true })
109-
writeFileSync(join(outputDir, 'junit.xml'), mergeJunit(reportPaths))
31+
export async function uploadAllJunit () {
32+
if (!existsSync(INPUT_DIR)) return []
11033

11134
const result = await runUpload('datadog-ci', [
112-
'junit', 'upload', '--service', 'dd-trace-js-tests', '--auto-discovery', outputDir,
113-
'--xpath-tag', NODE_VERSION_XPATH_TAG,
114-
], {
115-
GITHUB_WORKFLOW: run.name,
116-
GITHUB_RUN_ID: String(run.id),
117-
GITHUB_RUN_NUMBER: String(run.run_number),
118-
GITHUB_RUN_ATTEMPT: String(run.run_attempt ?? 1),
119-
})
35+
'junit', 'upload', '--service', 'dd-trace-js-tests', '--auto-discovery', INPUT_DIR,
36+
...XPATH_TAGS.flatMap(tag => ['--xpath-tag', tag]),
37+
])
12038
return [result]
12139
}
122-
123-
export { mergeJunit }

scripts/upload-junit.spec.mjs

Lines changed: 0 additions & 72 deletions
This file was deleted.

0 commit comments

Comments
 (0)