diff --git a/.github/release_plan.md b/.github/release_plan.md index 091ed559825b..45bb33cd9b3b 100644 --- a/.github/release_plan.md +++ b/.github/release_plan.md @@ -90,6 +90,8 @@ NOTE: this PR should make all CI relating to `main` be passing again (such as th - [ ] Click `run pipeline`. - [ ] for `branch/tag` select the release branch which is **`release/YYYY.minor`**. - NOTE: Please opt to release the python extension close to when VS Code is released to align when release notes go out. When we bump the VS Code engine number, our extension will not go out to stable until the VS Code stable release but this only occurs when we bump the engine number. +- [ ] After the official Build artifact exists, run the opt-in [packaged compatibility report](../build/releaseCompatibility/README.md) against that exact VSIX and a fixed supported Environments partner, with explicit on/off profiles. Record failed/infrastructure outcomes; this report does not replace the existing approval gate. +- [ ] Reconcile the tested VSIX SHA-256 with the final signed/published bytes without rebuilding when available. Until the shared post-sign integration is validated, record that acceptance as outstanding rather than claiming final-artifact certification. - [ ] 🧍🧍 Get approval on the release on the [CD](https://dev.azure.com/monacotools/Monaco/_build?definitionId=299). - [ ] Click "approve" in the publish step of [CD](https://dev.azure.com/monacotools/Monaco/_build?definitionId=299) to publish the release to the marketplace. 🎉 - [ ] Take the Github release out of draft. diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index d36b4262f658..334c915588ef 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -82,6 +82,115 @@ jobs: cargo_target: ${{ matrix.target }} vsix_target: ${{ matrix.vsix-target }} + candidate-compatibility: + name: Candidate Core packaged compatibility (${{ matrix.vsix-target }}) + needs: [build-vsix] + if: ${{ !cancelled() }} + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + permissions: + contents: read + actions: read + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + vsix-target: win32-x64 + - os: ubuntu-latest + vsix-target: linux-x64 + steps: + - name: Checkout the same run's test orchestration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize candidate infrastructure evidence + if: always() + shell: pwsh + run: | + $evidence = Join-Path $env:GITHUB_WORKSPACE 'candidate-core-evidence' + New-Item -ItemType Directory -Path $evidence -Force | Out-Null + @{ + schemaVersion = 1 + status = 'infrastructure-error' + scope = 'ci-built-candidate-core-compatibility' + message = 'Candidate download, preparation or execution has not completed.' + runId = $env:GITHUB_RUN_ID + sourceCommit = $env:GITHUB_SHA + artifactName = $env:PYTHON_CANDIDATE_ARTIFACT + certifiesOfficialCandidateOrSignedPublication = $false + } | ConvertTo-Json | Set-Content (Join-Path $evidence 'job-report.json') + env: + PYTHON_CANDIDATE_ARTIFACT: '${{ env.ARTIFACT_NAME_VSIX }}-${{ matrix.vsix-target }}' + + - name: Select repository Node version + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + + - name: Select Python for real fixture venvs + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Download original candidate VSIX from this workflow run + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: '${{ env.ARTIFACT_NAME_VSIX }}-${{ matrix.vsix-target }}' + path: candidate-core-artifact + + - name: Restore only reviewed test orchestration dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Exercise candidate Core and fixed released Environments on and off + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $artifact = Join-Path $env:GITHUB_WORKSPACE 'candidate-core-artifact' + $files = @(Get-ChildItem -LiteralPath $artifact -Filter '*.vsix' -File -Recurse) + $candidate = Join-Path $artifact "ms-python-insiders-$($env:PYTHON_CANDIDATE_TARGET).vsix" + if ($files.Count -ne 1 -or -not (Test-Path -LiteralPath $candidate -PathType Leaf)) { + throw 'The exact same-run candidate artifact must contain its one expected original VSIX.' + } + $evidence = Join-Path $env:GITHUB_WORKSPACE 'candidate-core-evidence' + $output = Join-Path $evidence 'compatibility' + $script = Join-Path (Join-Path (Join-Path $env:GITHUB_WORKSPACE 'build') 'releaseCompatibility') 'selfTest.js' + $python = (Get-Command python).Source + if ($IsLinux) { + & xvfb-run -a node $script $output $python $candidate + } else { + & node $script $output $python $candidate + } + $result = $LASTEXITCODE + $report = Get-Content (Join-Path $output 'input-report.json') -Raw | ConvertFrom-Json + $report | ConvertTo-Json -Depth 20 | Set-Content (Join-Path $evidence 'job-report.json') + exit $result + env: + PYTHON_CANDIDATE_TARGET: ${{ matrix.vsix-target }} + PYTHON_CANDIDATE_ARTIFACT: '${{ env.ARTIFACT_NAME_VSIX }}-${{ matrix.vsix-target }}' + PYTHON_CANDIDATE_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Retain candidate bytes provenance and all real-host outcomes + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: candidate-core-compatibility-${{ matrix.vsix-target }} + path: | + candidate-core-evidence/job-report.json + candidate-core-evidence/compatibility/input-report.json + candidate-core-evidence/compatibility/plan.json + candidate-core-evidence/compatibility/run/report.json + candidate-core-evidence/compatibility/run/environments-on/candidate/** + candidate-core-evidence/compatibility/run/environments-on/user-data/logs/** + candidate-core-evidence/compatibility/run/environments-on/workspace/probe-*.json + candidate-core-evidence/compatibility/run/explicit-off/candidate/** + candidate-core-evidence/compatibility/run/explicit-off/user-data/logs/** + candidate-core-evidence/compatibility/run/explicit-off/workspace/probe-*.json + if-no-files-found: error + retention-days: 7 + lint: name: Lint runs-on: ubuntu-latest diff --git a/.github/workflows/release-compatibility.yml b/.github/workflows/release-compatibility.yml new file mode 100644 index 000000000000..c51c3e7179c1 --- /dev/null +++ b/.github/workflows/release-compatibility.yml @@ -0,0 +1,93 @@ +name: Packaged compatibility guards + +on: + pull_request: + paths: + - '.github/workflows/release-compatibility.yml' + - 'build/releaseCompatibility/**' + - 'build/azure-pipeline.release-compatibility.yml' + - 'package.json' + - 'package-lock.json' + - '.nvmrc' + push: + branches: [main, 'release/**'] + paths: + - '.github/workflows/release-compatibility.yml' + - 'build/releaseCompatibility/**' + - 'build/azure-pipeline.release-compatibility.yml' + - 'package.json' + - 'package-lock.json' + - '.nvmrc' + workflow_dispatch: + +permissions: + contents: read + +jobs: + guards: + name: Guards and immutable harness (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [windows-2022, ubuntu-22.04] + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Select repository Node version + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .nvmrc + cache: npm + - name: Restore locked dependencies without install hooks + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Run package, provenance, interpreter, mode, adapter and packer guards + run: npm run test:releaseCompatibility + - name: Lint compatibility implementation + run: npx --no-install eslint --max-warnings 0 build/releaseCompatibility + - name: Produce immutable reusable test harness + run: npm run package:releaseCompatibility -- "${{ github.workspace }}/harness.zip" + - name: Select Python for real fixture venvs + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.10' + - name: Exercise the frozen released pair in real isolated on/off hosts + shell: pwsh + run: | + $output = Join-Path $env:GITHUB_WORKSPACE 'released-pair-evidence' + $script = Join-Path (Join-Path (Join-Path $env:GITHUB_WORKSPACE 'build') 'releaseCompatibility') 'selfTest.js' + $python = (Get-Command python).Source + if ($IsLinux) { + & xvfb-run -a node $script $output $python + } else { + & node $script $output $python + } + exit $LASTEXITCODE + - name: Retain verified harness bytes + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: python-release-compatibility-harness-${{ matrix.os }} + path: harness.zip + if-no-files-found: error + retention-days: 7 + - name: Retain real-host pass, failure and infrastructure evidence + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: released-pair-evidence-${{ matrix.os }} + path: | + released-pair-evidence/input-report.json + released-pair-evidence/plan.json + released-pair-evidence/run/report.json + released-pair-evidence/run/environments-on/candidate/** + released-pair-evidence/run/environments-on/user-data/logs/** + released-pair-evidence/run/environments-on/workspace/probe-*.json + released-pair-evidence/run/explicit-off/candidate/** + released-pair-evidence/run/explicit-off/user-data/logs/** + released-pair-evidence/run/explicit-off/workspace/probe-*.json + if-no-files-found: warn + retention-days: 7 diff --git a/build/azure-pipeline.release-compatibility.yml b/build/azure-pipeline.release-compatibility.yml new file mode 100644 index 000000000000..1f39a3e0c41e --- /dev/null +++ b/build/azure-pipeline.release-compatibility.yml @@ -0,0 +1,178 @@ +# Opt-in report only. This pipeline never signs, publishes, or approves a release. +trigger: none +pr: none + +parameters: + - name: target + type: string + default: win32-x64 + values: [win32-x64, linux-x64] + - name: coreDefinitionId + type: number + default: 0 + - name: coreBuildId + type: number + default: 0 + - name: coreArtifactName + type: string + default: '' + - name: coreVsixFile + type: string + default: '' + - name: coreVersion + type: string + default: '' + - name: coreSha256 + type: string + default: '' + - name: inputsDefinitionId + type: number + default: 0 + - name: inputsBuildId + type: number + default: 0 + - name: inputsSha256 + type: string + default: '' + +jobs: + - job: PackagedCompatibilityReport + displayName: Pre-sign packaged compatibility report + pool: + ${{ if eq(parameters.target, 'win32-x64') }}: + vmImage: windows-2022 + ${{ else }}: + vmImage: ubuntu-22.04 + steps: + - checkout: self + persistCredentials: false + + - pwsh: | + $evidence = Join-Path $env:PIPELINE_WORKSPACE 'compatibility-evidence' + New-Item -ItemType Directory -Path $evidence -Force | Out-Null + @{ + schemaVersion = 1 + status = 'infrastructure-error' + validationScope = 'pre-sign-build-artifact' + requiresFinalSignedHashReconciliation = $true + message = 'Preparation or execution has not completed; inspect job output.' + sourceVersion = $env:BUILD_SOURCEVERSION + requestedTarget = $env:COMPATIBILITY_TARGET + } | ConvertTo-Json | Set-Content (Join-Path $evidence 'pipeline-report.json') + foreach ($id in @($env:CORE_DEFINITION, $env:CORE_BUILD, $env:INPUTS_DEFINITION, $env:INPUTS_BUILD)) { + if ($id -notmatch '^[1-9][0-9]*$') { throw 'Exact positive producer definition/build IDs are required.' } + } + if ([string]::IsNullOrWhiteSpace($env:CORE_ARTIFACT) -or [string]::IsNullOrWhiteSpace($env:CORE_FILE)) { + throw 'The exact final Build artifact and VSIX file are required; no artifact name is guessed.' + } + $nodeVersion = (Get-Content (Join-Path $env:BUILD_SOURCESDIRECTORY '.nvmrc') -Raw).Trim().TrimStart('v') + Write-Host "##vso[task.setvariable variable=nodeVersion;isReadOnly=true]$nodeVersion" + displayName: Initialize explicit infrastructure evidence + condition: always() + env: + COMPATIBILITY_TARGET: ${{ parameters.target }} + CORE_DEFINITION: ${{ parameters.coreDefinitionId }} + CORE_BUILD: ${{ parameters.coreBuildId }} + CORE_ARTIFACT: ${{ parameters.coreArtifactName }} + CORE_FILE: ${{ parameters.coreVsixFile }} + INPUTS_DEFINITION: ${{ parameters.inputsDefinitionId }} + INPUTS_BUILD: ${{ parameters.inputsBuildId }} + + - task: UseNode@1 + inputs: + version: $(nodeVersion) + + - task: UsePythonVersion@0 + inputs: + versionSpec: '3.10' + architecture: x64 + + - task: DownloadPipelineArtifact@2 + inputs: + buildType: specific + project: $(System.TeamProject) + definition: ${{ parameters.coreDefinitionId }} + buildVersionToDownload: specific + pipelineId: ${{ parameters.coreBuildId }} + artifactName: ${{ parameters.coreArtifactName }} + targetPath: $(Pipeline.Workspace)/compatibility-core + displayName: Download one exact official final Build artifact + + - task: DownloadPipelineArtifact@2 + inputs: + buildType: specific + project: $(System.TeamProject) + definition: ${{ parameters.inputsDefinitionId }} + buildVersionToDownload: specific + pipelineId: ${{ parameters.inputsBuildId }} + artifactName: python-compatibility-inputs + targetPath: $(Pipeline.Workspace)/compatibility-inputs + displayName: Download one exact pinned partner input set + + - script: npm ci --ignore-scripts --no-audit --no-fund + displayName: Restore reviewed harness dependencies, not product source + + - pwsh: | + $ErrorActionPreference = 'Stop' + $root = Join-Path (Join-Path $env:BUILD_SOURCESDIRECTORY 'build') 'releaseCompatibility' + $evidence = Join-Path $env:PIPELINE_WORKSPACE 'compatibility-evidence' + $preparation = Join-Path $evidence 'preparation' + $output = Join-Path $evidence 'runner' + $coreDirectory = Join-Path $env:PIPELINE_WORKSPACE 'compatibility-core' + $core = [System.IO.Path]::GetFullPath((Join-Path $coreDirectory $env:CORE_FILE)) + $allowed = [System.IO.Path]::GetFullPath($coreDirectory) + [System.IO.Path]::DirectorySeparatorChar + if (-not $core.StartsWith($allowed, [System.StringComparison]::Ordinal)) { + throw 'The candidate file must remain inside the downloaded artifact.' + } + $inputs = Join-Path (Join-Path $env:PIPELINE_WORKSPACE 'compatibility-inputs') 'inputs.zip' + $python = (Get-Command python).Source + function Save-Report($status, $exitCode, $phase) { + @{ + schemaVersion = 1 + status = $status + phase = $phase + validationScope = 'pre-sign-build-artifact' + requiresFinalSignedHashReconciliation = $true + sourceVersion = $env:BUILD_SOURCEVERSION + coreBuildId = $env:CORE_BUILD + inputsBuildId = $env:INPUTS_BUILD + runnerExitCode = $exitCode + } | ConvertTo-Json | Set-Content (Join-Path $evidence 'pipeline-report.json') + } + & node (Join-Path $root 'prepare.js') --inputs $inputs --inputs-sha256 $env:INPUTS_HASH ` + --candidate $core --candidate-sha256 $env:CORE_HASH --candidate-version $env:CORE_VERSION ` + --python $python --output $preparation + $preparationExit = $LASTEXITCODE + if ($preparationExit -ne 0) { + $adapter = Get-Content (Join-Path $preparation 'adapter-report.json') -Raw | ConvertFrom-Json + Save-Report $adapter.status $preparationExit 'preparation' + exit $preparationExit + } + if ($IsLinux) { + & xvfb-run -a node (Join-Path $root 'run.js') --plan (Join-Path $preparation 'plan.json') --output $output + } else { + & node (Join-Path $root 'run.js') --plan (Join-Path $preparation 'plan.json') --output $output + } + $result = $LASTEXITCODE + $report = Get-Content (Join-Path $output 'report.json') -Raw | ConvertFrom-Json + Save-Report $report.status $result 'execution' + if ($result -ne 0) { + Write-Host '##vso[task.logissue type=warning]Packaged compatibility failed or hit an infrastructure error. Report only; no publishing gate was changed.' + } + exit $result + displayName: Report real packaged on/off compatibility + continueOnError: true + env: + CORE_FILE: ${{ parameters.coreVsixFile }} + CORE_VERSION: ${{ parameters.coreVersion }} + CORE_HASH: ${{ parameters.coreSha256 }} + CORE_BUILD: ${{ parameters.coreBuildId }} + INPUTS_HASH: ${{ parameters.inputsSha256 }} + INPUTS_BUILD: ${{ parameters.inputsBuildId }} + + - task: PublishPipelineArtifact@1 + condition: always() + inputs: + targetPath: $(Pipeline.Workspace)/compatibility-evidence + artifact: packaged-compatibility-report-${{ parameters.target }} + displayName: Retain failures, infrastructure status, and raw evidence diff --git a/build/releaseCompatibility/README.md b/build/releaseCompatibility/README.md new file mode 100644 index 000000000000..c2400f0db00c --- /dev/null +++ b/build/releaseCompatibility/README.md @@ -0,0 +1,79 @@ +# Packaged release compatibility (report only) + +This checks **installed final VSIX contents**, not a development checkout of Python or Python Environments. Existing source CI, native PET tests, and smoke tests remain unchanged. The new check addresses the narrower release gap: exercising an exact packaged pair with Environments explicitly on and explicitly off, including Windows. + +Production defaults, extension dependencies, PET bundling policy, and the supported opt-out are not changed. The deployed Environments-on default is the primary path; explicit false remains a required regression case. + +## Runner contract + +From the repository, restore the existing lockfile with `npm ci`. Run guard tests with `npm run test:releaseCompatibility`. The standalone harness uses Node 22 or newer and supports Windows x64 and Linux x64. + +```powershell +node .\build\releaseCompatibility\run.js --plan C:\test-inputs\plan.json --output C:\test-evidence\new-run +``` + +The output must be a **new** directory. It is never cleared or reused. Artifact paths are local, resolved relative to the plan; there are no Marketplace/latest downloads inside each case. VS Code is downloaded once at the exact version in the plan into the isolated output directory. + +```json +{ + "schemaVersion": 1, + "candidate": "core", + "vscodeVersion": "", + "pythonExecutable": "C:\\Python\\python.exe", + "core": { + "path": "candidate-core-win32-x64.vsix", + "version": "", + "sha256": "<64 hex characters>" + }, + "environments": { + "path": "released-environments-win32-x64.vsix", + "version": "", + "sha256": "<64 hex characters>", + "pet": "bundled" + } +} +``` + +Resolve partner versions, platform assets, and hashes once when preparing the release input set. Substitute the placeholders above; they are deliberately not runnable defaults. `candidate` may also be `environments` for the companion repository's adapter. Optional per-artifact `provenance` is retained, with absent metadata recorded as unknown; descriptive metadata alone is **not** an authenticated producer attestation. + +Core must contain its platform PET. Environments must match its declared `pet: "bundled"` or `"omitted"` in the actual archive. Both VSIX and package manifests must agree on identity, version, and x64 target. Archive extraction rejects traversal, symlinks, ambiguous Windows names, encryption, duplicate names, and unsupported compression. Expanded bytes are streamed and bounded before buffering, including data-descriptor entries; declared sizes cannot bypass the actual-byte limits. + +For the DevDiv cutover, supply `upgradeFromEnvironments` with the same artifact fields and `pet: "bundled"`, and set the final Environments artifact to `pet: "omitted"`. Both packages must be original official packaging outputs. Do not remove PET from an existing VSIX or inject a locally built replacement to manufacture a passing pair. + +Each boolean mode gets its own workspace, user-data directory, home, extension directory, and VS Code process. Settings are written **before host launch**. Two real `venv --without-pip` fixtures are created as direct workspace children, within the legacy locator's two-directory search depth, and their actual executable/prefix verified. The controller requires discovery of both fixtures before selecting each and invoking normal `python.execInTerminal`; observed candidates are retained even on failure. The script atomically writes a nonce, `sys.executable`, and `sys.prefix`; a terminal appearing or a command resolving is not a pass. + +Only the independent test controller is an `extensionDevelopmentPath`. Its own public `ExtensionContext` must report test mode for single-phase checks, or development mode for real-storage upgrade hosts. Products must load from the exact installed VSIX directories, outside that sole development root, with the expected identities/versions. The public `Extension` object does not expose another extension's context mode; it remains unknown rather than fabricating a `Production` value. Installed-path and controller-only launch evidence prove these are not source/development-extension runs. A transparent, restored observer of the public Environments `runInTerminal` method proves core's on-mode delegation without replacing execution. Prototype methods are shadowed only on the instance and restored by removing the shadow; shared prototypes are never modified. An immutable API that cannot be observed is an explicit infrastructure error, not a pass. + +For an upgrade, both phases use normal isolated VS Code hosts **without `--extensionTestsPath`**: [VS Code deliberately uses in-memory storage during extension tests](https://github.com/microsoft/vscode/blob/0870c2a0c7c0564e7631bfed2675573a94ba4455/src/vs/platform/storage/electron-main/storageMainService.ts#L99-L102). The independent controller runs on normal development-extension activation and requests a graceful quit after writing its report. The baseline fully exits before replacing only the installed Environments directory. Workspace settings, user profile, and selection storage are retained. An independent controller-owned global/workspace Memento token must survive the restart; failure is an infrastructure/storage error, not a product association verdict. The upgraded host must observe the last selected fixture **before any product selection write**, and explicit true/false must survive. Neither the probe nor the runner reseeds product selection. An unsuccessful baseline cannot certify an upgrade. + +## Evidence and isolation + +`report.json` records `passed`, `failed`, or `infrastructure-error`; CLI exit codes are 0, 1, and 2 respectively. Planned cases that could not run are explicit `not-run` records. Phase reports retain discovery, configuration, selection/delegation, real execution JSON, and process output. Raw extension-host logs remain in the isolated profile. + +The selected PET comes from the **actual locator startup log**, not merely package presence. On mode uses Environments' own PET when bundled, otherwise core's; off mode verifies core's PET when the native backend runs. Supported Core releases can instead use the legacy JavaScript locators in off mode; this requires positive Core discovery-log evidence plus the same successful real fixture discovery/execution checks, and records PET runtime identity as not started/unknown rather than inventing a binary or forcing an experiment. Missing on-mode PET evidence, an unexpected opt-out Environments startup, or unobserved discovery backend still fails. Observed native paths/hashes must match the installed VSIX. Original installed files and input VSIX hashes are checked again. Optional `pet --version` information remains explicitly unavailable if unsupported; no invented supplier version floor is applied. + +Candidate processes receive an allowlisted OS environment and isolated home/config/temp directories. Signing/publishing tokens, OAuth tokens, `NODE_OPTIONS`, `NODE_PATH`, `PYTHONPATH`, and developer test switches are not inherited. No developer VS Code profile is modified and no existing application processes are killed. Cleanup addresses only owned POSIX process groups or a private Windows kill-on-close job assigned before the child resumes, retaining ownership after a parent exits. Linux group completion is checked even on successful root exit, including redirected-stdio descendants, before accepting shutdown or upgrading; forced owned-descendant cleanup is recorded. Cleanup and report settlement are bounded; failures remain explicit. + +## Immutable harness handoff + +```powershell +npm run package:releaseCompatibility -- C:\test-inputs\harness.zip +``` + +The packer uses the repository's restored, locked dependencies. It emits a ZIP with `run.js` at its root, the controller, required runtime dependency tree, source/dependency hashes, and the producer lockfile. It does not include product source as an extension. Consumers must pin the **whole ZIP SHA-256** before extraction/execution. No dependency restore, `NODE_PATH`, floating branch, personal fork, or unmerged source lookup is required in a candidate job. + +The read-only `Packaged compatibility guards` GitHub workflow automatically runs the guard/adapter/packer suite on Windows and Linux when the harness, its pipeline/workflow, or dependency/Node inputs change. It also executes the frozen released Core 2026.10.0 / Environments 1.42.0 pair on VS Code 1.110.0 in real clean on/off hosts, using the exact platform asset hashes in `released-pairs.json`, and retains raw pass/failure/infrastructure evidence without uploading the whole editor cache. It uploads the immutable harness ZIP from that exact CI run. This validates the runner beyond isolated guards, but is **not** official candidate, no-PET upgrade, or final signed-publication certification; it adds no branch-protection or publishing rule. + +The existing PR/CI workflow also runs `Candidate Core packaged compatibility` after its unchanged `build-vsix` job. It downloads that **same workflow run's original** `ms-python-insiders-vsix-win32-x64` / `-linux-x64` artifact, requires the one exact expected VSIX, and passes it as the optional third input to `selfTest.js` (`output`, `python`, `candidate VSIX`). This orchestration reuses the fixed released Environments 1.42.0 preparation and the unchanged packaged runner. It reads the candidate version and SHA from the original archive, records producer run/artifact/source/head provenance, and never rebuilds the extension or replaces/injects PET. Both on/off hosts must discover/switch/execute the real fixtures. Missing candidate inputs fail explicitly rather than falling back to the released Core. These read-only jobs validate the current **CI-built candidate**, not the official CD/signing output; existing source-built PET policy, legacy checks, production defaults and release gates are unchanged. + +## Core release adapter and remaining acceptance + +`build/azure-pipeline.release-compatibility.yml` is an **opt-in, separate, pre-sign Build-artifact report pipeline**. It can consume either stable or prerelease official core output, selected by exact existing definition/build/artifact/file, version, and hash in the pipeline's **current project** (`System.TeamProject`). This allows a DevDiv-hosted consumer as well as the existing Monaco project, without cross-project credentials or a guessed core DevDiv publishing graph. The artifact name is a required input, not inferred from an unpublished template. + +Provide a hash-pinned `inputs.zip` in an exact same-project producer run's `python-compatibility-inputs` artifact. Its root `inputs.json` contains `schemaVersion: 1`, the exact `vscodeVersion`, and the fixed `environments` artifact fields; companion VSIX files are relative inside the ZIP. Optional `upgradeFromEnvironments` uses the same shape. `prepare.js` checks the input archive and creates the normal runner plan using the exact downloaded core VSIX and the agent-selected Python. + +The report job contains no signing, feed authentication, publishing, or approval steps. Nonzero runner results remain observable (`continueOnError` only makes this report-only); initialization/download/restore failures retain an infrastructure report. Existing stable human approval and prerelease publishing behavior remain intact. + +**Not yet a release certification or a post-sign gate:** the checked-in core definitions extend shared publishing templates, and no verified core DevDiv publishing graph/post-Sign hook is added here. Hosted owners must run/validate this new pipeline with actual artifact permissions, retain evidence, and reconcile the tested VSIX SHA-256 with the final signed/published VSIX **without rebuilding**. Detached-signature packaging must be verified to preserve the exact bytes. These cases must not be certified from local YAML parsing or a released-package walkthrough. + +Core stable currently obtains PET from Monaco definition 593 using a release branch; prerelease uses definition 591/latest. This contribution records the resulting packaged binary identity but does not silently change producer selection or add cross-project credentials. Resolving one trusted producer build before all packaging targets, and adding a final post-sign consumer with the shared-template owners, remain explicit release-owner handoff items rather than guessed pipeline wiring. diff --git a/build/releaseCompatibility/checks.js b/build/releaseCompatibility/checks.js new file mode 100644 index 000000000000..ccd476be08b0 --- /dev/null +++ b/build/releaseCompatibility/checks.js @@ -0,0 +1,533 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +class CheckFailure extends Error { + constructor(message) { + super(message); + this.name = 'CheckFailure'; + this.status = 'failed'; + } +} + +class InfrastructureError extends Error { + constructor(message) { + super(message); + this.name = 'InfrastructureError'; + this.status = 'infrastructure-error'; + } +} + +function check(condition, message) { + if (!condition) { + throw new CheckFailure(message); + } +} + +function object(value, label) { + check(value !== null && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`); +} + +function keys(value, allowed, label) { + object(value, label); + check( + Object.keys(value).every((key) => allowed.includes(key)), + `${label} contains an unknown field`, + ); +} + +function digest(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +async function hashFile(file) { + const hash = crypto.createHash('sha256'); + for await (const chunk of fs.createReadStream(file)) { + hash.update(chunk); + } + return hash.digest('hex'); +} + +function validateHash(value, label) { + check(typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value), `${label} must be an exact SHA-256`); + return value.toLowerCase(); +} + +function normalizedPath(value, platform = process.platform) { + const paths = platform === 'win32' ? path.win32 : path.posix; + check(typeof value === 'string' && paths.isAbsolute(value), `Expected an absolute path, got ${value}`); + const result = paths.normalize(value); + return platform === 'win32' ? result.toLowerCase() : result; +} + +function samePath(first, second, platform = process.platform) { + return normalizedPath(first, platform) === normalizedPath(second, platform); +} + +function within(parent, child) { + const relative = path.relative(parent, child); + return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative); +} + +function validateArtifact(value, name, directory) { + keys(value, ['path', 'sha256', 'version', 'pet', 'provenance'], name); + check( + typeof value.path === 'string' && value.path.length > 0 && !value.path.includes('\0'), + `${name}.path is required`, + ); + check(/\.vsix$/i.test(value.path), `${name}.path must identify a VSIX`); + check( + typeof value.version === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value.version), + `${name}.version must be an exact extension version`, + ); + if (value.provenance !== undefined) { + object(value.provenance, `${name}.provenance`); + const repository = name === 'core' ? 'microsoft/vscode-python' : 'microsoft/vscode-python-environments'; + check( + value.provenance.repository === undefined || + value.provenance.repository === null || + value.provenance.repository === repository, + `${name} has unexpected repository provenance`, + ); + } + return { + ...value, + path: path.resolve(directory, value.path), + sha256: validateHash(value.sha256, `${name}.sha256`), + provenance: value.provenance ?? null, + }; +} + +function validatePlan(value, directory) { + keys( + value, + [ + 'schemaVersion', + 'candidate', + 'vscodeVersion', + 'pythonExecutable', + 'core', + 'environments', + 'upgradeFromEnvironments', + ], + 'plan', + ); + check(value.schemaVersion === 1, 'Unsupported plan schemaVersion'); + check(value.candidate === 'core' || value.candidate === 'environments', 'candidate must be core or environments'); + check( + typeof value.vscodeVersion === 'string' && /^\d+\.\d+\.\d+$/.test(value.vscodeVersion), + 'vscodeVersion must pin one stable VS Code version, not latest or stable', + ); + check( + typeof value.pythonExecutable === 'string' && path.isAbsolute(value.pythonExecutable), + 'pythonExecutable must be an absolute executable path', + ); + const core = validateArtifact(value.core, 'core', directory); + const environments = validateArtifact(value.environments, 'environments', directory); + check(core.pet === undefined || core.pet === 'bundled', 'Core must supply its packaged PET'); + check( + environments.pet === 'bundled' || environments.pet === 'omitted', + 'environments.pet must be bundled or omitted', + ); + let upgradeFromEnvironments; + if (value.upgradeFromEnvironments !== undefined) { + upgradeFromEnvironments = validateArtifact(value.upgradeFromEnvironments, 'upgradeFromEnvironments', directory); + check(upgradeFromEnvironments.pet === 'bundled', 'The upgrade baseline must contain its own PET'); + check(environments.pet === 'omitted', 'The cutover upgrade must end with the PET-omitting package'); + check( + upgradeFromEnvironments.sha256 !== environments.sha256, + 'The upgrade baseline and candidate must be different artifacts', + ); + } + return { ...value, core, environments, upgradeFromEnvironments }; +} + +function archivePath(name) { + const parts = typeof name === 'string' ? name.replace(/\/$/, '').split('/') : []; + check( + typeof name === 'string' && + name.length > 0 && + !name.includes('\\') && + !name.includes(':') && + !name.includes('\0') && + !name.startsWith('/') && + parts.every( + (part) => + part.length > 0 && + part !== '..' && + part !== '.' && + !/[<>:"|?*\x00-\x1f]/.test(part) && + !/[. ]$/.test(part) && + !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(part), + ), + `Unsafe archive entry: ${name}`, + ); + return name; +} + +async function readArchive(file, visit) { + const StreamZip = require('node-stream-zip'); + const zip = new StreamZip.async({ file }); + try { + const entries = Object.values(await zip.entries()); + check(entries.length === (await zip.entriesCount), 'Archive contains duplicate central-directory entries'); + check(entries.length <= 100000, 'Archive contains too many entries'); + const names = new Set(); + let size = 0; + let expanded = 0; + for (const entry of entries) { + archivePath(entry.name); + const name = entry.name.toLowerCase(); + check(!names.has(name), `Duplicate archive entry: ${entry.name}`); + names.add(name); + check((entry.flags & 1) === 0, `Encrypted archive entry is not supported: ${entry.name}`); + check(entry.method === 0 || entry.method === 8, `Unsupported archive compression: ${entry.name}`); + check(((entry.attr >>> 16) & 0o170000) !== 0o120000, `Archive symlinks are not supported: ${entry.name}`); + size += entry.size; + check(entry.size <= 256 * 1024 * 1024 && size <= 1024 * 1024 * 1024, 'Archive exceeds extraction bounds'); + if (!entry.isDirectory) { + const stream = await zip.stream(entry.name); + const chunks = []; + let length = 0; + try { + for await (const chunk of stream) { + length += chunk.length; + expanded += chunk.length; + check( + length <= entry.size && length <= 256 * 1024 * 1024 && expanded <= 1024 * 1024 * 1024, + 'Expanded archive exceeds declared size or extraction bounds', + ); + chunks.push(chunk); + } + check(length === entry.size, `Archive entry size differs from expanded bytes: ${entry.name}`); + await visit(entry, Buffer.concat(chunks, length)); + } finally { + stream.destroy(); + } + } + } + } finally { + await zip.close(); + } +} + +async function inspectVsix(artifact, id, platform = process.platform) { + if (!fs.existsSync(artifact.path)) { + throw new InfrastructureError(`Required VSIX does not exist: ${artifact.path}`); + } + check((await hashFile(artifact.path)) === artifact.sha256, `VSIX SHA-256 mismatch: ${artifact.path}`); + const files = Object.create(null); + let manifest; + let vsixManifest; + await readArchive(artifact.path, (entry, data) => { + if (entry.name === 'extension/package.json') { + manifest = JSON.parse(data.toString('utf8')); + } + if (entry.name === 'extension.vsixmanifest') { + vsixManifest = data.toString('utf8'); + } + if (entry.name.startsWith('extension/')) { + files[entry.name.slice('extension/'.length)] = { + sha256: digest(data), + size: data.length, + mode: (entry.attr >>> 16) & 0o777, + }; + } + }); + check(manifest && `${manifest.publisher}.${manifest.name}` === id, `Wrong extension identity in ${artifact.path}`); + check(manifest.version === artifact.version, `Wrong extension version in ${artifact.path}`); + check(vsixManifest, `VSIX manifest is missing: ${artifact.path}`); + const { parseStringPromise } = require('xml2js'); + const xml = await parseStringPromise(vsixManifest, { explicitArray: false }); + const identity = xml.PackageManifest?.Metadata?.Identity?.$; + check( + identity?.Id === manifest.name && + identity?.Publisher === manifest.publisher && + identity?.Version === manifest.version, + `VSIX and package manifests disagree: ${artifact.path}`, + ); + check(identity.TargetPlatform === `${platform}-x64`, `VSIX does not target ${platform}-x64: ${artifact.path}`); + const petPath = `python-env-tools/bin/${platform === 'win32' ? 'pet.exe' : 'pet'}`; + const bundled = files[petPath] !== undefined; + check(bundled === (artifact.pet !== 'omitted'), `Unexpected PET package contents in ${artifact.path}`); + return { + ...artifact, + id, + manifest: { + publisher: manifest.publisher, + name: manifest.name, + version: manifest.version, + engines: manifest.engines, + targetPlatform: identity.TargetPlatform, + }, + files, + pet: bundled ? { relativePath: petPath, sha256: files[petPath].sha256 } : null, + }; +} + +async function extractArchive(file, directory, prefix = '') { + check(!fs.existsSync(directory), `Extraction directory already exists: ${directory}`); + await fs.promises.mkdir(directory, { recursive: true }); + await readArchive(file, async (entry, data) => { + if (entry.name.startsWith(prefix)) { + const relative = entry.name.slice(prefix.length); + const destination = path.resolve(directory, ...relative.split('/')); + check(within(directory, destination), `Entry escapes extraction directory: ${entry.name}`); + await fs.promises.mkdir(path.dirname(destination), { recursive: true }); + const mode = (entry.attr >>> 16) & 0o777; + await fs.promises.writeFile(destination, data, { flag: 'wx', mode: mode || 0o644 }); + } + }); +} + +async function installVsix(artifact, directory) { + check((await hashFile(artifact.path)) === artifact.sha256, 'VSIX changed after inspection'); + await extractArchive(artifact.path, directory, 'extension/'); + if (artifact.pet && process.platform !== 'win32') { + await fs.promises.chmod(path.join(directory, ...artifact.pet.relativePath.split('/')), 0o755); + } + await verifyInstalledFiles(artifact, directory); +} + +async function verifyInstalledFiles(artifact, directory) { + for (const [relative, expected] of Object.entries(artifact.files)) { + const file = path.join(directory, ...relative.split('/')); + check(fs.existsSync(file), `Installed package file is missing: ${file}`); + check((await hashFile(file)) === expected.sha256, `Installed package file changed: ${file}`); + } +} + +function verifyMode(requested, effective, explicit) { + check(typeof requested === 'boolean', 'Requested mode must be a boolean'); + check(effective === requested && explicit === requested, 'Requested, effective, and explicit global modes differ'); +} + +function verifyInstalledExtension(expected, actual, developmentRoot) { + check(actual, `Required installed extension is missing: ${expected.id}`); + check(actual.id === expected.id, `Unexpected loaded extension identity: ${actual.id}`); + check(samePath(actual.extensionPath, expected.location), `${expected.id} loaded from an unexpected directory`); + check( + !samePath(developmentRoot, actual.extensionPath) && !within(developmentRoot, actual.extensionPath), + `${expected.id} is under the controller development path`, + ); + check(actual.packageJSON?.version === expected.version, `${expected.id} loaded the wrong version`); +} + +function verifyPythonResult(expected, actual, nonce, platform = process.platform) { + object(actual, 'Python execution result'); + check(actual.nonce === nonce, 'Execution result is stale or belongs to another run'); + check(samePath(expected.executable, actual.executable, platform), 'Core executed the wrong Python executable'); + check(samePath(expected.prefix, actual.prefix, platform), 'Core executed Python with the wrong sys.prefix'); +} + +function locatorStarts(text, logFile) { + const result = []; + for (const line of text.split(/\r?\n/)) { + const match = /(?:\[pet\]\s*)?Starting Python Locator (.+) server\s*$/.exec(line); + if (match) { + result.push({ path: match[1], component: line.includes('[pet]') ? 'environments' : 'core', logFile, line }); + } + } + return result; +} + +function legacyDiscoveryLines(text, logFile) { + if (!logFile.replace(/\\/g, '/').endsWith('/ms-python.python/Python.log')) return []; + return text + .split(/\r?\n/) + .filter((line) => + /Searching for (interpreters in .+ locator|pyenv environments|conda environments|global virtual environments|custom virtual environments)/i.test( + line, + ), + ) + .map((line) => ({ logFile, line })); +} + +function locatorBackend(mode, starts, legacy) { + if (!mode) { + check( + !starts.some((item) => item.component === 'environments'), + 'Environments started PET despite explicit opt-out', + ); + } + const component = mode ? 'environments' : 'core'; + const selected = starts.filter((item) => item.component === component); + if (selected.length > 0) return { kind: 'pet', starts: selected }; + check(!mode && legacy.length > 0, `No positive runtime ${component} discovery-backend evidence was captured`); + return { kind: 'legacy-javascript', evidence: legacy }; +} + +function fixtureLayout(workspace) { + const names = ['.venv-one', '.venv-two']; + return { prefixes: names.map((name) => path.join(workspace, name)), searchPaths: names.map((name) => `./${name}`) }; +} + +function profileSettings(mode, layout) { + return { + user: { + 'python.useEnvironmentsExtension': mode, + 'python.languageServer': 'None', + 'python.terminal.executeInFileDir': true, + 'python.terminal.focusAfterLaunch': false, + 'extensions.autoUpdate': false, + 'extensions.autoCheckUpdates': false, + 'update.mode': 'none', + 'telemetry.telemetryLevel': 'off', + 'terminal.integrated.enablePersistentSessions': false, + }, + workspace: { + 'python.useEnvironmentsExtension': mode, + 'python-envs.workspaceSearchPaths': layout.searchPaths, + }, + }; +} + +async function storageContinuity(context, token, verifyExisting) { + if (typeof token !== 'string' || token.length === 0) { + throw new InfrastructureError('Persistent-host storage probe token is missing'); + } + const key = 'pythonReleaseCompatibility.storageContinuity'; + let global; + let workspace; + try { + if (!verifyExisting) { + await context.globalState.update(key, token); + await context.workspaceState.update(key, token); + } + global = context.globalState.get(key); + workspace = context.workspaceState.get(key); + } catch (error) { + throw new InfrastructureError(`Controller storage probe failed: ${error.message}`); + } + if (global !== token || workspace !== token) { + throw new InfrastructureError( + 'Controller storage did not persist across hosts; product selection is not certified', + ); + } + return { + kind: 'disk-backed-normal-host', + phase: verifyExisting ? 'verified-existing' : 'initialized', + globalToken: global, + workspaceToken: workspace, + }; +} + +function verifyPetIdentity(expected, actual, platform = process.platform) { + check(samePath(expected.path, actual.path, platform), 'Runtime PET came from an unexpected supplier or location'); + check(expected.sha256 === actual.sha256, 'Runtime PET differs from the final VSIX binary'); +} + +function observeMethod(api, name) { + const own = Object.getOwnPropertyDescriptor(api, name); + let owner = api; + let descriptor; + while (owner && !descriptor) { + descriptor = Object.getOwnPropertyDescriptor(owner, name); + owner = Object.getPrototypeOf(owner); + } + if ( + !descriptor || + typeof descriptor.value !== 'function' || + !descriptor.writable || + (!own && !Object.isExtensible(api)) + ) { + throw new InfrastructureError(`Cannot observe the supported API method ${name} without changing its contract`); + } + const calls = []; + Object.defineProperty(api, name, { + ...(own ?? { writable: true, configurable: true, enumerable: descriptor.enumerable }), + value: function (...args) { + calls.push(args); + return Reflect.apply(descriptor.value, this, args); + }, + }); + return { + calls, + restore: () => { + if (own) Object.defineProperty(api, name, own); + else if (!Reflect.deleteProperty(api, name)) { + throw new InfrastructureError(`Cannot restore the observed API method ${name}`); + } + }, + }; +} + +function childEnvironment(parent, home) { + const result = {}; + for (const key of [ + 'PATH', + 'Path', + 'SystemRoot', + 'SYSTEMROOT', + 'WINDIR', + 'windir', + 'ComSpec', + 'COMSPEC', + 'PATHEXT', + 'LANG', + 'LC_ALL', + 'DISPLAY', + 'XAUTHORITY', + 'WAYLAND_DISPLAY', + 'XDG_RUNTIME_DIR', + ]) { + if (parent[key] !== undefined) { + result[key] = parent[key]; + } + } + Object.assign(result, { + HOME: home, + USERPROFILE: home, + APPDATA: path.join(home, 'AppData', 'Roaming'), + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + XDG_CONFIG_HOME: path.join(home, '.config'), + XDG_CACHE_HOME: path.join(home, '.cache'), + XDG_DATA_HOME: path.join(home, '.local', 'share'), + TEMP: path.join(home, 'tmp'), + TMP: path.join(home, 'tmp'), + TMPDIR: path.join(home, 'tmp'), + PYTHONNOUSERSITE: '1', + PYTHONDONTWRITEBYTECODE: '1', + }); + if (process.platform === 'win32') { + result.HOMEDRIVE = path.parse(home).root.slice(0, 2); + result.HOMEPATH = home.slice(2); + } + return result; +} + +module.exports = { + CheckFailure, + InfrastructureError, + check, + digest, + hashFile, + validateHash, + normalizedPath, + samePath, + within, + validatePlan, + archivePath, + readArchive, + inspectVsix, + extractArchive, + installVsix, + verifyInstalledFiles, + verifyMode, + verifyInstalledExtension, + verifyPythonResult, + locatorStarts, + legacyDiscoveryLines, + locatorBackend, + fixtureLayout, + profileSettings, + storageContinuity, + verifyPetIdentity, + observeMethod, + childEnvironment, +}; diff --git a/build/releaseCompatibility/checks.test.js b/build/releaseCompatibility/checks.test.js new file mode 100644 index 000000000000..5e02fb5096c4 --- /dev/null +++ b/build/releaseCompatibility/checks.test.js @@ -0,0 +1,726 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const JSZip = require('jszip'); +const { + CheckFailure, + InfrastructureError, + digest, + validatePlan, + archivePath, + readArchive, + inspectVsix, + installVsix, + verifyInstalledFiles, + verifyMode, + verifyInstalledExtension, + verifyPythonResult, + locatorStarts, + legacyDiscoveryLines, + locatorBackend, + fixtureLayout, + profileSettings, + storageContinuity, + verifyPetIdentity, + observeMethod, + childEnvironment, +} = require('./checks'); +const { run, runProcess, hostLaunchArgs } = require('./run'); +const { prepare } = require('./prepare'); +const { pack } = require('./pack'); +const { candidateCore, selfTest } = require('./selfTest'); + +const hash = 'a'.repeat(64); +const version = '2026.10.0'; +let directory; + +function plan() { + return { + schemaVersion: 1, + candidate: 'core', + vscodeVersion: '1.105.0', + pythonExecutable: process.execPath, + core: { path: 'core.vsix', version, sha256: hash }, + environments: { path: 'env.vsix', version: '1.42.0', sha256: hash, pet: 'bundled' }, + }; +} + +async function fixtureVsix(id = 'ms-python.python', pet = true, target = `${process.platform}-x64`) { + const zip = new JSZip(); + const [publisher, name] = id.split('.'); + zip.file('extension/package.json', JSON.stringify({ publisher, name, version, main: './index.js' })); + zip.file( + 'extension.vsixmanifest', + ``, + ); + zip.file('extension/index.js', 'module.exports = {};'); + if (pet) + zip.file( + `extension/python-env-tools/bin/${process.platform === 'win32' ? 'pet.exe' : 'pet'}`, + 'unit-test-only PET bytes', + ); + const data = await zip.generateAsync({ type: 'nodebuffer' }); + const file = path.join(directory, `${name}.vsix`); + fs.writeFileSync(file, data); + return { path: file, version, sha256: digest(data) }; +} + +describe('Packaged release compatibility guards', () => { + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'python-release-compatibility-')); + }); + afterEach(() => { + fs.rmSync(directory, { recursive: true }); + }); + + it('accepts pinned inputs and resolves only local artifact paths', () => { + const result = validatePlan(plan(), directory); + assert.strictEqual(result.core.path, path.join(directory, 'core.vsix')); + assert.strictEqual(result.core.provenance, null); + }); + + it('rejects moving VS Code inputs, missing hashes, and unexpected provenance', () => { + const moving = plan(); + moving.vscodeVersion = 'stable'; + assert.throws(() => validatePlan(moving, directory), CheckFailure); + const missing = plan(); + delete missing.environments.sha256; + assert.throws(() => validatePlan(missing, directory), CheckFailure); + const provenance = plan(); + provenance.core.provenance = { repository: 'untrusted/fork' }; + assert.throws(() => validatePlan(provenance, directory), CheckFailure); + }); + + it('requires a real bundled-PET to omitted-PET upgrade', () => { + const value = plan(); + value.upgradeFromEnvironments = { ...value.environments }; + assert.throws(() => validatePlan(value, directory), CheckFailure); + value.environments.pet = 'omitted'; + assert.throws(() => validatePlan(value, directory), CheckFailure); + value.upgradeFromEnvironments.sha256 = 'b'.repeat(64); + assert.strictEqual(validatePlan(value, directory).upgradeFromEnvironments.pet, 'bundled'); + }); + + it('rejects archive traversal, Windows streams, and ambiguous separators', () => { + for (const entry of [ + '../outside', + 'extension/../../outside', + '/absolute', + 'C:/outside', + 'extension\\outside', + 'extension/file:stream', + 'extension/CON.txt', + 'extension/file.', + 'extension/file ', + 'extension//file', + ]) { + assert.throws(() => archivePath(entry), CheckFailure, entry); + } + assert.strictEqual(archivePath('extension/package.json'), 'extension/package.json'); + }); + + it('enforces actual streamed ZIP bytes even when a data descriptor bypasses library size checks', async () => { + const zip = new JSZip(); + zip.file('payload.bin', Buffer.alloc(65536, 65)); + const data = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); + const central = data.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); + data.writeUInt16LE(data.readUInt16LE(6) | 0x08, 6); + data.writeUInt16LE(data.readUInt16LE(central + 8) | 0x08, central + 8); + data.writeUInt32LE(1, 22); + data.writeUInt32LE(1, central + 24); + const file = path.join(directory, 'underreported.zip'); + fs.writeFileSync(file, data); + await assert.rejects( + readArchive(file, () => assert.fail('Oversized payload must never reach the visitor')), + (error) => error instanceof CheckFailure && /Expanded archive/.test(error.message), + ); + }); + + it('rejects a package for the wrong target platform', async () => { + const artifact = await fixtureVsix('ms-python.python', true, 'darwin-arm64'); + await assert.rejects(inspectVsix(artifact, 'ms-python.python'), CheckFailure); + }); + + it('checks artifact hash, manifest identity, and version rather than trusting filenames', async () => { + const artifact = await fixtureVsix(); + await assert.rejects(inspectVsix({ ...artifact, sha256: hash }, 'ms-python.python'), CheckFailure); + await assert.rejects(inspectVsix(artifact, 'ms-python.vscode-python-envs'), CheckFailure); + await assert.rejects(inspectVsix({ ...artifact, version: '2026.8.0' }, 'ms-python.python'), CheckFailure); + }); + + it('uses the original candidate VSIX version and hash without replacing its bytes or PET', async () => { + const fixture = await fixtureVsix(); + const before = fs.readFileSync(fixture.path); + const candidate = await candidateCore(fixture.path, { buildId: '123', artifactName: 'same-run-core' }); + assert.strictEqual(candidate.version, fixture.version); + assert.strictEqual(candidate.sha256, fixture.sha256); + assert.notStrictEqual( + candidate.sha256, + require('./released-pairs.json').platforms[process.platform].coreSha256, + ); + assert.strictEqual(candidate.provenance.channel, 'same-run-ci-built-core-candidate'); + assert.strictEqual(candidate.provenance.buildId, '123'); + assert.deepStrictEqual(fs.readFileSync(fixture.path), before); + }); + + it('fails missing, wrong-product or PET-free candidate inputs instead of substituting a released Core', async () => { + await assert.rejects(candidateCore(path.join(directory, 'missing.vsix')), InfrastructureError); + const wrong = await fixtureVsix('ms-python.vscode-python-envs'); + await assert.rejects(candidateCore(wrong.path), CheckFailure); + const missingPet = await fixtureVsix('ms-python.python', false); + await assert.rejects(candidateCore(missingPet.path), CheckFailure); + }); + + it('does not turn an explicitly empty candidate input into the frozen released-pair self-test', async () => { + const output = path.join(directory, 'empty-candidate'); + const report = await selfTest(output, process.execPath, ''); + assert.strictEqual(report.scope, 'ci-built-candidate-core-compatibility'); + assert.strictEqual(report.status, 'infrastructure-error'); + assert.match(report.error.message, /Original candidate Core VSIX is missing/); + assert(!fs.existsSync(path.join(output, 'plan.json'))); + }); + + it('distinguishes a missing artifact from a missing packaged PET', async () => { + await assert.rejects( + inspectVsix({ path: path.join(directory, 'missing.vsix'), sha256: hash }, 'ms-python.python'), + InfrastructureError, + ); + const missing = await fixtureVsix('ms-python.python', false); + await assert.rejects(inspectVsix(missing, 'ms-python.python'), CheckFailure); + const bundled = await fixtureVsix('ms-python.vscode-python-envs'); + await assert.rejects(inspectVsix({ ...bundled, pet: 'omitted' }, 'ms-python.vscode-python-envs'), CheckFailure); + }); + + it('accepts genuine archive omission without injecting a binary', async () => { + const artifact = await fixtureVsix('ms-python.vscode-python-envs', false); + const inspected = await inspectVsix({ ...artifact, pet: 'omitted' }, 'ms-python.vscode-python-envs'); + const installed = path.join(directory, 'installed'); + await installVsix(inspected, installed); + assert.strictEqual(inspected.pet, null); + assert(!fs.existsSync(path.join(installed, 'python-env-tools', 'bin'))); + }); + + it('detects installed package mutation after extraction', async () => { + const inspected = await inspectVsix(await fixtureVsix(), 'ms-python.python'); + const installed = path.join(directory, 'installed'); + await installVsix(inspected, installed); + fs.writeFileSync(path.join(installed, 'index.js'), 'changed'); + await assert.rejects(verifyInstalledFiles(inspected, installed), CheckFailure); + }); + + it('rejects incorrect mode and does not treat explicit false as absence', () => { + verifyMode(false, false, false); + assert.throws(() => verifyMode(false, true, false), CheckFailure); + assert.throws(() => verifyMode(false, false, undefined), CheckFailure); + assert.throws(() => verifyMode(true, false, true), CheckFailure); + }); + + it('proves installed product locations without inventing a public Extension.extensionMode field', () => { + const location = path.join(directory, 'installed', 'core'); + const development = path.join(directory, 'controller'); + const expected = { id: 'ms-python.python', location, version }; + const actual = { id: expected.id, extensionPath: location, packageJSON: { version } }; + verifyInstalledExtension(expected, actual, development); + assert.throws( + () => verifyInstalledExtension(expected, { ...actual, extensionPath: development }, development), + CheckFailure, + ); + assert.throws( + () => verifyInstalledExtension(expected, { ...actual, packageJSON: { version: '2026.8.0' } }, development), + CheckFailure, + ); + }); + + it('checks actual executable, prefix, and per-run nonce', () => { + const expected = { executable: 'C:\\fixture\\.venv\\Scripts\\python.exe', prefix: 'C:\\fixture\\.venv' }; + verifyPythonResult(expected, { ...expected, nonce: 'current' }, 'current', 'win32'); + verifyPythonResult( + expected, + { executable: expected.executable.toUpperCase(), prefix: expected.prefix, nonce: 'current' }, + 'current', + 'win32', + ); + assert.throws( + () => + verifyPythonResult( + expected, + { executable: 'C:\\global\\python.exe', prefix: expected.prefix, nonce: 'current' }, + 'current', + 'win32', + ), + CheckFailure, + ); + assert.throws( + () => + verifyPythonResult( + expected, + { executable: expected.executable, prefix: 'C:\\global', nonce: 'current' }, + 'current', + 'win32', + ), + CheckFailure, + ); + assert.throws( + () => verifyPythonResult(expected, { ...expected, nonce: 'stale' }, 'current', 'win32'), + CheckFailure, + ); + }); + + it('uses actual startup logs and rejects wrong runtime supplier or hash', () => { + const text = + '[info] [pet] Starting Python Locator C:\\installed\\core\\python-env-tools\\bin\\pet.exe server\n' + + '[info] Starting Python Locator C:\\installed\\core\\python-env-tools\\bin\\pet.exe server'; + const starts = locatorStarts(text, 'locator.log'); + assert.deepStrictEqual( + starts.map((item) => item.component), + ['environments', 'core'], + ); + const expected = { path: starts[0].path, sha256: hash }; + verifyPetIdentity(expected, { path: starts[0].path, sha256: hash }, 'win32'); + assert.throws( + () => verifyPetIdentity(expected, { path: 'C:\\locally-built\\pet.exe', sha256: hash }, 'win32'), + CheckFailure, + ); + assert.throws( + () => verifyPetIdentity(expected, { path: starts[0].path, sha256: 'b'.repeat(64) }, 'win32'), + CheckFailure, + ); + assert.deepStrictEqual(locatorStarts('a package contains pet.exe, but no locator started', 'empty.log'), []); + }); + + it('keeps fixture interpreters within the legacy workspace locator two-directory search depth', () => { + const layout = fixtureLayout(directory); + assert.strictEqual(layout.prefixes.length, 2); + for (const prefix of layout.prefixes) { + assert.strictEqual(path.dirname(prefix), directory); + const relative = path.relative(directory, path.join(prefix, 'bin', 'python')); + assert.strictEqual(relative.split(path.sep).length - 1, 2); + } + assert.deepStrictEqual(layout.searchPaths, ['./.venv-one', './.venv-two']); + }); + + it('sets workspace-only search paths only in workspace settings while preserving explicit global opt-out', () => { + const layout = fixtureLayout(directory); + const settings = profileSettings(false, layout); + assert.strictEqual(settings.user['python.useEnvironmentsExtension'], false); + assert.strictEqual(settings.workspace['python.useEnvironmentsExtension'], false); + assert.strictEqual(settings.user['python-envs.workspaceSearchPaths'], undefined); + assert.deepStrictEqual(settings.workspace['python-envs.workspaceSearchPaths'], layout.searchPaths); + assert.strictEqual(settings.workspace['extensions.autoUpdate'], undefined); + }); + + it('launches upgrade hosts without extensionTestsPath and preserves the same explicit storage directories', () => { + const testCase = { + workspace: path.join(directory, 'workspace'), + userData: path.join(directory, 'user-data'), + extensions: path.join(directory, 'extensions'), + persistentStorage: true, + }; + const args = hostLaunchArgs(testCase); + assert(!args.some((arg) => arg.startsWith('--extensionTestsPath'))); + assert(args.some((arg) => arg.startsWith('--extensionDevelopmentPath='))); + assert.strictEqual(args[args.indexOf('--user-data-dir') + 1], testCase.userData); + assert.strictEqual(args[args.indexOf('--extensions-dir') + 1], testCase.extensions); + assert.strictEqual(args[0], testCase.workspace); + }); + + it('proves controller Mementos persist before attributing missing product selection to an upgrade', async () => { + const global = new Map(); + const workspace = new Map(); + const memento = (values) => ({ + get: (key) => values.get(key), + update: async (key, value) => values.set(key, value), + }); + const baseline = { globalState: memento(global), workspaceState: memento(workspace) }; + await storageContinuity(baseline, 'same-profile-token', false); + const upgraded = { globalState: memento(global), workspaceState: memento(workspace) }; + assert.strictEqual((await storageContinuity(upgraded, 'same-profile-token', true)).phase, 'verified-existing'); + const resetHost = { globalState: memento(new Map()), workspaceState: memento(new Map()) }; + await assert.rejects(storageContinuity(resetHost, 'same-profile-token', true), InfrastructureError); + assert.strictEqual(resetHost.globalState.get('pythonReleaseCompatibility.storageContinuity'), undefined); + await assert.rejects(storageContinuity(upgraded, 'wrong-profile-token', true), InfrastructureError); + const readOnlyAfter = { + globalState: { + get: () => 'same-profile-token', + update: () => assert.fail('Must not reseed after restart'), + }, + workspaceState: { + get: () => 'same-profile-token', + update: () => assert.fail('Must not reseed after restart'), + }, + }; + await storageContinuity(readOnlyAfter, 'same-profile-token', true); + const failedStore = { + globalState: { + update: async () => { + throw new Error('disk unavailable'); + }, + }, + }; + await assert.rejects(storageContinuity(failedStore, 'same-profile-token', false), InfrastructureError); + }); + + it('accepts positively observed legacy JS discovery only for off mode, without fabricating PET identity', () => { + const legacy = legacyDiscoveryLines( + '[info] Searching for custom virtual environments', + '/logs/ms-python.python/Python.log', + ); + assert.strictEqual(locatorBackend(false, [], legacy).kind, 'legacy-javascript'); + assert.throws(() => locatorBackend(true, [], legacy), CheckFailure); + assert.throws(() => locatorBackend(false, [], []), CheckFailure); + assert.throws(() => locatorBackend(false, [{ component: 'environments' }], legacy), CheckFailure); + assert.strictEqual(locatorBackend(false, [{ component: 'core' }], legacy).kind, 'pet'); + assert.deepStrictEqual( + legacyDiscoveryLines('[info] Searching for custom virtual environments', '/other/Python.log'), + [], + ); + }); + + it('observes delegation without changing the result, receiver, or thrown error', () => { + const result = {}; + const api = { + value: result, + runInTerminal() { + return this.value; + }, + }; + const original = api.runInTerminal; + const observation = observeMethod(api, 'runInTerminal'); + assert.strictEqual(api.runInTerminal('argument'), result); + assert.deepStrictEqual(observation.calls, [['argument']]); + observation.restore(); + assert.strictEqual(api.runInTerminal, original); + const error = new Error('original failure'); + const failing = { + runInTerminal() { + throw error; + }, + }; + const failedObservation = observeMethod(failing, 'runInTerminal'); + assert.throws( + () => failing.runInTerminal(), + (actual) => actual === error, + ); + failedObservation.restore(); + }); + + it('fails observability explicitly for an immutable API', () => { + assert.throws(() => observeMethod(Object.freeze({ runInTerminal() {} }), 'runInTerminal'), InfrastructureError); + }); + + it('shadows a class prototype method without mutating its prototype and deletes the shadow on restoration', () => { + class Api { + runInTerminal(value) { + return { receiver: this, value }; + } + } + class InheritedApi extends Api {} + const api = new InheritedApi(); + const original = Api.prototype.runInTerminal; + const before = Object.getOwnPropertyNames(api); + const observation = observeMethod(api, 'runInTerminal'); + assert(Object.prototype.hasOwnProperty.call(api, 'runInTerminal')); + assert.strictEqual(Api.prototype.runInTerminal, original); + const result = api.runInTerminal('argument'); + assert.strictEqual(result.receiver, api); + assert.strictEqual(result.value, 'argument'); + observation.restore(); + assert.strictEqual(api.runInTerminal, original); + assert.deepStrictEqual(Object.getOwnPropertyNames(api), before); + assert.strictEqual(Api.prototype.runInTerminal, original); + assert.throws(() => observeMethod(Object.preventExtensions(new Api()), 'runInTerminal'), InfrastructureError); + }); + + it('does not pass credentials, Python overrides, or developer test flags to candidates', () => { + const env = childEnvironment( + { + PATH: 'allowed', + SYSTEM_ACCESSTOKEN: 'secret', + GITHUB_TOKEN: 'secret', + VSCE_PAT: 'secret', + NODE_OPTIONS: '--require developer-hook', + PYTHONPATH: 'developer-path', + VSC_PYTHON_SMOKE_TEST: '1', + }, + directory, + ); + assert.strictEqual(env.PATH, 'allowed'); + for (const key of [ + 'SYSTEM_ACCESSTOKEN', + 'GITHUB_TOKEN', + 'VSCE_PAT', + 'NODE_OPTIONS', + 'PYTHONPATH', + 'VSC_PYTHON_SMOKE_TEST', + ]) { + assert.strictEqual(env[key], undefined, key); + } + assert.strictEqual(env.USERPROFILE, directory); + assert.strictEqual(env.HOME, directory); + }); + + it('retains explicit infrastructure status instead of skipping a missing input', async () => { + const file = path.join(directory, 'plan.json'); + fs.writeFileSync(file, JSON.stringify(plan())); + const output = path.join(directory, 'evidence'); + const result = await run(file, output); + assert.strictEqual(result.status, 'infrastructure-error'); + assert.match(result.error.message, /Required VSIX/); + assert.strictEqual(JSON.parse(fs.readFileSync(path.join(output, 'report.json'), 'utf8')).status, result.status); + }); + + it('never overwrites earlier evidence', async () => { + const evidence = path.join(directory, 'report.json'); + fs.writeFileSync(evidence, 'previous evidence'); + await assert.rejects(run('missing-plan.json', directory), InfrastructureError); + assert.strictEqual(fs.readFileSync(evidence, 'utf8'), 'previous evidence'); + }); + + it('prepares one fixed partner set without resolving any latest artifact', async () => { + const core = await fixtureVsix(); + const env = await fixtureVsix('ms-python.vscode-python-envs'); + const zip = new JSZip(); + zip.file('env.vsix', fs.readFileSync(env.path)); + zip.file( + 'inputs.json', + JSON.stringify({ + schemaVersion: 1, + vscodeVersion: '1.105.0', + environments: { ...env, path: 'env.vsix', pet: 'bundled' }, + }), + ); + const data = await zip.generateAsync({ type: 'nodebuffer' }); + const inputs = path.join(directory, 'inputs.zip'); + fs.writeFileSync(inputs, data); + const output = path.join(directory, 'prepared'); + const result = await prepare([ + '--inputs', + inputs, + '--inputs-sha256', + digest(data), + '--candidate', + core.path, + '--candidate-sha256', + core.sha256, + '--candidate-version', + core.version, + '--python', + process.execPath, + '--output', + output, + ]); + assert.strictEqual(result.status, 'inputs-prepared'); + const prepared = JSON.parse(fs.readFileSync(result.plan, 'utf8')); + assert.strictEqual(prepared.core.sha256, core.sha256); + assert.strictEqual(prepared.environments.sha256, env.sha256); + assert.strictEqual(prepared.environments.path, path.join(output, 'inputs', 'env.vsix')); + }); + + it('retains a failed preparation report for bad pinned input provenance', async () => { + const inputs = path.join(directory, 'inputs.zip'); + fs.writeFileSync(inputs, 'not the pinned archive'); + const output = path.join(directory, 'prepared'); + await assert.rejects( + prepare([ + '--inputs', + inputs, + '--inputs-sha256', + hash, + '--candidate', + path.join(directory, 'core.vsix'), + '--candidate-sha256', + hash, + '--candidate-version', + version, + '--python', + process.execPath, + '--output', + output, + ]), + CheckFailure, + ); + const report = JSON.parse(fs.readFileSync(path.join(output, 'adapter-report.json'), 'utf8')); + assert.strictEqual(report.status, 'failed'); + assert.match(report.error.message, /inputs ZIP changed/); + }); + + it('retains preparation failures for companion paths outside the checked input archive', async () => { + const zip = new JSZip(); + zip.file( + 'inputs.json', + JSON.stringify({ + schemaVersion: 1, + vscodeVersion: '1.105.0', + environments: { path: '../outside.vsix', version: '1.42.0', sha256: hash, pet: 'bundled' }, + }), + ); + const data = await zip.generateAsync({ type: 'nodebuffer' }); + const inputs = path.join(directory, 'inputs.zip'); + fs.writeFileSync(inputs, data); + const output = path.join(directory, 'prepared'); + await assert.rejects( + prepare([ + '--inputs', + inputs, + '--inputs-sha256', + digest(data), + '--candidate', + path.join(directory, 'core.vsix'), + '--candidate-sha256', + hash, + '--candidate-version', + version, + '--python', + process.execPath, + '--output', + output, + ]), + CheckFailure, + ); + const report = JSON.parse(fs.readFileSync(path.join(output, 'adapter-report.json'), 'utf8')); + assert.strictEqual(report.status, 'failed'); + assert.match(report.error.message, /escaped/); + }); + + it('packages a standalone root runner, controller and locked dependency tree', async function () { + this.timeout(60000); + const output = path.join(directory, 'harness.zip'); + const result = await pack(output); + const data = fs.readFileSync(output); + assert.strictEqual(result.sha256, digest(data)); + const zip = await JSZip.loadAsync(data); + for (const file of [ + 'run.js', + 'checks.js', + 'process.ps1', + 'controller/index.js', + 'controller/package.json', + 'node_modules/@vscode/test-electron/out/runTest.js', + 'node_modules/node-stream-zip/node_stream_zip.js', + 'node_modules/xml2js/lib/xml2js.js', + 'node_modules/jszip/node_modules/readable-stream/node_modules/string_decoder/package.json', + 'repository-package-lock.json', + ]) { + assert(zip.file(file), file); + } + const manifest = JSON.parse(await zip.file('harness-manifest.json').async('string')); + assert.strictEqual(manifest.schemaVersion, 1); + for (const file of manifest.files) { + assert.strictEqual(digest(await zip.file(file.path).async('nodebuffer')), file.sha256, file.path); + } + }); + + it('records process errors and bounds only its own child process', async function () { + this.timeout(30000); + const result = await runProcess(process.execPath, ['-e', 'process.stdout.write("result"); process.exit(3)'], { + cwd: directory, + env: process.env, + timeout: process.platform === 'win32' ? 15000 : 3000, + }); + assert.strictEqual(result.code, 3); + assert.strictEqual(result.stdout, 'result'); + await assert.rejects( + runProcess(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + cwd: directory, + env: process.env, + timeout: 100, + }), + InfrastructureError, + ); + }); + + it('contains grandchildren holding pipes after their direct parent exits', async function () { + this.timeout(30000); + const log = path.join(directory, 'grandchild.log'); + const started = Date.now(); + const command = + process.platform === 'win32' + ? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + : process.execPath; + const args = + process.platform === 'win32' + ? [ + '-NoProfile', + '-NonInteractive', + '-Command', + [ + '$info = New-Object System.Diagnostics.ProcessStartInfo', + `$info.FileName = '${process.execPath.replace(/'/g, "''")}'`, + '$info.Arguments = \'-e "setInterval(() => {}, 1000)"\'', + '$info.UseShellExecute = $false', + '$info.CreateNoWindow = $true', + '$child = [System.Diagnostics.Process]::Start($info)', + 'Write-Output "OWNED_$($child.Id)"', + 'exit 0', + ].join('; '), + ] + : [ + '-e', + [ + 'const child = require("child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "inherit"});', + 'console.log("OWNED_" + child.pid);', + 'child.unref();', + 'setTimeout(() => process.exit(0), 50);', + ].join('\n'), + ]; + const timeout = process.platform === 'win32' ? 15000 : 3000; + await assert.rejects( + runProcess(command, args, { cwd: directory, env: process.env, timeout, log }), + InfrastructureError, + ); + assert(Date.now() - started < timeout + 6000, 'Cleanup must settle within its deadline'); + const match = /OWNED_(\d+)/.exec(fs.readFileSync(log, 'utf8')); + assert(match, 'The test must actually start the grandchild before timing out'); + const pid = Number(match[1]); + let alive = true; + const deadline = Date.now() + 2000; + while (alive && Date.now() < deadline) { + try { + process.kill(pid, 0); + if (process.platform === 'linux') { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + alive = !/\) Z /.test(stat); + } + } catch (error) { + if (error.code !== 'ESRCH' && error.code !== 'ENOENT') throw error; + alive = false; + } + if (alive) await new Promise((resolve) => setTimeout(resolve, 50)); + } + assert(!alive, 'An owned grandchild must not remain running after timeout cleanup'); + }); + + it('completes normal Linux shutdown before accepting a root with redirected-stdio descendants', async function () { + if (process.platform !== 'linux') this.skip(); + this.timeout(10000); + const result = await runProcess( + process.execPath, + [ + '-e', + [ + 'const child = require("child_process").spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"});', + 'console.log(child.pid);', + 'child.unref();', + 'setTimeout(() => process.exit(0), 50);', + ].join('\n'), + ], + { cwd: directory, env: process.env, timeout: 3000 }, + ); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.cleanup.forcedDescendantCleanup, true); + const pid = Number(result.stdout.trim()); + assert(result.cleanup.members.includes(pid)); + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + assert(/\) [ZX] /.test(stat), 'Owned redirected-stdio descendant must not still run'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + }); +}); diff --git a/build/releaseCompatibility/controller/index.js b/build/releaseCompatibility/controller/index.js new file mode 100644 index 000000000000..90faab2e7d71 --- /dev/null +++ b/build/releaseCompatibility/controller/index.js @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vscode = require('vscode'); +const { + check, + samePath, + verifyMode, + verifyInstalledExtension, + verifyPythonResult, + observeMethod, + storageContinuity, +} = require('../checks'); + +const TIMEOUT = 60000; +let controllerContext; + +function activate(context) { + controllerContext = context; + if (process.env.PYTHON_COMPATIBILITY_PERSISTENT_HOST === '1') { + setTimeout(() => { + run() + .then( + () => vscode.commands.executeCommand('workbench.action.quit'), + (error) => { + console.error('Persistent compatibility case failed', error); + return vscode.commands.executeCommand('workbench.action.quit'); + }, + ) + .catch((error) => console.error('Persistent compatibility host could not quit gracefully', error)); + }, 0); + } +} + +async function bounded(label, operation) { + let timer; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), TIMEOUT); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function until(label, read) { + const deadline = Date.now() + TIMEOUT; + do { + const result = await bounded(label, read()); + if (result) return result; + await new Promise((resolve) => setTimeout(resolve, 200)); + } while (Date.now() < deadline); + throw new Error(`Timed out waiting for ${label}`); +} + +function extension(expected) { + const actual = vscode.extensions.getExtension(expected.id); + verifyInstalledExtension(expected, actual, __dirname); + return actual; +} + +function configuredMode(testCase) { + const config = vscode.workspace.getConfiguration('python', vscode.Uri.file(testCase.workspace)); + const effective = config.get('useEnvironmentsExtension'); + const explicit = config.inspect('useEnvironmentsExtension')?.globalValue; + verifyMode(testCase.mode, effective, explicit); + return { requested: testCase.mode, effective, explicitGlobal: explicit }; +} + +function environmentExecutable(environment) { + const value = environment?.execInfo?.run?.executable; + return typeof value === 'string' ? value : value?.fsPath; +} + +async function discovered(testCase, coreApi, environmentApi, observe) { + if (testCase.mode) { + await bounded('Environments refresh', environmentApi.refreshEnvironments(undefined)); + } else { + await bounded('core legacy refresh', coreApi.environments.refreshEnvironments({ forceRefresh: true })); + } + return until('both fixture environments to be discovered, not merely resolved by explicit path', async () => { + const environments = testCase.mode ? await environmentApi.getEnvironments('all') : coreApi.environments.known; + observe( + environments.map((item) => ({ + executable: testCase.mode ? environmentExecutable(item) : item.executable?.uri?.fsPath, + prefix: testCase.mode ? item.sysPrefix : item.executable?.sysPrefix, + })), + ); + const matches = testCase.fixtures.map((fixture) => + environments.find((item) => { + const executable = testCase.mode ? environmentExecutable(item) : item.executable?.uri?.fsPath; + return executable && samePath(executable, fixture.executable); + }), + ); + return matches.every(Boolean) ? matches : undefined; + }); +} + +async function selected(testCase, coreApi, environmentApi, resource, expected) { + return until('selected environment to match the requested fixture', async () => { + const actual = testCase.mode + ? await environmentApi.getEnvironment(resource) + : coreApi.environments.getActiveEnvironmentPath(resource); + const executable = testCase.mode ? environmentExecutable(actual) : actual?.path; + return executable && samePath(executable, expected.executable) ? { executable } : undefined; + }); +} + +async function executeFixture(testCase, fixture, index, environmentApi) { + const file = path.join(testCase.workspace, `probe-${testCase.phase}-${index}.py`); + const resultFile = file.slice(0, -3) + '.json'; + const nonce = `${testCase.nonce}-${index}`; + fs.writeFileSync( + file, + [ + 'import json', + 'import pathlib', + 'import sys', + 'result = pathlib.Path(__file__).with_suffix(".json")', + 'temporary = result.with_suffix(".tmp")', + `temporary.write_text(json.dumps({"executable": sys.executable, "prefix": sys.prefix, "nonce": "${nonce}"}), encoding="utf-8")`, + 'temporary.replace(result)', + '', + ].join('\n'), + ); + const uri = vscode.Uri.file(file); + await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uri)); + const observation = testCase.mode ? observeMethod(environmentApi, 'runInTerminal') : undefined; + try { + await bounded( + 'normal core python.execInTerminal', + vscode.commands.executeCommand('python.execInTerminal', uri), + ); + const actual = await until('real Python execution result', async () => { + if (!fs.existsSync(resultFile)) return undefined; + const result = JSON.parse(fs.readFileSync(resultFile, 'utf8')); + verifyPythonResult(fixture, result, nonce); + return result; + }); + if (observation) { + check( + observation.calls.length === 1, + 'Core did not delegate normal execution to Environments exactly once', + ); + check( + samePath(environmentExecutable(observation.calls[0][0]), fixture.executable), + 'Core delegated execution using the wrong selected environment', + ); + } + return { file, resultFile, ...actual, delegatedCalls: observation?.calls.length ?? 0 }; + } finally { + observation?.restore(); + } +} + +async function run() { + const testCase = JSON.parse(fs.readFileSync(process.env.PYTHON_COMPATIBILITY_CASE, 'utf8')); + const report = { status: 'failed', mode: testCase.mode, phase: testCase.phase, runs: [] }; + try { + check(vscode.version === testCase.vscodeVersion, 'The extension host is not the pinned VS Code version'); + const controller = vscode.extensions.getExtension( + 'vscode-python-tests.python-release-compatibility-controller', + ); + check(controller, 'Independent test controller is missing'); + await bounded('controller activation', controller.activate()); + check( + controllerContext?.extensionMode === + (testCase.persistentStorage ? vscode.ExtensionMode.Development : vscode.ExtensionMode.Test), + 'Controller is not in the expected isolated host mode', + ); + check( + samePath(controllerContext.extensionPath, __dirname), + 'Controller loaded from an unexpected development path', + ); + report.controller = { + location: controllerContext.extensionPath, + extensionMode: controllerContext.extensionMode, + persistentStorage: Boolean(testCase.persistentStorage), + }; + if (testCase.persistentStorage) { + report.storage = await storageContinuity( + controllerContext, + testCase.storageProbeToken, + Boolean(testCase.persisted), + ); + } + report.vscode = { version: vscode.version, platform: process.platform, arch: process.arch }; + report.configurationBefore = configuredMode(testCase); + const core = extension(testCase.core); + const environments = extension(testCase.environments); + report.extensions = [core, environments].map((item) => ({ + id: item.id, + version: item.packageJSON.version, + location: item.extensionPath, + installation: 'installed-vsix-outside-sole-controller-development-path', + extensionContextMode: null, + })); + const coreApi = await bounded('core activation', core.activate()); + await bounded('core ready', coreApi.ready); + const environmentApi = testCase.mode + ? await bounded('Environments activation', environments.activate()) + : undefined; + check(coreApi.environments, 'Core public environments API is missing'); + if (testCase.mode) { + for (const method of [ + 'refreshEnvironments', + 'getEnvironments', + 'setEnvironment', + 'getEnvironment', + 'runInTerminal', + ]) { + check(typeof environmentApi?.[method] === 'function', `Environments public API is missing ${method}`); + } + } + report.configurationAfterActivation = configuredMode(testCase); + const matches = await discovered(testCase, coreApi, environmentApi, (snapshot) => { + report.discoveryObserved = snapshot; + }); + report.discovery = matches.map((item) => ({ + executable: testCase.mode ? environmentExecutable(item) : item.executable.uri.fsPath, + prefix: testCase.mode ? item.sysPrefix : item.executable.sysPrefix, + })); + const resource = vscode.Uri.file(path.join(testCase.workspace, 'selection.py')); + if (testCase.persisted) { + // This read must precede any selection write in the upgraded host. + report.persistedSelection = await selected(testCase, coreApi, environmentApi, resource, testCase.persisted); + } + for (let index = 0; index < testCase.fixtures.length; index += 1) { + const fixture = testCase.fixtures[index]; + if (testCase.mode) { + await bounded( + 'Environments selection', + environmentApi.setEnvironment(vscode.Uri.file(testCase.workspace), matches[index]), + ); + } else { + await bounded( + 'legacy core selection', + coreApi.environments.updateActiveEnvironmentPath(fixture.executable, resource), + ); + } + const selection = await selected(testCase, coreApi, environmentApi, resource, fixture); + const execution = await executeFixture(testCase, fixture, index, environmentApi); + report.runs.push({ selection, execution }); + } + report.configurationAfterExecution = configuredMode(testCase); + report.status = 'passed'; + } catch (error) { + report.status = error.status ?? 'failed'; + report.error = { name: error.name, message: error.message, stack: error.stack }; + throw error; + } finally { + fs.writeFileSync(testCase.stateFile, `${JSON.stringify(report, null, 2)}\n`); + for (const terminal of vscode.window.terminals) terminal.dispose(); + } +} + +module.exports = { activate, run }; diff --git a/build/releaseCompatibility/controller/package.json b/build/releaseCompatibility/controller/package.json new file mode 100644 index 000000000000..cdc0d6191352 --- /dev/null +++ b/build/releaseCompatibility/controller/package.json @@ -0,0 +1,13 @@ +{ + "name": "python-release-compatibility-controller", + "publisher": "vscode-python-tests", + "version": "1.0.0", + "private": true, + "engines": { + "vscode": "^1.95.0" + }, + "main": "./index.js", + "activationEvents": [ + "*" + ] +} diff --git a/build/releaseCompatibility/pack.js b/build/releaseCompatibility/pack.js new file mode 100644 index 000000000000..881640700631 --- /dev/null +++ b/build/releaseCompatibility/pack.js @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { check, digest, within } = require('./checks'); + +function packageRoot(name, from) { + try { + return path.dirname(require.resolve(`${name}/package.json`, { paths: [from] })); + } catch (error) { + if (error.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') throw error; + } + let directory = path.dirname(require.resolve(name, { paths: [from] })); + for (;;) { + const file = path.join(directory, 'package.json'); + if (fs.existsSync(file) && JSON.parse(fs.readFileSync(file, 'utf8')).name === name) return directory; + const parent = path.dirname(directory); + check(parent !== directory, `Cannot find installed dependency manifest: ${name}`); + directory = parent; + } +} + +async function pack(output) { + check(path.isAbsolute(output) && output.endsWith('.zip'), 'Output must be an absolute ZIP path'); + check(!fs.existsSync(output), 'An existing harness archive is never overwritten'); + const JSZip = require('jszip'); + const zip = new JSZip(); + const files = []; + const date = new Date('2000-01-01T00:00:00Z'); + const add = (name, data, mode = 0o644) => { + zip.file(name, data, { date, unixPermissions: mode }); + files.push({ path: name, sha256: digest(data) }); + }; + const addDirectory = (directory, destination, root) => { + for (const item of fs + .readdirSync(directory, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name))) { + if (item.name === 'node_modules') continue; + const file = path.join(directory, item.name); + const name = `${destination}/${item.name}`; + if (item.isDirectory()) addDirectory(file, name, root); + else { + check( + item.isFile() || (item.isSymbolicLink() && within(root, fs.realpathSync(file))), + `Unsupported dependency file: ${file}`, + ); + add(name, fs.readFileSync(file), fs.statSync(file).mode & 0o777); + } + } + }; + const addDependency = (name, from, destination, ancestors = []) => { + const root = packageRoot(name, from); + if (ancestors.includes(root)) return; + const installed = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const target = `${destination}/${name}`; + addDirectory(root, target, root); + for (const dependency of Object.keys(installed.dependencies ?? {}).sort()) { + addDependency(dependency, root, `${target}/node_modules`, [...ancestors, root]); + } + }; + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); + const repository = path.resolve(__dirname, '..', '..'); + const lockFile = fs.readFileSync(path.join(repository, 'package-lock.json')); + const lock = JSON.parse(lockFile.toString('utf8')); + for (const [name, version] of Object.entries(manifest.dependencies)) { + const root = packageRoot(name, __dirname); + check( + JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version === version, + `Harness dependency ${name} does not match its pinned version`, + ); + check( + lock.packages[`node_modules/${name}`]?.version === version, + `Harness dependency ${name} does not match the repository lockfile`, + ); + addDependency(name, __dirname, 'node_modules'); + } + for (const file of ['run.js', 'checks.js', 'process.ps1', 'package.json', 'README.md']) { + add(file, fs.readFileSync(path.join(__dirname, file))); + } + addDirectory(path.join(__dirname, 'controller'), 'controller', path.join(__dirname, 'controller')); + add('repository-package-lock.json', lockFile); + add( + 'harness-manifest.json', + Buffer.from( + JSON.stringify( + { + schemaVersion: 1, + nodeVersion: process.version, + repositoryLockSha256: digest(lockFile), + files: files.sort((a, b) => a.path.localeCompare(b.path)), + }, + null, + 2, + ), + ), + ); + fs.mkdirSync(path.dirname(output), { recursive: true }); + const bytes = await zip.generateAsync({ + type: 'nodebuffer', + platform: 'UNIX', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + fs.writeFileSync(output, bytes, { flag: 'wx' }); + return { path: output, sha256: digest(bytes) }; +} + +if (require.main === module) { + pack(process.argv[2]) + .then((result) => console.log(JSON.stringify(result))) + .catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} + +module.exports = { pack }; diff --git a/build/releaseCompatibility/package.json b/build/releaseCompatibility/package.json new file mode 100644 index 000000000000..11ff7289ec15 --- /dev/null +++ b/build/releaseCompatibility/package.json @@ -0,0 +1,14 @@ +{ + "name": "python-release-compatibility", + "version": "1.0.0", + "private": true, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@vscode/test-electron": "3.1.0", + "jszip": "3.10.1", + "node-stream-zip": "1.15.0", + "xml2js": "0.5.0" + } +} diff --git a/build/releaseCompatibility/prepare.js b/build/releaseCompatibility/prepare.js new file mode 100644 index 000000000000..5ebbaaa15c8e --- /dev/null +++ b/build/releaseCompatibility/prepare.js @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { check, hashFile, validateHash, validatePlan, extractArchive, within } = require('./checks'); + +async function prepare(args) { + const allowed = [ + '--inputs', + '--inputs-sha256', + '--candidate', + '--candidate-sha256', + '--candidate-version', + '--python', + '--output', + ]; + check(args.length === allowed.length * 2, 'Missing compatibility preparation inputs'); + const values = {}; + for (let index = 0; index < args.length; index += 2) { + check( + allowed.includes(args[index]) && values[args[index]] === undefined, + 'Unknown or repeated preparation argument', + ); + values[args[index]] = args[index + 1]; + } + const output = values['--output']; + check(path.isAbsolute(output) && !fs.existsSync(output), 'Preparation output must be a new absolute directory'); + fs.mkdirSync(output, { recursive: true }); + const reportFile = path.join(output, 'adapter-report.json'); + const report = { schemaVersion: 1, status: 'infrastructure-error', validationScope: 'pre-sign-build-artifact' }; + fs.writeFileSync(reportFile, JSON.stringify(report, null, 2)); + try { + const inputs = path.resolve(values['--inputs']); + check( + (await hashFile(inputs)) === validateHash(values['--inputs-sha256'], 'inputs ZIP hash'), + 'Pinned compatibility inputs ZIP changed', + ); + const extracted = path.join(output, 'inputs'); + await extractArchive(inputs, extracted); + const metadata = JSON.parse(fs.readFileSync(path.join(extracted, 'inputs.json'), 'utf8')); + check( + metadata.schemaVersion === 1 && metadata.environments, + 'inputs.json must contain a pinned Environments partner', + ); + const partner = (artifact) => { + check( + artifact && typeof artifact.path === 'string' && !path.isAbsolute(artifact.path), + 'Companion VSIX paths must be relative to the inputs ZIP', + ); + const file = path.resolve(extracted, artifact.path); + check(within(extracted, file), 'Companion VSIX escaped the checked inputs ZIP'); + return { ...artifact, path: file }; + }; + const plan = validatePlan( + { + schemaVersion: 1, + candidate: 'core', + vscodeVersion: metadata.vscodeVersion, + pythonExecutable: values['--python'], + core: { + path: path.resolve(values['--candidate']), + version: values['--candidate-version'], + sha256: values['--candidate-sha256'], + }, + environments: partner(metadata.environments), + ...(metadata.upgradeFromEnvironments + ? { upgradeFromEnvironments: partner(metadata.upgradeFromEnvironments) } + : {}), + }, + output, + ); + const planFile = path.join(output, 'plan.json'); + fs.writeFileSync(planFile, `${JSON.stringify(plan, null, 2)}\n`); + report.status = 'inputs-prepared'; + report.inputs = { path: inputs, sha256: await hashFile(inputs) }; + report.plan = planFile; + report.requiresFinalSignedHashReconciliation = true; + return report; + } catch (error) { + report.status = error.status ?? 'infrastructure-error'; + report.error = { name: error.name, message: error.message, stack: error.stack }; + throw error; + } finally { + fs.writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`); + } +} + +if (require.main === module) { + prepare(process.argv.slice(2)) + .then((report) => console.log(JSON.stringify(report))) + .catch((error) => { + console.error(error); + process.exitCode = error.status === 'failed' ? 1 : 2; + }); +} + +module.exports = { prepare }; diff --git a/build/releaseCompatibility/process.ps1 b/build/releaseCompatibility/process.ps1 new file mode 100644 index 000000000000..d00a83fafb0d --- /dev/null +++ b/build/releaseCompatibility/process.ps1 @@ -0,0 +1,152 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +param([Parameter(Mandatory = $true)][string]$RequestFile) +$ErrorActionPreference = 'Stop' + +# A private job retains ownership of grandchildren even after their parent exits. +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +public static class CompatibilityProcessJob { + [StructLayout(LayoutKind.Sequential)] + struct BasicLimits { + public long ProcessTime, JobTime; + public uint Flags; + public UIntPtr MinimumWorkingSet, MaximumWorkingSet; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + [StructLayout(LayoutKind.Sequential)] + struct IoCounters { + public ulong ReadCount, WriteCount, OtherCount, ReadBytes, WriteBytes, OtherBytes; + } + [StructLayout(LayoutKind.Sequential)] + struct ExtendedLimits { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + [StructLayout(LayoutKind.Sequential)] + struct Accounting { + public long TotalUser, TotalKernel, PeriodUser, PeriodKernel; + public uint PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses; + } + [StructLayout(LayoutKind.Sequential)] + struct StartupInfo { + public uint Size; + public IntPtr Reserved, Desktop, Title; + public uint X, Y, XSize, YSize, XChars, YChars, Fill, Flags; + public ushort Show, ReservedSize; + public IntPtr ReservedData, Input, Output, Error; + } + [StructLayout(LayoutKind.Sequential)] + struct ProcessInfo { + public IntPtr Process, Thread; + public uint ProcessId, ThreadId; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr job, int type, ref ExtendedLimits limits, uint size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool QueryInformationJobObject(IntPtr job, int type, out Accounting info, uint size, IntPtr returned); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool CreateProcess(string app, StringBuilder args, IntPtr processAttributes, IntPtr threadAttributes, + bool inherit, uint flags, IntPtr environment, string directory, ref StartupInfo startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetExitCodeProcess(IntPtr process, out uint code); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll")] + static extern IntPtr GetStdHandle(int id); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + static void Require(bool success) { + if (!success) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + static string Quote(string value) { + var result = new StringBuilder("\""); + int slashes = 0; + foreach (char c in value) { + if (c == '\\') slashes++; + else { + result.Append('\\', c == '"' ? slashes * 2 + 1 : slashes); + result.Append(c); + slashes = 0; + } + } + result.Append('\\', slashes * 2); + return result.Append('"').ToString(); + } + public static int Run(string executable, string[] args, string directory, int timeout) { + IntPtr job = CreateJobObject(IntPtr.Zero, null); + Require(job != IntPtr.Zero); + var process = new ProcessInfo(); + bool assigned = false; + try { + var limits = new ExtendedLimits(); + limits.Basic.Flags = 0x2000; // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + Require(SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf(typeof(ExtendedLimits)))); + var startup = new StartupInfo(); + startup.Size = (uint)Marshal.SizeOf(typeof(StartupInfo)); + startup.Flags = 0x100; // STARTF_USESTDHANDLES + startup.Input = GetStdHandle(-10); + startup.Output = GetStdHandle(-11); + startup.Error = GetStdHandle(-12); + Require(SetHandleInformation(startup.Input, 1, 1)); + Require(SetHandleInformation(startup.Output, 1, 1)); + Require(SetHandleInformation(startup.Error, 1, 1)); + var command = new StringBuilder(Quote(executable)); + foreach (string arg in args) command.Append(' ').Append(Quote(arg)); + Require(CreateProcess(executable, command, IntPtr.Zero, IntPtr.Zero, true, + 0x08000004, IntPtr.Zero, directory, ref startup, out process)); + Require(AssignProcessToJobObject(job, process.Process)); + assigned = true; + Require(ResumeThread(process.Thread) != UInt32.MaxValue); + var clock = Stopwatch.StartNew(); + while (true) { + Accounting accounting; + Require(QueryInformationJobObject(job, 1, out accounting, + (uint)Marshal.SizeOf(typeof(Accounting)), IntPtr.Zero)); + if (accounting.ActiveProcesses == 0) break; + if (clock.ElapsedMilliseconds >= timeout) { + Require(TerminateJobObject(job, 124)); + return 124; + } + Thread.Sleep(25); + } + uint code; + Require(GetExitCodeProcess(process.Process, out code)); + return unchecked((int)code); + } finally { + if (process.Process != IntPtr.Zero && !assigned) TerminateProcess(process.Process, 125); + CloseHandle(job); + if (process.Thread != IntPtr.Zero) CloseHandle(process.Thread); + if (process.Process != IntPtr.Zero) CloseHandle(process.Process); + } + } +} +'@ + +$request = Get-Content -LiteralPath $RequestFile -Raw | ConvertFrom-Json +$code = [CompatibilityProcessJob]::Run( + [string]$request.command, [string[]]$request.args, [string]$request.cwd, [int]$request.timeout) +exit $code diff --git a/build/releaseCompatibility/released-pairs.json b/build/releaseCompatibility/released-pairs.json new file mode 100644 index 000000000000..b8a6e6b86314 --- /dev/null +++ b/build/releaseCompatibility/released-pairs.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "vscodeVersion": "1.110.0", + "coreVersion": "2026.10.0", + "environmentsVersion": "1.42.0", + "platforms": { + "win32": { + "target": "win32-x64", + "coreSha256": "ab1c1bf0f5ab273118ca3218e62934a4030203cce2e4b24bb86f0f416de05768", + "environmentsSha256": "ce79778a60dfd83892cbd572f5e23f2919e66e6cdaf6880ba2970604a4d7fa98" + }, + "linux": { + "target": "linux-x64", + "coreSha256": "9a84de731b622ee440e0bd979965f6a7577afa48bea6c3d4257d8865efc74780", + "environmentsSha256": "05b8f25276fdc622e8bc70710a70f643a51ef034f5afe054a27dd0520084b184" + } + } +} diff --git a/build/releaseCompatibility/run.js b/build/releaseCompatibility/run.js new file mode 100644 index 000000000000..48d31bdabf0f --- /dev/null +++ b/build/releaseCompatibility/run.js @@ -0,0 +1,594 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +const { spawn, spawnSync } = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { + CheckFailure, + InfrastructureError, + check, + hashFile, + samePath, + validatePlan, + inspectVsix, + installVsix, + verifyInstalledFiles, + locatorStarts, + legacyDiscoveryLines, + locatorBackend, + fixtureLayout, + profileSettings, + verifyPetIdentity, + childEnvironment, +} = require('./checks'); + +const HOST_TIMEOUT = 5 * 60 * 1000; + +function writeJson(file, value) { + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} + +function errorRecord(error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + status: error.status ?? 'infrastructure-error', + ownedProcessPid: error.ownedProcessPid ?? null, + cleanupError: error.cleanupError ?? null, + }; +} + +function stopTree(child) { + if (process.platform === 'win32') { + if (child.exitCode !== null || child.signalCode !== null) return; + const executable = path.join(process.env.SystemRoot, 'System32', 'taskkill.exe'); + const result = spawnSync(executable, ['/PID', String(child.pid), '/T', '/F'], { + encoding: 'utf8', + windowsHide: true, + timeout: 10000, + }); + if (result.error || result.status !== 0) { + throw new InfrastructureError( + `Could not stop owned test process ${child.pid}: ${result.error ?? result.stderr}`, + ); + } + } else { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } +} + +function liveGroupMembers(group) { + const members = []; + for (const pid of fs.readdirSync('/proc').filter((name) => /^[0-9]+$/.test(name))) { + let stat; + try { + stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ESRCH') continue; + throw error; + } + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); + if (Number(fields[2]) === group && fields[0] !== 'Z' && fields[0] !== 'X') members.push(Number(pid)); + } + return members; +} + +async function finishOwnedGroup(child) { + const members = liveGroupMembers(child.pid); + const cleanup = { kind: 'owned-posix-group', forcedDescendantCleanup: members.length > 0, members }; + const deadline = Date.now() + 5000; + while (liveGroupMembers(child.pid).length > 0) { + stopTree(child); + if (Date.now() >= deadline) { + throw new InfrastructureError(`Owned process group ${child.pid} did not terminate within cleanup bounds`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return cleanup; +} + +function runProcess(command, args, options) { + return new Promise((resolve, reject) => { + let executable = command; + let argumentsToLaunch = args; + if (process.platform === 'win32') { + const request = path.join(options.cwd, `.compatibility-process-${crypto.randomUUID()}.json`); + writeJson(request, { command, args, cwd: options.cwd, timeout: options.timeout ?? 60000 }); + executable = path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + argumentsToLaunch = [ + '-NoProfile', + '-NonInteractive', + '-File', + path.join(__dirname, 'process.ps1'), + '-RequestFile', + request, + ]; + } + const child = spawn(executable, argumentsToLaunch, { + cwd: options.cwd, + env: options.env, + shell: false, + detached: process.platform !== 'win32', + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + let failure; + let settled = false; + let cleaning = false; + let cleanupTimer; + let timer; + const settle = async (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + clearTimeout(cleanupTimer); + let containment; + try { + containment = + process.platform === 'linux' + ? await finishOwnedGroup(child) + : { kind: 'private-windows-job-completed' }; + } catch (error) { + failure = failure ?? new InfrastructureError(`Cannot complete owned process cleanup: ${error.message}`); + failure.cleanupError = error.message; + } + try { + if (options.log) { + fs.writeFileSync( + options.log, + `COMMAND ${command}\nARGS ${JSON.stringify(args)}\n${stdout}\nSTDERR\n${stderr}`, + ); + } + } catch (error) { + failure = new InfrastructureError(`Cannot retain test process output: ${error.message}`); + } + if (!failure && code === 124 && process.platform === 'win32') { + failure = new InfrastructureError(`Owned Windows process job exceeded its deadline: ${command}`); + } + if (failure) { + failure.ownedProcessPid = child.pid; + reject(failure); + } else resolve({ code, signal, stdout, stderr, pid: child.pid, cleanup: containment }); + }; + const cleanup = () => { + if (cleaning) return; + cleaning = true; + clearTimeout(timer); + try { + stopTree(child); + } catch (error) { + failure.cleanupError = `Owned process ${child.pid} cleanup failed: ${error.message}`; + } + cleanupTimer = setTimeout(() => { + child.stdout.destroy(); + child.stderr.destroy(); + child.stdin.destroy(); + child.unref(); + failure.cleanupError = + failure.cleanupError ?? 'Owned process pipes did not close after bounded cleanup'; + settle(null, 'cleanup-deadline'); + }, 5000); + }; + timer = setTimeout(() => { + failure = new InfrastructureError(`Owned test process exceeded ${options.timeout ?? 60000}ms: ${command}`); + cleanup(); + }, options.timeout ?? 60000); + const capture = (target, data) => { + const text = data.toString(); + const remaining = Math.max(0, 16 * 1024 * 1024 - stdout.length - stderr.length); + if (target === 'stdout') stdout += text.slice(0, remaining); + else stderr += text.slice(0, remaining); + if (!failure && text.length > remaining) { + failure = new InfrastructureError(`Test process output exceeds capture bounds: ${command}`); + cleanup(); + } + }; + child.stdout.on('data', (data) => capture('stdout', data)); + child.stderr.on('data', (data) => capture('stderr', data)); + child.on('error', (error) => { + failure = new InfrastructureError(`Cannot start ${command}: ${error.message}`); + }); + child.on('close', settle); + child.stdin.end(); + }); +} + +async function pythonProbe(executable, cwd, env) { + const result = await runProcess( + executable, + ['-c', 'import json,sys; print(json.dumps({"executable":sys.executable,"prefix":sys.prefix}))'], + { cwd, env }, + ); + if (result.code !== 0) { + throw new InfrastructureError(`Cannot probe fixture Python: ${result.stderr}`); + } + return JSON.parse(result.stdout); +} + +function logFiles(directory) { + if (!fs.existsSync(directory)) return []; + const files = []; + for (const item of fs.readdirSync(directory, { withFileTypes: true })) { + const file = path.join(directory, item.name); + if (item.isDirectory()) files.push(...logFiles(file)); + else if (item.isFile() && item.name.endsWith('.log')) files.push(file); + } + return files; +} + +function logOffsets(directory) { + return Object.fromEntries(logFiles(directory).map((file) => [file, fs.statSync(file).size])); +} + +function observeLogs(directory, offsets) { + const starts = []; + const errors = []; + const legacy = []; + for (const file of logFiles(directory)) { + const size = fs.statSync(file).size; + if (size > 64 * 1024 * 1024) throw new InfrastructureError(`Host log exceeds capture bounds: ${file}`); + const offset = size >= (offsets[file] ?? 0) ? offsets[file] ?? 0 : 0; + const text = fs.readFileSync(file).subarray(offset).toString('utf8'); + starts.push(...locatorStarts(text, file)); + legacy.push(...legacyDiscoveryLines(text, file)); + errors.push( + ...text + .split(/\r?\n/) + .filter((line) => /\[error\]|process error|exited immediately/i.test(line)) + .map((line) => ({ logFile: file, line })), + ); + } + return { starts, errors, legacy }; +} + +async function makeProfile(directory, mode, pythonExecutable) { + const workspace = path.join(directory, 'workspace'); + const userData = path.join(directory, 'user-data'); + const home = path.join(directory, 'home'); + const extensions = path.join(directory, 'extensions'); + for (const dir of [workspace, path.join(workspace, '.vscode'), path.join(userData, 'User'), extensions]) { + await fs.promises.mkdir(dir, { recursive: true }); + } + const env = childEnvironment(process.env, home); + for (const key of [ + 'HOME', + 'APPDATA', + 'LOCALAPPDATA', + 'XDG_CONFIG_HOME', + 'XDG_CACHE_HOME', + 'XDG_DATA_HOME', + 'TEMP', + ]) { + await fs.promises.mkdir(env[key], { recursive: true }); + } + const layout = fixtureLayout(workspace); + const configuration = profileSettings(mode, layout); + const settings = configuration.user; + if (process.platform === 'win32') { + settings['terminal.integrated.profiles.windows'] = { + 'Compatibility Command Prompt': { + path: process.env.ComSpec ?? path.join(process.env.SystemRoot, 'System32', 'cmd.exe'), + }, + }; + settings['terminal.integrated.defaultProfile.windows'] = 'Compatibility Command Prompt'; + } else { + settings['terminal.integrated.profiles.linux'] = { + 'Compatibility Bash': { path: '/bin/bash', args: ['--noprofile', '--norc'] }, + }; + settings['terminal.integrated.defaultProfile.linux'] = 'Compatibility Bash'; + } + writeJson(path.join(userData, 'User', 'settings.json'), settings); + writeJson(path.join(workspace, '.vscode', 'settings.json'), configuration.workspace); + fs.writeFileSync(path.join(workspace, 'app.py'), ''); + const fixtures = []; + for (const prefix of layout.prefixes) { + const creation = await runProcess(pythonExecutable, ['-m', 'venv', '--without-pip', prefix], { + cwd: workspace, + env, + }); + if (creation.code !== 0) throw new InfrastructureError(`Fixture venv creation failed: ${creation.stderr}`); + const executable = path.join( + prefix, + process.platform === 'win32' ? 'Scripts' : 'bin', + process.platform === 'win32' ? 'python.exe' : 'python', + ); + const fixture = await pythonProbe(executable, workspace, env); + check(samePath(fixture.executable, executable), 'Fixture bootstrap used an unexpected Python executable'); + check(samePath(fixture.prefix, prefix), 'Fixture bootstrap did not create an isolated venv'); + fixtures.push(fixture); + } + return { + directory, + workspace, + userData, + home, + extensions, + env, + fixtures, + storageProbeToken: crypto.randomBytes(16).toString('hex'), + }; +} + +async function runPhase(plan, profile, core, environments, phase, vscodeExecutablePath, persisted) { + const record = { mode: profile.mode, phase, status: 'infrastructure-error' }; + const phaseDirectory = path.join(profile.directory, phase); + fs.mkdirSync(phaseDirectory); + const coreLocation = path.join(profile.extensions, core.id); + const environmentLocation = path.join(profile.extensions, environments.id); + const stateFile = path.join(phaseDirectory, 'host-report.json'); + const caseFile = path.join(phaseDirectory, 'case.json'); + const logDirectory = path.join(profile.userData, 'logs'); + const offsets = logOffsets(logDirectory); + const testCase = { + mode: profile.mode, + phase, + workspace: profile.workspace, + stateFile, + core: { id: core.id, version: core.version, location: coreLocation }, + environments: { id: environments.id, version: environments.version, location: environmentLocation }, + fixtures: profile.fixtures, + persisted, + persistentStorage: Boolean(plan.upgradeFromEnvironments), + storageProbeToken: profile.storageProbeToken, + vscodeVersion: plan.vscodeVersion, + nonce: crypto.randomBytes(16).toString('hex'), + vscodeExecutablePath, + userData: profile.userData, + extensions: profile.extensions, + }; + writeJson(caseFile, testCase); + try { + const persistent = testCase.persistentStorage; + const result = await runProcess( + persistent ? vscodeExecutablePath : process.execPath, + persistent ? hostLaunchArgs(testCase) : [__filename, '--worker', caseFile], + { + cwd: phaseDirectory, + env: persistent + ? { ...profile.env, PYTHON_COMPATIBILITY_CASE: caseFile, PYTHON_COMPATIBILITY_PERSISTENT_HOST: '1' } + : profile.env, + timeout: HOST_TIMEOUT, + log: path.join(phaseDirectory, 'process.log'), + }, + ); + record.process = { pid: result.pid, exitCode: result.code, signal: result.signal, cleanup: result.cleanup }; + if (fs.existsSync(stateFile)) record.host = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + if (record.host?.status === 'failed') throw new CheckFailure(record.host.error.message); + if (record.host?.status === 'infrastructure-error') throw new InfrastructureError(record.host.error.message); + if (result.code !== 0 || record.host?.status !== 'passed') { + throw new InfrastructureError(`Extension host did not complete successfully; see ${phaseDirectory}`); + } + record.logs = observeLogs(logDirectory, offsets); + const backend = locatorBackend(profile.mode, record.logs.starts, record.logs.legacy); + record.discoveryBackend = backend; + if (backend.kind === 'legacy-javascript') { + record.pet = { + status: 'not-started-by-observed-legacy-backend', + supplier: null, + path: null, + sha256: null, + info: null, + observations: [], + packagedCorePet: core.pet, + }; + } else { + const supplier = profile.mode && environments.pet ? environments : core; + const location = supplier === core ? coreLocation : environmentLocation; + const expected = { + path: path.join(location, ...supplier.pet.relativePath.split('/')), + sha256: supplier.pet.sha256, + }; + record.pet = { status: 'observed', supplier: supplier.id, expected, observations: [] }; + for (const start of backend.starts) { + check(samePath(expected.path, start.path), 'Runtime PET selected an unexpected location'); + check(fs.existsSync(start.path), 'The runtime-selected PET binary is missing'); + const actual = { ...start, sha256: await hashFile(start.path) }; + verifyPetIdentity(expected, actual); + record.pet.observations.push(actual); + } + try { + const info = await runProcess(expected.path, ['--version'], { + cwd: phaseDirectory, + env: profile.env, + timeout: 10000, + }); + record.pet.info = { + status: info.code === 0 ? 'observed' : 'unavailable', + command: '--version', + exitCode: info.code, + stdout: info.stdout, + stderr: info.stderr, + }; + } catch (infoError) { + record.pet.info = { status: 'unavailable', command: '--version', error: errorRecord(infoError) }; + } + } + await verifyInstalledFiles(core, coreLocation); + await verifyInstalledFiles(environments, environmentLocation); + record.status = 'passed'; + } catch (error) { + record.status = error.status ?? 'infrastructure-error'; + record.error = errorRecord(error); + if (!record.logs) { + try { + record.logs = observeLogs(logDirectory, offsets); + } catch (logError) { + record.logCaptureError = errorRecord(logError); + } + } + } + writeJson(path.join(phaseDirectory, 'result.json'), record); + return record; +} + +function hostLaunchArgs(testCase) { + return [ + testCase.workspace, + '--extensions-dir', + testCase.extensions, + '--user-data-dir', + testCase.userData, + '--disable-workspace-trust', + '--skip-welcome', + '--skip-release-notes', + '--disable-updates', + '--enable-proposed-api=ms-python.python', + '--enable-proposed-api=ms-python.vscode-python-envs', + ...(process.platform === 'linux' ? ['--no-sandbox'] : []), + ...(testCase.persistentStorage + ? ['--new-window', `--extensionDevelopmentPath=${path.join(__dirname, 'controller')}`] + : []), + ]; +} + +async function worker(caseFile) { + const testCase = JSON.parse(fs.readFileSync(caseFile, 'utf8')); + if (testCase.persistentStorage) { + throw new InfrastructureError( + 'Persistent cases must use the directly owned normal host, not extensionTestsPath', + ); + } + const launchArgs = hostLaunchArgs(testCase); + const { runTests } = require('@vscode/test-electron'); + await runTests({ + vscodeExecutablePath: testCase.vscodeExecutablePath, + extensionDevelopmentPath: path.join(__dirname, 'controller'), + extensionTestsPath: path.join(__dirname, 'controller', 'index.js'), + extensionTestsEnv: { PYTHON_COMPATIBILITY_CASE: caseFile }, + launchArgs, + }); +} + +async function run(planFile, output) { + if (!path.isAbsolute(output) || fs.existsSync(output)) { + throw new InfrastructureError( + '--output must be a new absolute directory; existing evidence is never overwritten', + ); + } + fs.mkdirSync(output, { recursive: true }); + const reportFile = path.join(output, 'report.json'); + const report = { + schemaVersion: 1, + status: 'infrastructure-error', + platform: process.platform, + arch: process.arch, + requestedModes: [true, false], + cases: [], + }; + writeJson(reportFile, report); + try { + if ((process.platform !== 'win32' && process.platform !== 'linux') || process.arch !== 'x64') { + throw new InfrastructureError('This bounded compatibility runner supports Windows x64 and Linux x64 only'); + } + const plan = validatePlan(JSON.parse(fs.readFileSync(planFile, 'utf8')), path.dirname(path.resolve(planFile))); + report.plan = plan; + for (const mode of [true, false]) { + for (const phase of plan.upgradeFromEnvironments ? ['before-upgrade', 'after-upgrade'] : ['candidate']) { + report.cases.push({ + mode, + phase, + status: 'not-run', + reason: 'Input/profile/host preconditions not yet satisfied', + }); + } + } + writeJson(reportFile, report); + const core = await inspectVsix(plan.core, 'ms-python.python'); + const environments = await inspectVsix(plan.environments, 'ms-python.vscode-python-envs'); + const baseline = plan.upgradeFromEnvironments + ? await inspectVsix(plan.upgradeFromEnvironments, 'ms-python.vscode-python-envs') + : undefined; + report.artifacts = { core, environments, baseline: baseline ?? null }; + const { downloadAndUnzipVSCode } = require('@vscode/test-electron'); + const vscodeExecutablePath = await downloadAndUnzipVSCode({ + version: plan.vscodeVersion, + cachePath: path.join(output, 'vscode-cache'), + }); + report.vscode = { requestedVersion: plan.vscodeVersion, executable: vscodeExecutablePath }; + report.vscode.extensionDevelopmentPaths = [path.join(__dirname, 'controller')]; + for (const mode of [true, false]) { + const profile = await makeProfile( + path.join(output, mode ? 'environments-on' : 'explicit-off'), + mode, + plan.pythonExecutable, + ); + profile.mode = mode; + await installVsix(core, path.join(profile.extensions, core.id)); + await installVsix(baseline ?? environments, path.join(profile.extensions, environments.id)); + const phase = baseline ? 'before-upgrade' : 'candidate'; + const result = await runPhase( + plan, + profile, + core, + baseline ?? environments, + phase, + vscodeExecutablePath, + undefined, + ); + report.cases[report.cases.findIndex((item) => item.mode === mode && item.phase === phase)] = result; + if (baseline && result.status === 'passed') { + // Only the installed product directory is replaced; workspace and profile storage survive. + fs.rmSync(path.join(profile.extensions, environments.id), { recursive: true }); + await installVsix(environments, path.join(profile.extensions, environments.id)); + const upgraded = await runPhase( + plan, + profile, + core, + environments, + 'after-upgrade', + vscodeExecutablePath, + profile.fixtures[1], + ); + report.cases[ + report.cases.findIndex((item) => item.mode === mode && item.phase === 'after-upgrade') + ] = upgraded; + } else if (baseline) { + const pending = report.cases.find((item) => item.mode === mode && item.phase === 'after-upgrade'); + pending.reason = 'Baseline did not establish a verified persisted selection; upgrade was not certified'; + } + writeJson(reportFile, report); + } + for (const artifact of [core, environments, ...(baseline ? [baseline] : [])]) { + check((await hashFile(artifact.path)) === artifact.sha256, 'Input VSIX bytes changed during validation'); + } + report.status = report.cases.some((item) => item.status === 'failed') + ? 'failed' + : report.cases.some((item) => item.status !== 'passed') + ? 'infrastructure-error' + : 'passed'; + } catch (error) { + report.status = error.status ?? 'infrastructure-error'; + report.error = errorRecord(error); + } + writeJson(reportFile, report); + return report; +} + +async function main(args) { + if (args.length === 2 && args[0] === '--worker') return worker(args[1]); + if (args.length !== 4 || args[0] !== '--plan' || args[2] !== '--output') { + throw new InfrastructureError('Usage: node run.js --plan --output '); + } + const report = await run(path.resolve(args[1]), args[3]); + console.log(JSON.stringify({ status: report.status, report: path.join(args[3], 'report.json') })); + process.exitCode = report.status === 'passed' ? 0 : report.status === 'failed' ? 1 : 2; +} + +if (require.main === module) { + main(process.argv.slice(2)).catch((error) => { + console.error(JSON.stringify(errorRecord(error))); + process.exitCode = error.status === 'failed' ? 1 : 2; + }); +} + +module.exports = { run, runProcess, observeLogs, errorRecord, hostLaunchArgs }; diff --git a/build/releaseCompatibility/selfTest.js b/build/releaseCompatibility/selfTest.js new file mode 100644 index 000000000000..d6ab11e5b353 --- /dev/null +++ b/build/releaseCompatibility/selfTest.js @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { check, hashFile, readArchive, inspectVsix, InfrastructureError } = require('./checks'); +const { run, errorRecord } = require('./run'); +const pins = require('./released-pairs.json'); + +async function download(url, file) { + const response = await fetch(url, { signal: AbortSignal.timeout(120000) }); + if (!response.ok || !response.body) { + throw new InfrastructureError(`Released fixture download failed: ${response.status} ${url}`); + } + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + check(size <= 512 * 1024 * 1024, 'Released fixture exceeds download bounds'); + chunks.push(Buffer.from(chunk)); + } + fs.writeFileSync(file, Buffer.concat(chunks, size), { flag: 'wx' }); +} + +async function candidateCore(file, provenance = {}) { + if (typeof file !== 'string' || !path.isAbsolute(file) || !fs.existsSync(file)) { + throw new InfrastructureError(`Original candidate Core VSIX is missing: ${file}`); + } + let manifest; + await readArchive(file, (entry, data) => { + if (entry.name === 'extension/package.json') manifest = JSON.parse(data.toString('utf8')); + }); + check(manifest?.publisher === 'ms-python' && manifest?.name === 'python', 'Candidate artifact is not Python Core'); + check(typeof manifest.version === 'string' && manifest.version.length > 0, 'Candidate Core version is missing'); + const artifact = { + path: file, + version: manifest.version, + sha256: await hashFile(file), + provenance: { + ...provenance, + repository: 'microsoft/vscode-python', + channel: 'same-run-ci-built-core-candidate', + }, + }; + await inspectVsix(artifact, 'ms-python.python'); + return artifact; +} + +async function selfTest(output, python, candidate) { + const hasCandidate = candidate !== undefined; + if (!path.isAbsolute(output) || fs.existsSync(output)) { + throw new InfrastructureError('Self-test output must be a new absolute directory'); + } + fs.mkdirSync(output, { recursive: true }); + const reportFile = path.join(output, 'input-report.json'); + const report = { + schemaVersion: 1, + status: 'infrastructure-error', + scope: hasCandidate ? 'ci-built-candidate-core-compatibility' : 'released-package-harness-self-test', + }; + fs.writeFileSync(reportFile, JSON.stringify(report, null, 2)); + try { + const target = pins.platforms[process.platform]; + if (process.arch !== 'x64' || !target) { + throw new InfrastructureError('Released harness self-test requires Windows or Linux x64'); + } + const artifacts = {}; + if (hasCandidate) { + artifacts.core = await candidateCore(candidate, { + producerRepository: process.env.GITHUB_REPOSITORY ?? null, + sourceCommit: process.env.GITHUB_SHA ?? null, + headCommit: process.env.PYTHON_CANDIDATE_HEAD_SHA ?? null, + buildId: process.env.GITHUB_RUN_ID ?? null, + buildAttempt: process.env.GITHUB_RUN_ATTEMPT ?? null, + artifactName: process.env.PYTHON_CANDIDATE_ARTIFACT ?? null, + }); + report.candidate = artifacts.core; + } + for (const [role, name, version, expected] of [ + ['core', 'python', pins.coreVersion, target.coreSha256], + ['environments', 'vscode-python-envs', pins.environmentsVersion, target.environmentsSha256], + ]) { + if (hasCandidate && role === 'core') continue; + const url = `https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-python/vsextensions/${name}/${version}/vspackage?targetPlatform=${target.target}`; + const file = path.join(output, `${role}-${version}-${target.target}.vsix`); + await download(url, file); + check((await hashFile(file)) === expected, `Frozen ${role} released fixture SHA-256 differs`); + artifacts[role] = { + path: file, + version, + sha256: expected, + provenance: { channel: 'released-package-harness-self-test' }, + }; + } + artifacts.environments.pet = 'bundled'; + const plan = { + schemaVersion: 1, + candidate: 'core', + vscodeVersion: pins.vscodeVersion, + pythonExecutable: python, + ...artifacts, + }; + const planFile = path.join(output, 'plan.json'); + fs.writeFileSync(planFile, `${JSON.stringify(plan, null, 2)}\n`); + const result = await run(planFile, path.join(output, 'run')); + report.status = result.status; + report.plan = planFile; + report.runnerReport = path.join(output, 'run', 'report.json'); + report.certifiesOfficialCandidateOrSignedPublication = false; + } catch (error) { + report.status = error.status ?? 'infrastructure-error'; + report.error = errorRecord(error); + } + fs.writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`); + return report; +} + +if (require.main === module) { + selfTest(process.argv[2], process.argv[3], process.argv[4]) + .then((report) => { + console.log(JSON.stringify(report)); + process.exitCode = report.status === 'passed' ? 0 : report.status === 'failed' ? 1 : 2; + }) + .catch((error) => { + console.error(error); + process.exitCode = 2; + }); +} + +module.exports = { selfTest, candidateCore }; diff --git a/package.json b/package.json index 12be1534f50e..89246d804f73 100644 --- a/package.json +++ b/package.json @@ -1693,6 +1693,8 @@ "testMultiWorkspace": "node ./out/test/testBootstrap.js ./out/test/multiRootTest.js", "testPerformance": "node ./out/test/testBootstrap.js ./out/test/performanceTest.js", "testSmoke": "cross-env INSTALL_JUPYTER_EXTENSION=true \"node ./out/test/smokeTest.js\"", + "test:releaseCompatibility": "mocha --timeout 10000 build/releaseCompatibility/checks.test.js", + "package:releaseCompatibility": "node build/releaseCompatibility/pack.js", "testInsiders": "cross-env VSC_PYTHON_CI_TEST_VSC_CHANNEL=insiders INSTALL_PYLANCE_EXTENSION=true TEST_FILES_SUFFIX=insiders.test CODE_TESTS_WORKSPACE=src/testMultiRootWkspc/smokeTests \"node ./out/test/standardTest.js\"", "lint-staged": "node gulpfile.js", "lint": "eslint src build pythonExtensionApi",