Skip to content

Commit cee5bf4

Browse files
authored
Merge branch 'main' into fix/sync-junit-jar-versions
2 parents 11ee221 + 04d1702 commit cee5bf4

5 files changed

Lines changed: 630 additions & 22 deletions

File tree

.github/workflows/health-check.yml

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
name: Health Check - Maven Central Data Sources
2+
3+
# Verifies that the upstream Maven Central data sources used by the
4+
# "Enable Java Tests" download flow (see scripts/checkVersionSources.js)
5+
# are still healthy. This catches situations like microsoft/vscode-java-test#1866,
6+
# where the legacy search.maven.org Solr index was silently frozen and
7+
# kept returning a pre-release as the "latest" stable version.
8+
#
9+
# - Runs on every PR and push to main so a code change cannot break the lookup.
10+
# - Runs weekly so purely upstream changes are caught within days.
11+
# - Can be triggered manually from the Actions tab.
12+
13+
on:
14+
push:
15+
branches: [ main ]
16+
pull_request:
17+
branches: [ main ]
18+
schedule:
19+
# Monday 09:00 UTC.
20+
- cron: '0 9 * * 1'
21+
workflow_dispatch:
22+
23+
permissions:
24+
contents: read
25+
issues: write
26+
27+
jobs:
28+
check-version-sources:
29+
name: Check Maven Central version sources
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 10
32+
steps:
33+
- uses: actions/checkout@v4
34+
35+
- name: Setup Node.js environment
36+
uses: actions/setup-node@v4
37+
with:
38+
node-version: 20
39+
40+
- name: Run version source health check
41+
run: node scripts/checkVersionSources.js
42+
43+
- name: Open issue on scheduled failure
44+
if: failure() && github.event_name == 'schedule'
45+
uses: actions/github-script@v7
46+
with:
47+
script: |
48+
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
49+
const title = '[health-check] Maven Central data source check failed';
50+
const labelName = 'ci-health';
51+
const body = [
52+
'The scheduled health check for the Maven Central data sources used by',
53+
'`scripts/checkVersionSources.js` failed.',
54+
'',
55+
`Failed run: ${runUrl}`,
56+
'',
57+
'This typically means one of the following:',
58+
'- An upstream `maven-metadata.xml` is no longer reachable (HTTP 4xx/5xx).',
59+
'- An upstream `<release>` tag has drifted to a pre-release version',
60+
' (e.g. `-M3`, `-RC1`, `-beta-1`).',
61+
'- A jar download URL is no longer reachable.',
62+
'',
63+
'See microsoft/vscode-java-test#1866 for the original failure mode.',
64+
'',
65+
'Please investigate before users start hitting the broken download.',
66+
].join('\n');
67+
68+
// Ensure the label exists so issues.create({ labels: [...] }) does not 422.
69+
try {
70+
await github.rest.issues.createLabel({
71+
owner: context.repo.owner,
72+
repo: context.repo.repo,
73+
name: labelName,
74+
color: 'd93f0b',
75+
description: 'Automated CI health-check failure',
76+
});
77+
core.info(`Created label "${labelName}".`);
78+
} catch (err) {
79+
if (err.status === 422) {
80+
core.info(`Label "${labelName}" already exists.`);
81+
} else {
82+
throw err;
83+
}
84+
}
85+
86+
const { data: existing } = await github.rest.issues.listForRepo({
87+
owner: context.repo.owner,
88+
repo: context.repo.repo,
89+
state: 'open',
90+
labels: labelName,
91+
per_page: 100,
92+
});
93+
const alreadyOpen = existing.find((issue) => issue.title === title);
94+
if (alreadyOpen) {
95+
await github.rest.issues.createComment({
96+
owner: context.repo.owner,
97+
repo: context.repo.repo,
98+
issue_number: alreadyOpen.number,
99+
body: `Health check failed again. Run: ${runUrl}`,
100+
});
101+
core.info(`Commented on existing issue #${alreadyOpen.number}.`);
102+
} else {
103+
const { data: created } = await github.rest.issues.create({
104+
owner: context.repo.owner,
105+
repo: context.repo.repo,
106+
title,
107+
body,
108+
labels: [labelName],
109+
});
110+
core.info(`Opened issue #${created.number}.`);
111+
}

