Skip to content

Commit 8b62678

Browse files
refactor(ci): proper XML parser, Scality download, single github-script step
Co-authored-by: francoisferrand <3909027+francoisferrand@users.noreply.github.com>
1 parent 1617024 commit 8b62678

File tree

3 files changed

+153
-184
lines changed

3 files changed

+153
-184
lines changed

.github/actions/archive-artifacts/action.yaml

Lines changed: 29 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -32,71 +32,39 @@ inputs:
3232
runs:
3333
using: composite
3434
steps:
35-
- name: Upload current JUnit reports
36-
if: always()
37-
uses: actions/upload-artifact@v4
35+
- name: Setup artifacts link
36+
id: setup-artifacts
37+
uses: scality/action-artifacts@v4
3838
with:
39-
name: junit-reports-${{ github.job }}-attempt-${{ github.run_attempt }}
40-
path: ${{ inputs.junit-paths }}
41-
if-no-files-found: ignore
39+
method: setup
40+
url: https://artifacts.scality.net
41+
user: ${{ inputs.user }}
42+
password: ${{ inputs.password }}
4243

43-
- name: Download previous JUnit reports
44-
if: ${{ github.run_attempt > 1 }}
44+
- name: Install XML parser
45+
shell: bash
46+
run: npm install --prefix /tmp/xmldom-install @xmldom/xmldom
47+
48+
- name: Download previous reports and merge
4549
uses: actions/github-script@v7
50+
env:
51+
NODE_PATH: /tmp/xmldom-install/node_modules
52+
ARTIFACTS_LINK: ${{ steps.setup-artifacts.outputs.link }}
53+
ARTIFACTS_USER: ${{ inputs.user }}
54+
ARTIFACTS_PASSWORD: ${{ inputs.password }}
4655
with:
4756
script: |
48-
const fs = require('fs');
49-
const { execSync } = require('child_process');
50-
const runAttempt = parseInt('${{ github.run_attempt }}');
51-
const jobName = '${{ github.job }}';
52-
for (let attempt = 1; attempt < runAttempt; attempt++) {
53-
const artifactName = `junit-reports-${jobName}-attempt-${attempt}`;
54-
try {
55-
const resp = await github.rest.actions.listWorkflowRunArtifacts({
56-
owner: context.repo.owner,
57-
repo: context.repo.repo,
58-
run_id: context.runId,
59-
per_page: 100,
60-
});
61-
const artifact = resp.data.artifacts.find(a => a.name === artifactName);
62-
if (!artifact) {
63-
core.info(`No artifact '${artifactName}' found, skipping.`);
64-
continue;
65-
}
66-
const zip = await github.rest.actions.downloadArtifact({
67-
owner: context.repo.owner,
68-
repo: context.repo.repo,
69-
artifact_id: artifact.id,
70-
archive_format: 'zip',
71-
});
72-
const dir = `/tmp/junit-previous/attempt-${attempt}`;
73-
fs.mkdirSync(dir, { recursive: true });
74-
fs.writeFileSync(`${dir}.zip`, Buffer.from(zip.data));
75-
execSync(`unzip -o ${dir}.zip -d ${dir}`);
76-
core.info(`Extracted attempt ${attempt} reports to ${dir}`);
77-
} catch (e) {
78-
core.warning(`Failed to download attempt ${attempt}: ${e.message}`);
79-
}
80-
}
81-
82-
- name: Merge JUnit reports
83-
shell: bash
84-
run: |-
85-
shopt -s nullglob globstar
86-
# inputs.junit-paths is a glob pattern; the array assignment expands it
87-
current_files=(${{ inputs.junit-paths }})
88-
prev_files=(/tmp/junit-previous/**/*.xml)
89-
all_files=("${current_files[@]}" "${prev_files[@]}")
90-
91-
if [ ${#all_files[@]} -eq 0 ]; then
92-
echo "No JUnit XML files found; skipping merge."
93-
exit 0
94-
fi
95-
96-
mkdir -p /tmp/artifacts/data/${{ inputs.stage }}
97-
node "$GITHUB_WORKSPACE/.github/scripts/merge-junit-reports.js" \
98-
/tmp/artifacts/data/${{ inputs.stage }}/junit-merged.xml \
99-
"${all_files[@]}"
57+
const mergeJUnitReports = require('./.github/scripts/merge-junit-reports.js')
58+
await mergeJUnitReports({
59+
core, glob,
60+
link: process.env.ARTIFACTS_LINK,
61+
user: process.env.ARTIFACTS_USER,
62+
password: process.env.ARTIFACTS_PASSWORD,
63+
jobName: '${{ github.job }}',
64+
runAttempt: ${{ github.run_attempt }},
65+
junitGlob: '${{ inputs.junit-paths }}',
66+
outputDir: '/tmp/artifacts/data/${{ inputs.stage }}',
67+
})
10068
10169
- name: Publish test report
10270
uses: mikepenz/action-junit-report@v4
@@ -111,7 +79,7 @@ runs:
11179
continue-on-error: true
11280

11381
- name: Upload results
114-
if: always() && inputs.trunk_token && job.status != 'cancelled'
82+
if: inputs.trunk_token && job.status != 'cancelled'
11583
uses: trunk-io/analytics-uploader@v1
11684
with:
11785
junit-paths: ${{ inputs.junit-paths }}
@@ -202,7 +170,6 @@ runs:
202170

203171
- name: Upload artifacts # move into `archive-artifacts` action
204172
uses: scality/action-artifacts@v4
205-
if: always()
206173
with:
207174
method: upload
208175
url: https://artifacts.scality.net
Lines changed: 124 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,148 +1,151 @@
1-
#!/usr/bin/env node
21
'use strict';
32
/**
4-
* Merge multiple JUnit XML files for cross-run flaky test detection.
3+
* Download previous-attempt JUnit reports from Scality artifacts,
4+
* merge them with the current attempt's reports, and write two output files:
55
*
6-
* Suites with the same name across files are combined so that
7-
* mikepenz/action-junit-report check_retries can detect tests that failed in
8-
* one attempt but passed in another (both testcase entries end up in the same
9-
* suite, which is what check_retries inspects).
6+
* {outputDir}/raw-reports.xml — current-attempt only (for future runs to download)
7+
* {outputDir}/junit-merged.xml — all attempts merged (for mikepenz/action-junit-report)
108
*
11-
* Assumes standard JUnit format with no nested <testsuite> elements
12-
* (i.e. <testsuites> -> <testsuite> -> <testcase>).
9+
* Suites with the same name are merged so that `check_retries: true` in
10+
* mikepenz/action-junit-report can detect tests that failed in one attempt
11+
* but passed in another.
1312
*
14-
* Usage: node merge-junit-reports.js <output> <input1> [input2 ...]
13+
* Called from actions/github-script@v7 via require():
14+
* const merge = require('./.github/scripts/merge-junit-reports.js')
15+
* await merge({ core, glob, link, user, password, jobName, runAttempt, junitGlob, outputDir })
1516
*/
1617

18+
const https = require('https');
19+
const http = require('http');
1720
const fs = require('fs');
1821
const path = require('path');
22+
/* @xmldom/xmldom is installed at build time; NODE_PATH is set by the calling step */
23+
const { DOMParser, XMLSerializer } = require('@xmldom/xmldom');
24+
25+
const parser = new DOMParser();
26+
const serializer = new XMLSerializer();
27+
28+
/** Fetch a URL with Basic auth. Returns the response body, or null on failure. */
29+
async function fetchText(url, user, password) {
30+
return new Promise((resolve) => {
31+
const auth = `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`;
32+
const lib = url.startsWith('https://') ? https : http;
33+
lib.get(url, { headers: { Authorization: auth } }, (res) => {
34+
if (res.statusCode !== 200) { res.resume(); resolve(null); return; }
35+
const chunks = [];
36+
res.on('data', c => chunks.push(c));
37+
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
38+
}).on('error', () => resolve(null));
39+
});
40+
}
1941

20-
const [, , output, ...inputs] = process.argv;
21-
22-
if (!output || inputs.length === 0) {
23-
process.stderr.write('Usage: node merge-junit-reports.js <output> <input1> [input2 ...]\n');
24-
process.exit(1);
42+
/** Parse a JUnit XML string and return all <testsuite> elements. */
43+
function parseSuites(xml) {
44+
const doc = parser.parseFromString(xml, 'application/xml');
45+
return Array.from(doc.getElementsByTagName('testsuite'));
2546
}
2647

2748
/**
28-
* Extract all top-level <testsuite>...</testsuite> blocks from an XML string.
29-
* Returns an array of objects: { name, inner }
30-
* where `inner` is the content between the opening and closing testsuite tags.
31-
*
32-
* Handles both <testsuites> root and bare <testsuite> root.
33-
* Assumes no nested <testsuite> elements (standard JUnit format).
49+
* Build a merged <testsuites> XML from an array of <testsuite> DOM elements.
50+
* Suites sharing the same name are combined into one suite.
3451
*/
35-
function extractSuites(xml) {
36-
// Strip XML declaration
37-
xml = xml.replace(/<\?xml[^?]*\?>\s*/i, '');
38-
39-
// Unwrap <testsuites> root if present
40-
const tsMatch = xml.match(/^<testsuites\b[^>]*>([\s\S]*)<\/testsuites>\s*$/);
41-
const inner = tsMatch ? tsMatch[1] : xml;
42-
43-
const suites = [];
44-
let pos = 0;
45-
while (pos < inner.length) {
46-
const start = inner.indexOf('<testsuite', pos);
47-
if (start === -1) break;
48-
49-
// Get the opening tag (up to and including '>')
50-
const openEnd = inner.indexOf('>', start);
51-
if (openEnd === -1) break;
52-
const openTag = inner.slice(start, openEnd + 1);
53-
54-
// Self-closing <testsuite ... /> — treat as empty suite
55-
if (openTag.endsWith('/>')) {
56-
const nameMatch = openTag.match(/\bname="([^"]*)"/);
57-
suites.push({ name: nameMatch ? nameMatch[1] : '', inner: '' });
58-
pos = openEnd + 1;
59-
continue;
60-
}
61-
62-
// Find closing tag
63-
const closeStart = inner.indexOf('</testsuite>', openEnd + 1);
64-
if (closeStart === -1) break;
52+
function buildXml(allSuites) {
53+
const suiteMap = new Map();
54+
for (const s of allSuites) {
55+
const name = s.getAttribute('name') || '';
56+
if (!suiteMap.has(name)) suiteMap.set(name, []);
57+
suiteMap.get(name).push(s);
58+
}
6559

66-
const nameMatch = openTag.match(/\bname="([^"]*)"/);
67-
suites.push({
68-
name: nameMatch ? nameMatch[1] : '',
69-
inner: inner.slice(openEnd + 1, closeStart),
70-
});
71-
pos = closeStart + '</testsuite>'.length;
60+
let totalTests = 0, totalFailures = 0, totalErrors = 0;
61+
const suiteParts = [];
62+
63+
for (const [name, group] of suiteMap) {
64+
const testcases = group.flatMap(s => Array.from(s.getElementsByTagName('testcase')));
65+
const failures = testcases.filter(tc => tc.getElementsByTagName('failure').length > 0).length;
66+
const errors = testcases.filter(tc => tc.getElementsByTagName('error').length > 0).length;
67+
totalTests += testcases.length;
68+
totalFailures += failures;
69+
totalErrors += errors;
70+
71+
const tcXml = testcases.map(tc => serializer.serializeToString(tc)).join('\n ');
72+
suiteParts.push(
73+
` <testsuite name="${escapeXmlAttr(name)}" tests="${testcases.length}" failures="${failures}" errors="${errors}">\n ${tcXml}\n </testsuite>`
74+
);
7275
}
73-
return suites;
76+
77+
return (
78+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
79+
`<testsuites tests="${totalTests}" failures="${totalFailures}" errors="${totalErrors}">\n` +
80+
suiteParts.join('\n') +
81+
'\n</testsuites>\n'
82+
);
7483
}
7584

76-
// Collect suites keyed by name; merge same-name suites across files
77-
const suiteMap = new Map(); // name -> concatenated inner content
85+
/** Escape a string for use as an XML attribute value. */
86+
function escapeXmlAttr(s) {
87+
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
88+
}
7889

79-
let fileCount = 0;
80-
for (const file of inputs) {
81-
if (!fs.existsSync(file)) {
82-
process.stderr.write(`Warning: file not found: ${file}\n`);
83-
continue;
90+
module.exports = async function mergeJUnitReports({
91+
core, glob,
92+
link, user, password,
93+
jobName, runAttempt,
94+
junitGlob, outputDir,
95+
}) {
96+
// ── Current attempt's XML files ───────────────────────────────────────────
97+
const globber = await glob.create(junitGlob);
98+
const currentFiles = await globber.glob();
99+
core.info(`Current JUnit files: ${currentFiles.length ? currentFiles.join(', ') : '(none)'}`);
100+
101+
const currentSuites = [];
102+
for (const f of currentFiles) {
103+
try {
104+
const suites = parseSuites(fs.readFileSync(f, 'utf8'));
105+
currentSuites.push(...suites);
106+
core.info(`Parsed ${path.basename(f)}: ${suites.length} suite(s)`);
107+
} catch (e) {
108+
core.warning(`Could not parse ${f}: ${e.message}`);
109+
}
84110
}
85111

86-
let xml;
87-
try {
88-
xml = fs.readFileSync(file, 'utf8');
89-
} catch (e) {
90-
process.stderr.write(`Warning: could not read ${file}: ${e.message}\n`);
91-
continue;
112+
// Write current-attempt raw report so future re-runs can download it
113+
if (currentSuites.length > 0) {
114+
fs.mkdirSync(outputDir, { recursive: true });
115+
fs.writeFileSync(path.join(outputDir, 'raw-reports.xml'), buildXml(currentSuites), 'utf8');
116+
core.info(`Written raw-reports.xml for attempt ${runAttempt}`);
92117
}
93118

94-
const suites = extractSuites(xml);
95-
fileCount++;
96-
97-
for (const { name, inner } of suites) {
98-
if (suiteMap.has(name)) {
99-
suiteMap.set(name, suiteMap.get(name) + '\n' + inner);
100-
} else {
101-
suiteMap.set(name, inner);
119+
// ── Download previous attempts' raw reports from Scality ─────────────────
120+
const allSuites = [...currentSuites];
121+
if (link && runAttempt > 1) {
122+
const base = link.replace(/\/$/, '');
123+
for (let attempt = 1; attempt < runAttempt; attempt++) {
124+
const url = `${base}/data/${jobName}.${attempt}/raw-reports.xml`;
125+
core.info(`Downloading attempt ${attempt} from ${url}...`);
126+
const xml = await fetchText(url, user, password);
127+
if (xml) {
128+
try {
129+
const suites = parseSuites(xml);
130+
allSuites.push(...suites);
131+
core.info(`Downloaded attempt ${attempt}: ${suites.length} suite(s)`);
132+
} catch (e) {
133+
core.warning(`Could not parse attempt ${attempt} reports: ${e.message}`);
134+
}
135+
} else {
136+
core.warning(`Attempt ${attempt} reports not available (skipped)`);
137+
}
102138
}
103139
}
104140

105-
process.stdout.write(`Parsed ${file}: ${suites.length} suite(s)\n`);
106-
}
107-
108-
if (fileCount === 0) {
109-
process.stderr.write('No input files were found or readable\n');
110-
process.exit(1);
111-
}
112-
113-
// Build merged XML
114-
let totalTests = 0;
115-
let totalFailures = 0;
116-
let totalErrors = 0;
117-
118-
const outputSuites = [];
119-
for (const [name, combined] of suiteMap) {
120-
const tests = (combined.match(/<testcase\b/g) || []).length;
121-
const failures = (combined.match(/<failure\b/g) || []).length;
122-
const errors = (combined.match(/<error\b/g) || []).length;
123-
124-
totalTests += tests;
125-
totalFailures += failures;
126-
totalErrors += errors;
127-
128-
const escapedName = name
129-
.replace(/&/g, '&amp;')
130-
.replace(/"/g, '&quot;')
131-
.replace(/</g, '&lt;')
132-
.replace(/>/g, '&gt;');
133-
outputSuites.push(
134-
` <testsuite name="${escapedName}" tests="${tests}" failures="${failures}" errors="${errors}">${combined}\n </testsuite>`
135-
);
136-
}
141+
if (allSuites.length === 0) {
142+
core.warning('No JUnit XML files found; skipping merge.');
143+
return;
144+
}
137145

138-
const merged =
139-
'<?xml version="1.0" encoding="UTF-8"?>\n' +
140-
`<testsuites tests="${totalTests}" failures="${totalFailures}" errors="${totalErrors}">\n` +
141-
outputSuites.join('\n') +
142-
'\n</testsuites>\n';
143-
144-
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
145-
fs.writeFileSync(output, merged, 'utf8');
146-
process.stdout.write(
147-
`Merged ${fileCount} file(s), ${suiteMap.size} suite(s) into ${output}\n`
148-
);
146+
// ── Write merged XML ──────────────────────────────────────────────────────
147+
fs.mkdirSync(outputDir, { recursive: true });
148+
const mergedPath = path.join(outputDir, 'junit-merged.xml');
149+
fs.writeFileSync(mergedPath, buildXml(allSuites), 'utf8');
150+
core.info(`Merged ${allSuites.length} suite(s) → ${mergedPath}`);
151+
};

.github/workflows/end2end.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,6 @@ jobs:
693693

694694
write-final-status:
695695
runs-on: ubuntu-24.04
696-
if: ${{ always() }}
697696
needs:
698697
- check-alerts
699698
- check-dashboard-versions

0 commit comments

Comments
 (0)