diff --git a/.github/actions/authenticate-feeds/action.yml b/.github/actions/authenticate-feeds/action.yml new file mode 100644 index 00000000..b3ba3d1d --- /dev/null +++ b/.github/actions/authenticate-feeds/action.yml @@ -0,0 +1,18 @@ +name: 'Authenticate package feeds' +description: 'Authenticate to Pylance_PublicPackages using GitHub OIDC' + +runs: + using: 'composite' + steps: + - name: Azure OIDC login + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: 92c669e8-02ad-4ce6-ad73-f222fc7177e2 + tenant-id: 72f988bf-86f1-41af-91ab-2d7cd011db47 + allow-no-subscriptions: true + + - name: Configure authenticated npm and pip feeds + shell: bash + run: | + token="$(az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken --output tsv | tr -d '\r')" + NODE_AUTH_TOKEN="$token" node "$GITHUB_ACTION_PATH/../../../build/setup-github-feeds.js" diff --git a/.github/actions/build-vsix/action.yml b/.github/actions/build-vsix/action.yml index 775d04f1..f853f933 100644 --- a/.github/actions/build-vsix/action.yml +++ b/.github/actions/build-vsix/action.yml @@ -9,6 +9,9 @@ inputs: runs: using: 'composite' steps: + - name: Authenticate package feeds + uses: ./.github/actions/authenticate-feeds + - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: diff --git a/.github/actions/lint/action.yml b/.github/actions/lint/action.yml index 22a8ee0f..97234331 100644 --- a/.github/actions/lint/action.yml +++ b/.github/actions/lint/action.yml @@ -9,6 +9,9 @@ inputs: runs: using: 'composite' steps: + - name: Authenticate package feeds + uses: ./.github/actions/authenticate-feeds + - name: Install Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0effe3b0..3ff88e42 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -21,8 +21,7 @@ on: - cron: '15 16 * * 2' env: - NPM_CONFIG_REGISTRY: https://packagefeedproxy.microsoft.io/npm/ - PIP_INDEX_URL: https://packagefeedproxy.microsoft.io/pypi/simple/ + NPM_CONFIG_REGISTRY: https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/ jobs: analyze: @@ -32,6 +31,7 @@ jobs: actions: read contents: read security-events: write + id-token: write strategy: fail-fast: false @@ -46,6 +46,9 @@ jobs: with: persist-credentials: false + - name: Authenticate package feeds + uses: ./.github/actions/authenticate-feeds + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 diff --git a/.github/workflows/info-needed-closer.yml b/.github/workflows/info-needed-closer.yml index f1db26d7..3e919e52 100644 --- a/.github/workflows/info-needed-closer.yml +++ b/.github/workflows/info-needed-closer.yml @@ -9,10 +9,31 @@ on: permissions: issues: write +env: + NPM_CONFIG_REGISTRY: https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/ + jobs: main: runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + issues: write steps: + - name: Checkout feed authentication action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: ci-actions + persist-credentials: false + sparse-checkout: | + /.github/actions/authenticate-feeds/ + /build/setup-github-feeds.js + /build/package-feeds.json + sparse-checkout-cone-mode: false + + - name: Authenticate package feeds + uses: ./ci-actions/.github/actions/authenticate-feeds + - name: Checkout Actions uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/issue-labels.yml b/.github/workflows/issue-labels.yml index bd034bbc..90a9871e 100644 --- a/.github/workflows/issue-labels.yml +++ b/.github/workflows/issue-labels.yml @@ -5,6 +5,7 @@ on: types: [opened, reopened] env: + NPM_CONFIG_REGISTRY: https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/ TRIAGERS: '["karthiknadig","eleanorjboyd","anthonykim1"]' permissions: @@ -15,7 +16,25 @@ jobs: add-classify-label: name: "Add 'triage-needed' and remove assignees" runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + issues: write steps: + - name: Checkout feed authentication action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: ci-actions + persist-credentials: false + sparse-checkout: | + /.github/actions/authenticate-feeds/ + /build/setup-github-feeds.js + /build/package-feeds.json + sparse-checkout-cone-mode: false + + - name: Authenticate package feeds + uses: ./ci-actions/.github/actions/authenticate-feeds + - name: Checkout Actions uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 47812b0c..f881624e 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -6,7 +6,7 @@ on: permissions: {} env: - PIP_INDEX_URL: https://packagefeedproxy.microsoft.io/pypi/simple/ + NPM_CONFIG_REGISTRY: https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/ NODE_VERSION: 22.17.0 TEST_RESULTS_DIRECTORY: . # Force a path with spaces and unicode chars to test extension works in these scenarios @@ -17,6 +17,9 @@ jobs: build-vsix: name: Create VSIX runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -31,6 +34,9 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -45,6 +51,9 @@ jobs: tests: name: Tests runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write defaults: run: working-directory: ${{ env.special-working-directory }} @@ -61,6 +70,20 @@ jobs: path: ${{ env.special-working-directory-relative }} persist-credentials: false + - name: Checkout feed authentication action + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: ci-actions + persist-credentials: false + sparse-checkout: | + /.github/actions/authenticate-feeds/ + /build/setup-github-feeds.js + /build/package-feeds.json + sparse-checkout-cone-mode: false + + - name: Authenticate package feeds + uses: ./ci-actions/.github/actions/authenticate-feeds + # Install bundled libs using 3.10 (minimum supported; debugpy dropped 3.9 support, # and pip 26.1+ requires Python >= 3.10). - name: Use Python 3.10 diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index c958f625..4e14dd30 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -11,7 +11,7 @@ on: permissions: {} env: - PIP_INDEX_URL: https://packagefeedproxy.microsoft.io/pypi/simple/ + NPM_CONFIG_REGISTRY: https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/ NODE_VERSION: 22.17.0 TEST_RESULTS_DIRECTORY: . # Force a path with spaces and unicode chars to test extension works in these scenarios @@ -22,6 +22,9 @@ jobs: build-vsix: name: Create VSIX runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -36,6 +39,9 @@ jobs: lint: name: Lint runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -50,6 +56,9 @@ jobs: tests: name: Tests runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write defaults: run: working-directory: ${{ env.special-working-directory }} @@ -66,6 +75,9 @@ jobs: path: ${{ env.special-working-directory-relative }} persist-credentials: false + - name: Authenticate package feeds + uses: ./testDir/.github/actions/authenticate-feeds + # Install bundled libs using 3.10 (minimum supported; debugpy dropped 3.9 support, # and pip 26.1+ requires Python >= 3.10). - name: Use Python 3.10 diff --git a/README.md b/README.md index 75731974..4f776ffa 100644 --- a/README.md +++ b/README.md @@ -121,9 +121,9 @@ To normalize manually, run `npm run normalize-lockfile`, review the changes, and ### GitHub Actions package feeds -The PR and push workflows set `PIP_INDEX_URL` to `https://packagefeedproxy.microsoft.io/pypi/simple/` for pip and nox installs. Their Node installation steps select `https://packagefeedproxy.microsoft.io/npm/` explicitly through `npm run ci:msft`, so these workflows do not set `NPM_CONFIG_REGISTRY`. CodeQL retains both environment variables because its autobuild step does not use the install helper. The npm endpoint follows the [TypeScript CI package-feed-proxy job](https://github.com/microsoft/TypeScript/blob/main/.github/workflows/ci.yml). +Package-installing GitHub workflows authenticate to the DevDiv `Pylance_PublicPackages` npm and pip feeds using the shared `.github/actions/authenticate-feeds` action and Azure OIDC. Authenticating jobs require `contents: read` and `id-token: write`. Authentication runs before Python setup or package installation, exporting a masked, authenticated `PIP_INDEX_URL`, `PIP_NO_INPUT=1`, `NODE_AUTH_TOKEN`, and a temporary `NPM_CONFIG_USERCONFIG`. Workflows retain `NPM_CONFIG_REGISTRY` for npm requests; credentials are not written into repository files. -The shared lint and VSIX build actions and applicable PR/push test jobs use `npm run ci:msft`, rewriting working-copy lockfile URLs before installation and explicitly selecting the proxy for npm requests. Workflows do not commit these rewrites or change local registry configuration or Dependabot configuration. Existing dependency caches are retained; the VSIX build also retains its offline-cache preference. CodeQL retains workflow-level feed settings for its autobuild step. +The shared lint and VSIX build actions and applicable PR/push test jobs use `npm run ci:msft`, rewriting working-copy lockfile URLs before installation and explicitly selecting the feed for npm requests. Workflows do not commit these rewrites or change local registry configuration or Dependabot configuration. Existing dependency caches are retained; the VSIX build also retains its offline-cache preference. Test jobs continue bundling separately on each platform: debugpy's default wheel download selects a host-specific wheel, so the Ubuntu VSIX bundle cannot safely be shared with Windows tests. ### Azure release package feeds diff --git a/build/azure-devdiv-pipeline.pre-release.yml b/build/azure-devdiv-pipeline.pre-release.yml index 8ad15e48..df51f4af 100644 --- a/build/azure-devdiv-pipeline.pre-release.yml +++ b/build/azure-devdiv-pipeline.pre-release.yml @@ -34,9 +34,10 @@ variables: - name: VsixName value: python-debugger.vsix - name: NPM_CONFIG_REGISTRY - value: 'https://packagefeedproxy.microsoft.io/npm/' + value: 'https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/' - name: PIP_PROXY_INDEX_URL - value: 'https://packagefeedproxy.microsoft.io/pypi/simple/' + # PipAuthenticate supplies the internal index URL with credentials before buildSteps. + value: '$(PIP_INDEX_URL)' parameters: - name: publishExtension diff --git a/build/azure-devdiv-pipeline.stable.yml b/build/azure-devdiv-pipeline.stable.yml index feaccba7..c13fdfa9 100644 --- a/build/azure-devdiv-pipeline.stable.yml +++ b/build/azure-devdiv-pipeline.stable.yml @@ -29,9 +29,10 @@ variables: - name: VsixName value: python-debugger.vsix - name: NPM_CONFIG_REGISTRY - value: 'https://packagefeedproxy.microsoft.io/npm/' + value: 'https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/' - name: PIP_PROXY_INDEX_URL - value: 'https://packagefeedproxy.microsoft.io/pypi/simple/' + # PipAuthenticate supplies the internal index URL with credentials before buildSteps. + value: '$(PIP_INDEX_URL)' parameters: - name: publishExtension diff --git a/build/package-feeds.json b/build/package-feeds.json index c32a71a0..e6bde26c 100644 --- a/build/package-feeds.json +++ b/build/package-feeds.json @@ -4,7 +4,7 @@ "pip": "https://pypi.org/simple/" }, "microsoft": { - "npm": "https://packagefeedproxy.microsoft.io/npm/", - "pip": "https://packagefeedproxy.microsoft.io/pypi/simple/" + "npm": "https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/", + "pip": "https://pkgs.dev.azure.com/devdiv/DevDiv/_packaging/Pylance_PublicPackages/pypi/simple/" } } diff --git a/build/setup-github-feeds.js b/build/setup-github-feeds.js new file mode 100644 index 00000000..964da43e --- /dev/null +++ b/build/setup-github-feeds.js @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const { appendFileSync, writeFileSync } = require('node:fs'); +const path = require('node:path'); +const { URL } = require('node:url'); +const { microsoft } = require('./package-feeds.json'); + +try { + const { NODE_AUTH_TOKEN: token, RUNNER_TEMP: temp, GITHUB_ENV: environmentFile } = process.env; + if (!token || token === 'null' || /[\r\n]/.test(token)) { + throw new Error('A nonempty, single-line Azure DevOps token is required.'); + } + if (!temp || !environmentFile) { + throw new Error('RUNNER_TEMP and GITHUB_ENV are required.'); + } + + console.log(`::add-mask::${token}`); + const pipIndex = new URL(microsoft.pip); + pipIndex.username = 'build'; + pipIndex.password = token; + console.log(`::add-mask::${pipIndex.href}`); + + const registry = new URL(microsoft.npm); + const registryScope = `//${registry.host}${registry.pathname}`; + const feedScope = registryScope.replace(/registry\/$/, ''); + const npmrc = path.join(temp, 'python-debugger-feeds.npmrc'); + writeFileSync( + npmrc, + [ + `registry=${registry.href}`, + `${registryScope}:_authToken=\${NODE_AUTH_TOKEN}`, + `${feedScope}:_authToken=\${NODE_AUTH_TOKEN}`, + '', + ].join('\n'), + 'utf8', + ); + appendFileSync( + environmentFile, + [ + `NODE_AUTH_TOKEN=${token}`, + `NPM_CONFIG_USERCONFIG=${npmrc}`, + `PIP_INDEX_URL=${pipIndex.href}`, + 'PIP_NO_INPUT=1', + '', + ].join('\n'), + 'utf8', + ); +} catch (error) { + console.error(`[setup] GitHub feed authentication failed: ${error.message}`); + process.exitCode = 1; +} diff --git a/build/setup-npm-registry.js b/build/setup-npm-registry.js index 8f084651..f902c595 100644 --- a/build/setup-npm-registry.js +++ b/build/setup-npm-registry.js @@ -6,7 +6,11 @@ const { readFileSync, writeFileSync } = require('node:fs'); const path = require('node:path'); const { microsoft } = require('./package-feeds.json'); -function rewritePackageLock(source) { +function normalizeRegistry(registry) { + return registry.endsWith('/') ? registry : `${registry}/`; +} + +function rewritePackageLock(source, registry = microsoft.npm) { const lockfile = JSON.parse(source); if ( ![2, 3].includes(lockfile.lockfileVersion) || @@ -17,18 +21,19 @@ function rewritePackageLock(source) { throw new Error('Expected an npm lockfile with lockfileVersion 2 or 3 and a packages section.'); } + const normalizedRegistry = normalizeRegistry(registry); return source.replace(/("resolved"\s*:\s*)("(?:\\.|[^"\\])*")/g, (match, prefix, encodedUrl) => { const url = JSON.parse(encodedUrl); - const rewritten = url.replace(/^https:\/\/registry\.(?:npmjs\.org|yarnpkg\.com)\//, microsoft.npm); + const rewritten = url.replace(/^https:\/\/registry\.(?:npmjs\.org|yarnpkg\.com)\//, normalizedRegistry); return rewritten === url ? match : prefix + JSON.stringify(rewritten); }); } -function rewritePackageLocks(root = path.resolve(__dirname, '..')) { +function rewritePackageLocks(root = path.resolve(__dirname, '..'), registry = microsoft.npm) { const files = [path.join(root, 'package-lock.json')]; const updates = files.map((file) => { const source = readFileSync(file, 'utf8'); - return { file, source, rewritten: rewritePackageLock(source) }; + return { file, source, rewritten: rewritePackageLock(source, registry) }; }); for (const { file, source, rewritten } of updates) { if (rewritten !== source) { @@ -48,13 +53,15 @@ if (require.main === module) { if (install && !process.env.npm_execpath) { throw new Error('Run npm install using npm run ci:msft.'); } - rewritePackageLocks(); + const registry = normalizeRegistry( + process.env.npm_config_registry || process.env.NPM_CONFIG_REGISTRY || microsoft.npm, + ); + rewritePackageLocks(undefined, registry); if (install) { - const result = spawnSync( - process.execPath, - [process.env.npm_execpath, 'ci', `--registry=${microsoft.npm}`], - { cwd: path.resolve(__dirname, '..'), stdio: 'inherit' }, - ); + const result = spawnSync(process.execPath, [process.env.npm_execpath, 'ci', `--registry=${registry}`], { + cwd: path.resolve(__dirname, '..'), + stdio: 'inherit', + }); if (result.error || result.signal || result.status !== 0) { const reason = result.error?.message || result.signal || `exit code ${result.status}`; const error = new Error(`npm ci failed: ${reason}`); diff --git a/build/setup-npm-registry.test.js b/build/setup-npm-registry.test.js index 3d7eae5c..ed928415 100644 --- a/build/setup-npm-registry.test.js +++ b/build/setup-npm-registry.test.js @@ -2,16 +2,18 @@ // Licensed under the MIT License. const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); const { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs'); const { tmpdir } = require('node:os'); const path = require('node:path'); const { test } = require('node:test'); +const { URL } = require('node:url'); const vm = require('node:vm'); const { rewritePackageLock, rewritePackageLocks } = require('./setup-npm-registry'); const { normalizePackageLock } = require('./normalize-package-lock'); const publicRegistry = 'https://registry.npmjs.org/'; -const proxyRegistry = 'https://packagefeedproxy.microsoft.io/npm/'; +const microsoftRegistry = 'https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/'; const source = `${JSON.stringify( { lockfileVersion: 3, @@ -34,20 +36,27 @@ const source = `${JSON.stringify( test('rewrites public resolved URLs without changing versions, integrity or formatting', () => { const expected = source - .replaceAll(publicRegistry, proxyRegistry) - .replace('https://registry.yarnpkg.com/', proxyRegistry); + .replaceAll(publicRegistry, microsoftRegistry) + .replace('https://registry.yarnpkg.com/', microsoftRegistry); assert.equal(rewritePackageLock(source), expected); assert.equal(rewritePackageLock(expected), expected); assert.equal(normalizePackageLock(expected), source.replace('https://registry.yarnpkg.com/', publicRegistry)); }); +test('normalizes slashless registry overrides before rewriting resolved URLs', () => { + for (const registry of [publicRegistry, microsoftRegistry]) { + const expected = source.replaceAll(publicRegistry, registry).replace('https://registry.yarnpkg.com/', registry); + assert.equal(rewritePackageLock(source, registry.slice(0, -1)), expected); + } +}); + test('supports version 2 legacy dependencies as well as packages', () => { const legacy = JSON.stringify({ lockfileVersion: 2, packages: {}, dependencies: { example: { resolved: `${publicRegistry}example/-/example.tgz` } }, }); - assert.equal(rewritePackageLock(legacy), legacy.replace(publicRegistry, proxyRegistry)); + assert.equal(rewritePackageLock(legacy), legacy.replace(publicRegistry, microsoftRegistry)); }); test('preserves other fields, private feeds, Git dependencies and lookalike hosts', () => { @@ -100,12 +109,12 @@ test('missing or invalid root lockfile fails explicitly', (t) => { assert.throws(() => rewritePackageLocks(root), /Expected an npm lockfile/); }); -function runCli({ args = ['--ci'], npmCli = 'npm-cli.js', result = { status: 0 } } = {}) { +function runCli({ args = ['--ci'], npmCli = 'npm-cli.js', result = { status: 0 }, env = {} } = {}) { const calls = []; const errors = []; const process = { argv: ['node', 'setup-npm-registry.js', ...args], - env: { npm_execpath: npmCli }, + env: { npm_execpath: npmCli, ...env }, execPath: 'node', }; const module = { exports: {} }; @@ -113,7 +122,7 @@ function runCli({ args = ['--ci'], npmCli = 'npm-cli.js', result = { status: 0 } if (name === 'node:fs') { return { readFileSync: () => source, - writeFileSync: (file) => calls.push({ file }), + writeFileSync: (file, source) => calls.push({ file, source }), }; } if (name === 'node:child_process') { @@ -137,12 +146,12 @@ function runCli({ args = ['--ci'], npmCli = 'npm-cli.js', result = { status: 0 } return { calls, errors, process }; } -test('ci:msft rewrites the root lockfile before installing with the shared proxy registry', () => { +test('ci:msft rewrites the root lockfile before installing with the Microsoft registry', () => { const context = runCli(); assert.equal(context.calls.length, 2); assert.ok(context.calls[0].file.endsWith('package-lock.json')); assert.equal(context.calls[1].command, 'node'); - assert.deepEqual(context.calls[1].args, ['npm-cli.js', 'ci', `--registry=${proxyRegistry}`]); + assert.deepEqual(context.calls[1].args, ['npm-cli.js', 'ci', `--registry=${microsoftRegistry}`]); assert.equal(context.calls[1].cwd, path.resolve(__dirname, '..')); assert.equal(context.calls[1].stdio, 'inherit'); assert.equal(context.errors.length, 0); @@ -150,6 +159,33 @@ test('ci:msft rewrites the root lockfile before installing with the shared proxy assert.equal(scripts['ci:msft'], 'node ./build/setup-npm-registry.js --ci'); }); +test('ci:msft preserves template authentication for the configured internal registry', () => { + const registry = 'https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/'; + for (const settings of [ + { NPM_CONFIG_REGISTRY: registry }, + { npm_config_registry: registry }, + { NPM_CONFIG_REGISTRY: registry.slice(0, -1) }, + { npm_config_registry: registry.slice(0, -1) }, + { NPM_CONFIG_REGISTRY: 'https://stale.example/npm/', npm_config_registry: registry }, + ]) { + const env = { + ...settings, + NPM_CONFIG_USERCONFIG: path.join(tmpdir(), 'authenticated.npmrc'), + NODE_AUTH_TOKEN: 'test-token', + }; + const context = runCli({ env }); + const install = context.calls.at(-1); + assert.deepEqual(install.args, ['npm-cli.js', 'ci', `--registry=${registry}`]); + assert.equal(install.env, undefined); + assert.equal(context.process.env.NPM_CONFIG_USERCONFIG, env.NPM_CONFIG_USERCONFIG); + assert.equal(context.process.env.NODE_AUTH_TOKEN, env.NODE_AUTH_TOKEN); + for (const write of context.calls.slice(0, -1)) { + assert.equal(write.source, rewritePackageLock(source, registry)); + } + assert.equal(context.errors.length, 0); + } +}); + for (const result of [{ status: 7 }, { status: null, signal: 'SIGTERM' }, { error: new Error('not found') }]) { test(`ci:msft reports npm failure: ${result.signal || result.error?.message || result.status}`, () => { const context = runCli({ result }); @@ -173,3 +209,55 @@ test('standalone registry rewrite does not install dependencies or require npm', assert.ok(context.calls.every((call) => call.file)); assert.equal(context.errors.length, 0); }); + +function configureGitHubFeeds(t, token) { + const root = mkdtempSync(path.join(__dirname, '..', '.feed-auth-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const environmentFile = path.join(root, 'github-env'); + writeFileSync(environmentFile, 'EXISTING=value\n'); + const result = spawnSync(process.execPath, [path.join(__dirname, 'setup-github-feeds.js')], { + encoding: 'utf8', + env: { ...process.env, RUNNER_TEMP: root, GITHUB_ENV: environmentFile, NODE_AUTH_TOKEN: token }, + }); + return { root, environmentFile, result }; +} + +test('GitHub feed authentication configures both feeds without storing npm credentials in npmrc', (t) => { + const token = 'test-token.+/='; + const { root, environmentFile, result } = configureGitHubFeeds(t, token); + assert.equal(result.status, 0, result.stderr); + const npmrc = path.join(root, 'python-debugger-feeds.npmrc'); + const config = readFileSync(npmrc, 'utf8'); + assert.ok(config.includes(`registry=${microsoftRegistry}\n`)); + assert.ok(config.includes('/npm/registry/:_authToken=${NODE_AUTH_TOKEN}\n')); + assert.ok(config.includes('/npm/:_authToken=${NODE_AUTH_TOKEN}\n')); + assert.ok(!config.includes(token)); + const environment = readFileSync(environmentFile, 'utf8'); + assert.ok(environment.startsWith('EXISTING=value\n')); + assert.ok(environment.includes(`NPM_CONFIG_USERCONFIG=${npmrc}\n`)); + assert.ok(environment.includes(`NODE_AUTH_TOKEN=${token}\n`)); + assert.ok(environment.includes('PIP_NO_INPUT=1\n')); + const index = new URL( + environment + .split('\n') + .find((line) => line.startsWith('PIP_INDEX_URL=')) + .slice(14), + ); + assert.equal(index.hostname, 'pkgs.dev.azure.com'); + assert.equal(index.pathname, '/devdiv/DevDiv/_packaging/Pylance_PublicPackages/pypi/simple/'); + assert.equal(index.username, 'build'); + assert.equal(decodeURIComponent(index.password), token); + assert.ok(result.stdout.includes(`::add-mask::${token}\n`)); + assert.ok(result.stdout.includes(`::add-mask::${index.href}\n`)); +}); + +test('GitHub feed authentication rejects missing or multiline tokens without modifying configuration', (t) => { + for (const token of ['', 'null', 'token\nINJECTED=value']) { + const { root, environmentFile, result } = configureGitHubFeeds(t, token); + assert.equal(result.status, 1); + assert.match(result.stderr, /single-line Azure DevOps token is required/); + assert.equal(readFileSync(environmentFile, 'utf8'), 'EXISTING=value\n'); + assert.throws(() => readFileSync(path.join(root, 'python-debugger-feeds.npmrc')), /ENOENT/); + assert.ok(!result.stderr.includes('INJECTED')); + } +}); diff --git a/build/setup.test.js b/build/setup.test.js index e75ac2f6..bf003f21 100644 --- a/build/setup.test.js +++ b/build/setup.test.js @@ -90,11 +90,15 @@ for (const platform of ['win32', 'linux']) { for (const call of calls) { assert.equal( call.env.npm_config_registry, - microsoft ? 'https://packagefeedproxy.microsoft.io/npm/' : 'https://registry.npmjs.org/', + microsoft + ? 'https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/' + : 'https://registry.npmjs.org/', ); assert.equal( call.env.PIP_INDEX_URL, - microsoft ? 'https://packagefeedproxy.microsoft.io/pypi/simple/' : 'https://pypi.org/simple/', + microsoft + ? 'https://pkgs.dev.azure.com/devdiv/DevDiv/_packaging/Pylance_PublicPackages/pypi/simple/' + : 'https://pypi.org/simple/', ); assert.equal(call.env.NPM_CONFIG_REGISTRY, undefined); assert.equal(call.env.HTTPS_PROXY, context.originalEnv.HTTPS_PROXY); @@ -178,18 +182,26 @@ test('Microsoft entry point selects the shared Microsoft setup implementation', assert.equal(scripts['setup:msft'], 'node ./build/setup-msft.js'); }); -test('PR and push installs use the npm helper without a redundant registry environment setting', () => { +test('CI installs use authenticated feeds and retain the npm registry for the install helper', () => { const root = path.join(__dirname, '..'); + const { microsoft } = require('./package-feeds.json'); for (const file of ['pr-check.yml', 'push-check.yml']) { const workflow = readFileSync(path.join(root, '.github', 'workflows', file), 'utf8'); - assert.doesNotMatch(workflow, /^\s*NPM_CONFIG_REGISTRY:/m); - assert.match(workflow, /^\s*PIP_INDEX_URL: https:\/\/packagefeedproxy\.microsoft\.io\/pypi\/simple\//m); + assert.ok(workflow.split(/\r?\n/).includes(` NPM_CONFIG_REGISTRY: ${microsoft.npm}`)); + assert.doesNotMatch(workflow, /^\s*PIP_INDEX_URL:/m); + assert.match(workflow, /uses: \.\/(?:ci-actions|testDir)\/\.github\/actions\/authenticate-feeds/); assert.doesNotMatch(workflow, /^\s*run: npm ci\b/m); } for (const action of ['lint', 'build-vsix']) { const workflow = readFileSync(path.join(root, '.github', 'actions', action, 'action.yml'), 'utf8'); assert.match(workflow, /^\s*run: npm run ci:msft\s*$/m); + assert.match( + workflow, + /steps:\s+- name: Authenticate package feeds\s+uses: \.\/\.github\/actions\/authenticate-feeds/, + ); } const codeql = readFileSync(path.join(root, '.github', 'workflows', 'codeql-analysis.yml'), 'utf8'); - assert.match(codeql, /^\s*NPM_CONFIG_REGISTRY: https:\/\/packagefeedproxy\.microsoft\.io\/npm\//m); + assert.ok(codeql.split(/\r?\n/).includes(` NPM_CONFIG_REGISTRY: ${microsoft.npm}`)); + assert.doesNotMatch(codeql, /^\s*PIP_INDEX_URL:/m); + assert.match(codeql, /uses: \.\/\.github\/actions\/authenticate-feeds/); });