scripts/checkVersionSources.js

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
4+
/**
5+
* Health check for the Maven Central data sources that
6+
* `src/commands/testDependenciesCommands.ts` relies on for the
7+
* "Enable Java Tests" download flow.
8+
*
9+
* For every artifact that vscode-java-test fetches at runtime, this script:
10+
*
11+
* 1. Downloads the artifact's `maven-metadata.xml` from repo1.maven.org.
12+
* 2. Extracts the `<release>` element and asserts it is a stable version
13+
* (dot-separated digits only — no `-M3`, `-RC1`, `-beta-1`, etc.).
14+
* 3. Issues an HTTP HEAD against the resolved `.jar` download URL and
15+
* asserts a 2xx response.
16+
*
17+
* This guards against silent upstream drift, e.g.
18+
* - the data source moving / being deprecated (microsoft/vscode-java-test#1866,
19+
* where the legacy search.maven.org Solr index was frozen for ~a year
20+
* and kept returning a milestone build as the "latest"),
21+
* - a maintainer accidentally publishing a pre-release as `<release>`,
22+
* - the jar layout under repo1.maven.org changing.
23+
*
24+
* Runs on PRs (so a code change that breaks the lookup never lands) and
25+
* on a weekly cron (so a purely upstream change is caught within days
26+
* instead of months).
27+
*
28+
* Pure Node, no dependencies — works before `npm install` runs.
29+
*/
30+
31+
'use strict';
32+
33+
const ARTIFACTS = [
34+
// JUnit 5 / Jupiter (the path that originally surfaced #1866).
35+
// The console-standalone GAV publishes two coexisting release lines
36+
// (1.x for legacy Jupiter 5, 6.x for Jupiter 6). The extension routes
37+
// TestKind.JUnit5 to the 1.x line and TestKind.JUnit6 to the 6.x line,
38+
// so the health check covers both.
39+
{
40+
groupId: 'org.junit.platform',
41+
artifactId: 'junit-platform-console-standalone',
42+
versionLine: '1',
43+
},
44+
{
45+
groupId: 'org.junit.platform',
46+
artifactId: 'junit-platform-console-standalone',
47+
versionLine: '6',
48+
},
49+
50+
// JUnit 4 + its hamcrest-core dependency.
51+
{ groupId: 'junit', artifactId: 'junit' },
52+
// hamcrest-core is pinned to 1.3 in the extension; verify both that 1.3
53+
// still resolves and that the artifact metadata is reachable.
54+
{ groupId: 'org.hamcrest', artifactId: 'hamcrest-core', pinnedVersion: '1.3' },
55+
56+
// TestNG + its transitive deps that we ship.
57+
{ groupId: 'org.testng', artifactId: 'testng' },
58+
{ groupId: 'com.beust', artifactId: 'jcommander' },
59+
{ groupId: 'org.slf4j', artifactId: 'slf4j-api' },
60+
];
61+
62+
const MAX_ATTEMPTS = 3;
63+
const RETRY_BASE_DELAY_MS = 2000;
64+
const REQUEST_TIMEOUT_MS = 15000;
65+
66+
const STABLE_VERSION_REGEX = /^\d+(\.\d+)*$/;
67+
68+
function groupPath(groupId) {
69+
return groupId.split('.').join('/');
70+
}
71+
72+
function metadataUrl(groupId, artifactId) {
73+
return `https://repo1.maven.org/maven2/${groupPath(groupId)}/${artifactId}/maven-metadata.xml`;
74+
}
75+
76+
function jarUrl(groupId, artifactId, version) {
77+
return `https://repo1.maven.org/maven2/${groupPath(groupId)}/${artifactId}/${version}/${artifactId}-${version}.jar`;
78+
}
79+
80+
function sleep(ms) {
81+
return new Promise((resolve) => setTimeout(resolve, ms));
82+
}
83+
84+
async function fetchWithRetry(url, init) {
85+
let lastError;
86+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
87+
const controller = new AbortController();
88+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
89+
try {
90+
const response = await fetch(url, { ...init, signal: controller.signal });
91+
clearTimeout(timer);
92+
return response;
93+
} catch (err) {
94+
clearTimeout(timer);
95+
lastError = err;
96+
if (attempt < MAX_ATTEMPTS) {
97+
const delay = RETRY_BASE_DELAY_MS * attempt;
98+
console.warn(` ! attempt ${attempt}/${MAX_ATTEMPTS} for ${url} failed: ${err.message}. Retrying in ${delay}ms...`);
99+
await sleep(delay);
100+
}
101+
}
102+
}
103+
throw lastError;
104+
}
105+
106+
function parseLatestStableVersion(xml, versionLine) {
107+
if (versionLine === undefined) {
108+
const releaseMatch = xml.match(/<release>([^<]+)<\/release>/);
109+
if (releaseMatch && STABLE_VERSION_REGEX.test(releaseMatch[1])) {
110+
return releaseMatch[1];
111+
}
112+
}
113+
const lineRegex = versionLine !== undefined
114+
? new RegExp(`^${versionLine.replace(/\./g, '\\.')}(\\.|$)`)
115+
: undefined;
116+
const versions = [];
117+
const versionRegex = /<version>([^<]+)<\/version>/g;
118+
let match;
119+
while ((match = versionRegex.exec(xml)) !== null) {
120+
versions.push(match[1]);
121+
}
122+
for (let i = versions.length - 1; i >= 0; i--) {
123+
const candidate = versions[i];
124+
if (!STABLE_VERSION_REGEX.test(candidate)) {
125+
continue;
126+
}
127+
if (lineRegex && !lineRegex.test(candidate)) {
128+
continue;
129+
}
130+
return candidate;
131+
}
132+
return undefined;
133+
}
134+
135+
async function checkArtifact(artifact) {
136+
const { groupId, artifactId, pinnedVersion, versionLine } = artifact;
137+
const scope = versionLine ? ` (${versionLine}.x line)` : '';
138+
console.log(`\n== ${groupId}:${artifactId}${scope} ==`);
139+
140+
const metaUrl = metadataUrl(groupId, artifactId);
141+
console.log(` GET ${metaUrl}`);
142+
const metaResponse = await fetchWithRetry(metaUrl);
143+
if (!metaResponse.ok) {
144+
throw new Error(`maven-metadata.xml returned HTTP ${metaResponse.status} ${metaResponse.statusText}`);
145+
}
146+
const xml = await metaResponse.text();
147+
148+
if (versionLine === undefined) {
149+
const releaseMatch = xml.match(/<release>([^<]+)<\/release>/);
150+
if (releaseMatch) {
151+
const rawRelease = releaseMatch[1];
152+
if (!STABLE_VERSION_REGEX.test(rawRelease)) {
153+
console.warn(` ! <release> is a pre-release: "${rawRelease}". Falling back to <versions> scan.`);
154+
}
155+
} else {
156+
console.warn(' ! <release> tag missing — relying entirely on <versions> fallback.');
157+
}
158+
}
159+
160+
const latestStable = parseLatestStableVersion(xml, versionLine);
161+
if (!latestStable) {
162+
throw new Error(`No stable version found${scope} in maven-metadata.xml.`);
163+
}
164+
console.log(` ok latest stable version${scope} = ${latestStable}`);
165+
166+
const versionsToProbe = pinnedVersion && pinnedVersion !== latestStable
167+
? [latestStable, pinnedVersion]
168+
: [latestStable];
169+
170+
for (const version of versionsToProbe) {
171+
const downloadUrl = jarUrl(groupId, artifactId, version);
172+
console.log(` HEAD ${downloadUrl}`);
173+
const headResponse = await fetchWithRetry(downloadUrl, { method: 'HEAD' });
174+
if (!headResponse.ok) {
175+
throw new Error(`jar HEAD returned HTTP ${headResponse.status} ${headResponse.statusText} for ${downloadUrl}`);
176+
}
177+
console.log(` ok jar reachable (HTTP ${headResponse.status})`);
178+
}
179+
}
180+
181+
async function main() {
182+
console.log('Checking Maven Central data sources for vscode-java-test...');
183+
const failures = [];
184+
for (const artifact of ARTIFACTS) {
185+
try {
186+
await checkArtifact(artifact);
187+
} catch (err) {
188+
failures.push({ artifact, error: err });
189+
console.error(` FAIL ${artifact.groupId}:${artifact.artifactId}${err.message}`);
190+
}
191+
}
192+
193+
console.log('\n----');
194+
if (failures.length === 0) {
195+
console.log(`All ${ARTIFACTS.length} artifacts healthy.`);
196+
return;
197+
}
198+
199+
console.error(`${failures.length} of ${ARTIFACTS.length} artifact checks FAILED:`);
200+
for (const { artifact, error } of failures) {
201+
console.error(` - ${artifact.groupId}:${artifact.artifactId}: ${error.message}`);
202+
}
203+
process.exit(1);
204+
}
205+
206+
main().catch((err) => {
207+
console.error('Unexpected error:', err);
208+
process.exit(1);
209+
});

0 commit comments

Comments
 (0)