From 7b55ea8898889e9437120781b882ecf932f2eedc Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 4 Nov 2026 17:04:58 -0800 Subject: [PATCH 1/4] Fix npm release network isolation policy Allow GitHub and Node tooling through the supported 1ES policy setting while retaining the Preferred baseline and enforced restrictions. Configure the authenticated npm mirror in the release job as well as the build job. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7992926e-a6ef-49ad-b6c9-120b52c0de51 --- build/azure-pipeline.npm.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/build/azure-pipeline.npm.yml b/build/azure-pipeline.npm.yml index 1643fd50f..e7e0de155 100644 --- a/build/azure-pipeline.npm.yml +++ b/build/azure-pipeline.npm.yml @@ -54,6 +54,8 @@ variables: extends: template: azure-pipelines/MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate parameters: + settings: + networkIsolationPolicy: Preferred,GitHub,NodeTool sdl: sourceAnalysisPool: VSEng-MicroBuildVSStable codeSignValidation: @@ -143,10 +145,11 @@ extends: artifact: npm-package displayName: Download npm-package artifact - - task: NodeTool@0 - inputs: - versionSpec: $(NodeVersion) - displayName: Select Node version + - template: azure-pipelines/extension/templates/steps/setup.yml@templates + parameters: + installPython: false + customNPMRegistry: $(AZURE_ARTIFACTS_FEED) + nodeVersion: $(NodeVersion) - script: npm ci --ignore-scripts workingDirectory: $(Build.SourcesDirectory)/api From b2e7b8e6aaa3579409d5044133f80012d8538b4d Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 5 Nov 2026 10:13:18 -0800 Subject: [PATCH 2/4] Avoid GitHub REST API in npm release validation Resolve the public latest-release redirect, peel the release tag with Git, and compare shared sources at the immutable commit through public raw URLs. Preserve baseline and artifact validation without requiring GitHub API credentials. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7992926e-a6ef-49ad-b6c9-120b52c0de51 --- .github/workflows/pr-check.yml | 2 - CONTRIBUTING.md | 7 ++- api/scripts/check-extension-version.cjs | 72 ++++++++++++++++--------- api/test/release-validation.test.cjs | 56 ++++++++++++------- 4 files changed, 89 insertions(+), 48 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index d353976b9..1c5328859 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -55,8 +55,6 @@ jobs: if: steps.scope.outputs.relevant == 'true' run: npm run check:release working-directory: api - env: - GITHUB_TOKEN: ${{ github.token }} - name: Build and test API package if: steps.scope.outputs.relevant == 'true' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a370c5936..3ae3e4add 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,8 +167,11 @@ new stable release appeared during review or between packaging and publishing, explicitly update the minimum, rebuild, retest, and rerun the pipeline; do not restamp the existing artifact. Both `latest` and `next` npm tags currently use this stable baseline: preview-only API publishing needs a separate policy. -Public GitHub lookups use bounded retries and fail closed. Local runs can set -`GITHUB_TOKEN` to avoid anonymous rate limits; PRs use their read-only token. +Public HTTP lookups use bounded retries and fail closed. The baseline uses +GitHub's public `releases/latest` redirect, and provenance resolves the release +tag with `git ls-remote` before comparing raw sources at that immutable commit. +These checks do not use the GitHub REST API or require a GitHub token. Git must +be installed for provenance checks. - **Releasing:** merging a stable version change in `api/package.json` into `main` automatically starts the [npm pipeline](./build/azure-pipeline.npm.yml), including its existing build, package tests, and MicroBuild approval process. Other manifest edits build and validate the package without publishing. The version check compares the merged commit with its parent, relying on the repository's squash-only merge policy; direct pushes to `main` also trigger it. Preview releases remain manual: queue the pipeline with `quality: preview` and `publishPackage: true`. Manual runs otherwise remain build-only unless `publishPackage` is explicitly enabled. diff --git a/api/scripts/check-extension-version.cjs b/api/scripts/check-extension-version.cjs index aae36d8b4..476fecb10 100644 --- a/api/scripts/check-extension-version.cjs +++ b/api/scripts/check-extension-version.cjs @@ -27,16 +27,16 @@ function validateMinimumExtensionVersion(manifest) { return version; } -/** Reads public repository metadata with bounded retries for transient failures only. */ -async function requestGitHub(resource, { fetchImpl = fetch, token = process.env.GITHUB_TOKEN, wait = sleep } = {}) { +/** Reads public GitHub endpoints without credentials, retrying transient failures only. */ +async function requestGitHub(resource, { fetchImpl = fetch, wait = sleep, method = 'GET', redirect = 'follow' } = {}) { for (let attempt = 0; attempt < 3; attempt += 1) { let response; try { - response = await fetchImpl(`https://api.github.com/repos/${repository}/${resource}`, { + response = await fetchImpl(resource, { + method, + redirect, headers: { - Accept: 'application/vnd.github+json', 'User-Agent': 'python-environments-api-release-check', - ...(token ? { Authorization: `Bearer ${token}` } : {}), }, signal: AbortSignal.timeout(15000), }); @@ -50,8 +50,8 @@ async function requestGitHub(resource, { fetchImpl = fetch, token = process.env. await wait(1000 * 2 ** attempt); continue; } - if (response.ok) { - return response.json(); + if (response.ok || (redirect === 'manual' && response.status === 302)) { + return response; } const retryable = response.status === 429 || @@ -68,17 +68,27 @@ async function requestGitHub(resource, { fetchImpl = fetch, token = process.env. } } -/** Resolves GitHub's designated latest stable release, not the development manifest or preview channel. */ +/** Resolves GitHub's public latest-release redirect, rejecting unrelated URLs and prerelease tags. */ async function getLatestStableRelease(options) { - const release = await requestGitHub('releases/latest', options); - if (release?.draft !== false || release.prerelease !== false || typeof release.tag_name !== 'string') { + const latestUrl = `https://github.com/${repository}/releases/latest`; + const response = await requestGitHub(latestUrl, { ...options, method: 'HEAD', redirect: 'manual' }); + const releaseUrl = new URL(response.headers.get('location') ?? '', latestUrl); + const prefix = `/${repository}/releases/tag/`; + if ( + response.status !== 302 || + releaseUrl.origin !== 'https://github.com' || + !releaseUrl.pathname.startsWith(prefix) || + releaseUrl.search || + releaseUrl.hash + ) { throw new Error('GitHub did not return a valid latest stable extension release.'); } - const version = validateMinimumExtensionVersion({ minimumExtensionVersion: release.tag_name.replace(/^v/, '') }); + const tag = decodeURIComponent(releaseUrl.pathname.slice(prefix.length)); + const version = validateMinimumExtensionVersion({ minimumExtensionVersion: tag.replace(/^v/, '') }); return { version, - tag: release.tag_name, - url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(release.tag_name)}`, + tag, + url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(tag)}`, }; } @@ -93,25 +103,37 @@ function assertLatestStable(manifest, release) { } /** Rejects unreleased shared API sources, including stale staged copies, at packaging time. */ -async function verifySourceProvenance(packageRoot, release, options) { - let object = (await requestGitHub(`git/ref/tags/${encodeURIComponent(release.tag)}`, options)).object; - for (let depth = 0; object?.type === 'tag' && depth < 5; depth += 1) { - object = (await requestGitHub(`git/tags/${object.sha}`, options)).object; - } - if (object?.type !== 'commit' || !/^[a-f0-9]{40}$/.test(object.sha)) { +async function verifySourceProvenance(packageRoot, release, options = {}) { + const tagRef = `refs/tags/${release.tag}`; + const output = (options.execFileSyncImpl ?? execFileSync)( + 'git', + ['ls-remote', '--tags', `https://github.com/${repository}.git`, tagRef, `${tagRef}^{}`], + { encoding: 'utf8', timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, + ); + const refs = new Map( + output + .trim() + .split(/\r?\n/) + .map((line) => { + const [sha, ref] = line.split(/\s+/); + return [ref, sha]; + }), + ); + const commit = refs.get(`${tagRef}^{}`) ?? refs.get(tagRef); + if (!/^[a-f0-9]{40}$/.test(commit)) { throw new Error(`Cannot resolve ${release.tag} to an immutable release commit.`); } const normalize = (contents) => contents.replace(/\r\n/g, '\n'); for (const source of sharedSources) { - const remote = await requestGitHub(`contents/src/${source}?ref=${object.sha}`, options); - if (remote.encoding !== 'base64' || typeof remote.content !== 'string') { - throw new Error(`Cannot read released API source src/${source} at ${object.sha}.`); - } - const released = normalize(Buffer.from(remote.content, 'base64').toString('utf8')); + const remote = await requestGitHub( + `https://raw.githubusercontent.com/${repository}/${commit}/src/${source}`, + options, + ); + const released = normalize(await remote.text()); for (const local of [path.join(packageRoot, '..', 'src', source), path.join(packageRoot, 'src', source)]) { if (normalize(fs.readFileSync(local, 'utf8')) !== released) { throw new Error( - `${local} differs from ${release.tag} (${object.sha}). Publish the stable extension containing these API sources, then update the npm baseline and rebuild.`, + `${local} differs from ${release.tag} (${commit}). Publish the stable extension containing these API sources, then update the npm baseline and rebuild.`, ); } } diff --git a/api/test/release-validation.test.cjs b/api/test/release-validation.test.cjs index 5c1c2c401..e5ecec217 100644 --- a/api/test/release-validation.test.cjs +++ b/api/test/release-validation.test.cjs @@ -71,9 +71,15 @@ test('staging regenerates the embedded floor after a manifest change and rejects test('latest stable uses the release tag and rejects invalid release metadata', async () => { for (const tag of ['v1.40.0', '1.40.0']) { const release = await getLatestStableRelease({ - fetchImpl: async (url) => { - assert.ok(url.endsWith('/releases/latest')); - return response({ ...stable, tag_name: tag, target_commitish: 'main' }); + fetchImpl: async (url, options) => { + assert.equal(url, 'https://github.com/microsoft/vscode-python-environments/releases/latest'); + assert.equal(options.method, 'HEAD'); + assert.equal(options.redirect, 'manual'); + assert.equal(options.headers.Authorization, undefined); + return new Response(null, { + status: 302, + headers: { location: `/microsoft/vscode-python-environments/releases/tag/${tag}` }, + }); }, }); assert.equal(release.version, '1.40.0'); @@ -87,14 +93,21 @@ test('latest stable uses the release tag and rejects invalid release metadata', /latest stable is 1.40.0/, ); } - for (const invalid of [ - { ...stable, draft: true }, - { ...stable, prerelease: true }, - {}, - { ...stable, tag_name: 'v1.41.0-rc.1' }, + for (const location of [ + '', + 'https://example.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', + '/microsoft/another-repository/releases/tag/v1.40.0', + '/microsoft/vscode-python-environments/releases', + '/microsoft/vscode-python-environments/releases/tag/v1.41.0-rc.1', + '/microsoft/vscode-python-environments/releases/tag/v1.40.0?untrusted=true', ]) { - await assert.rejects(getLatestStableRelease({ fetchImpl: async () => response(invalid) })); + await assert.rejects( + getLatestStableRelease({ + fetchImpl: async () => new Response(null, { status: 302, headers: { location } }), + }), + ); } + await assert.rejects(getLatestStableRelease({ fetchImpl: async () => response(stable) })); }); test('GitHub lookups retry network/server failures, rate limits, and fail closed after exhaustion', async () => { @@ -110,7 +123,7 @@ test('GitHub lookups retry network/server failures, rate limits, and fail closed }, wait: async (delay) => waits.push(delay), }); - assert.deepEqual(result, stable); + assert.deepEqual(await result.json(), stable); assert.deepEqual(waits, [1000, 2000]); for (const status of [429, 403]) { attempts = 0; @@ -158,20 +171,25 @@ test('provenance follows annotated tags to an immutable commit and checks origin } } const options = { + execFileSyncImpl: (command, args) => { + assert.equal(command, 'git'); + assert.deepEqual(args.slice(-2), [`refs/tags/${stable.tag_name}`, `refs/tags/${stable.tag_name}^{}`]); + return `${tagSha}\trefs/tags/${stable.tag_name}\n${sha}\trefs/tags/${stable.tag_name}^{}\n`; + }, fetchImpl: async (url) => { - if (url.includes('/git/ref/tags/')) { - return response({ object: { type: 'tag', sha: tagSha } }); - } - if (url.endsWith(`/git/tags/${tagSha}`)) { - return response({ object: { type: 'commit', sha } }); - } - assert.equal(new URL(url).searchParams.get('ref'), sha); + assert.ok( + url.startsWith(`https://raw.githubusercontent.com/microsoft/vscode-python-environments/${sha}/src/`), + ); const source = new URL(url).pathname.split('/').pop(); - return response({ encoding: 'base64', content: Buffer.from(`${source}\n`).toString('base64') }); + return new Response(`${source}\n`); }, }; const release = { tag: stable.tag_name }; await verifySourceProvenance(packageRoot, release, options); + await verifySourceProvenance(packageRoot, release, { + ...options, + execFileSyncImpl: () => `${sha}\trefs/tags/${stable.tag_name}\n`, + }); for (const directory of [path.join(root, 'src'), path.join(packageRoot, 'src')]) { const file = path.join(directory, 'types.ts'); fs.writeFileSync(file, 'unreleased contract\n'); @@ -180,7 +198,7 @@ test('provenance follows annotated tags to an immutable commit and checks origin } await assert.rejects( verifySourceProvenance(packageRoot, release, { - fetchImpl: async () => response({ object: { type: 'tree', sha } }), + execFileSyncImpl: () => `${sha}\trefs/tags/unrelated\n`, }), /immutable release commit/, ); From cdad6f5cc7dafda3fd70eec3b0c9354b35abb808 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 5 Nov 2026 10:33:00 -0800 Subject: [PATCH 3/4] Simplify npm release baseline validation Validate the declared extension minimum locally instead of requiring the newest stable release. Keep explicit release-time provenance pinned to that baseline and compare parsed source tokens while ignoring comments and whitespace. Preserve packed metadata checks and publication approvals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7992926e-a6ef-49ad-b6c9-120b52c0de51 --- .github/workflows/pr-check.yml | 2 +- CONTRIBUTING.md | 50 ++++----- api/CHANGELOG.md | 4 +- api/README.md | 8 +- api/package.json | 2 +- api/scripts/check-extension-version.cjs | 95 +++++++++-------- api/test/release-validation.test.cjs | 129 +++++++++++++----------- build/azure-pipeline.npm.yml | 4 +- docs/README.md | 7 +- 9 files changed, 156 insertions(+), 145 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1c5328859..75740ab8a 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -51,7 +51,7 @@ jobs: run: npm ci working-directory: api - - name: Validate latest stable extension baseline + - name: Validate declared extension minimum if: steps.scope.outputs.relevant == 'true' run: npm run check:release working-directory: api diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ae3e4add..8a4a04df1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,23 +125,24 @@ Its generated `api/src` sources are **not committed** (see [`api/.gitignore`](./ constant. Install dependencies with `npm --prefix api ci` first. - Run `npm --prefix api test` after compilation. This tests the release validator and packs/installs the package for modern/legacy TypeScript and ESM/CommonJS - runtime checks. `npm --prefix api run check:release` checks the live baseline; - add `-- --provenance` to verify original and staged sources against that release. + runtime checks. `npm --prefix api run check:release` validates the declared + minimum locally; add `-- --provenance` for a release-time check of original and + staged sources against that specific extension release. - Shared API sources are also checked by the extension's lint and TypeScript compile. The PR's **API package compatibility** job builds/tests npm release - inputs and validates their minimum against the latest stable release. - Documentation-only and unrelated PRs do not perform the external lookup. + inputs and validates their declared minimum without a GitHub lookup. Configure that job as a required check in branch protection. ### API versioning and publication The npm version in [`api/package.json`](./api/package.json) is independent of the extension version in [`package.json`](./package.json). Each new npm release must -declare `minimumExtensionVersion` as an exact stable SemVer matching GitHub's -designated latest stable extension release tag (without its leading `v`). Ranges, -prerelease floors, and build metadata are rejected. The helper checks installed -extension versions before activation using normal SemVer precedence, including -already-active extensions. Published npm releases keep their original minimum. +declare `minimumExtensionVersion` as an exact stable SemVer identifying the +chosen supported extension release (without its leading `v`). It need not be the +latest release. Ranges, prerelease floors, and build metadata are rejected. +The helper checks installed extension versions before activation using normal +SemVer precedence, including already-active extensions. Published npm releases +keep their original minimum. Defer npm version bumps to release PRs by applying `skip api version` to API implementation PRs. Any PR editing a shared public API source or @@ -154,24 +155,25 @@ Release in this order: merge API changes without an npm version bump, ship the stable extension, then merge an npm release PR updating `minimumExtensionVersion`, the npm version, and the changelog. The stable npm version bump starts the publication pipeline automatically. Development can merge -before the extension ships: source provenance is checked at packaging, not on PRs. -The npm pipeline resolves the stable release tag to an immutable commit and -requires all five shared source files, including their staged copies, to match. -Only line-ending differences are ignored; even comment-only differences block -publication. This conservative provenance check is not a semantic API analyzer. +before the extension ships: source provenance is checked explicitly by the npm +release pipeline, not on PRs or during ordinary packing. The pipeline verifies +that the declared baseline has a public release, resolves its `v` +tag to an immutable commit, and compares all five shared source files, including +their staged copies. TypeScript parsing and printing ignore comments and +formatting while preserving type and code changes, including meaningful line +breaks. This conservative syntax check is not a comprehensive compatibility analyzer. The npm-only wrapper can evolve without changing those shared extension sources. The packed manifest and both compiled runtime floors must agree. The publish -stage checks the actual tarball against the latest stable release again. If a -new stable release appeared during review or between packaging and publishing, -explicitly update the minimum, rebuild, retest, and rerun the pipeline; do not -restamp the existing artifact. Both `latest` and `next` npm tags currently use -this stable baseline: preview-only API publishing needs a separate policy. -Public HTTP lookups use bounded retries and fail closed. The baseline uses -GitHub's public `releases/latest` redirect, and provenance resolves the release -tag with `git ls-remote` before comparing raw sources at that immutable commit. -These checks do not use the GitHub REST API or require a GitHub token. Git must -be installed for provenance checks. +stage checks this metadata in the actual tarball locally, without a GitHub +lookup. A newer stable extension does not invalidate the selected baseline or +require repacking. To change the minimum, update the manifest, rebuild, retest, +and rerun the pipeline; do not restamp the existing artifact. Both `latest` and +`next` npm tags currently use a stable baseline: preview-only API publishing +needs a separate policy. Release-time public HTTP lookups use bounded retries +and fail closed. Provenance uses the specific public release page, `git ls-remote`, +and raw sources at the resolved commit, without GitHub REST API calls or tokens. +Git and the package's TypeScript dev dependency are required for provenance. - **Releasing:** merging a stable version change in `api/package.json` into `main` automatically starts the [npm pipeline](./build/azure-pipeline.npm.yml), including its existing build, package tests, and MicroBuild approval process. Other manifest edits build and validate the package without publishing. The version check compares the merged commit with its parent, relying on the repository's squash-only merge policy; direct pushes to `main` also trigger it. Preview releases remain manual: queue the pipeline with `quality: preview` and `publishPackage: true`. Manual runs otherwise remain build-only unless `publishPackage` is explicitly enabled. diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 2192d637c..2cacdd303 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -11,11 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `minimumExtensionVersion` metadata recording the independently versioned extension baseline required by this npm release. - An npm-only compatibility guard that rejects older or invalid extension versions before activation or access to the API, with localized update guidance. -- PR validation against the latest stable extension release, packed-metadata checks, and publish-time verification that shared API sources have shipped in that stable release. +- Offline PR validation of the declared extension minimum, packed-metadata checks, and release-time verification that shared API source syntax has shipped in the selected stable release. ### Changed -- New npm releases require the latest stable extension baseline. Existing npm releases keep their original minimum; consumers targeting older extensions may need to pin an older npm package. +- New npm releases select a supported stable extension baseline rather than requiring the latest release. Existing npm releases keep their original minimum; source provenance ignores comments and formatting. ### Notes diff --git a/api/README.md b/api/README.md index 1e819a4df..5bbbda645 100644 --- a/api/README.md +++ b/api/README.md @@ -49,10 +49,10 @@ accepts `1.40.0`, `1.40.1`, and `1.41.0` (including odd-minor preview-channel bu but rejects `1.40.0-rc.1`. A newer prerelease such as `1.41.0-rc.1` meets that floor. Build metadata does not affect ordering. -The minimum is fixed for an already-published npm release. New npm releases use the -latest stable extension as their baseline, which can raise the minimum even when -their API changes would otherwise work on an older extension. Pin an older npm -package if your extension must retain support for an older runtime. +The minimum is fixed for an already-published npm release. New npm releases select +a supported stable extension as their baseline; it need not be the newest stable +release. A newer extension release alone does not raise this minimum. If an npm +update raises the floor beyond your supported runtime, pin an older npm package. Acquire the API through this helper: type-only imports and direct access to the extension's exports bypass the check. The floor does not replace feature detection diff --git a/api/package.json b/api/package.json index 1894fb322..a85b15b9f 100644 --- a/api/package.json +++ b/api/package.json @@ -40,7 +40,7 @@ "scripts": { "prepublishOnly": "echo \"⛔ Can only publish from a secure pipeline ⛔\" && node -e \"process.exitCode = 1\"", "prepack": "npm run all:publish", - "all:publish": "npm ci && npm run compile && npm run check:release -- --provenance && npm test", + "all:publish": "npm ci && npm run compile && npm run check:release && npm test", "copy:sources": "node ./scripts/copy-sources.cjs", "compile": "npm run copy:sources && npm run clean && npm run compile:types && npm run compile:esm && npm run compile:cjs", "compile:types": "tsc -b ./tsconfig.types.json", diff --git a/api/scripts/check-extension-version.cjs b/api/scripts/check-extension-version.cjs index 476fecb10..efaf3a683 100644 --- a/api/scripts/check-extension-version.cjs +++ b/api/scripts/check-extension-version.cjs @@ -50,7 +50,7 @@ async function requestGitHub(resource, { fetchImpl = fetch, wait = sleep, method await wait(1000 * 2 ** attempt); continue; } - if (response.ok || (redirect === 'manual' && response.status === 302)) { + if (response.ok) { return response; } const retryable = @@ -68,43 +68,16 @@ async function requestGitHub(resource, { fetchImpl = fetch, wait = sleep, method } } -/** Resolves GitHub's public latest-release redirect, rejecting unrelated URLs and prerelease tags. */ -async function getLatestStableRelease(options) { - const latestUrl = `https://github.com/${repository}/releases/latest`; - const response = await requestGitHub(latestUrl, { ...options, method: 'HEAD', redirect: 'manual' }); - const releaseUrl = new URL(response.headers.get('location') ?? '', latestUrl); - const prefix = `/${repository}/releases/tag/`; - if ( - response.status !== 302 || - releaseUrl.origin !== 'https://github.com' || - !releaseUrl.pathname.startsWith(prefix) || - releaseUrl.search || - releaseUrl.hash - ) { - throw new Error('GitHub did not return a valid latest stable extension release.'); - } - const tag = decodeURIComponent(releaseUrl.pathname.slice(prefix.length)); - const version = validateMinimumExtensionVersion({ minimumExtensionVersion: tag.replace(/^v/, '') }); - return { - version, - tag, - url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(tag)}`, - }; -} - -/** Enforces the new-npm-release policy without modifying already-published package metadata. */ -function assertLatestStable(manifest, release) { - const minimum = validateMinimumExtensionVersion(manifest); - if (minimum !== release.version) { - throw new Error( - `minimumExtensionVersion is ${minimum}; latest stable is ${release.version} (${release.url}). Update api/package.json, rebuild, and rerun validation.`, - ); - } -} - -/** Rejects unreleased shared API sources, including stale staged copies, at packaging time. */ -async function verifySourceProvenance(packageRoot, release, options = {}) { - const tagRef = `refs/tags/${release.tag}`; +/** Checks shared API syntax against the declared release, ignoring comments and formatting. */ +async function verifySourceProvenance(packageRoot, minimum, options = {}) { + validateMinimumExtensionVersion({ minimumExtensionVersion: minimum }); + const tag = `v${minimum}`; + await requestGitHub(`https://github.com/${repository}/releases/tag/${tag}`, { + ...options, + method: 'HEAD', + redirect: 'error', + }); + const tagRef = `refs/tags/${tag}`; const output = (options.execFileSyncImpl ?? execFileSync)( 'git', ['ls-remote', '--tags', `https://github.com/${repository}.git`, tagRef, `${tagRef}^{}`], @@ -121,19 +94,45 @@ async function verifySourceProvenance(packageRoot, release, options = {}) { ); const commit = refs.get(`${tagRef}^{}`) ?? refs.get(tagRef); if (!/^[a-f0-9]{40}$/.test(commit)) { - throw new Error(`Cannot resolve ${release.tag} to an immutable release commit.`); + throw new Error(`Cannot resolve ${tag} to an immutable release commit.`); } - const normalize = (contents) => contents.replace(/\r\n/g, '\n'); + const ts = require('typescript'); + const printer = ts.createPrinter({ removeComments: true, newLine: ts.NewLineKind.LineFeed }); + const normalize = (contents, fileName) => { + const source = ts.createSourceFile(fileName, contents, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + if (source.parseDiagnostics.length) { + throw new Error(`Cannot parse API source ${fileName} for comparison with ${tag}.`); + } + // Printing makes automatic semicolon insertion explicit; parsed tokens retain literal contents. + const printed = ts.createSourceFile( + fileName, + printer.printFile(source), + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS, + ); + const tokens = []; + const visit = (node) => { + const children = node.getChildren(printed); + if (children.length) { + children.forEach(visit); + } else { + tokens.push([node.kind, node.getText(printed)]); + } + }; + visit(printed); + return JSON.stringify(tokens); + }; for (const source of sharedSources) { const remote = await requestGitHub( `https://raw.githubusercontent.com/${repository}/${commit}/src/${source}`, options, ); - const released = normalize(await remote.text()); + const released = normalize(await remote.text(), source); for (const local of [path.join(packageRoot, '..', 'src', source), path.join(packageRoot, 'src', source)]) { - if (normalize(fs.readFileSync(local, 'utf8')) !== released) { + if (normalize(fs.readFileSync(local, 'utf8'), source) !== released) { throw new Error( - `${local} differs from ${release.tag} (${commit}). Publish the stable extension containing these API sources, then update the npm baseline and rebuild.`, + `${local} differs from the declared baseline ${tag} (${commit}). Select a released extension containing these API sources and rebuild.`, ); } } @@ -184,21 +183,17 @@ async function main() { const manifest = artifact ? validatePackedArtifact(artifact) : JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); - validateMinimumExtensionVersion(manifest); - const release = await getLatestStableRelease(); - assertLatestStable(manifest, release); + const minimum = validateMinimumExtensionVersion(manifest); if (values.provenance) { - await verifySourceProvenance(packageRoot, release); + await verifySourceProvenance(packageRoot, minimum); } - process.stdout.write(`Extension compatibility baseline verified: ${release.version} (${release.url}).\n`); + process.stdout.write(`Extension compatibility baseline verified: ${minimum} (declared minimum).\n`); } module.exports = { sharedSources, validateMinimumExtensionVersion, requestGitHub, - getLatestStableRelease, - assertLatestStable, verifySourceProvenance, validatePackedArtifact, }; diff --git a/api/test/release-validation.test.cjs b/api/test/release-validation.test.cjs index e5ecec217..6442868a2 100644 --- a/api/test/release-validation.test.cjs +++ b/api/test/release-validation.test.cjs @@ -12,14 +12,16 @@ const { sharedSources, validateMinimumExtensionVersion, requestGitHub, - getLatestStableRelease, - assertLatestStable, verifySourceProvenance, validatePackedArtifact, } = require('../scripts/check-extension-version.cjs'); const stable = { tag_name: 'v1.40.0', draft: false, prerelease: false }; const response = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers }); +const validator = path.join(__dirname, '..', 'scripts', 'check-extension-version.cjs'); +const offlineGuard = `data:text/javascript,${encodeURIComponent( + 'globalThis.fetch = () => { throw new Error("Release metadata validation must not access the network"); };', +)}`; test('minimum metadata rejects ranges, noncanonical versions, prereleases, and build metadata', () => { assert.equal(validateMinimumExtensionVersion({ minimumExtensionVersion: '1.40.0' }), '1.40.0'); @@ -68,52 +70,15 @@ test('staging regenerates the embedded floor after a manifest change and rejects assert.throws(() => copySources(packageRoot), /exact stable SemVer/); }); -test('latest stable uses the release tag and rejects invalid release metadata', async () => { - for (const tag of ['v1.40.0', '1.40.0']) { - const release = await getLatestStableRelease({ - fetchImpl: async (url, options) => { - assert.equal(url, 'https://github.com/microsoft/vscode-python-environments/releases/latest'); - assert.equal(options.method, 'HEAD'); - assert.equal(options.redirect, 'manual'); - assert.equal(options.headers.Authorization, undefined); - return new Response(null, { - status: 302, - headers: { location: `/microsoft/vscode-python-environments/releases/tag/${tag}` }, - }); - }, - }); - assert.equal(release.version, '1.40.0'); - assertLatestStable({ minimumExtensionVersion: '1.40.0' }, release); - assert.throws( - () => assertLatestStable({ minimumExtensionVersion: '1.38.0' }, release), - /latest stable is 1.40.0/, - ); - assert.throws( - () => assertLatestStable({ minimumExtensionVersion: '1.42.0' }, release), - /latest stable is 1.40.0/, - ); - } - for (const location of [ - '', - 'https://example.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', - '/microsoft/another-repository/releases/tag/v1.40.0', - '/microsoft/vscode-python-environments/releases', - '/microsoft/vscode-python-environments/releases/tag/v1.41.0-rc.1', - '/microsoft/vscode-python-environments/releases/tag/v1.40.0?untrusted=true', - ]) { - await assert.rejects( - getLatestStableRelease({ - fetchImpl: async () => new Response(null, { status: 302, headers: { location } }), - }), - ); - } - await assert.rejects(getLatestStableRelease({ fetchImpl: async () => response(stable) })); +test('ordinary release validation accepts the declared minimum without a network lookup', () => { + const output = execFileSync(process.execPath, ['--import', offlineGuard, validator], { encoding: 'utf8' }); + assert.match(output, /Extension compatibility baseline verified: .* \(declared minimum\)/); }); test('GitHub lookups retry network/server failures, rate limits, and fail closed after exhaustion', async () => { const waits = []; let attempts = 0; - const result = await requestGitHub('releases/latest', { + const result = await requestGitHub('https://github.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', { fetchImpl: async () => { attempts += 1; if (attempts === 1) { @@ -127,7 +92,7 @@ test('GitHub lookups retry network/server failures, rate limits, and fail closed assert.deepEqual(waits, [1000, 2000]); for (const status of [429, 403]) { attempts = 0; - await requestGitHub('releases/latest', { + await requestGitHub('https://github.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', { fetchImpl: async () => (++attempts === 1 ? response({}, status, { 'retry-after': '1' }) : response(stable)), wait: async () => {}, }); @@ -135,7 +100,7 @@ test('GitHub lookups retry network/server failures, rate limits, and fail closed } attempts = 0; await assert.rejects( - requestGitHub('releases/latest', { + requestGitHub('https://github.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', { fetchImpl: async () => { attempts += 1; return response({}, 503); @@ -147,7 +112,7 @@ test('GitHub lookups retry network/server failures, rate limits, and fail closed assert.equal(attempts, 3); attempts = 0; await assert.rejects( - requestGitHub('releases/latest', { + requestGitHub('https://github.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', { fetchImpl: async () => { attempts += 1; return response({}, 404); @@ -158,16 +123,18 @@ test('GitHub lookups retry network/server failures, rate limits, and fail closed assert.equal(attempts, 1); }); -test('provenance follows annotated tags to an immutable commit and checks original and staged sources', async (t) => { +test('release provenance pins the declared baseline and ignores only source trivia', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'python-api-provenance-')); t.after(() => fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })); const packageRoot = path.join(root, 'api'); const sha = 'a'.repeat(40); const tagSha = 'b'.repeat(40); + const releasedSource = + 'export interface Api { id: string; }\nexport function createApi() { return { id: "value" }; }\nexport const pattern = /a b/;\nexport const message = `id ${1} a b`;\n'; for (const directory of [path.join(root, 'src'), path.join(packageRoot, 'src')]) { fs.mkdirSync(directory, { recursive: true }); for (const source of sharedSources) { - fs.writeFileSync(path.join(directory, source), `${source}\r\n`); + fs.writeFileSync(path.join(directory, source), releasedSource.replace(/\n/g, '\r\n')); } } const options = { @@ -176,32 +143,69 @@ test('provenance follows annotated tags to an immutable commit and checks origin assert.deepEqual(args.slice(-2), [`refs/tags/${stable.tag_name}`, `refs/tags/${stable.tag_name}^{}`]); return `${tagSha}\trefs/tags/${stable.tag_name}\n${sha}\trefs/tags/${stable.tag_name}^{}\n`; }, - fetchImpl: async (url) => { + fetchImpl: async (url, request) => { + if (url === `https://github.com/microsoft/vscode-python-environments/releases/tag/${stable.tag_name}`) { + assert.equal(request.method, 'HEAD'); + assert.equal(request.redirect, 'error'); + assert.equal(request.headers.Authorization, undefined); + return new Response(null); + } assert.ok( url.startsWith(`https://raw.githubusercontent.com/microsoft/vscode-python-environments/${sha}/src/`), ); - const source = new URL(url).pathname.split('/').pop(); - return new Response(`${source}\n`); + return new Response(releasedSource); }, }; - const release = { tag: stable.tag_name }; - await verifySourceProvenance(packageRoot, release, options); - await verifySourceProvenance(packageRoot, release, { + const minimum = '1.40.0'; + await verifySourceProvenance(packageRoot, minimum, options); + await verifySourceProvenance(packageRoot, minimum, { ...options, execFileSyncImpl: () => `${sha}\trefs/tags/${stable.tag_name}\n`, }); for (const directory of [path.join(root, 'src'), path.join(packageRoot, 'src')]) { const file = path.join(directory, 'types.ts'); - fs.writeFileSync(file, 'unreleased contract\n'); - await assert.rejects(verifySourceProvenance(packageRoot, release, options), /differs from v1.40.0/); - fs.writeFileSync(file, 'types.ts\n'); + fs.writeFileSync( + file, + releasedSource + .replace( + 'export interface Api { id: string; }', + '// Updated documentation\n\nexport interface Api {\n id: string;\n}', + ) + .replace( + 'export function createApi() { return { id: "value" }; }', + 'export function createApi() {\n return { id: "value" };\n}', + ) + .replace(/\n/g, '\r\n'), + ); + await verifySourceProvenance(packageRoot, minimum, options); + for (const changed of [ + releasedSource.replace('id: string', 'id: number'), + releasedSource.replace('return {', 'return\n{'), + releasedSource.replace('/a b/', '/a b/'), + releasedSource.replace('`id ${1} a b`', '`id ${1} a b`'), + ]) { + fs.writeFileSync(file, changed); + await assert.rejects( + verifySourceProvenance(packageRoot, minimum, options), + /differs from the declared baseline v1.40.0/, + ); + } + fs.writeFileSync(file, releasedSource); } await assert.rejects( - verifySourceProvenance(packageRoot, release, { + verifySourceProvenance(packageRoot, minimum, { + ...options, execFileSyncImpl: () => `${sha}\trefs/tags/unrelated\n`, }), /immutable release commit/, ); + await assert.rejects( + verifySourceProvenance(packageRoot, minimum, { + ...options, + fetchImpl: async () => response({}, 404), + }), + /GitHub lookup failed \(404\)/, + ); }); test('packed artifact validation detects a manifest/compiled-floor mismatch in either runtime format', (t) => { @@ -226,6 +230,15 @@ test('packed artifact validation detects a manifest/compiled-floor mismatch in e assert.throws(() => validatePackedArtifact(artifact), /does not match the packed minimumExtensionVersion/); } else { assert.equal(validatePackedArtifact(artifact).minimumExtensionVersion, '1.40.0'); + for (const args of [ + ['--artifact', artifact], + ['--artifact-directory', root], + ]) { + const output = execFileSync(process.execPath, ['--import', offlineGuard, validator, ...args], { + encoding: 'utf8', + }); + assert.match(output, /verified: 1.40.0 \(declared minimum\)/); + } } } }); diff --git a/build/azure-pipeline.npm.yml b/build/azure-pipeline.npm.yml index e7e0de155..c061b0637 100644 --- a/build/azure-pipeline.npm.yml +++ b/build/azure-pipeline.npm.yml @@ -115,7 +115,7 @@ extends: - script: npm run check:release -- --provenance workingDirectory: $(Build.SourcesDirectory)/api - displayName: Verify latest stable baseline and released API sources + displayName: Verify API sources against declared extension baseline - script: npm test workingDirectory: $(Build.SourcesDirectory)/api @@ -157,7 +157,7 @@ extends: - script: npm run check:release -- --artifact-directory "$(Pipeline.Workspace)/npm-package" workingDirectory: $(Build.SourcesDirectory)/api - displayName: Revalidate packed compatibility baseline before publishing + displayName: Validate packed compatibility metadata before publishing - template: MicroBuild.Publish.yml@MicroBuildTemplate parameters: diff --git a/docs/README.md b/docs/README.md index 6e933125d..6c212eed0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1996,9 +1996,10 @@ using SemVer precedence. A `1.40.0` floor rejects `1.40.0-rc.1`, but accepts Incompatible versions produce an error asking the consumer to update the extension and reload VS Code; invalid or missing version metadata also rejects. -New npm releases use the latest stable extension as their baseline. Already -published packages keep their original floor, so supporting an older extension -may require pinning an older npm package. This is a minimum-version guarantee, +New npm releases select a supported stable extension as their baseline, not +necessarily the latest release. Already published packages keep their original +floor. Pin an older npm package if an update raises the floor beyond your +supported extension version. This is a minimum-version guarantee, not protection against future breaking extension changes. Type-only imports or direct access to the extension's exports bypass the helper's runtime check. From a271744bb03bf8e6ed5d8dc4987dd32cd6e37a12 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 5 Nov 2026 10:49:25 -0800 Subject: [PATCH 4/4] Remove deferred GitHub release provenance checks Remove release lookups, source provenance comparison, and the Azure provenance step. Keep local minimum-version, packed metadata, and consumer validation. Defer a release-PR gate without adding publication branch-policy changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7992926e-a6ef-49ad-b6c9-120b52c0de51 --- CONTRIBUTING.md | 20 ++-- api/CHANGELOG.md | 4 +- api/scripts/check-extension-version.cjs | 128 +--------------------- api/test/release-validation.test.cjs | 137 ------------------------ build/azure-pipeline.npm.yml | 4 - 5 files changed, 13 insertions(+), 280 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a4a04df1..15433a4a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -126,8 +126,7 @@ Its generated `api/src` sources are **not committed** (see [`api/.gitignore`](./ - Run `npm --prefix api test` after compilation. This tests the release validator and packs/installs the package for modern/legacy TypeScript and ESM/CommonJS runtime checks. `npm --prefix api run check:release` validates the declared - minimum locally; add `-- --provenance` for a release-time check of original and - staged sources against that specific extension release. + minimum locally without network requests. - Shared API sources are also checked by the extension's lint and TypeScript compile. The PR's **API package compatibility** job builds/tests npm release inputs and validates their declared minimum without a GitHub lookup. @@ -155,14 +154,10 @@ Release in this order: merge API changes without an npm version bump, ship the stable extension, then merge an npm release PR updating `minimumExtensionVersion`, the npm version, and the changelog. The stable npm version bump starts the publication pipeline automatically. Development can merge -before the extension ships: source provenance is checked explicitly by the npm -release pipeline, not on PRs or during ordinary packing. The pipeline verifies -that the declared baseline has a public release, resolves its `v` -tag to an immutable commit, and compares all five shared source files, including -their staged copies. TypeScript parsing and printing ignore comments and -formatting while preserving type and code changes, including meaningful line -breaks. This conservative syntax check is not a comprehensive compatibility analyzer. -The npm-only wrapper can evolve without changing those shared extension sources. +before the extension ships. Release authors must confirm that the declared +minimum contains the APIs required by the npm package. Automated GitHub release +and source-provenance checks are deferred to a separate PR; neither PR checks +nor the publication pipeline currently performs those lookups. The packed manifest and both compiled runtime floors must agree. The publish stage checks this metadata in the actual tarball locally, without a GitHub @@ -170,10 +165,7 @@ lookup. A newer stable extension does not invalidate the selected baseline or require repacking. To change the minimum, update the manifest, rebuild, retest, and rerun the pipeline; do not restamp the existing artifact. Both `latest` and `next` npm tags currently use a stable baseline: preview-only API publishing -needs a separate policy. Release-time public HTTP lookups use bounded retries -and fail closed. Provenance uses the specific public release page, `git ls-remote`, -and raw sources at the resolved commit, without GitHub REST API calls or tokens. -Git and the package's TypeScript dev dependency are required for provenance. +needs a separate policy. - **Releasing:** merging a stable version change in `api/package.json` into `main` automatically starts the [npm pipeline](./build/azure-pipeline.npm.yml), including its existing build, package tests, and MicroBuild approval process. Other manifest edits build and validate the package without publishing. The version check compares the merged commit with its parent, relying on the repository's squash-only merge policy; direct pushes to `main` also trigger it. Preview releases remain manual: queue the pipeline with `quality: preview` and `publishPackage: true`. Manual runs otherwise remain build-only unless `publishPackage` is explicitly enabled. diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 2cacdd303..3222f9868 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -11,11 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `minimumExtensionVersion` metadata recording the independently versioned extension baseline required by this npm release. - An npm-only compatibility guard that rejects older or invalid extension versions before activation or access to the API, with localized update guidance. -- Offline PR validation of the declared extension minimum, packed-metadata checks, and release-time verification that shared API source syntax has shipped in the selected stable release. +- Local validation of the declared extension minimum and packed-metadata consistency checks. ### Changed -- New npm releases select a supported stable extension baseline rather than requiring the latest release. Existing npm releases keep their original minimum; source provenance ignores comments and formatting. +- New npm releases select a supported stable extension baseline rather than requiring the latest release. Existing npm releases keep their original minimum. ### Notes diff --git a/api/scripts/check-extension-version.cjs b/api/scripts/check-extension-version.cjs index efaf3a683..e404d472b 100644 --- a/api/scripts/check-extension-version.cjs +++ b/api/scripts/check-extension-version.cjs @@ -5,10 +5,8 @@ const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); const { parseArgs } = require('node:util'); -const { setTimeout: sleep } = require('node:timers/promises'); const semver = require('semver'); -const repository = 'microsoft/vscode-python-environments'; const sharedSources = ['api.ts', 'types.ts', 'publicErrors.ts', 'capabilities.ts', 'legacyCapabilities.ts']; /** Returns the manifest's canonical stable SemVer floor, rejecting ranges and prerelease versions. */ @@ -27,118 +25,6 @@ function validateMinimumExtensionVersion(manifest) { return version; } -/** Reads public GitHub endpoints without credentials, retrying transient failures only. */ -async function requestGitHub(resource, { fetchImpl = fetch, wait = sleep, method = 'GET', redirect = 'follow' } = {}) { - for (let attempt = 0; attempt < 3; attempt += 1) { - let response; - try { - response = await fetchImpl(resource, { - method, - redirect, - headers: { - 'User-Agent': 'python-environments-api-release-check', - }, - signal: AbortSignal.timeout(15000), - }); - } catch (error) { - if ( - attempt === 2 || - !(error instanceof TypeError || error.name === 'AbortError' || error.name === 'TimeoutError') - ) { - throw error; - } - await wait(1000 * 2 ** attempt); - continue; - } - if (response.ok) { - return response; - } - const retryable = - response.status === 429 || - response.status >= 500 || - (response.status === 403 && - (response.headers.get('x-ratelimit-remaining') === '0' || response.headers.has('retry-after'))); - if (!retryable || attempt === 2) { - throw new Error( - `GitHub lookup failed (${response.status}) for ${resource}. Retry the check after resolving the lookup failure.`, - ); - } - const retryAfter = Number(response.headers.get('retry-after')); - await wait(Math.min(10000, Math.max(1000 * 2 ** attempt, retryAfter * 1000 || 0))); - } -} - -/** Checks shared API syntax against the declared release, ignoring comments and formatting. */ -async function verifySourceProvenance(packageRoot, minimum, options = {}) { - validateMinimumExtensionVersion({ minimumExtensionVersion: minimum }); - const tag = `v${minimum}`; - await requestGitHub(`https://github.com/${repository}/releases/tag/${tag}`, { - ...options, - method: 'HEAD', - redirect: 'error', - }); - const tagRef = `refs/tags/${tag}`; - const output = (options.execFileSyncImpl ?? execFileSync)( - 'git', - ['ls-remote', '--tags', `https://github.com/${repository}.git`, tagRef, `${tagRef}^{}`], - { encoding: 'utf8', timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, - ); - const refs = new Map( - output - .trim() - .split(/\r?\n/) - .map((line) => { - const [sha, ref] = line.split(/\s+/); - return [ref, sha]; - }), - ); - const commit = refs.get(`${tagRef}^{}`) ?? refs.get(tagRef); - if (!/^[a-f0-9]{40}$/.test(commit)) { - throw new Error(`Cannot resolve ${tag} to an immutable release commit.`); - } - const ts = require('typescript'); - const printer = ts.createPrinter({ removeComments: true, newLine: ts.NewLineKind.LineFeed }); - const normalize = (contents, fileName) => { - const source = ts.createSourceFile(fileName, contents, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); - if (source.parseDiagnostics.length) { - throw new Error(`Cannot parse API source ${fileName} for comparison with ${tag}.`); - } - // Printing makes automatic semicolon insertion explicit; parsed tokens retain literal contents. - const printed = ts.createSourceFile( - fileName, - printer.printFile(source), - ts.ScriptTarget.Latest, - false, - ts.ScriptKind.TS, - ); - const tokens = []; - const visit = (node) => { - const children = node.getChildren(printed); - if (children.length) { - children.forEach(visit); - } else { - tokens.push([node.kind, node.getText(printed)]); - } - }; - visit(printed); - return JSON.stringify(tokens); - }; - for (const source of sharedSources) { - const remote = await requestGitHub( - `https://raw.githubusercontent.com/${repository}/${commit}/src/${source}`, - options, - ); - const released = normalize(await remote.text(), source); - for (const local of [path.join(packageRoot, '..', 'src', source), path.join(packageRoot, 'src', source)]) { - if (normalize(fs.readFileSync(local, 'utf8'), source) !== released) { - throw new Error( - `${local} differs from the declared baseline ${tag} (${commit}). Select a released extension containing these API sources and rebuild.`, - ); - } - } - } -} - /** Checks the packed manifest and both compiled metadata modules without executing package code. */ function validatePackedArtifact(artifact) { const read = (entry) => @@ -159,10 +45,9 @@ function validatePackedArtifact(artifact) { return manifest; } -async function main() { +function main() { const { values } = parseArgs({ options: { - provenance: { type: 'boolean', default: false }, artifact: { type: 'string' }, 'artifact-directory': { type: 'string' }, }, @@ -184,23 +69,20 @@ async function main() { ? validatePackedArtifact(artifact) : JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); const minimum = validateMinimumExtensionVersion(manifest); - if (values.provenance) { - await verifySourceProvenance(packageRoot, minimum); - } process.stdout.write(`Extension compatibility baseline verified: ${minimum} (declared minimum).\n`); } module.exports = { sharedSources, validateMinimumExtensionVersion, - requestGitHub, - verifySourceProvenance, validatePackedArtifact, }; if (require.main === module) { - main().catch((error) => { + try { + main(); + } catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; - }); + } } diff --git a/api/test/release-validation.test.cjs b/api/test/release-validation.test.cjs index 6442868a2..097ca83fd 100644 --- a/api/test/release-validation.test.cjs +++ b/api/test/release-validation.test.cjs @@ -11,13 +11,9 @@ const { copySources } = require('../scripts/copy-sources.cjs'); const { sharedSources, validateMinimumExtensionVersion, - requestGitHub, - verifySourceProvenance, validatePackedArtifact, } = require('../scripts/check-extension-version.cjs'); -const stable = { tag_name: 'v1.40.0', draft: false, prerelease: false }; -const response = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers }); const validator = path.join(__dirname, '..', 'scripts', 'check-extension-version.cjs'); const offlineGuard = `data:text/javascript,${encodeURIComponent( 'globalThis.fetch = () => { throw new Error("Release metadata validation must not access the network"); };', @@ -75,139 +71,6 @@ test('ordinary release validation accepts the declared minimum without a network assert.match(output, /Extension compatibility baseline verified: .* \(declared minimum\)/); }); -test('GitHub lookups retry network/server failures, rate limits, and fail closed after exhaustion', async () => { - const waits = []; - let attempts = 0; - const result = await requestGitHub('https://github.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', { - fetchImpl: async () => { - attempts += 1; - if (attempts === 1) { - throw new TypeError('network unavailable'); - } - return attempts === 2 ? response({}, 503) : response(stable); - }, - wait: async (delay) => waits.push(delay), - }); - assert.deepEqual(await result.json(), stable); - assert.deepEqual(waits, [1000, 2000]); - for (const status of [429, 403]) { - attempts = 0; - await requestGitHub('https://github.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', { - fetchImpl: async () => (++attempts === 1 ? response({}, status, { 'retry-after': '1' }) : response(stable)), - wait: async () => {}, - }); - assert.equal(attempts, 2); - } - attempts = 0; - await assert.rejects( - requestGitHub('https://github.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', { - fetchImpl: async () => { - attempts += 1; - return response({}, 503); - }, - wait: async () => {}, - }), - /GitHub lookup failed \(503\)/, - ); - assert.equal(attempts, 3); - attempts = 0; - await assert.rejects( - requestGitHub('https://github.com/microsoft/vscode-python-environments/releases/tag/v1.40.0', { - fetchImpl: async () => { - attempts += 1; - return response({}, 404); - }, - }), - /GitHub lookup failed \(404\)/, - ); - assert.equal(attempts, 1); -}); - -test('release provenance pins the declared baseline and ignores only source trivia', async (t) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'python-api-provenance-')); - t.after(() => fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })); - const packageRoot = path.join(root, 'api'); - const sha = 'a'.repeat(40); - const tagSha = 'b'.repeat(40); - const releasedSource = - 'export interface Api { id: string; }\nexport function createApi() { return { id: "value" }; }\nexport const pattern = /a b/;\nexport const message = `id ${1} a b`;\n'; - for (const directory of [path.join(root, 'src'), path.join(packageRoot, 'src')]) { - fs.mkdirSync(directory, { recursive: true }); - for (const source of sharedSources) { - fs.writeFileSync(path.join(directory, source), releasedSource.replace(/\n/g, '\r\n')); - } - } - const options = { - execFileSyncImpl: (command, args) => { - assert.equal(command, 'git'); - assert.deepEqual(args.slice(-2), [`refs/tags/${stable.tag_name}`, `refs/tags/${stable.tag_name}^{}`]); - return `${tagSha}\trefs/tags/${stable.tag_name}\n${sha}\trefs/tags/${stable.tag_name}^{}\n`; - }, - fetchImpl: async (url, request) => { - if (url === `https://github.com/microsoft/vscode-python-environments/releases/tag/${stable.tag_name}`) { - assert.equal(request.method, 'HEAD'); - assert.equal(request.redirect, 'error'); - assert.equal(request.headers.Authorization, undefined); - return new Response(null); - } - assert.ok( - url.startsWith(`https://raw.githubusercontent.com/microsoft/vscode-python-environments/${sha}/src/`), - ); - return new Response(releasedSource); - }, - }; - const minimum = '1.40.0'; - await verifySourceProvenance(packageRoot, minimum, options); - await verifySourceProvenance(packageRoot, minimum, { - ...options, - execFileSyncImpl: () => `${sha}\trefs/tags/${stable.tag_name}\n`, - }); - for (const directory of [path.join(root, 'src'), path.join(packageRoot, 'src')]) { - const file = path.join(directory, 'types.ts'); - fs.writeFileSync( - file, - releasedSource - .replace( - 'export interface Api { id: string; }', - '// Updated documentation\n\nexport interface Api {\n id: string;\n}', - ) - .replace( - 'export function createApi() { return { id: "value" }; }', - 'export function createApi() {\n return { id: "value" };\n}', - ) - .replace(/\n/g, '\r\n'), - ); - await verifySourceProvenance(packageRoot, minimum, options); - for (const changed of [ - releasedSource.replace('id: string', 'id: number'), - releasedSource.replace('return {', 'return\n{'), - releasedSource.replace('/a b/', '/a b/'), - releasedSource.replace('`id ${1} a b`', '`id ${1} a b`'), - ]) { - fs.writeFileSync(file, changed); - await assert.rejects( - verifySourceProvenance(packageRoot, minimum, options), - /differs from the declared baseline v1.40.0/, - ); - } - fs.writeFileSync(file, releasedSource); - } - await assert.rejects( - verifySourceProvenance(packageRoot, minimum, { - ...options, - execFileSyncImpl: () => `${sha}\trefs/tags/unrelated\n`, - }), - /immutable release commit/, - ); - await assert.rejects( - verifySourceProvenance(packageRoot, minimum, { - ...options, - fetchImpl: async () => response({}, 404), - }), - /GitHub lookup failed \(404\)/, - ); -}); - test('packed artifact validation detects a manifest/compiled-floor mismatch in either runtime format', (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'python-api-artifact-')); t.after(() => fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })); diff --git a/build/azure-pipeline.npm.yml b/build/azure-pipeline.npm.yml index c061b0637..93ca82965 100644 --- a/build/azure-pipeline.npm.yml +++ b/build/azure-pipeline.npm.yml @@ -113,10 +113,6 @@ extends: workingDirectory: $(Build.SourcesDirectory)/api displayName: Compile TypeScript - - script: npm run check:release -- --provenance - workingDirectory: $(Build.SourcesDirectory)/api - displayName: Verify API sources against declared extension baseline - - script: npm test workingDirectory: $(Build.SourcesDirectory)/api displayName: Pack and validate npm package