From 7bcee1f0db1ed17682ed357e98da5bae46ab635e Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 30 May 2026 14:40:57 -0400 Subject: [PATCH] [release/13.4] Add Aspire CLI npm package release integration Backport of #17297 to release/13.4. Conflicts resolved in extension/Extension.proj, extension/build.ps1, and extension/build.sh by taking the PR's Corepack-based build flow, since the PR also adds the Corepack bootstrap infrastructure (install-corepack.yml, prepareCorepackYarn.mjs, corepack-version.txt) that release/13.4 lacked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/build-cli-native-archives.yml | 32 +- .github/workflows/tests.yml | 117 +- .gitignore | 1 + docs/release-process.md | 444 ++++--- docs/specs/npm-cli-package.md | 205 ++++ eng/Publishing.props | 31 + eng/Signing.props | 10 + eng/clipack/Common.projitems | 21 +- eng/clipack/npm/aspire.js | 252 ++++ eng/pipelines/azure-pipelines-codeql.yml | 14 +- eng/pipelines/azure-pipelines-unofficial.yml | 19 +- eng/pipelines/azure-pipelines.yml | 127 +- eng/pipelines/common-variables.yml | 9 + eng/pipelines/release-publish-nuget.yml | 1063 ++++++++++++++++- eng/pipelines/templates/BuildAndTest.yml | 64 + eng/pipelines/templates/build_sign_native.yml | 20 + eng/pipelines/templates/install-corepack.yml | 55 + .../templates/prepare-npm-cli-packages.yml | 341 ++++++ .../templates/public-pipeline-template.yml | 9 + eng/scripts/pack-cli-npm-package.ps1 | 239 ++++ .../stage-native-cli-tool-packages.ps1 | 183 ++- eng/scripts/verify-cli-npm-package.ps1 | 222 ++++ extension/CONTRIBUTING.MD | 40 +- extension/Extension.proj | 57 +- extension/build.ps1 | 117 +- extension/build.sh | 99 +- extension/package.json | 1 + extension/scripts/corepack-version.txt | 1 + extension/scripts/prepareCorepackYarn.mjs | 215 ++++ src/Aspire.Cli/Commands/UpdateCommand.cs | 40 +- .../UpdateCommandStrings.Designer.cs | 1 + .../Resources/UpdateCommandStrings.resx | 3 + .../Resources/xlf/UpdateCommandStrings.cs.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.de.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.es.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.fr.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.it.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.ja.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.ko.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.pl.xlf | 5 + .../xlf/UpdateCommandStrings.pt-BR.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.ru.xlf | 5 + .../Resources/xlf/UpdateCommandStrings.tr.xlf | 5 + .../xlf/UpdateCommandStrings.zh-Hans.xlf | 5 + .../xlf/UpdateCommandStrings.zh-Hant.xlf | 5 + src/Aspire.Cli/Utils/CliUpdateNotifier.cs | 6 +- src/Aspire.Cli/Utils/NpmInstallDetection.cs | 109 ++ .../Commands/UpdateCommandTests.cs | 120 ++ .../CliUpdateNotificationServiceTests.cs | 52 + .../Utils/NpmInstallDetectionTests.cs | 78 ++ .../Pipelines/NpmCliPackageTests.cs | 122 ++ .../ReleasePublishNugetPipelineTests.cs | 387 ++++++ .../StageNativeCliToolPackagesTests.cs | 55 +- 53 files changed, 4679 insertions(+), 367 deletions(-) create mode 100644 docs/specs/npm-cli-package.md create mode 100644 eng/clipack/npm/aspire.js create mode 100644 eng/pipelines/templates/install-corepack.yml create mode 100644 eng/pipelines/templates/prepare-npm-cli-packages.yml create mode 100644 eng/scripts/pack-cli-npm-package.ps1 create mode 100644 eng/scripts/verify-cli-npm-package.ps1 create mode 100644 extension/scripts/corepack-version.txt create mode 100644 extension/scripts/prepareCorepackYarn.mjs create mode 100644 src/Aspire.Cli/Utils/NpmInstallDetection.cs create mode 100644 tests/Aspire.Cli.Tests/Utils/NpmInstallDetectionTests.cs create mode 100644 tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs create mode 100644 tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs diff --git a/.github/workflows/build-cli-native-archives.yml b/.github/workflows/build-cli-native-archives.yml index 22dde6ad906..e44973766e2 100644 --- a/.github/workflows/build-cli-native-archives.yml +++ b/.github/workflows/build-cli-native-archives.yml @@ -53,6 +53,16 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # Node.js is required because PackDotnetTool now depends on PackNpmPackage, + # which shells out to `npm pack` to produce the @microsoft/aspire-cli npm + # tarballs alongside the existing dotnet tool packages. GitHub-hosted + # runners ship Node by default, but installing it explicitly pins the + # version and avoids relying on the runner image. + - name: Install node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22.x + # Build RID-specific NuGet packages (DCP + Dashboard) locally instead of # downloading them from the build_packages job. This allows the CLI archive # build to run in parallel with build_packages. @@ -121,8 +131,25 @@ jobs: -Rid $rid ` -ArchivePath $archive[0].FullName - # Upload DCP, Dashboard, and CLI tool NuGets so test/polyglot jobs can download them - - name: Upload RID-specific NuGets + - name: Verify CLI npm package + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $rid = '${{ matrix.targets.rids }}' + $archiveExtension = if ($rid.StartsWith('win-')) { 'zip' } else { 'tar.gz' } + $archive = @(Get-ChildItem -Path 'artifacts/packages/${{ inputs.configuration }}' -Filter "aspire-cli-$rid-*.$archiveExtension" -Recurse -File -ErrorAction SilentlyContinue) + if ($archive.Count -ne 1) { + throw "Expected exactly one CLI archive for $rid, but found $($archive.Count): $($archive.FullName -join ', ')" + } + + eng/scripts/verify-cli-npm-package.ps1 ` + -PackagesDir artifacts/packages/${{ inputs.configuration }} ` + -Rid $rid ` + -ArchivePath $archive[0].FullName + + # Upload DCP, Dashboard, and CLI tool packages so test/polyglot jobs can download them + - name: Upload RID-specific packages uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: built-nugets-for-${{ matrix.targets.rids }} @@ -130,6 +157,7 @@ jobs: artifacts/packages/${{ inputs.configuration }}/Shipping/Aspire.Hosting.Orchestration.${{ matrix.targets.rids }}.*.nupkg artifacts/packages/${{ inputs.configuration }}/Shipping/Aspire.Dashboard.Sdk.${{ matrix.targets.rids }}.*.nupkg artifacts/packages/${{ inputs.configuration }}/Shipping/Aspire.Cli.*.nupkg + artifacts/packages/${{ inputs.configuration }}/Shipping/microsoft-aspire-cli*.tgz if-no-files-found: error retention-days: 15 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0e07409471b..6aacbfb4001 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -312,6 +312,9 @@ jobs: extension_tests_win: name: Run VS Code extension tests (Windows) runs-on: windows-latest + env: + NPM_REGISTRY: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 defaults: run: working-directory: ./extension @@ -322,23 +325,126 @@ jobs: uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: '20.x' + - name: Install Corepack + run: | + # Scope Corepack's cache to this job so prepareCorepackYarn.mjs cannot + # collide with any other build sharing the runner's user profile. + # Cannot be set at job-level env: the `runner` context is unavailable + # in job env evaluation, so we forward it through $GITHUB_ENV instead. + $CorepackHome = Join-Path $env:RUNNER_TEMP 'corepack' + $env:COREPACK_HOME = $CorepackHome + "COREPACK_HOME=$CorepackHome" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + $CorepackVersion = (Get-Content -Raw -Path 'scripts/corepack-version.txt').Trim() + # The hosted Windows image already has a yarn shim in npm's global + # prefix. The npm Corepack package owns that shim too, so force only + # in CI where the tool install is isolated to this ephemeral job. + npm install --global --force --registry "$env:NPM_REGISTRY" "corepack@$CorepackVersion" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $npmGlobalBin = (npm prefix --global).Trim() + $env:PATH = "$npmGlobalBin$([IO.Path]::PathSeparator)$env:PATH" + $npmGlobalBin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + $installed = (corepack --version).Trim() + if ($installed -ne $CorepackVersion) { + Write-Error "corepack version mismatch: expected $CorepackVersion, got '$installed'. The bundled Corepack on PATH may be taking precedence over the npm-global install." + exit 1 + } + + corepack enable + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # Seed Corepack's cache from the internal dnceng npm feed. Corepack's + # built-in `corepack prepare --activate` would download Yarn 1.x from + # registry.yarnpkg.com (hardcoded in Corepack 0.34's config.json and + # not redirectable via COREPACK_NPM_REGISTRY), bypassing the dnceng + # mirror this workflow is supposed to validate. The shared + # prepareCorepackYarn.mjs script does the equivalent via `npm pack` + # against $NPM_REGISTRY, then drops the same on-disk layout Corepack + # would have written. + node ./scripts/prepareCorepackYarn.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + corepack yarn --version - name: Validate lockfile registries - run: node -e "const fs = require('fs'); const lock = fs.readFileSync('yarn.lock', 'utf8'); if (/registry\\.(?:npmjs\\.org|yarnpkg\\.com)/.test(lock)) { throw new Error('extension/yarn.lock contains public npm registry URLs. Regenerate it using the internal dotnet-public-npm feed before restoring.'); }" + # Allowlist scoped to lines starting with "resolved" (the only lines in + # yarn.lock that carry a tarball URL — `npm:` aliases don't). + # CONTRIBUTING.MD asks contributors to regenerate yarn.lock through the + # internal dotnet-public-npm feed; an allowlist catches drift to any + # other public mirror (npmmirror.com, jsr.io, github.com tarballs, ...) + # rather than only npmjs.org and yarnpkg.com. + run: | + node -e "const fs = require('fs'); const allow = 'pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm'; const bad = fs.readFileSync('yarn.lock', 'utf8').split(/\r?\n/).filter(l => /^\s*resolved\s+\x22/.test(l)).filter(l => !l.includes(allow)); if (bad.length) { throw new Error('extension/yarn.lock contains resolved entries outside the internal dotnet-public-npm feed. Regenerate it through the internal feed before restoring. First offender -> ' + bad[0]); }" - name: Install dependencies - run: yarn install --frozen-lockfile --non-interactive + run: corepack yarn install --frozen-lockfile --non-interactive - name: Run tests - run: yarn test + run: corepack yarn test - name: Override extension version for PR builds if: ${{ inputs.extensionVersionOverride != '' }} - run: yarn version --new-version "${{ inputs.extensionVersionOverride }}" --no-git-tag-version + run: corepack yarn version --new-version "${{ inputs.extensionVersionOverride }}" --no-git-tag-version - name: Package VSIX - run: yarn run vsce package --pre-release -o out/aspire-extension.vsix + run: corepack yarn run vsce package --pre-release -o out/aspire-extension.vsix - name: Upload VSIX uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: aspire-extension path: extension/out/aspire-extension.vsix + extension_bootstrap_linux: + name: Validate VS Code extension bootstrap (Linux) + # Without this, the non-Windows code paths in + # extension/scripts/prepareCorepackYarn.mjs (npm invocation that does not go + # through node.exe, POSIX tar) are only exercised on contributor machines + # and never on a fresh CI image. extension_tests_win covers the Windows + # paths; this job covers Linux/macOS. macOS is omitted because the only + # platform-specific branch beyond Linux is the cache path, which is + # explicitly overridden via COREPACK_HOME in the build entrypoints. + runs-on: ubuntu-latest + env: + NPM_REGISTRY: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + defaults: + run: + working-directory: ./extension + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Node.js environment + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: '20.x' + - name: Install Corepack + run: | + set -euo pipefail + # Scope Corepack's cache to this job. Set via $GITHUB_ENV because the + # `runner` context is not available in job-level env evaluation. + export COREPACK_HOME="$RUNNER_TEMP/corepack" + echo "COREPACK_HOME=$COREPACK_HOME" >> "$GITHUB_ENV" + + CorepackVersion="$(tr -d '[:space:]' < scripts/corepack-version.txt)" + npm install --global --force --registry "$NPM_REGISTRY" "corepack@${CorepackVersion}" + + installed="$(corepack --version 2>/dev/null || true)" + if [ "$installed" != "$CorepackVersion" ]; then + echo "corepack version mismatch: expected $CorepackVersion, got '$installed'. The bundled Corepack on PATH may be taking precedence over the npm-global install." + exit 1 + fi + + corepack enable + - name: Seed Corepack Yarn cache via prepareCorepackYarn.mjs + # Exercises the script's non-Windows branches against a clean cache + # directory. Failing here means a contributor on Linux/macOS following + # CONTRIBUTING.MD would also be broken. + run: node ./scripts/prepareCorepackYarn.mjs + - name: Validate lockfile registries + # Mirror of the same allowlist guard in extension_tests_win so this + # cross-platform bootstrap also catches drift to a public-registry URL. + run: | + node -e "const fs = require('fs'); const allow = 'pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm'; const bad = fs.readFileSync('yarn.lock', 'utf8').split(/\r?\n/).filter(l => /^\s*resolved\s+\x22/.test(l)).filter(l => !l.includes(allow)); if (bad.length) { throw new Error('extension/yarn.lock contains resolved entries outside the internal dotnet-public-npm feed. Regenerate it through the internal feed before restoring. First offender -> ' + bad[0]); }" + - name: Validate seeded cache via yarn install + run: corepack yarn install --frozen-lockfile --non-interactive + typescript_sdk_tests: name: TypeScript SDK Unit Tests uses: ./.github/workflows/typescript-sdk-tests.yml @@ -364,6 +470,7 @@ jobs: prepare_homebrew_installer_artifacts, build_cli_e2e_image, extension_tests_win, + extension_bootstrap_linux, cli_starter_validation_windows, typescript_sdk_tests, typescript_api_compat, diff --git a/.gitignore b/.gitignore index 60371d10272..edfd6e9b891 100644 --- a/.gitignore +++ b/.gitignore @@ -197,6 +197,7 @@ extension/dist/ extension/.localization/ extension/out/ extension/node_modules/ +extension/.corepack-cache/ **/.vscode-test/ extension/.version diff --git a/docs/release-process.md b/docs/release-process.md index a3c8cb00926..8bba0913b6b 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -4,25 +4,24 @@ This document describes the release process for microsoft/aspire, including both ## Overview -The Aspire release process involves two main automation components: - -1. **Azure DevOps Pipeline** ([`release-publish-nuget`](https://dev.azure.com/dnceng/internal/_build?definitionId=1600&_a=summary), source: `eng/pipelines/release-publish-nuget.yml`) - - Publishes NuGet packages to NuGet.org - - Promotes the build to the GA channel via darc - - Submits WinGet manifest PRs - - Validates the Homebrew cask against the live GitHub release - (cask version bumps themselves are submitted by upstream autobump — - see [Installer channels](#installer-channels)) - - Dispatches the GitHub Actions workflow below as the `aspire-repo-bot` - GitHub App and waits for it to complete - -2. **GitHub Actions Workflow** (`.github/workflows/release-github-tasks.yml`) - - Creates Git tags - - Creates GitHub Releases - - Creates merge-back PRs - - Creates baseline version update PRs - - Normally dispatched automatically by the AzDO pipeline above; can also - be run manually as a fallback for partial-failure re-runs +The Aspire release process uses two main automation components: + +1. **Azure DevOps pipeline** ([`release-publish-nuget`](https://dev.azure.com/dnceng/internal/_build?definitionId=1600&_a=summary), source: `eng/pipelines/release-publish-nuget.yml`) + - Downloads signed artifacts from a selected official source build. + - Re-publishes NuGet, npm, and WinGet release inputs so 1ES can generate release SBOMs. + - Publishes NuGet packages to NuGet.org. + - Publishes Aspire CLI npm packages through ESRP/MicroBuild. + - Promotes the build to the GA channel via darc. + - Submits WinGet manifest PRs. + - Validates the Homebrew cask against the live GitHub release (cask version bumps themselves are submitted by upstream autobump; see [Installer channels](#installer-channels)). + - Dispatches the GitHub Actions workflow below as the `aspire-repo-bot` GitHub App and waits for it to complete. + - Uploads `aspire-cli-*` archives from the source build's `BlobArtifacts` onto the GitHub Release as the `aspire-repo-bot`. +2. **GitHub Actions workflow** (`.github/workflows/release-github-tasks.yml`) + - Creates Git tags. + - Creates GitHub Releases. + - Creates merge-back PRs. + - Creates baseline version update PRs. + - Normally dispatched automatically by the AzDO pipeline above; it can also be run manually as a fallback for partial-failure re-runs. ## Installer channels @@ -42,301 +41,266 @@ The CLI identifies which channel installed it via a per-install sidecar so that Before starting a release: -1. **Signed Build**: Have a successful signed build from the official [`microsoft-aspire`](https://dev.azure.com/dnceng/internal/_build?definitionId=1602) pipeline - - The build will be selected from a dropdown when running the release pipeline - - The build should have a `BAR ID - NNNNNN` tag (auto-extracted by the pipeline) +1. **Signed build**: Have a successful signed build from the official [`microsoft-aspire`](https://dev.azure.com/dnceng/internal/_build?definitionId=1602) pipeline. + - Select this build from the `aspire-build` resource dropdown when running the release pipeline. + - The build should have a `BAR ID - NNNNNN` tag, which the pipeline extracts automatically. + - The build should also have a `release-version - X.Y.Z` tag, which the pipeline uses when `ReleaseVersion` is left as `auto`. + - The build must include native CLI NuGet packages, `microsoft-aspire-cli*.tgz` npm tarballs from the native archive jobs, matching `.tgz.sig` detached signature sidecars, and the Windows, Linux, and macOS npm install validation summaries. +2. **Release branch**: Ensure the release branch exists, for example `release/9.2`. +3. **Permissions and approvals**: + - Access to run Azure DevOps pipelines with the publishing pool. + - Permission to use the NuGet.org service connection. + - Approval to use the DevDiv ESRP service connection for MicroBuild npm publishing. + - Valid ESRP owner and approver aliases for npm publishing. + - GitHub write access for creating tags, releases, and PRs if you need to run the GitHub workflow manually. +4. **AzDO secrets** (already configured for chained dispatch): + - `aspire-bot-app-id` — `aspire-repo-bot` GitHub App ID. + - `aspire-bot-private-key` — `aspire-repo-bot` GitHub App PEM private key. -2. **Release Branch**: Ensure the release branch exists (e.g., `release/9.2`) + Both live in the **`Aspire-Release-Secrets`** variable group (AzDO → Pipelines → Library) and are marked as secret. To rotate the private key, generate a new one from the App settings page (github.com/organizations/dotnet/settings/apps/aspire-repo-bot → "Private keys" → "Generate a private key"), paste the full PEM (including the `-----BEGIN/END-----` lines) into the `aspire-bot-private-key` variable, save, then revoke the old key from the same App settings page. The App ID does not change on rotation. -3. **Permissions**: - - Access to run Azure DevOps pipelines with the publishing pool - - GitHub write access for creating tags/releases/PRs (only required for manual GH workflow runs) +## Step-by-step release process -4. **AzDO secrets** (already configured for chained dispatch): - - `aspire-bot-app-id` — `aspire-repo-bot` GitHub App ID - - `aspire-bot-private-key` — `aspire-repo-bot` GitHub App PEM private key - - Both live in the **`Aspire-Release-Secrets`** variable group (AzDO → - Pipelines → Library) and are marked as secret. To rotate the private key: - generate a new one from the App settings page - (github.com/organizations/dotnet/settings/apps/aspire-repo-bot → - "Private keys" → "Generate a private key"), paste the full PEM (including - the `-----BEGIN/END-----` lines) into the `aspire-bot-private-key` - variable, save, then revoke the old key from the same App settings page. - The App ID does not change on rotation. - -## Step-by-Step Release Process - -### Step 1: Run the AzDO release pipeline (one click for everything) - -1. Navigate to the Azure DevOps pipeline: - [release-publish-nuget](https://dev.azure.com/dnceng/internal/_build?definitionId=1600&_a=summary) - (definition `1600` in `dnceng/internal`) -2. Click "Run pipeline" -3. Fill in the parameters. **Most should stay at their defaults** — the - ones flagged `[Advanced]` in the run-pipeline form are only for - re-running after a partial failure or for testing pipeline changes on a - topic branch. +### Step 1: Run the AzDO release pipeline + +1. Navigate to the Azure DevOps pipeline: [release-publish-nuget](https://dev.azure.com/dnceng/internal/_build?definitionId=1600&_a=summary) (definition `1600` in `dnceng/internal`). +2. Click **Run pipeline**. +3. Fill in the parameters. Most should stay at their defaults; the ones flagged `[Advanced]` in the run-pipeline form are only for re-running after a partial failure or for testing pipeline changes on a topic branch. **Common (you may set these every release):** | Parameter | Description | Example | |-----------|-------------|---------| - | `ReleaseVersion` | Override for the version label (used as `v` tag). **Leave as `auto` to derive from the source build's `release-version - *` tag** — the normal case. Only set this when re-shipping under a corrected tag. | `auto` | - | `IsPrerelease` | `true` for preview releases | `false` | - | `DryRun` | Set `true` to test without publishing or tagging | `false` | - | `GaChannelName` | Target GA channel | `Aspire 9.x GA` | + | `ReleaseVersion` | Override for the version label (used as `v` tag). Leave as `auto` to derive from the source build's `release-version - *` tag, which is the normal case. Only set this when re-shipping under a corrected tag. | `auto` | + | `IsPrerelease` | `true` for preview releases. Non-dry-run npm publishing is blocked for prereleases until the MicroBuild npm publish path supports non-`latest` dist-tags. | `false` | + | `DryRun` | Set `true` to test without publishing, promoting, tagging, or creating PRs. | `false` | + | `GaChannelName` | Target GA channel. | `Aspire 9.x GA` | **Advanced (leave defaults unless you know what you're doing):** | Parameter | Description | Default | |-----------|-------------|---------| - | `SkipNuGetPublish` | Set `true` if re-running after NuGet success | `false` | - | `SkipChannelPromotion` | Set `true` if re-running after darc success | `false` | - | `SkipWinGetPublish` | Set `true` if re-running after WinGet success | `false` | - | `SkipGitHubTasks` | Set `true` to skip dispatching the GH workflow | `false` | - | `SkipReleaseAssets` | Set `true` to skip uploading aspire-cli-* assets to the GitHub release | `false` | - | `SkipHomebrewValidation` | Set `true` if re-running after a successful Homebrew cask validation (validates against the live GH release) | `false` | - | `GitHubTasksWorkflowRef` | Ref to load `release-github-tasks.yml` from when dispatching. Only affects the workflow source — the release branch/commit are passed via inputs. Override only when testing pipeline changes on a topic branch. | `main` | - -4. Select the **Resources** button in the bottom right, then select the source build from the `aspire-build` dropdown - - The picker shows all recent builds from the `microsoft-aspire` - pipeline regardless of branch. Pick the build that corresponds to the - release branch and version you intend to ship. - - Each build's tags are shown alongside its number — verify the - `release-version - X.Y.Z` tag matches the version you intend to ship - **before** clicking Run. If the tag is missing, either re-run the - source build (after the tag-emitting change in `azure-pipelines.yml` - is on that release branch) or pass an explicit `ReleaseVersion` - override below. -5. Click "Run" and monitor the pipeline. The final stage (`GitHubTasks`) - dispatches `release-github-tasks.yml`, waits for it to complete, and - then uploads the `aspire-cli-*` archives from the source build's - `BlobArtifacts` onto the newly-created GitHub release — the AzDO - pipeline only succeeds if both pieces succeed. -6. Verify packages appear on NuGet.org and that the `aspire-cli-*` - archives are attached to the GitHub release. - -`commit_sha` and `release_branch` for the GitHub workflow are derived -automatically from the source build resource — no need to copy them by hand. - -> **Tip**: Use `DryRun: true` to test end-to-end without publishing, -> promoting, tagging, or creating PRs. The dry-run state is propagated to -> the GitHub workflow as `dry_run: true`. + | `SkipNuGetPublish` | Set `true` if re-running after NuGet success. | `false` | + | `SkipNpmPublish` | Set `true` if re-running after all npm packages are published. | `false` | + | `SkipNpmRidPublish` | Set `true` if npm RID packages published but the pointer package did not. | `false` | + | `SkipNpmPointerPublish` | Set `true` if the pointer package published but a later validation or promotion step failed. Registry validation still runs. | `false` | + | `SkipChannelPromotion` | Set `true` if re-running after darc success. | `false` | + | `SkipWinGetPublish` | Set `true` if re-running after WinGet success. | `true` | + | `SkipGitHubTasks` | Set `true` to skip dispatching the GH workflow. | `false` | + | `SkipReleaseAssets` | Set `true` to skip uploading `aspire-cli-*` assets to the GitHub release. | `false` | + | `SkipHomebrewValidation` | Set `true` if re-running after a successful Homebrew cask validation against the live GitHub release. | `false` | + | `NpmPublishOwners` | Comma-separated ESRP owner aliases or emails. Required when `DryRun` is `false` and npm publishing is not skipped; must include `joperezr` and `ankj`. | `joperezr,ankj` | + | `NpmPublishApprovers` | Comma-separated ESRP approver aliases or emails. Required when `DryRun` is `false` and npm publishing is not skipped; must include `adamratzman` and must not overlap owners. | `adamratzman` | + | `NpmRegistryPropagationDelayMinutes` | Delay between npm RID package and pointer package submissions. | `10` | + | `GitHubTasksWorkflowRef` | Ref to load `release-github-tasks.yml` from when dispatching. Only affects the workflow source; the release branch and commit are passed via inputs. Override only when testing pipeline changes on a topic branch. | `main` | + +4. Select the **Resources** button in the bottom right, then select the source build from the `aspire-build` dropdown. + - The picker shows all recent builds from the `microsoft-aspire` pipeline regardless of branch. Pick the build that corresponds to the release branch and version you intend to ship. + - Each build's tags are shown alongside its number. Verify the `release-version - X.Y.Z` tag matches the version you intend to ship before clicking **Run**. If the tag is missing, either re-run the source build after the tag-emitting change in `azure-pipelines.yml` is on that release branch or pass an explicit `ReleaseVersion` override. +5. Click **Run** and monitor the pipeline. The final stage (`GitHubTasks`) dispatches `release-github-tasks.yml`, waits for it to complete, uploads the `aspire-cli-*` archives from the source build's `BlobArtifacts` onto the newly-created GitHub release, and validates the Homebrew cask against that live release. The AzDO pipeline only succeeds if the enabled GitHub tasks, asset upload, and Homebrew validation succeed. +6. Verify packages appear on NuGet.org and npm, and verify that the `aspire-cli-*` archives are attached to the GitHub release. + +The npm release path validates Windows, Linux, and macOS install summaries, publishes the seven RID packages first, waits for ESRP completion, waits for the configured propagation delay, and then publishes the top-level `@microsoft/aspire-cli` pointer package. After the pointer package publishes, the pipeline installs it from the live npm registry and runs `aspire --version` before channel promotion. This avoids installing a pointer package whose optional RID dependencies are not visible yet and catches registry propagation issues before the release is promoted. For prereleases, set `SkipNpmPublish=true` unless the npm publishing path has gained explicit non-`latest` dist-tag support. + +`commit_sha` and `release_branch` for the GitHub workflow are derived automatically from the source build resource, so there is no need to copy them by hand. + +> **Tip**: Use `DryRun: true` to test end-to-end without publishing, promoting, tagging, creating PRs, or uploading release assets. The dry-run state is propagated to the GitHub workflow as `dry_run: true`. ### Step 2 (fallback): Manually re-run the GitHub workflow -The GitHub workflow is normally dispatched by the AzDO pipeline as the -`aspire-repo-bot` GitHub App, with its `authorize` job bypassed for the -bot. If a GitHub-side step fails partway through and you need to re-run -only the GitHub work, you can: +The GitHub workflow is normally dispatched by the AzDO pipeline as the `aspire-repo-bot` GitHub App, with its `authorize` job bypassed for the bot. If a GitHub-side step fails partway through and you need to re-run only the GitHub work, you can: -1. Re-run the AzDO pipeline with `SkipNuGetPublish`, `SkipChannelPromotion`, - `SkipWinGetPublish` all set to `true` (and the appropriate other skips), - keeping `SkipGitHubTasks: false`. The - `GitHubTasks` stage will dispatch the workflow again with the right - inputs, and the workflow's own `skip_*` idempotency makes the - completed steps no-ops. -2. Or, navigate to Actions → "Release GitHub Tasks", click "Run workflow", - and fill in the parameters manually: +1. Re-run the AzDO pipeline with completed AzDO-side work skipped, such as `SkipNuGetPublish`, `SkipNpmPublish`, `SkipNpmRidPublish`, `SkipChannelPromotion`, `SkipWinGetPublish`, `SkipHomebrewValidation`, and `SkipReleaseAssets` set as appropriate, keeping `SkipGitHubTasks: false`. The `GitHubTasks` stage will dispatch the workflow again with the right inputs, and the workflow's own `skip_*` idempotency makes the completed steps no-ops. +2. Or, navigate to Actions → **Release GitHub Tasks**, click **Run workflow**, and fill in the parameters manually: | Parameter | Description | Example | |-----------|-------------|---------| - | `release_version` | The version being released | `13.0.0` | - | `commit_sha` | Full 40-char commit SHA from the build | `abc123...` | - | `release_branch` | Release branch name | `release/9.2` | - | `is_prerelease` | `true` for preview releases | `false` | - | `dry_run` | `true` to validate without making changes | `false` | - | `skip_tagging` | Skip if tag already created | `false` | - | `skip_github_release` | Skip if release already exists | `false` | - | `skip_merge_pr` | Skip if PR already created | `false` | - | `skip_baseline_pr` | Skip if PR already created | `false` | + | `release_version` | The version being released. | `13.0.0` | + | `commit_sha` | Full 40-character commit SHA from the build. | `abc123...` | + | `release_branch` | Release branch name. | `release/9.2` | + | `is_prerelease` | `true` for preview releases. | `false` | + | `dry_run` | `true` to validate without making changes. | `false` | + | `skip_tagging` | Skip if tag already created. | `false` | + | `skip_github_release` | Skip if release already exists. | `false` | + | `skip_merge_pr` | Skip if PR already created. | `false` | + | `skip_baseline_pr` | Skip if PR already created. | `false` | - Manual runs go through the normal `authorize` check (admin/maintain - permission required). +Manual runs go through the normal `authorize` check (admin/maintain permission required). -### Step 3: Post-Release Tasks (Manual) +### Step 3: Post-release tasks After automation completes: 1. **Review and merge automatically created PRs**: - - Merge-back PR: `$RELEASE_BRANCH` → `main` - - Baseline version PR: Updates `PackageValidationBaselineVersion` - + - Merge-back PR: `$RELEASE_BRANCH` → `main`. + - Baseline version PR: updates `PackageValidationBaselineVersion`. 2. **Verify the release**: - - Check the [GitHub Releases page](https://github.com/microsoft/aspire/releases) - - Verify packages on [NuGet.org](https://www.nuget.org/packages?q=owner%3Aaspire) - - Test installation: `dotnet new install Aspire.ProjectTemplates::VERSION` and `aspire update --self` - + - Check the [GitHub Releases page](https://github.com/microsoft/aspire/releases). + - Verify packages on [NuGet.org](https://www.nuget.org/packages?q=Aspire). + - Verify npm packages on the Microsoft npm profile. + - Test installation: `dotnet new install Aspire.ProjectTemplates::VERSION` and `aspire update --self`. 3. **Communicate**: - - Update any tracking issues - - Notify stakeholders - -## Handling Failures + - Update any tracking issues. + - Notify stakeholders. -Both automations are designed to be **idempotent** and safe to re-run. +## Handling failures -### Azure DevOps Pipeline Failures +Both automations are designed to be idempotent and safe to re-run. -The pipeline runs as a single stage with all steps in sequence. If a step fails: +### Azure DevOps pipeline failures -| Step Failed | Resolution | +| Step failed | Resolution | |-------------|------------| -| Validate Parameters | Fix the input parameters and re-run | -| Extract BAR Build ID | Check that the build has a `BAR ID - NNNNNN` tag | -| List/Verify Packages | Check that the build artifacts are available | -| Push Packages to NuGet.org | Check NuGet.org for partial success; the `1ES.PublishNuget@1` task handles duplicates via `allowPackageConflicts: true` | -| Promote Build to Channel | Re-run with `SkipNuGetPublish: true` | - -### GitHub Actions Failures - -The GitHub workflow runs as a single `release` job with all tasks as -sequential steps. The previous design split these across six jobs and paid -the runner-provisioning tax on each; consolidation cuts that to one. If a -step fails, drill into the run UI to see exactly which step (tag, release, -merge PR, baseline PR) hit the issue. - -Re-run with the corresponding `skip_*` input set to `true` to skip steps -that have already succeeded. The skip inputs are still passed step-by-step -so partial-failure re-runs behave the same way they did before -consolidation. - -| Step Failed | Resolution | +| Validate Parameters | Fix the input parameters and re-run. | +| Derive ReleaseVersion | Check that the build has a `release-version - X.Y.Z` tag, or pass `ReleaseVersion` explicitly. | +| Extract BAR Build ID | Check that the build has a `BAR ID - NNNNNN` tag. | +| Prepare/List/Verify NuGet Packages | Check that the selected source build produced `PackageArtifacts`. | +| Prepare/List npm Packages | Check that the selected source build produced all eight `microsoft-aspire-cli*.tgz` tarballs and matching `.tgz.sig` sidecars in `BlobArtifacts`. | +| Push Packages to NuGet.org | Check NuGet.org for partial success, then re-run with already-completed steps skipped as needed. | +| MicroBuild npm Publish | Check the ESRP release result. If RID packages published but the pointer package did not, re-run with `SkipNuGetPublish: true`, `SkipNpmRidPublish: true`, and `SkipChannelPromotion: true`; do not set `SkipNpmPublish` until the pointer package is published. | +| Validate Published npm Package from Registry | Confirm the pointer package is visible on npm and that `npm install -g @microsoft/aspire-cli@` works. If registry propagation is slow, re-run with completed publish steps skipped after the package is visible. | +| Promote Build to Channel | Re-run with completed publish steps skipped. | +| WinGet publishing / Homebrew validation | Re-run with the corresponding skip flags for completed work. | +| GitHubTasks dispatch | Re-run with completed AzDO-side work skipped and `SkipGitHubTasks: false`; set `SkipReleaseAssets` according to whether release asset upload already completed. | +| Release asset upload | Re-run with `SkipGitHubTasks: true` and `SkipReleaseAssets: false` after the GitHub release exists. | + +### GitHub Actions failures + +The GitHub workflow runs as a single `release` job with all tasks as sequential steps. If a step fails, drill into the run UI to see exactly which step (tag, release, merge PR, baseline PR) hit the issue. + +Re-run with the corresponding `skip_*` input set to `true` to skip steps that have already succeeded. The skip inputs are still passed step-by-step so partial-failure re-runs behave the same way they did before consolidation. + +| Step failed | Resolution | |-------------|------------| -| Authorize | Caller lacks admin/maintain permission (or AzDO bot identity check failed) | -| Validate Version Format / Commit SHA | Fix the input parameters and re-run | -| Create Tag | If tag exists with wrong SHA, requires manual resolution | -| Create GitHub Release | Re-run with `skip_tagging: true` | -| Create Merge PR | Re-run with `skip_tagging: true`, `skip_github_release: true` | -| Create Baseline PR | Re-run with all prior skips set to `true` | +| Authorize | Caller lacks admin/maintain permission, or the AzDO bot identity check failed. | +| Validate Version Format / Commit SHA | Fix the input parameters and re-run. | +| Create Tag | If the tag exists with the wrong SHA, resolve it manually. | +| Create GitHub Release | Re-run with `skip_tagging: true`. | +| Create Merge PR | Re-run with `skip_tagging: true` and `skip_github_release: true`. | +| Create Baseline PR | Re-run with all prior skips set to `true`. | ## Configuration -### 1ES Pipeline Compliance +### 1ES and MicroBuild compliance -The AzDO pipeline extends the 1ES Official Pipeline Templates (`v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates`) to be compliant with Microsoft organization requirements. This provides: -- SDL (Security Development Lifecycle) compliance scanning -- Proper pool configuration for internal pipelines -- Component governance integration -- **Secure NuGet publishing** via the `1ES.PublishNuget@1` task with managed service connections +The AzDO pipeline extends the MicroBuild publish-enabled 1ES template (`azure-pipelines/1ES.Official.Publish.yml@MicroBuildTemplate`) to be compliant with Microsoft organization requirements and to grant `MicroBuild.Publish.yml@MicroBuildTemplate` access to the DevDiv ESRP service connection for npm submissions. The source build creates, signs where platform signing applies, verifies, and stages the package artifacts; the release pipeline consumes those pre-built artifacts and does not rebuild or re-pack the CLI. -> **Note**: This pipeline does not use the MicroBuild template since we're not signing packages - packages are already signed during the main build pipeline. We only download and publish pre-signed artifacts. +### Variable groups -### Variable Groups (Azure DevOps) +The pipeline uses: -The pipeline uses the `Aspire-Release-Secrets` variable group. Note that NuGet publishing credentials are managed via a service connection, not a variable group secret. +| Variable group | Purpose | +|----------------|---------| +| `Aspire-Release-Secrets` | Release pipeline secrets, including the `aspire-repo-bot` GitHub App credentials. NuGet publishing uses a service connection rather than a variable-group API key. | +| `Aspire-Secrets` | WinGet bot token. | -### Service Connections (Azure DevOps) +### Service connections -| Connection Name | Purpose | +| Connection name | Purpose | |-----------------|---------| -| `NuGet.org - microsoft/aspire` | NuGet service connection for publishing packages to NuGet.org | -| `Darc: Maestro Production` | Used for darc channel promotion | +| `NuGet.org - dotnet/aspire` | NuGet service connection for publishing packages to NuGet.org. | +| `DevDivEsrpAzDoSrvConn` | ESRP service connection used by the MicroBuild publish template for npm publishing. | +| `Darc: Maestro Production` | Used for darc channel promotion. | -> **Note**: The `NuGet.org - microsoft/aspire` service connection must be configured in Azure DevOps Project Settings → Service connections with: -> - **Type**: NuGet -> - **Authentication**: ApiKey -> - **Feed URL**: `https://api.nuget.org/v3/index.json` -> - **ApiKey**: A scoped NuGet.org API key with push permissions for Aspire packages +The release definition must be approved for the 1ES and MicroBuild publishing templates and must have permission to use `DevDivEsrpAzDoSrvConn`. ### Approved GitHub Actions The workflow uses only pre-approved actions: -- `actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683` (v4) -- `./.github/actions/create-pull-request` (local composite action) +- `actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683` (v4). +- `./.github/actions/create-pull-request` (local composite action). ## Troubleshooting -### "Could not find BAR ID tag" +### Could not find BAR ID tag The pipeline expects the build to have a tag in format `BAR ID - NNNNNN`. This is normally added automatically by the Maestro publishing process. If missing: -1. Check if the build completed its post-build steps -2. Manually look up the BAR ID in Maestro and add the tag -3. Contact the engineering team if the issue persists +1. Check if the build completed its post-build steps. +2. Manually look up the BAR ID in Maestro and add the tag. +3. Contact the engineering team if the issue persists. + +### npm tarballs are missing from release artifacts + +The official source build should fail staging if npm tarballs are missing. If the release pipeline cannot find them: + +1. Confirm the selected source build is from a branch that includes npm packaging. +2. Check the native archive jobs for `verify-cli-npm-package.ps1` failures. +3. Check `BlobArtifacts` for the eight `microsoft-aspire-cli*.tgz` files. + +### npm publish fails after RID packages published + +If ESRP published the RID packages but failed before publishing `@microsoft/aspire-cli`: + +1. Verify the RID packages are visible on npm. +2. Re-run the release pipeline with completed non-npm steps skipped. +3. Set `SkipNpmRidPublish: true` and keep `SkipNpmPublish: false` so only the pointer package is submitted. +4. Set `SkipNpmPublish: true` only after the pointer package is visible. + +If the pointer package published but the live npm registry validation failed afterward, re-run with `SkipNpmRidPublish: true`, `SkipNpmPointerPublish: true`, and `SkipNpmPublish: false` so the pipeline retries the install smoke without resubmitting already-published packages. -### "Tag already exists but points to different commit" +### Tag already exists but points to different commit This indicates a mismatch between the expected release commit and an existing tag. Resolution: -1. Verify you're using the correct commit SHA -2. If the existing tag is wrong, it must be manually deleted (requires admin) -3. If the SHA is wrong, correct it and re-run +1. Verify you're using the correct commit SHA. +2. If the existing tag is wrong, it must be manually deleted by someone with the required permission. +3. If the SHA is wrong, correct it and re-run. ### NuGet publish failures -The `1ES.PublishNuget@1` task is configured with `allowPackageConflicts: true`, which means it will skip packages that already exist on NuGet.org. If publishing fails: +The `1ES.PublishNuget@1` task is configured with `allowPackageConflicts: true`, which means it skips packages that already exist on NuGet.org. If publishing fails: -1. Check the pipeline logs for specific error messages -2. Verify the service connection `NuGet.org - microsoft/aspire` is properly configured -3. Ensure the API key in the service connection has push permissions for the package IDs -4. Re-run the pipeline (it will skip already-published packages) +1. Check the pipeline logs for specific error messages. +2. Verify the service connection `NuGet.org - dotnet/aspire` is properly configured. +3. Ensure the service connection has push permissions for the package IDs. +4. Re-run the pipeline with skip flags for work that already completed. ### PR creation fails The workflow checks for existing PRs before creating. If a PR exists with a different title: -1. Close or merge the existing PR -2. Re-run the workflow +1. Close or merge the existing PR. +2. Re-run the workflow. -## Architecture Diagram +## Architecture diagram ```text -┌─────────────────────────────────────────────────────────────────────────┐ -│ RELEASE PROCESS FLOW │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Azure DevOps Pipeline │ │ -│ │ release-publish-nuget.yml │ │ -│ │ (1ES.Official.PipelineTemplate.yml) │ │ -│ │ │ │ -│ │ Resource: aspire-build (select from dropdown) │ │ -│ │ Input: GaChannelName │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ -│ │ │ Validate │──▶│ Extract BAR │──▶│ Download & Verify │ │ │ -│ │ │ Inputs │ │ Build ID │ │ Packages │ │ │ -│ │ └─────────────┘ └──────────────┘ └──────────────────────┘ │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ ┌──────────────────────┐ │ │ -│ │ │ 1ES.PublishNuget@1 │ │ │ -│ │ │ (via svc connection)│ │ │ -│ │ └──────────────────────┘ │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ ┌──────────────────────┐ │ │ -│ │ │ Promote to Channel │ │ │ -│ │ │ (via darc) │ │ │ -│ │ └──────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ GitHub Actions Workflow │ │ -│ │ .github/workflows/release-github-tasks.yml │ │ -│ │ │ │ -│ │ Input: release_version, commit_sha, release_branch │ │ -│ │ │ │ -│ │ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ -│ │ │ Validate │──▶│ Create Tag │──▶│ Create GitHub │ │ │ -│ │ │ Inputs │ │ v{version} │ │ Release │ │ │ -│ │ └─────────────┘ └──────────────┘ └──────────────────────┘ │ │ -│ │ │ │ │ -│ │ ┌─────────────────────────┼─────────┐ │ │ -│ │ ▼ ▼ │ │ │ -│ │ ┌───────────────────────┐ ┌────────────────────┐ │ │ -│ │ │ Create Merge PR │ │ Create Baseline PR │ │ │ -│ │ │ release/X.Y → main │ │ Update version │ │ │ -│ │ └───────────────────────┘ └────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ +Official source build + -> Native archive jobs + -> signed native archives / native CLI packages + -> npm tarballs verified against the native archive + -> PackageArtifacts: NuGet packages + -> BlobArtifacts: microsoft-aspire-cli*.tgz and aspire-cli-* release assets + +Azure DevOps release-publish-nuget.yml + -> PrepareArtifacts + -> republish NuGet artifacts with SBOM + -> split npm RID and pointer artifacts with SBOM + -> republish WinGet artifacts with SBOM when selected + -> ReleaseJob + -> verify NuGet signatures + -> publish NuGet through 1ES.PublishNuget@1 + -> publish npm RID packages through MicroBuild.Publish + -> wait for npm propagation + -> publish npm pointer package through MicroBuild.Publish + -> install the pointer package from npm and run aspire --version + -> promote BAR build to GA channel + -> WinGetJob + -> GitHubTasks + -> dispatch release-github-tasks.yml as aspire-repo-bot + -> upload aspire-cli-* assets to the GitHub release + -> validate Homebrew cask against the live release + +GitHub release-github-tasks.yml + -> create tag + -> create GitHub release + -> create merge-back PR + -> create baseline version PR ``` -## Related Documentation +## Related documentation - [Contributing Guide](contributing.md) - [Quarantined Tests](quarantined-tests.md) diff --git a/docs/specs/npm-cli-package.md b/docs/specs/npm-cli-package.md new file mode 100644 index 00000000000..e302115de85 --- /dev/null +++ b/docs/specs/npm-cli-package.md @@ -0,0 +1,205 @@ +# Aspire CLI npm package + +## Summary + +The Aspire CLI is distributed through npm using the same signed native binary archives that feed the dotnet tool packages. The npm package shape follows the native-package convention used by tools such as esbuild and `@vscode/ripgrep`: a small top-level package exposes the command, while platform-specific packages carry the native payload. + +The published package is `@microsoft/aspire-cli`. It supports global installation (`npm install -g @microsoft/aspire-cli`) on Windows, macOS, and Linux (glibc and musl). The CLI detects npm installs at runtime and routes `aspire update --self` and update notifications through the npm package manager rather than the GitHub-binary downloader. + +## Research and prior art + +This design follows the native npm package pattern used by established packages rather than introducing a custom installer. + +- npm's package metadata defines the primitives this package uses: `bin` exposes a command on PATH, `optionalDependencies` allow platform-specific packages to be skipped when they do not apply, `files` limits packed content, and `os`/`cpu` select packages by `process.platform` and `process.arch`. See the npm package.json documentation for [`bin`](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#bin), [`optionalDependencies`](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#optionaldependencies), [`files`](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#files), [`os`](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#os), and [`cpu`](https://docs.npmjs.com/cli/v10/configuring-npm/package-json#cpu). +- [esbuild](https://github.com/evanw/esbuild/blob/main/npm/esbuild/package.json) uses a top-level package with a `bin` entry and platform-specific packages such as [`@esbuild/linux-x64`](https://github.com/evanw/esbuild/blob/main/npm/@esbuild/linux-x64/package.json) listed as optional dependencies. Its platform resolver maps the current Node platform to an optional package and resolves the binary through Node package resolution rather than hardcoded `node_modules` paths. +- [@vscode/ripgrep](https://github.com/microsoft/vscode-ripgrep/blob/main/packages/ripgrep/package.json) uses the same top-level package plus optional platform package shape for an external CLI binary. A platform package such as [`@vscode/ripgrep-linux-x64`](https://github.com/microsoft/vscode-ripgrep/blob/main/packages/ripgrep-linux-x64/package.json) contains a `bin/` payload and declares `os`/`cpu` metadata. +- Rollup, SWC, and sharp show the modern `libc` split for Linux native packages. Examples include [`@rollup/rollup-linux-x64-gnu`](https://github.com/rollup/rollup/blob/master/npm/linux-x64-gnu/package.json), [`@swc/core-linux-x64-gnu`](https://github.com/swc-project/swc/blob/main/packages/core/scripts/npm/linux-x64-gnu/package.json), and [`@img/sharp-linux-x64`](https://github.com/lovell/sharp/blob/main/npm/linux-x64/package.json). Their runtime loaders also distinguish glibc from musl before selecting a native package. +- Yarn's package portability guidance says packages should not write inside their own package directory outside postinstall because package directories may be read-only or backed by package-manager stores. See ["Packages should never write inside their own folder outside of postinstall"](https://yarnpkg.com/advanced/rulebook#packages-should-never-write-inside-their-own-folder-outside-of-postinstall). + +The Aspire-specific adaptation is the writable cache. Unlike most native npm CLIs, the Aspire CLI self-extracts an embedded bundle relative to its process path on first run. That means running the binary directly from the RID package would make the extraction root depend on the package-manager layout. Copying to `~/.aspire/npm///bin` keeps npm installation mechanics separate from Aspire's first-run extraction layout. + +## Goals + +- Produce npm tarballs as part of the same native CLI package build that produces the dotnet tool packages. +- Keep the signed native CLI archive as the canonical payload for both dotnet tool and npm package outputs. +- Use a top-level npm package with a JavaScript `bin` launcher and RID-specific optional native packages. +- Avoid running the self-extracting native binary directly from `node_modules` or package-manager stores. +- Verify generated npm packages against the native archive before staging them. +- Validate the published packages with a real `npm install -g` test in CI before allowing the release pipeline to submit them. +- Publish through the Azure DevOps release pipeline using Microsoft's ESRP-backed MicroBuild publishing flow. +- Detect npm installs at runtime so `aspire update --self` and update notifications use `npm install -g @microsoft/aspire-cli@latest` instead of the GitHub-binary downloader. + +## Non-goals + +- Replacing the dotnet tool package flow. +- Emitting a Sigstore provenance attestation (`npm publish --provenance`). The ESRP-backed publish path does not currently issue Sigstore attestations from a public OIDC publisher. Integrity is anchored at the signed native binary and the ESRP-tracked submission identity instead. See "Product and security tradeoffs" below. + +## Package layout + +The top-level package is: + +```text +@microsoft/aspire-cli +``` + +It contains: + +```text +package.json +README.md +bin/aspire.js +bin/aspire-package-map.json +``` + +The top-level `package.json` declares: + +- `bin.aspire = "bin/aspire.js"` +- `optionalDependencies` for every supported RID package at the same version +- `files = ["bin", "README.md"]` + +RID-specific packages are named by appending the RID: + +```text +@microsoft/aspire-cli-win-x64 +@microsoft/aspire-cli-win-arm64 +@microsoft/aspire-cli-linux-x64 +@microsoft/aspire-cli-linux-arm64 +@microsoft/aspire-cli-linux-musl-x64 +@microsoft/aspire-cli-osx-x64 +@microsoft/aspire-cli-osx-arm64 +``` + +Each RID package contains: + +```text +package.json +README.md +bin/aspire +``` + +or on Windows: + +```text +package.json +README.md +bin/aspire.exe +``` + +RID package metadata uses npm's platform selectors: + +- Windows packages set `os = ["win32"]` and the matching `cpu`. +- macOS packages set `os = ["darwin"]` and the matching `cpu`. +- Linux glibc packages set `os = ["linux"]`, matching `cpu`, and `libc = ["glibc"]`. +- Linux musl packages set `os = ["linux"]`, matching `cpu`, and `libc = ["musl"]`. + +## Launcher behavior + +The top-level `aspire` command runs `bin/aspire.js`. + +The launcher: + +1. Loads `bin/aspire-package-map.json`. +2. Detects the current RID from `process.platform`, `process.arch`, and libc detection for Linux. +3. Resolves the matching RID package with `require.resolve("/package.json")`. +4. Finds `bin/aspire` or `bin/aspire.exe` inside that package. +5. Copies the native binary to an Aspire-owned writable cache. +6. Spawns the cached binary with inherited stdio and forwards all command-line arguments. + +The default cache path is: + +```text +~/.aspire/npm///bin/aspire +``` + +or on Windows: + +```text +%USERPROFILE%\.aspire\npm\\\bin\aspire.exe +``` + +The cache root can be overridden with `ASPIRE_NPM_CACHE_DIR` for tests and diagnostics. + +The copy step is required because the native Aspire CLI self-extracts its embedded bundle relative to the process path on first run. Running the binary directly from `node_modules` could make the extraction target a package directory, pnpm store, Yarn unplugged location, global npm cache, or other read-only package-manager path. Copying to the Aspire cache makes first-run extraction land under an Aspire-owned writable layout. + +The launcher sets these environment variables so the CLI can detect that it was launched from an npm install (see `Aspire.Cli.Utils.NpmInstallDetection`) and route `aspire update --self` and update notifications through `npm` instead of the GitHub-binary downloader: + +```text +ASPIRE_NPM_PACKAGE +ASPIRE_NPM_PACKAGE_VERSION +ASPIRE_NPM_PACKAGE_RID +``` + +The launcher's cache-freshness check compares both file size and `mtime`. A cached binary is reused only when its size matches the source and its `mtime` is greater than or equal to the source binary's `mtime`. Any other state (different size, older `mtime`, or unreadable cache target) copies the source through a temp file and atomically renames the temp file over the cache target. + +## Build integration + +`eng\clipack\Common.projitems` wires npm packing into the existing native CLI package flow. + +`PackDotnetTool` depends on `PackNpmPackage`, so the native package build produces: + +- the existing dotnet tool pointer package +- the existing dotnet tool RID-specific package +- the npm pointer tarball +- the npm RID-specific tarball + +`PackNpmPackage` depends on `_ExtractNativeBinaryFromArchive`, which extracts `aspire` or `aspire.exe` from the native CLI archive. The npm pack script receives that extracted binary and repackages it without rebuilding or substituting another payload. + +`eng\scripts\pack-cli-npm-package.ps1` generates temporary npm package directories and invokes `npm pack` for the RID package and pointer package. + +## Verification and staging + +`eng\scripts\verify-cli-npm-package.ps1` verifies each RID build output by: + +1. Finding exactly one RID-specific npm tarball. +2. Finding exactly one npm pointer tarball. +3. Extracting both tarballs. +4. Extracting the native CLI archive. +5. Comparing the RID tarball binary byte-for-byte with the archive binary. +6. Verifying pointer package metadata, `bin/aspire.js`, `aspire-package-map.json`, and optional dependency version alignment. + +`eng/pipelines/templates/prepare-npm-cli-packages.yml` runs after the byte-for-byte verification and performs real end-to-end installation tests on Windows, Linux, and the native macOS build-pool RID: + +1. Installs the just-built pointer and matching RID tarball into a scratch npm prefix (`npm install -g`). +2. Invokes the installed `aspire --version` and asserts the version matches the build version. +3. Confirms the launcher's runtime cache landed under the expected `~/.aspire/npm///bin` layout. +4. Emits a `validation-summary.json` for each platform listing each check's status. The release pipeline refuses to submit the npm packages to ESRP without successful Windows, Linux, and macOS summaries, mirroring the brew cask flow. + +`eng\scripts\stage-native-cli-tool-packages.ps1` stages npm `.tgz` artifacts alongside existing native CLI nupkgs. Official CI invokes the script with `-RequireNpmPackages`, so source builds fail before release if the npm tarballs are missing. The default remains warning-only for local or older nupkg-only flows that call the script directly. + +Only one pointer package is staged. The default canonical pointer source is `native_archives_win_x64`, matching the existing native CLI package staging convention. All RID-specific packages are staged. + +## Pipeline integration + +The native archive workflows verify npm tarballs after the existing nupkg verification and then run the end-to-end install test above. + +Azure Pipelines installs Node.js before native package build because `npm pack` runs during packaging, verifies the npm packages, downloads `microsoft-aspire-cli*.tgz` in the staging job, and stages them with the native CLI packages. The source build then signs the npm `.tgz` files with detached `.tgz.sig` sidecars and publishes both as shipping flat blob artifacts so the release pipeline can consume them without treating npm tarballs as NuGet-like BAR package assets. The install-test jobs also publish platform-specific validation summary artifacts that the release pipeline aggregates into `NpmValidationSummary` and requires before publishing. + +## Publishing + +The npm packages are published from `eng/pipelines/release-publish-nuget.yml`, not from GitHub Actions. The release pipeline extends the MicroBuild publish-enabled 1ES template and submits packages through `MicroBuild.Publish.yml@MicroBuildTemplate` with `intent: PackageDistribution`, `contentType: npm`, and `contentSource: Folder`. + +The release pipeline prepares two npm artifact folders and one validation artifact: + +1. `NpmRidPackageArtifacts` contains the seven RID packages. +2. `NpmPointerPackageArtifacts` contains the top-level `@microsoft/aspire-cli` pointer package. +3. `NpmValidationSummary` contains the platform `validation-summary.json` files emitted by the source-build install tests. The release pipeline reads these summaries and refuses to invoke `MicroBuild.Publish` unless every required check passed on Windows, Linux, and macOS. + +The package split is intentional. The release job submits RID packages first, waits for the ESRP submission to complete, waits an additional npm registry propagation delay, and then submits the pointer package. Publishing the pointer package last avoids optional dependency resolution races when a user installs the top-level package immediately after release. + +Before publishing, the release pipeline validates that exactly one pointer tarball and exactly one tarball for each supported RID are present, that every tarball has a detached `.tgz.sig` sidecar, that all tarballs have one version, and that the `NpmValidationSummary` artifact reports `validatedByPreparePipeline: true` with every required check `passed` for Windows, Linux, and macOS install validation. Non-dry-run npm publishing also requires release managers to provide `NpmPublishOwners` and `NpmPublishApprovers`, which are passed to ESRP. Re-runs can use `SkipNpmRidPublish` if the RID packages published but the pointer package did not, and `SkipNpmPublish` after npm publishing has completed. + +MicroBuild's npm publish template documentation does not currently expose an npm `dist-tag` parameter. Non-dry-run prerelease npm publishing is blocked until preview packages can be submitted under a non-`latest` tag; release managers can still use `DryRun=true` to inspect the npm publish set without submitting packages. + +## Product and security tradeoffs + +- **No GitHub npm publishing token**: Publishing is centralized through ESRP/MicroBuild and the Microsoft1ES npm maintainer identity instead of storing an npm token in GitHub or using a repository-local GitHub Actions publisher. +- **Signed payload and tarball sidecar**: The npm tarball is created from the native CLI archive produced by the signed CI flow, and verification compares the RID tarball binary byte-for-byte with that archive. The `.tgz` package also receives an Arcade/MicroBuild detached signature sidecar (`.tgz.sig`); the release pipeline validates sidecar coverage while passing only `.tgz` package files to the npm publishing folders. +- **No Sigstore `npm publish --provenance` attestation**: ESRP's npm publish path is not currently configured to emit Sigstore provenance attestations from a public OIDC publisher. Integrity is anchored at the signed native binary, the Microsoft1ES npm maintainer identity, and the ESRP submission audit trail. When ESRP gains support for npm provenance attestations, the release pipeline should opt in and this tradeoff should be revisited. +- **Writable cache**: Copying the native binary to `~/.aspire/npm///bin` avoids self-extraction into package-manager stores, but it means the launcher owns cache creation and freshness checks. The launcher uses size + `mtime` comparison so a stale cache from an earlier same-version install does not silently shadow a re-downloaded binary. +- **Update behavior**: npm updates remain package-manager driven (`npm install -g @microsoft/aspire-cli@latest`). The CLI detects npm installs via the launcher's environment variables and prints the npm-specific update command instead of attempting to overwrite the npm-owned binary with the GitHub-binary downloader. +- **Linux libc split**: Separate glibc and musl packages avoid shipping one Linux binary that is wrong for Alpine-style environments, at the cost of one additional optional dependency package. + +## Open follow-ups + +- Add Sigstore provenance once ESRP/MicroBuild's npm publish path supports it. +- Add `npm install --no-optional` guidance and a clearer error message in the launcher when no RID package is installed. diff --git a/eng/Publishing.props b/eng/Publishing.props index 9b46fc8f629..117dc29bd6d 100644 --- a/eng/Publishing.props +++ b/eng/Publishing.props @@ -40,6 +40,8 @@ <_ExtensionSignatureFiles Include="$(ArtifactsPackagesDir)**\aspire-vscode-*.signature.p7s" /> <_CliToolPackagesToPublish Include="$(ArtifactsShippingPackagesDir)**\Aspire.Cli*.nupkg" Exclude="$(ArtifactsShippingPackagesDir)**\*.symbols.nupkg" /> + <_CliNpmPackagesToPublish Include="$(ArtifactsShippingPackagesDir)**\microsoft-aspire-cli*.tgz" /> + <_CliNpmPackageSignaturesToPublish Include="$(ArtifactsShippingPackagesDir)**\microsoft-aspire-cli*.tgz.sig" /> + <_CliRidSpecificNpmPackageFilesWithRid Include="@(_CliRidSpecificNpmPackageFiles)"> + $([System.Text.RegularExpressions.Regex]::Match('%(_CliRidSpecificNpmPackageFiles.FileName)', '^microsoft-aspire-cli-(.*?)-\d+.*').Groups[1].Value) + + + <_MissingCliNpmPackageRids Include="@(_ExpectedCliRids)" Exclude="@(_CliRidSpecificNpmPackageFilesWithRid -> '%(ExtractedRid)')" /> + <_UnexpectedCliNpmPackageRids Include="@(_CliRidSpecificNpmPackageFilesWithRid -> '%(ExtractedRid)')" Exclude="@(_ExpectedCliRids)" /> + <_CliPointerNpmPackages Include="@(_CliNpmPackagesToPublish)" Exclude="@(_CliRidSpecificNpmPackageFilesWithRid)" /> + + + + + @@ -177,6 +196,16 @@ true Package + + true + true + $(_UploadPathRoot)/$(_PackageVersion)/%(Filename)%(Extension) + + + true + true + $(_UploadPathRoot)/$(_PackageVersion)/%(Filename)%(Extension) + false true @@ -211,6 +240,8 @@ + + diff --git a/eng/Signing.props b/eng/Signing.props index 3e583dccc76..249203f1fee 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -66,6 +66,15 @@ + + + + @@ -75,6 +84,7 @@ + diff --git a/eng/clipack/Common.projitems b/eng/clipack/Common.projitems index d9bfc4e9dab..ce8fe37fe76 100644 --- a/eng/clipack/Common.projitems +++ b/eng/clipack/Common.projitems @@ -165,7 +165,7 @@ --> + DependsOnTargets="_InitializeDotnetToolPackProperties;_ExtractNativeBinaryFromArchive;_PackRidSpecificDotnetTool;_PackPointerDotnetTool;PackNpmPackage" /> $([MSBuild]::NormalizeDirectory($(IntermediateOutputPath), 'tool-publish')) <_CliToolPublishDir>$([MSBuild]::NormalizeDirectory($(_CliToolPublishBaseDir), '$(CliRuntime)')) <_CliToolPointerPublishDir>$([MSBuild]::NormalizeDirectory($(_CliToolPublishBaseDir), 'pointer')) + <_CliNpmPackageStagingDir>$([MSBuild]::NormalizeDirectory($(IntermediateOutputPath), 'npm-packages', '$(CliRuntime)')) + <_CliNpmPackageStagingDirArg>$(_CliNpmPackageStagingDir.TrimEnd('\').TrimEnd('/')) + <_PackageOutputPathArg>$(PackageOutputPath.TrimEnd('\').TrimEnd('/')) + @microsoft/aspire-cli @@ -258,4 +262,19 @@ RemoveProperties="OutputPath;TargetFramework" /> + + + + + + <_PackCliNpmPackageScript>$([MSBuild]::NormalizePath($(RepoRoot), 'eng', 'scripts', 'pack-cli-npm-package.ps1')) + + + + + diff --git a/eng/clipack/npm/aspire.js b/eng/clipack/npm/aspire.js new file mode 100644 index 00000000000..81539a98b8f --- /dev/null +++ b/eng/clipack/npm/aspire.js @@ -0,0 +1,252 @@ +#!/usr/bin/env node +'use strict'; + +const childProcess = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// Package names are generated at pack time so changing the npm package name in +// MSBuild does not require editing this launcher. Resolved lazily inside main() +// so a missing/corrupt aspire-package-map.json surfaces through the same +// friendly error path used by the rest of the launcher (try/catch around main). +let ridPackageNames = null; + +function loadRidPackageNames() { + const packageMapPath = path.join(__dirname, 'aspire-package-map.json'); + let raw; + try { + raw = fs.readFileSync(packageMapPath, 'utf8'); + } catch (error) { + throw new Error( + `Aspire CLI installation is corrupted: package map '${packageMapPath}' could not be read. ` + + 'Reinstall @microsoft/aspire-cli.', + { cause: error }); + } + try { + return new Map(Object.entries(JSON.parse(raw))); + } catch (error) { + throw new Error( + `Aspire CLI installation is corrupted: package map '${packageMapPath}' is not valid JSON. ` + + 'Reinstall @microsoft/aspire-cli.', + { cause: error }); + } +} + +function detectRid() { + const platform = process.platform; + const arch = process.arch; + + if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) { + return `win-${arch}`; + } + + if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) { + return `osx-${arch}`; + } + + if (platform === 'linux') { + // libc-mismatched binaries crash at exec with cryptic dynamic-linker errors + // (e.g. missing ld-linux-aarch64.so.1 / "GLIBC_X.Y not found"). Detect musl + // for all supported arches so unsupported combinations fall through to the + // friendly "Unsupported platform" error below, instead of silently + // resolving the glibc-linked RID package. + const musl = isMusl(); + if (arch === 'x64' && musl) { + return 'linux-musl-x64'; + } + if (arch === 'arm64' && musl) { + throw new Error(`Unsupported platform: ${platform} musl ${arch}`); + } + + if (arch === 'x64' || arch === 'arm64') { + return `linux-${arch}`; + } + } + + throw new Error(`Unsupported platform: ${platform} ${arch}`); +} + +function isMusl() { + // npm supports libc-specific packages. Prefer Node's runtime report because + // it avoids spawning a process on glibc systems, then fall back to ldd. + if (process.report && typeof process.report.getReport === 'function') { + const report = process.report.getReport(); + if (report && report.header && report.header.glibcVersionRuntime) { + return false; + } + } + + const lddResult = childProcess.spawnSync('ldd', ['--version'], { encoding: 'utf8' }); + const lddOutput = `${lddResult.stdout || ''}${lddResult.stderr || ''}`.toLowerCase(); + return lddOutput.includes('musl'); +} + +function resolveNativeBinary(rid) { + const packageName = ridPackageNames.get(rid); + if (!packageName) { + throw new Error(`No Aspire CLI npm package is available for RID '${rid}'.`); + } + + let packageJsonPath; + try { + packageJsonPath = require.resolve(`${packageName}/package.json`); + } catch (error) { + throw new Error( + `The Aspire CLI native package '${packageName}' was not installed. ` + + 'Reinstall @microsoft/aspire-cli with optional dependencies enabled.', + { cause: error }); + } + + const binaryName = process.platform === 'win32' ? 'aspire.exe' : 'aspire'; + const binaryPath = path.join(path.dirname(packageJsonPath), 'bin', binaryName); + if (!fs.existsSync(binaryPath)) { + throw new Error(`The Aspire CLI native package '${packageName}' is missing '${binaryName}'.`); + } + + return { binaryPath, packageName, binaryName }; +} + +function ensureCachedBinary(sourcePath, binaryName, version, rid) { + const home = os.homedir() || os.tmpdir(); + const cacheRoot = process.env.ASPIRE_NPM_CACHE_DIR || path.join(home, '.aspire', 'npm'); + const targetDirectory = path.join(cacheRoot, version, rid, 'bin'); + const targetPath = path.join(targetDirectory, binaryName); + + // The Aspire CLI self-extracts relative to its process path on first run. + // Running directly from node_modules could write into read-only package + // stores, so copy the native binary to an Aspire-owned writable layout. + fs.mkdirSync(targetDirectory, { recursive: true }); + + if (!needsCopy(sourcePath, targetPath)) { + return targetPath; + } + + // Copy through a temp file and atomically rename it over the previous cache + // entry so concurrent first runs never observe a missing or partial executable. + const tempPath = path.join(targetDirectory, `${binaryName}.${process.pid}.${Date.now()}.tmp`); + fs.copyFileSync(sourcePath, tempPath); + + if (process.platform !== 'win32') { + fs.chmodSync(tempPath, 0o755); + } + + // Node's rename uses replace-existing semantics on POSIX. On Windows, the + // rename fails with EBUSY/EPERM if the cached executable is currently + // running (e.g., another concurrent first-run already populated the cache + // and is executing it). When that happens, check whether the existing + // target is already a valid copy of the source - if it is, the other + // process won the race and our tmp can be discarded without failing the + // launcher. Any other error is unexpected and must propagate. + try { + fs.renameSync(tempPath, targetPath); + } catch (error) { + try { + if (!needsCopy(sourcePath, targetPath)) { + fs.rmSync(tempPath, { force: true }); + return targetPath; + } + } catch { + // Fall through and rethrow the original rename error below. + } + + fs.rmSync(tempPath, { force: true }); + throw error; + } + + return targetPath; +} + +function needsCopy(sourcePath, targetPath) { + try { + const source = fs.statSync(sourcePath); + const target = fs.statSync(targetPath); + + // Size mismatch always means stale cache. Even when the size matches, the + // cached binary is only trusted if its mtime is at or after the source's + // mtime. This catches the case where a same-version reinstall replaces the + // source binary but the cache was left from a prior install with identical + // content size (e.g., partial overwrite, corruption, or a swapped build). + if (source.size !== target.size) { + return true; + } + + if (target.mtimeMs < source.mtimeMs) { + return true; + } + + return false; + } catch { + return true; + } +} + +function main() { + // Lazy-initialize so a missing/corrupt aspire-package-map.json reaches the + // top-level try/catch and produces a friendly error instead of a Node stack. + if (ridPackageNames === null) { + ridPackageNames = loadRidPackageNames(); + } + const packageJson = require(path.join(__dirname, '..', 'package.json')); + const rid = detectRid(); + const nativeBinary = resolveNativeBinary(rid); + const executablePath = ensureCachedBinary(nativeBinary.binaryPath, nativeBinary.binaryName, packageJson.version, rid); + const child = childProcess.spawn(executablePath, process.argv.slice(2), { + stdio: 'inherit', + env: { + ...process.env, + // Surface the install context to the CLI so `aspire update --self` and + // update notifications can route through `npm install -g` instead of + // overwriting npm-owned files with the GitHub-binary downloader. See + // Aspire.Cli.Utils.NpmInstallDetection. + ASPIRE_NPM_PACKAGE: packageJson.name, + ASPIRE_NPM_PACKAGE_VERSION: packageJson.version, + ASPIRE_NPM_PACKAGE_RID: rid + } + }); + + // Forward terminating signals to the child so programmatic `kill ` + // does not orphan the native CLI (especially important for long-lived + // `aspire run` sessions that keep an AppHost alive). In TTY usage the kernel + // already broadcasts SIGINT/SIGQUIT to the whole foreground process group, so + // this primarily covers tooling that targets the wrapper PID directly. + // SIGHUP/SIGQUIT are POSIX-only; on Windows Node maps SIGTERM/SIGINT to + // TerminateProcess on the child, which is semantically what callers expect. + // Use `once` so a second signal can still terminate the wrapper if the child + // ignores the first one. + const forwardedSignals = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT']; + for (const signal of forwardedSignals) { + process.once(signal, () => { + if (!child.killed) { + try { + child.kill(signal); + } catch { + // Best-effort: the child may have exited between the check and kill, + // or the signal may be unsupported on this platform. Either way we + // let the 'exit' handler below run to propagate the final state. + } + } + }); + } + + child.on('error', error => { + console.error(error.message); + process.exit(1); + }); + + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code === null ? 1 : code); + }); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/eng/pipelines/azure-pipelines-codeql.yml b/eng/pipelines/azure-pipelines-codeql.yml index 5863fffbd4d..273802c5170 100644 --- a/eng/pipelines/azure-pipelines-codeql.yml +++ b/eng/pipelines/azure-pipelines-codeql.yml @@ -56,14 +56,18 @@ jobs: inputs: version: '20.x' + - task: npmAuthenticate@0 + displayName: NPM authenticate + inputs: + workingFile: $(Build.SourcesDirectory)\.npmrc + - task: PowerShell@2 - displayName: Install yarn + displayName: Set .npmrc environment inputs: targetType: 'inline' - script: | - npm install -g yarn@1.22.22 - yarn --version - workingDirectory: '$(Build.SourcesDirectory)' + script: Write-Host "##vso[task.setvariable variable=NPM_CONFIG_USERCONFIG]$(Build.SourcesDirectory)\.npmrc" + + - template: /eng/pipelines/templates/install-corepack.yml - task: PowerShell@2 displayName: Install vsce diff --git a/eng/pipelines/azure-pipelines-unofficial.yml b/eng/pipelines/azure-pipelines-unofficial.yml index 0e93eb5bfd3..6c6304582af 100644 --- a/eng/pipelines/azure-pipelines-unofficial.yml +++ b/eng/pipelines/azure-pipelines-unofficial.yml @@ -173,18 +173,20 @@ extends: targetPath: '$(Build.SourcesDirectory)/artifacts/signed-archives/$(_BuildConfig)' - task: DownloadPipelineArtifact@2 - displayName: 🟣Download Native CLI Tool Packages + displayName: 🟣Download Native CLI Packages inputs: itemPattern: | **/Aspire.Cli*.nupkg + **/microsoft-aspire-cli*.tgz targetPath: '$(Build.SourcesDirectory)/artifacts/native-cli-packages/$(_BuildConfig)' - pwsh: | $ErrorActionPreference = 'Stop' & "$(Build.SourcesDirectory)/eng/scripts/stage-native-cli-tool-packages.ps1" ` -DownloadRoot "$(Build.SourcesDirectory)/artifacts/native-cli-packages/$(_BuildConfig)" ` - -ShippingDir "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/Shipping" - displayName: 🟣Stage Native CLI Tool Packages + -ShippingDir "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/Shipping" ` + -RequireNpmPackages + displayName: 🟣Stage Native CLI Packages - task: PowerShell@2 displayName: 🟣List artifacts packages contents @@ -209,14 +211,9 @@ extends: targetType: 'inline' script: Write-Host "##vso[task.setvariable variable=NPM_CONFIG_USERCONFIG]$(Build.SourcesDirectory)\.npmrc" - - task: PowerShell@2 - displayName: 🟣Install yarn - inputs: - targetType: 'inline' - script: | - npm install -g yarn@1.22.22 - yarn --version - workingDirectory: '$(Build.SourcesDirectory)' + - template: /eng/pipelines/templates/install-corepack.yml + parameters: + displayPrefix: '🟣' - task: PowerShell@2 displayName: 🟣Install vsce diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml index 78e8be0a050..e848b20e8fa 100644 --- a/eng/pipelines/azure-pipelines.yml +++ b/eng/pipelines/azure-pipelines.yml @@ -283,18 +283,20 @@ extends: targetPath: '$(Build.SourcesDirectory)/artifacts/signed-archives/$(_BuildConfig)' - task: DownloadPipelineArtifact@2 - displayName: 🟣Download Native CLI Tool Packages + displayName: 🟣Download Native CLI Packages inputs: itemPattern: | **/Aspire.Cli*.nupkg + **/microsoft-aspire-cli*.tgz targetPath: '$(Build.SourcesDirectory)/artifacts/native-cli-packages/$(_BuildConfig)' - pwsh: | $ErrorActionPreference = 'Stop' & "$(Build.SourcesDirectory)/eng/scripts/stage-native-cli-tool-packages.ps1" ` -DownloadRoot "$(Build.SourcesDirectory)/artifacts/native-cli-packages/$(_BuildConfig)" ` - -ShippingDir "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/Shipping" - displayName: 🟣Stage Native CLI Tool Packages + -ShippingDir "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)/Shipping" ` + -RequireNpmPackages + displayName: 🟣Stage Native CLI Packages - task: PowerShell@2 displayName: 🟣List artifacts packages contents @@ -306,7 +308,7 @@ extends: - task: UseNode@1 displayName: 🟣Install node.js inputs: - version: '20.x' + version: '22.x' - task: npmAuthenticate@0 displayName: 🟣NPM authenticate @@ -319,14 +321,9 @@ extends: targetType: 'inline' script: Write-Host "##vso[task.setvariable variable=NPM_CONFIG_USERCONFIG]$(Build.SourcesDirectory)\.npmrc" - - task: PowerShell@2 - displayName: 🟣Install yarn - inputs: - targetType: 'inline' - script: | - npm install -g yarn@1.22.22 - yarn --version - workingDirectory: '$(Build.SourcesDirectory)' + - template: /eng/pipelines/templates/install-corepack.yml + parameters: + displayPrefix: '🟣' - task: PowerShell@2 displayName: 🟣Install vsce @@ -454,7 +451,7 @@ extends: # MirrorBranch: main # ---------------------------------------------------------------- - # Generate and test installer packages (WinGet + Homebrew). + # Generate installer packages (WinGet + Homebrew) and validate npm install. # Artifacts are consumed by release-publish-nuget.yml for stable # publishing. No publishing happens from this pipeline. # ---------------------------------------------------------------- @@ -548,3 +545,107 @@ extends: version: $(aspireVersion) channel: $(installerChannel) archiveRoot: $(Pipeline.Workspace)/native-archives + + - job: NpmInstall_Windows_x64 + displayName: npm install validation (win-x64) + timeoutInMinutes: 30 + pool: + name: NetCore1ESPool-Internal + image: 1es-windows-2022 + os: windows + steps: + - checkout: self + fetchDepth: 1 + # Download the just-built npm tarballs the build stage published as + # blob artifacts via stage-native-cli-tool-packages.ps1. Filter to + # avoid pulling unrelated blob assets. + - task: DownloadPipelineArtifact@2 + displayName: 🟣Download npm packages from build + inputs: + artifact: BlobArtifacts + itemPattern: '**/microsoft-aspire-cli*.tgz' + targetPath: '$(Pipeline.Workspace)/npm-packages' + - task: NodeTool@0 + displayName: 🟣Install Node.js + inputs: + versionSpec: '22.x' + - template: /eng/pipelines/templates/prepare-npm-cli-packages.yml@self + parameters: + npmPackagesDir: $(Pipeline.Workspace)/npm-packages + expectedVersion: $(aspireVersion) + rid: win-x64 + validationSummaryArtifactName: npm-validation-summary-win-x64 + skipRegistryResolution: true + + - job: NpmInstall_Linux_x64 + displayName: npm install validation (linux-x64) + timeoutInMinutes: 30 + pool: + name: NetCore1ESPool-Internal + image: 1es-ubuntu-2204 + os: linux + steps: + - checkout: self + fetchDepth: 1 + # Download the just-built npm tarballs the build stage published as + # blob artifacts via stage-native-cli-tool-packages.ps1. Filter to + # avoid pulling unrelated blob assets. + - task: DownloadPipelineArtifact@2 + displayName: 🟣Download npm packages from build + inputs: + artifact: BlobArtifacts + itemPattern: '**/microsoft-aspire-cli*.tgz' + targetPath: '$(Pipeline.Workspace)/npm-packages' + - task: NodeTool@0 + displayName: 🟣Install Node.js + inputs: + versionSpec: '22.x' + - template: /eng/pipelines/templates/prepare-npm-cli-packages.yml@self + parameters: + npmPackagesDir: $(Pipeline.Workspace)/npm-packages + expectedVersion: $(aspireVersion) + rid: linux-x64 + validationSummaryArtifactName: npm-validation-summary-linux-x64 + skipRegistryResolution: true + + - job: NpmInstall_macOS + displayName: npm install validation (macOS native RID) + timeoutInMinutes: 30 + pool: + name: Azure Pipelines + vmImage: macOS-latest-internal + os: macOS + steps: + - checkout: self + fetchDepth: 1 + - pwsh: | + $ErrorActionPreference = 'Stop' + $rid = switch ('$(Agent.OSArchitecture)') { + 'ARM64' { 'osx-arm64' } + 'X64' { 'osx-x64' } + default { throw "Unsupported macOS agent architecture '$(Agent.OSArchitecture)'." } + } + + Write-Host "Resolved macOS npm validation RID: $rid" + Write-Host "##vso[task.setvariable variable=NpmValidationRid]$rid" + displayName: 🟣Resolve native macOS RID + # Download the just-built npm tarballs the build stage published as + # blob artifacts via stage-native-cli-tool-packages.ps1. Filter to + # avoid pulling unrelated blob assets. + - task: DownloadPipelineArtifact@2 + displayName: 🟣Download npm packages from build + inputs: + artifact: BlobArtifacts + itemPattern: '**/microsoft-aspire-cli*.tgz' + targetPath: '$(Pipeline.Workspace)/npm-packages' + - task: NodeTool@0 + displayName: 🟣Install Node.js + inputs: + versionSpec: '22.x' + - template: /eng/pipelines/templates/prepare-npm-cli-packages.yml@self + parameters: + npmPackagesDir: $(Pipeline.Workspace)/npm-packages + expectedVersion: $(aspireVersion) + rid: $(NpmValidationRid) + validationSummaryArtifactName: npm-validation-summary-osx + skipRegistryResolution: true diff --git a/eng/pipelines/common-variables.yml b/eng/pipelines/common-variables.yml index d5852f31656..30d5b0cbcc0 100644 --- a/eng/pipelines/common-variables.yml +++ b/eng/pipelines/common-variables.yml @@ -10,6 +10,15 @@ variables: - name: _InternalBuildArgs value: '' + # npm global installs and the Corepack Yarn cache seeder don't use the repo + # .npmrc, so pass this registry explicitly. + - name: NPM_REGISTRY + value: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ + # Disable the interactive "Do you want to download yarn@x.y.z?" prompt so + # pipeline steps don't hang waiting for stdin. + - name: COREPACK_ENABLE_DOWNLOAD_PROMPT + value: '0' + - ${{ if notin(variables['Build.Reason'], 'PullRequest') }}: - name: _RunAsPublic value: False diff --git a/eng/pipelines/release-publish-nuget.yml b/eng/pipelines/release-publish-nuget.yml index fc6193373e7..e1bf66506cc 100644 --- a/eng/pipelines/release-publish-nuget.yml +++ b/eng/pipelines/release-publish-nuget.yml @@ -1,8 +1,8 @@ -# Release Pipeline: Publish NuGet Packages and Promote to GA Channel +# Release Pipeline: Publish NuGet/npm Packages and Promote to GA Channel # # This pipeline automates the release process for microsoft/aspire: # 1. Downloads signed packages from a specified build -# 2. Publishes packages to NuGet.org +# 2. Publishes packages to NuGet.org and npm # 3. Promotes the build to the Aspire GA channel via darc # 4. Submits WinGet manifests # 5. Dispatches the release-github-tasks GitHub Actions workflow as the @@ -10,6 +10,7 @@ # baseline PR) and waits for it to complete # 6. Uploads aspire-cli-* archives from the source build's BlobArtifacts # onto the GitHub Release as the aspire-repo-bot +# 7. Validates the Homebrew cask against the live GitHub release # # For full documentation, see: docs/release-process.md @@ -52,6 +53,21 @@ parameters: type: boolean default: false + - name: SkipNpmPublish + displayName: 'Skip npm Publishing (set true if already completed)' + type: boolean + default: false + + - name: SkipNpmRidPublish + displayName: 'Skip npm RID Package Publishing (set true if RID packages already completed but pointer package did not)' + type: boolean + default: false + + - name: SkipNpmPointerPublish + displayName: 'Skip npm Pointer Package Publishing (set true if pointer package already completed)' + type: boolean + default: false + - name: SkipChannelPromotion displayName: '[Advanced] Skip Channel Promotion (re-run after success)' type: boolean @@ -77,6 +93,21 @@ parameters: type: boolean default: false + - name: NpmPublishOwners + displayName: 'npm ESRP owners (comma-separated Microsoft aliases or emails)' + type: string + default: 'joperezr,ankj' + + - name: NpmPublishApprovers + displayName: 'npm ESRP approvers (comma-separated Microsoft aliases or emails)' + type: string + default: 'adamratzman' + + - name: NpmRegistryPropagationDelayMinutes + displayName: 'Minutes to wait between npm RID and pointer package submissions' + type: number + default: 10 + # Ref used when invoking `workflow_dispatch` against release-github-tasks.yml. # GitHub loads the workflow file *from this ref*, so it controls which version # of the workflow runs. The branch/commit being released is passed separately @@ -96,12 +127,21 @@ variables: - group: Aspire-Release-Secrets # Variable group containing aspire-winget-bot-pat for WinGet publishing - group: Aspire-Secrets + # Required by the MicroBuildCleanup@1 telemetry task auto-injected by + # MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate at the END of every job. + # If absent, the task fails with: "The TeamName variable is required to use MicroBuild." + # common-variables.yml defines `_TeamName: dotnet-aspire` for Arcade build conventions but + # MicroBuild specifically reads the unprefixed `TeamName` variable, so we set it explicitly + # here at pipeline scope so it propagates to all jobs (PrepareJob, ReleaseJob, WinGetJob, etc.). + # See https://devdiv.visualstudio.com/DevDiv/_wiki/wikis/DevDiv.wiki/?pagePath=/MicroBuild/Plugins/MicroBuild + - name: TeamName + value: dotnet-aspire resources: repositories: - - repository: 1ESPipelineTemplates + - repository: MicroBuildTemplate type: git - name: 1ESPipelineTemplates/1ESPipelineTemplates + name: 1ESPipelineTemplates/MicroBuildTemplate ref: refs/tags/release pipelines: - pipeline: aspire-build @@ -110,7 +150,16 @@ resources: trigger: none # Manual trigger only - no automatic releases extends: - template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates + # Use the MicroBuild + 1ES Official composite template (NOT the plain 1ES.Official.Publish.yml). + # The `MicroBuild.` prefix is REQUIRED for ESRP-based publishing — it wires the MicroBuild + # signing/publish credential context so `MicroBuild.Publish.yml@MicroBuildTemplate` and the + # auto-injected `MicroBuildAuthorizePublishPlugin` task can authenticate against the + # `devdiv.pkgs.visualstudio.com/_packaging/MicroBuildToolset` feed. + # Pattern from microsoft/vscode-azuretools, microsoft/pyright, microsoft/vscode-python-environments + # and other Microsoft-owned npm-publishing-via-ESRP pipelines. + # The plain `1ES.Official.Publish.yml@MicroBuildTemplate` (no `MicroBuild.` prefix) injects the + # authorize task without supplying credentials, causing a 401 on the MicroBuildToolset feed. + template: azure-pipelines/MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate parameters: pool: name: NetCore1ESPool-Internal @@ -145,11 +194,38 @@ extends: variables: SourceBuildId: $(resources.pipeline.aspire-build.runID) templateContext: + # Disable the auto-injected MicroBuildAuthorizePublishPlugin@0 task for jobs + # that do not perform an ESRP publish. PrepareJob only downloads artifacts + # and re-publishes them as pipeline artifacts (so 1ES PT can attach SBOM); + # the actual NuGet/npm push happens in ReleaseJob via 1ES.PublishNuget@1 and + # MicroBuild.Publish.yml. Without this opt-out, the plugin tries to fetch its + # nuget package from devdiv.pkgs.visualstudio.com/_packaging/MicroBuildToolset + # using credentials this pipeline (in the dnceng collection) does not have, + # producing a 401 and failing the stage. See MicroBuildTemplate Jobs/PublishJob.yml. + mb: + publish: + enabled: false outputs: - output: pipelineArtifact displayName: 'Publish PackageArtifacts with SBOM' targetPath: '$(Pipeline.Workspace)/packages/PackageArtifacts' artifactName: 'PackageArtifacts' + - output: pipelineArtifact + displayName: 'Publish npm RID Package Artifacts with SBOM' + targetPath: '$(Pipeline.Workspace)/npm/rid-packages' + artifactName: 'NpmRidPackageArtifacts' + - output: pipelineArtifact + displayName: 'Publish npm Pointer Package Artifact with SBOM' + targetPath: '$(Pipeline.Workspace)/npm/pointer-package' + artifactName: 'NpmPointerPackageArtifacts' + - output: pipelineArtifact + displayName: 'Publish npm Validation Summary' + targetPath: '$(Pipeline.Workspace)/npm/validation-summary' + artifactName: 'NpmValidationSummary' + - output: pipelineArtifact + displayName: 'Publish npm Signature Sidecars' + targetPath: '$(Pipeline.Workspace)/npm/signatures' + artifactName: 'NpmSignatureArtifacts' - ${{ if eq(parameters.SkipWinGetPublish, false) }}: - output: pipelineArtifact displayName: 'Publish WinGet Manifests' @@ -163,7 +239,7 @@ extends: Write-Host "Source Build ID: $(resources.pipeline.aspire-build.runID)" Write-Host "Source Build Name: $(resources.pipeline.aspire-build.runName)" Write-Host "This stage downloads artifacts and re-publishes them so 1ES PT can generate SBOM." - Write-Host "Installer-only mode: ${{ and(eq(parameters.SkipNuGetPublish, true), eq(parameters.SkipChannelPromotion, true)) }}" + Write-Host "Installer-only mode: ${{ and(eq(parameters.SkipNuGetPublish, true), eq(parameters.SkipNpmPublish, true), eq(parameters.SkipChannelPromotion, true)) }}" Write-Host "===============================" displayName: 'Log Stage Info' @@ -267,6 +343,26 @@ extends: artifact: PackageArtifacts patterns: '**/*.nupkg' + - ${{ if eq(parameters.SkipNpmPublish, false) }}: + - download: aspire-build + displayName: 'Download npm packages from Source Build' + artifact: BlobArtifacts + patterns: | + **/microsoft-aspire-cli*.tgz + **/microsoft-aspire-cli*.tgz.sig + + - download: aspire-build + displayName: 'Download npm validation summary from Source Build (win-x64)' + artifact: npm-validation-summary-win-x64 + + - download: aspire-build + displayName: 'Download npm validation summary from Source Build (linux-x64)' + artifact: npm-validation-summary-linux-x64 + + - download: aspire-build + displayName: 'Download npm validation summary from Source Build (macOS)' + artifact: npm-validation-summary-osx + - ${{ if eq(parameters.SkipWinGetPublish, false) }}: - download: aspire-build displayName: 'Download WinGet Manifests from Source Build' @@ -307,6 +403,190 @@ extends: Write-Host "Installer-only run detected; created empty PackageArtifacts placeholder." displayName: 'Prepare Empty PackageArtifacts Placeholder' + - ${{ if eq(parameters.SkipNpmPublish, false) }}: + - powershell: | + $sourcePath = "$(Pipeline.Workspace)/aspire-build/BlobArtifacts" + $ridTargetPath = "$(Pipeline.Workspace)/npm/rid-packages" + $pointerTargetPath = "$(Pipeline.Workspace)/npm/pointer-package" + $signatureTargetPath = "$(Pipeline.Workspace)/npm/signatures" + $expectedRids = @( + 'win-x64', + 'win-arm64', + 'linux-x64', + 'linux-arm64', + 'linux-musl-x64', + 'osx-x64', + 'osx-arm64' + ) + + New-Item -ItemType Directory -Path $ridTargetPath -Force | Out-Null + New-Item -ItemType Directory -Path $pointerTargetPath -Force | Out-Null + New-Item -ItemType Directory -Path $signatureTargetPath -Force | Out-Null + + Write-Host "Preparing npm packages from $sourcePath" + if (!(Test-Path $sourcePath)) { + # Avoid PowerShell here-strings inside YAML block scalars: the + # PowerShell parser requires the closing "@ at column 0, while + # YAML requires every line to be at or above the block indent. + # Composing the message from an array keeps both grammars happy. + $errorLines = @( + "BlobArtifacts was not downloaded from the source build at '$sourcePath'.", + "npm CLI packages ship as flat blobs in BlobArtifacts (configured via eng/Publishing.props).", + "Possible causes:", + " 1. The source build (azure-pipelines.yml) ran before the npm CLI package work landed.", + " Re-run the release pipeline with SkipNpmPublish=true, or rebuild from a newer commit.", + " 2. The source build failed to produce or publish the BlobArtifacts pipeline artifact.", + " 3. The wrong source build was selected." + ) + Write-Error ($errorLines -join [Environment]::NewLine) + exit 1 + } + + $versionPattern = '\d+(?:\.\d+){1,3}(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?' + $pointerPattern = "^microsoft-aspire-cli-($versionPattern)\.tgz$" + $ridPattern = "^microsoft-aspire-cli-(?.+?)-(?$versionPattern)\.tgz$" + $packages = @(Get-ChildItem -Path $sourcePath -Filter "microsoft-aspire-cli*.tgz" -Recurse -File) + $signatureSidecars = @(Get-ChildItem -Path $sourcePath -Filter "microsoft-aspire-cli*.tgz.sig" -Recurse -File) + $pointerPackages = @($packages | Where-Object { $_.Name -match $pointerPattern }) + $ridPackages = @($packages | Where-Object { $_.Name -match $ridPattern }) + $unexpectedPackages = @($packages | Where-Object { $_.Name -notmatch $pointerPattern -and $_.Name -notmatch $ridPattern }) + $signatureSidecarsByPackageName = @{} + $duplicateSignatureSidecars = [System.Collections.Generic.List[string]]::new() + + Write-Host "Found $($packages.Count) npm package(s)." + foreach ($pkg in $packages) { + Write-Host " - $($pkg.Name)" + } + + Write-Host "Found $($signatureSidecars.Count) npm detached signature sidecar(s)." + foreach ($signatureSidecar in $signatureSidecars) { + Write-Host " - $($signatureSidecar.Name)" + + $packageName = $signatureSidecar.Name.Substring(0, $signatureSidecar.Name.Length - '.sig'.Length) + if ($signatureSidecarsByPackageName.ContainsKey($packageName)) { + $duplicateSignatureSidecars.Add($signatureSidecar.Name) + continue + } + + $signatureSidecarsByPackageName[$packageName] = $signatureSidecar + } + + if ($duplicateSignatureSidecars.Count -gt 0) { + Write-Error "Duplicate npm detached signature sidecar(s): $($duplicateSignatureSidecars -join ', ')" + exit 1 + } + + if ($unexpectedPackages.Count -gt 0) { + Write-Error "Unexpected npm package filename(s): $($unexpectedPackages.Name -join ', ')" + exit 1 + } + + $missingSignaturePackages = @($packages | Where-Object { !$signatureSidecarsByPackageName.ContainsKey($_.Name) }) + if ($missingSignaturePackages.Count -gt 0) { + Write-Error "Missing detached signature sidecar(s) for npm package(s): $($missingSignaturePackages.Name -join ', ')" + exit 1 + } + + $packageNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($pkg in $packages) { + [void]$packageNames.Add($pkg.Name) + } + + $orphanSignatureSidecars = @($signatureSidecarsByPackageName.GetEnumerator() | Where-Object { !$packageNames.Contains($_.Key) } | ForEach-Object { $_.Value }) + if ($orphanSignatureSidecars.Count -gt 0) { + Write-Error "Found detached signature sidecar(s) without a matching npm package: $($orphanSignatureSidecars.Name -join ', ')" + exit 1 + } + + if ($pointerPackages.Count -ne 1) { + Write-Error "Expected exactly one Aspire CLI npm pointer package, but found $($pointerPackages.Count): $($pointerPackages.FullName -join ', ')" + exit 1 + } + + $ridPackageDetails = @($ridPackages | ForEach-Object { + if ($_.Name -match $ridPattern) { + [pscustomobject]@{ + File = $_ + Rid = $Matches.Rid + Version = $Matches.Version + } + } + }) + + $versions = @( + if ($pointerPackages[0].Name -match $pointerPattern) { + $Matches[1] + } + $ridPackageDetails | ForEach-Object { $_.Version } + ) | Sort-Object -Unique + + if ($versions.Count -ne 1) { + Write-Error "Expected one npm package version, but found: $($versions -join ', ')" + exit 1 + } + + $duplicateRidGroups = @($ridPackageDetails | Group-Object Rid | Where-Object { $_.Count -gt 1 }) + if ($duplicateRidGroups.Count -gt 0) { + $details = @($duplicateRidGroups | ForEach-Object { "$($_.Name): $($_.Group.File.Name -join ', ')" }) + Write-Error "Expected exactly one npm RID package per RID, but found duplicates: $($details -join '; ')" + exit 1 + } + + $actualRids = @($ridPackageDetails | ForEach-Object { $_.Rid }) + $missingRids = @($expectedRids | Where-Object { $_ -notin $actualRids }) + $unexpectedRids = @($actualRids | Where-Object { $_ -notin $expectedRids }) + if ($missingRids.Count -gt 0 -or $unexpectedRids.Count -gt 0) { + Write-Error "npm RID package set mismatch. Missing: $($missingRids -join ', '); Unexpected: $($unexpectedRids -join ', ')" + exit 1 + } + + foreach ($pkg in $ridPackages) { + Copy-Item $pkg.FullName -Destination $ridTargetPath -Force + } + Copy-Item $pointerPackages[0].FullName -Destination $pointerTargetPath -Force + foreach ($signatureSidecar in $signatureSidecars) { + Copy-Item $signatureSidecar.FullName -Destination $signatureTargetPath -Force + } + + $summaryTarget = "$(Pipeline.Workspace)/npm/validation-summary" + New-Item -ItemType Directory -Path $summaryTarget -Force | Out-Null + + # Copy each install-test validation summary into its own folder. + # Stage 2 re-validates every summary before submitting to ESRP/MicroBuild. + $validationSummaries = @( + @{ Name = 'win-x64'; Artifact = 'npm-validation-summary-win-x64' }, + @{ Name = 'linux-x64'; Artifact = 'npm-validation-summary-linux-x64' }, + @{ Name = 'osx'; Artifact = 'npm-validation-summary-osx' } + ) + + foreach ($validationSummary in $validationSummaries) { + $summarySource = "$(Pipeline.Workspace)/aspire-build/$($validationSummary.Artifact)/validation-summary.json" + $summaryOutputDirectory = Join-Path $summaryTarget $validationSummary.Name + New-Item -ItemType Directory -Path $summaryOutputDirectory -Force | Out-Null + + if (!(Test-Path $summarySource)) { + Write-Error "npm validation summary was not downloaded from the source build at '$summarySource'. The source build's npm install-test job for '$($validationSummary.Name)' must have failed or did not run." + exit 1 + } + + Copy-Item $summarySource -Destination (Join-Path $summaryOutputDirectory 'validation-summary.json') -Force + } + + Write-Host "Prepared $($ridPackages.Count) RID npm package(s), pointer package version $($versions[0]), and $($signatureSidecars.Count) detached signature sidecar(s)." + displayName: 'Prepare npm Artifacts for Publishing' + + - ${{ if eq(parameters.SkipNpmPublish, true) }}: + - powershell: | + New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/rid-packages" -Force | Out-Null + New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/pointer-package" -Force | Out-Null + New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/validation-summary" -Force | Out-Null + New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/validation-summary/win-x64" -Force | Out-Null + New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/validation-summary/linux-x64" -Force | Out-Null + New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/validation-summary/osx" -Force | Out-Null + New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/signatures" -Force | Out-Null + Write-Host "Created empty npm artifact placeholders because SkipNpmPublish=true." + displayName: 'Prepare Empty npm Artifact Placeholders' + # Copy installer artifacts to expected locations for output. # Entries whose source directory doesn't exist are silently skipped, which # handles re-runs where SkipWinGetPublish caused the corresponding @@ -346,14 +626,14 @@ extends: displayName: 'Prepare Installer Artifacts' # ===== STAGE 2: RELEASE ===== - # This releaseJob consumes the artifacts with SBOM and publishes to NuGet.org + # This releaseJob consumes the artifacts with SBOM and publishes to NuGet.org/npm - stage: Release - displayName: 'Release to NuGet and Promote' + displayName: 'Release to NuGet/npm and Promote' dependsOn: PrepareArtifacts jobs: - job: ReleaseJob displayName: 'Validate, Publish, and Promote' - timeoutInMinutes: 120 + timeoutInMinutes: 240 pool: name: NetCore1ESPool-Internal image: windows.vs2026preview.scout.amd64 @@ -365,10 +645,34 @@ extends: templateContext: type: releaseJob isProduction: true + # ReleaseJob is the only job that performs ESRP-backed publish (1ES.PublishNuget@1, + # MicroBuild.Publish.yml for npm via DryRun=true gating). The auto-injected + # MicroBuildAuthorizePublishPlugin@0 task must therefore be able to authenticate + # to the MicroBuildToolset feed. The dnceng collection (b55de4ed-4b5a-4215-a8e4-0a0a5f71e7d8) + # hosts its own mirror at pkgs.dev.azure.com/dnceng/_packaging/MicroBuildToolset, + # which is accessible to builds in this collection automatically. Using the dnceng + # mirror avoids needing the DevDivEsrpAzDoSrvConn service connection (which is + # only required for the actual sign/publish ESRP calls, gated by DryRun=false). + # Pattern: dotnet/roslyn uses the same dnceng MicroBuildToolset mirror. + mb: + publish: + feedSource: 'https://pkgs.dev.azure.com/dnceng/_packaging/MicroBuildToolset/nuget/v3/index.json' inputs: - input: pipelineArtifact artifactName: PackageArtifacts targetPath: '$(Pipeline.Workspace)/packages/PackageArtifacts' + - input: pipelineArtifact + artifactName: NpmRidPackageArtifacts + targetPath: '$(Pipeline.Workspace)/npm/rid-packages' + - input: pipelineArtifact + artifactName: NpmPointerPackageArtifacts + targetPath: '$(Pipeline.Workspace)/npm/pointer-package' + - input: pipelineArtifact + artifactName: NpmValidationSummary + targetPath: '$(Pipeline.Workspace)/npm/validation-summary' + - input: pipelineArtifact + artifactName: NpmSignatureArtifacts + targetPath: '$(Pipeline.Workspace)/npm/signatures' steps: # ===== VALIDATION ===== # Note: No checkout - releaseJob doesn't allow it. All scripts are inlined. @@ -381,10 +685,94 @@ extends: Write-Host "Source Build Name: $(resources.pipeline.aspire-build.runName)" Write-Host "Source Build URI: $(resources.pipeline.aspire-build.runURI)" Write-Host "GA Channel: ${{ parameters.GaChannelName }}" + Write-Host "Is Prerelease: ${{ parameters.IsPrerelease }}" Write-Host "Dry Run: ${{ parameters.DryRun }}" Write-Host "Skip NuGet Publish: ${{ parameters.SkipNuGetPublish }}" + Write-Host "Skip npm Publish: ${{ parameters.SkipNpmPublish }}" + Write-Host "Skip npm RID Publish: ${{ parameters.SkipNpmRidPublish }}" + Write-Host "Skip npm Pointer Publish: ${{ parameters.SkipNpmPointerPublish }}" Write-Host "Skip Channel Promotion: ${{ parameters.SkipChannelPromotion }}" - Write-Host "Installer-only mode: ${{ and(eq(parameters.SkipNuGetPublish, true), eq(parameters.SkipChannelPromotion, true)) }}" + Write-Host "Installer-only mode: ${{ and(eq(parameters.SkipNuGetPublish, true), eq(parameters.SkipNpmPublish, true), eq(parameters.SkipChannelPromotion, true)) }}" + + if ("${{ parameters.DryRun }}" -eq "false" -and "${{ parameters.SkipNpmPublish }}" -eq "false") { + if ("${{ parameters.IsPrerelease }}" -eq "true") { + Write-Error "npm publishing is blocked for prerelease runs because the MicroBuild npm publish template does not yet expose a dist-tag parameter. Set SkipNpmPublish=true or run with DryRun=true until prereleases can publish under a non-latest tag." + exit 1 + } + + function ConvertTo-NpmReleaseAliasSet([string] $value, [string] $parameterName) { + $aliases = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + + foreach ($entry in $value -split ',') { + $alias = $entry.Trim() + if ([string]::IsNullOrWhiteSpace($alias)) { + continue + } + + if ($alias.EndsWith('@microsoft.com', [StringComparison]::OrdinalIgnoreCase)) { + $alias = $alias.Substring(0, $alias.Length - '@microsoft.com'.Length) + } elseif ($alias.Contains('@')) { + Write-Error "$parameterName entry '$entry' must be a Microsoft alias or @microsoft.com email address." + exit 1 + } + + [void]$aliases.Add($alias.ToLowerInvariant()) + } + + return ,$aliases + } + + function Assert-ContainsRequiredNpmAliases( + [System.Collections.Generic.HashSet[string]] $actualAliases, + [string[]] $requiredAliases, + [string] $parameterName) { + $missing = @($requiredAliases | Where-Object { -not $actualAliases.Contains($_) }) + if ($missing.Count -gt 0) { + Write-Error "$parameterName must include required ESRP alias(es): $($missing -join ', ')." + exit 1 + } + } + + $owners = "${{ parameters.NpmPublishOwners }}" + $approvers = "${{ parameters.NpmPublishApprovers }}" + $requiredNpmOwners = @('joperezr', 'ankj') + $requiredNpmApprovers = @('adamratzman') + + if ([string]::IsNullOrWhiteSpace($owners)) { + Write-Error "NpmPublishOwners must be set before publishing npm packages." + exit 1 + } + + if ([string]::IsNullOrWhiteSpace($approvers)) { + Write-Error "NpmPublishApprovers must be set before publishing npm packages." + exit 1 + } + + $normalizedOwners = ConvertTo-NpmReleaseAliasSet $owners 'NpmPublishOwners' + $normalizedApprovers = ConvertTo-NpmReleaseAliasSet $approvers 'NpmPublishApprovers' + + if ($normalizedOwners.Count -eq 0) { + Write-Error "NpmPublishOwners must contain at least one alias before publishing npm packages." + exit 1 + } + + if ($normalizedApprovers.Count -eq 0) { + Write-Error "NpmPublishApprovers must contain at least one alias before publishing npm packages." + exit 1 + } + + Assert-ContainsRequiredNpmAliases $normalizedOwners $requiredNpmOwners 'NpmPublishOwners' + Assert-ContainsRequiredNpmAliases $normalizedApprovers $requiredNpmApprovers 'NpmPublishApprovers' + + $overlappingAliases = @($normalizedOwners | Where-Object { $normalizedApprovers.Contains($_) }) + if ($overlappingAliases.Count -gt 0) { + Write-Error "NpmPublishOwners and NpmPublishApprovers must not contain the same alias(es): $($overlappingAliases -join ', ')." + exit 1 + } + + Write-Host "npm ESRP owners and approvers were provided and include the required release contacts." + } + Write-Host "===================================" displayName: 'Validate Parameters' @@ -549,6 +937,622 @@ extends: Write-Host "=== Skipping NuGet Publishing (SkipNuGetPublish=true) ===" displayName: 'Skip NuGet Publish (flagged)' + # ===== PUBLISH TO npm ===== + - ${{ if eq(parameters.SkipNpmPublish, false) }}: + - task: NodeTool@0 + displayName: 'Install Node.js for npm Validation' + inputs: + versionSpec: '22.x' + + - ${{ if eq(parameters.SkipNpmPublish, false) }}: + - powershell: | + $ErrorActionPreference = 'Stop' + $validationRoot = "$(Pipeline.Workspace)/npm/validation-summary" + # The source build validates the macOS package on the build pool's native architecture. + # Accept either RID here while still requiring a macOS install test to have run. + $validationSummaries = @( + @{ Name = 'win-x64'; ExpectedRids = @('win-x64') }, + @{ Name = 'linux-x64'; ExpectedRids = @('linux-x64') }, + @{ Name = 'osx'; ExpectedRids = @('osx-x64', 'osx-arm64') } + ) + $requiredChecks = @('install', 'version', 'launcher', 'uninstall') + $validatedRids = [System.Collections.Generic.List[string]]::new() + $expectedVersion = $null + + foreach ($validationSummary in $validationSummaries) { + $summaryPath = Join-Path $validationRoot "$($validationSummary.Name)/validation-summary.json" + + if (!(Test-Path $summaryPath)) { + Write-Error "npm validation summary not found: $summaryPath. Refusing to publish without verified prepare-stage validation for '$($validationSummary.Name)'." + exit 1 + } + + try { + $summary = Get-Content -Raw $summaryPath | ConvertFrom-Json + } + catch { + Write-Error "npm validation summary is invalid JSON: $summaryPath" + throw + } + + if (-not $summary.validatedByPreparePipeline) { + Write-Error "npm validation summary '$summaryPath' does not indicate successful prepare-stage validation." + exit 1 + } + + $summaryRid = "$($summary.rid)" + if ($validationSummary.ExpectedRids -notcontains $summaryRid) { + Write-Error "npm validation summary '$summaryPath' reported RID '$summaryRid', expected one of: $($validationSummary.ExpectedRids -join ', ')." + exit 1 + } + + $summaryVersion = "$($summary.expectedVersion)" + if ([string]::IsNullOrWhiteSpace($summaryVersion)) { + Write-Error "npm validation summary '$summaryPath' did not report an expectedVersion." + exit 1 + } + + if ($null -eq $expectedVersion) { + $expectedVersion = $summaryVersion + } elseif ($summaryVersion -ne $expectedVersion) { + Write-Error "npm validation summaries disagree on expectedVersion. '$summaryPath' reported '$summaryVersion', but earlier summaries reported '$expectedVersion'." + exit 1 + } + + foreach ($checkName in $requiredChecks) { + $check = $summary.checks.$checkName + if ($null -eq $check) { + Write-Error "npm validation summary '$summaryPath' is missing required check '$checkName'." + exit 1 + } + + $status = "$($check.status)" + + # AzDO does not error on referencing an unset task.setvariable; the + # variable expansion produces the literal '$(VarName)' token. The + # prepare template initializes every NpmCheck* var to 'failed' to + # prevent this, but defend against schema drift by rejecting any + # status that still looks like an unexpanded variable. + if ($status -like '$(*)') { + Write-Error "npm validation check '$checkName' in '$summaryPath' has unexpanded status token '$status'. This indicates the prepare-stage script aborted before recording a real result." + exit 1 + } + + if ($status -ne 'passed') { + Write-Error "npm validation check '$checkName' in '$summaryPath' did not pass (status='$status')." + exit 1 + } + } + + $validatedRids.Add($summaryRid) + } + + Write-Host "npm validation summaries verified ($($validatedRids -join ', '), version $expectedVersion)." + displayName: 'Validate npm Prepare-Stage Summaries' + + - powershell: | + $ridPackagesPath = "$(Pipeline.Workspace)/npm/rid-packages" + $pointerPackagePath = "$(Pipeline.Workspace)/npm/pointer-package" + $signaturePath = "$(Pipeline.Workspace)/npm/signatures" + Write-Host "=== npm Package Inventory ===" + + $ridPackages = @(Get-ChildItem -Path $ridPackagesPath -Filter "*.tgz" -File) + $pointerPackages = @(Get-ChildItem -Path $pointerPackagePath -Filter "*.tgz" -File) + $signatureSidecars = @(Get-ChildItem -Path $signaturePath -Filter "*.tgz.sig" -File) + + Write-Host "RID packages: $($ridPackages.Count)" + foreach ($pkg in $ridPackages) { + $sizeMB = [math]::Round($pkg.Length / 1MB, 2) + Write-Host " - $($pkg.Name) ($sizeMB MB)" + } + + Write-Host "Pointer packages: $($pointerPackages.Count)" + foreach ($pkg in $pointerPackages) { + $sizeMB = [math]::Round($pkg.Length / 1MB, 2) + Write-Host " - $($pkg.Name) ($sizeMB MB)" + } + + Write-Host "Signature sidecars: $($signatureSidecars.Count)" + foreach ($signatureSidecar in $signatureSidecars) { + $sizeKB = [math]::Round($signatureSidecar.Length / 1KB, 2) + Write-Host " - $($signatureSidecar.Name) ($sizeKB KB)" + } + + if ($ridPackages.Count -ne 7 -or $pointerPackages.Count -ne 1) { + Write-Error "Expected 7 npm RID packages and 1 npm pointer package." + exit 1 + } + + $signatureNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($signatureSidecar in $signatureSidecars) { + [void]$signatureNames.Add($signatureSidecar.Name) + } + + $missingSignatures = @($ridPackages + $pointerPackages | Where-Object { !$signatureNames.Contains("$($_.Name).sig") }) + if ($missingSignatures.Count -gt 0) { + Write-Error "Missing detached signature sidecar(s) for npm package(s): $($missingSignatures.Name -join ', ')" + exit 1 + } + + # Content sanity check: catch the most likely failure mode + # where ESRP/Arcade silently produces an empty/garbage `.sig` + # file. Full PGP signature verification would require + # importing the LinuxSign500180PGP public key and running + # `gpg --verify` (gpg is not guaranteed on 1ES Windows + # agents). At minimum, assert each sidecar (a) is non-empty + # (a real PGP signature is hundreds of bytes), and (b) + # contains an OpenPGP signature marker — either the + # ASCII-armored header `-----BEGIN PGP SIGNATURE-----` + # (RFC 9580 §6) or a binary OpenPGP signature packet (tag + # 2, RFC 9580 §5.2). RFC 9580 §4.3 packet tag encoding: + # old format with tag 2 = 0x88..0x8B; new format with tag + # 2 = 0xC2; any signature packet must begin with one of + # these bytes when the sig file is binary. + $invalidSignatures = New-Object System.Collections.Generic.List[string] + foreach ($signatureSidecar in $signatureSidecars) { + if ($signatureSidecar.Length -lt 64) { + $invalidSignatures.Add("$($signatureSidecar.Name) (only $($signatureSidecar.Length) bytes)") | Out-Null + continue + } + # Peek at the first chunk so we don't load whole sigs. + # ASCII-armored PGP signatures start with the header + # within the first few bytes; binary OpenPGP signature + # packets are tag 2 and begin with 0x88-0x8B (old) or + # 0xC2 (new). We read 64 bytes which is enough to see + # the armor header verbatim. + $bytes = [System.IO.File]::ReadAllBytes($signatureSidecar.FullName)[0..63] + $asciiPreview = [System.Text.Encoding]::ASCII.GetString($bytes) + $looksArmored = $asciiPreview.Contains('-----BEGIN PGP SIGNATURE-----') + $firstByte = $bytes[0] + $looksBinarySignaturePacket = ($firstByte -ge 0x88 -and $firstByte -le 0x8B) -or $firstByte -eq 0xC2 + if (-not $looksArmored -and -not $looksBinarySignaturePacket) { + $hexPrefix = ($bytes[0..15] | ForEach-Object { '{0:x2}' -f $_ }) -join ' ' + $invalidSignatures.Add("$($signatureSidecar.Name) (no PGP signature marker; first 16 bytes: $hexPrefix)") | Out-Null + } + } + if ($invalidSignatures.Count -gt 0) { + Write-Error ("Detached signature sidecar(s) failed content sanity check: " + ($invalidSignatures -join '; ')) + exit 1 + } + + Write-Host "==============================" + displayName: 'List npm Packages' + + - ${{ if and(eq(parameters.DryRun, true), eq(parameters.SkipNpmPublish, false)) }}: + - powershell: | + Write-Host "=== DRY RUN MODE ===" + Write-Host "The following packages would be submitted to ESRP for npm publishing." + Write-Host "" + if ("${{ parameters.SkipNpmRidPublish }}" -eq "true") { + Write-Host "RID package submission would be skipped because SkipNpmRidPublish=true." + } else { + Write-Host "RID packages would be submitted first:" + Get-ChildItem -Path "$(Pipeline.Workspace)/npm/rid-packages" -Filter "*.tgz" -File | ForEach-Object { + Write-Host " - $($_.Name)" + } + Write-Host "" + Write-Host "Then the pipeline would wait ${{ parameters.NpmRegistryPropagationDelayMinutes }} minute(s) for registry propagation." + } + Write-Host "" + if ("${{ parameters.SkipNpmPointerPublish }}" -eq "true") { + Write-Host "Pointer package submission would be skipped because SkipNpmPointerPublish=true." + } else { + Write-Host "The pointer package would be submitted last:" + Get-ChildItem -Path "$(Pipeline.Workspace)/npm/pointer-package" -Filter "*.tgz" -File | ForEach-Object { + Write-Host " - $($_.Name)" + } + } + Write-Host "" + Write-Host "=== DRY RUN - No npm packages were actually published ===" + displayName: 'Dry Run - List npm Packages (No Publish)' + + - pwsh: | + $ErrorActionPreference = 'Stop' + $pointerPackagePath = "$(Pipeline.Workspace)/npm/pointer-package" + $pointerPackages = @(Get-ChildItem -Path $pointerPackagePath -Filter "*.tgz" -File) + if ($pointerPackages.Count -ne 1) { + Write-Error "Expected exactly one npm pointer package in '$pointerPackagePath', found $($pointerPackages.Count)." + exit 1 + } + + $metadataRoot = Join-Path "$(Agent.TempDirectory)" "npm-pointer-package-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $metadataRoot -Force | Out-Null + try { + tar -xzf $pointerPackages[0].FullName -C $metadataRoot + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to extract pointer package '$($pointerPackages[0].FullName)'." + exit 1 + } + + $packageJsonPath = Join-Path $metadataRoot 'package/package.json' + if (!(Test-Path -LiteralPath $packageJsonPath)) { + Write-Error "Pointer package '$($pointerPackages[0].Name)' is missing package/package.json." + exit 1 + } + + $packageJson = Get-Content -Raw -LiteralPath $packageJsonPath | ConvertFrom-Json + $packageName = "$($packageJson.name)" + $packageVersion = "$($packageJson.version)" + if ([string]::IsNullOrWhiteSpace($packageName) -or [string]::IsNullOrWhiteSpace($packageVersion)) { + Write-Error "Pointer package '$($pointerPackages[0].Name)' must include name and version in package.json." + exit 1 + } + + $packageSpec = "$packageName@$packageVersion" + Write-Host "Dry-run registry smoke target: $packageSpec" + } + finally { + if (Test-Path -LiteralPath $metadataRoot) { + Remove-Item -LiteralPath $metadataRoot -Recurse -Force + } + } + + Write-Host "node version: $(node --version)" + Write-Host "npm version: $(npm --version)" + $pingOutput = npm ping --registry=https://registry.npmjs.org/ --loglevel=warn 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host $pingOutput + Write-Error "Unable to reach https://registry.npmjs.org/ from the release job." + exit 1 + } + + Write-Host $pingOutput + Write-Host "Dry run skipped installing '$packageSpec' because the package has not been published." + displayName: 'Dry Run - Validate npm Registry Reachability' + + - ${{ if and(eq(parameters.DryRun, false), eq(parameters.SkipNpmPublish, false), eq(parameters.IsPrerelease, false)) }}: + - ${{ if eq(parameters.SkipNpmRidPublish, false) }}: + - template: MicroBuild.Publish.yml@MicroBuildTemplate + parameters: + intent: 'PackageDistribution' + contentType: 'npm' + contentSource: 'Folder' + folderLocation: '$(Pipeline.Workspace)\npm\rid-packages' + waitForReleaseCompletion: true + owners: '${{ parameters.NpmPublishOwners }}' + approvers: '${{ parameters.NpmPublishApprovers }}' + + - powershell: | + $delayMinutes = [int]'${{ parameters.NpmRegistryPropagationDelayMinutes }}' + if ($delayMinutes -le 0) { + Write-Host "Skipping npm registry propagation delay because NpmRegistryPropagationDelayMinutes=$delayMinutes." + exit 0 + } + + Write-Host "Waiting $delayMinutes minute(s) before publishing the pointer package so npm can observe the RID packages." + Start-Sleep -Seconds ($delayMinutes * 60) + displayName: 'Wait for npm RID Package Propagation' + + - ${{ if eq(parameters.SkipNpmRidPublish, true) }}: + - powershell: | + Write-Host "Skipping npm RID package publish because SkipNpmRidPublish=true. The pointer package will still be submitted." + displayName: 'Skip npm RID Publish (flagged)' + + - ${{ if eq(parameters.SkipNpmPointerPublish, false) }}: + # Preflight: the pointer package pins all RID packages via + # optionalDependencies at the same version. If even one RID + # tarball is missing from npm at this moment (operator set + # SkipNpmRidPublish=true to re-run only the pointer, or only + # a subset of RIDs landed in a prior attempt), the pointer + # install at end-user time will silently succeed but the + # launcher will throw "The Aspire CLI native package '…' was + # not installed" on first invocation. The post-publish smoke + # below only validates the publish pool's own RID, so a + # missing linux-arm64 would otherwise reach customers + # invisibly. Fail loudly here instead. + # + # Read the RID list from the pointer tarball's own + # optionalDependencies so we don't drift if a RID is added or + # removed. + - pwsh: | + $ErrorActionPreference = 'Stop' + $pointerPackagePath = "$(Pipeline.Workspace)/npm/pointer-package" + $pointerPackages = @(Get-ChildItem -Path $pointerPackagePath -Filter "*.tgz" -File) + if ($pointerPackages.Count -ne 1) { + Write-Error "Expected exactly one npm pointer package in '$pointerPackagePath', found $($pointerPackages.Count)." + exit 1 + } + + $metadataRoot = Join-Path "$(Agent.TempDirectory)" "npm-pointer-preflight-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $metadataRoot -Force | Out-Null + try { + tar -xzf $pointerPackages[0].FullName -C $metadataRoot + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to extract pointer package '$($pointerPackages[0].FullName)'." + exit 1 + } + + $packageJsonPath = Join-Path $metadataRoot 'package/package.json' + $packageJson = Get-Content -Raw -LiteralPath $packageJsonPath | ConvertFrom-Json + $packageName = "$($packageJson.name)" + $packageVersion = "$($packageJson.version)" + + $optionalDeps = $packageJson.optionalDependencies + if ($null -eq $optionalDeps) { + Write-Error "Pointer package '$packageName' has no optionalDependencies; nothing to preflight." + exit 1 + } + + $depNames = @($optionalDeps.PSObject.Properties.Name) + if ($depNames.Count -eq 0) { + Write-Error "Pointer package '$packageName' has empty optionalDependencies." + exit 1 + } + + $missing = New-Object System.Collections.Generic.List[string] + # Same propagation tolerance as the post-publish smoke + # below: npm CDN propagation of 7 freshly-published scoped + # tarballs can exceed the fixed NpmRegistryPropagationDelayMinutes + # wait, especially across multiple regions. A single-shot + # `npm view` would fail closed on transient propagation lag + # AFTER all 7 RID packages are already published, forcing a + # manual re-run with SkipNpmRidPublish=true. Bound it with + # the same 10×30s retry the post-publish smoke uses. + $preflightAttempts = 10 + $preflightDelaySeconds = 30 + # Strict semver pattern (1.2.3, 1.2.3-prerelease, 1.2.3+build). + # `npm view` with `--loglevel=warn` merges deprecation / + # peer-dep / EBADENGINE warnings onto stderr; combining + # stderr with stdout and taking `Select-Object -First 1` + # could latch the warning line as the version. Filter to + # the first line that matches a semver shape and only + # then compare to the pinned version. + $semverRegex = '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$' + foreach ($depName in $depNames) { + $depVersion = "$($optionalDeps.$depName)" + $spec = "$depName@$depVersion" + $resolvedVersion = $null + $lastError = $null + for ($preflightAttempt = 1; $preflightAttempt -le $preflightAttempts; $preflightAttempt++) { + Write-Host "Checking npm registry for $spec (attempt $preflightAttempt/$preflightAttempts)" + # Every other npm call in the release job explicitly pins + # `--registry=https://registry.npmjs.org/`. The agent's + # ambient registry is not guaranteed to be public npmjs + # (an internal mirror may be configured via .npmrc or + # npm_config_registry). Without the explicit pin, this + # preflight could (a) spuriously fail after a successful + # public publish if the mirror lacks the new package, + # or (b) pass against a stale mirror and let a pointer + # publish reference RIDs the public registry can't serve. + $resolved = & npm view $spec version --registry=https://registry.npmjs.org/ 2>$null + if ($LASTEXITCODE -eq 0 -and $resolved) { + $candidate = $resolved | ForEach-Object { "$_".Trim() } | Where-Object { $_ -match $semverRegex } | Select-Object -First 1 + if ($candidate -eq $depVersion) { + $resolvedVersion = $candidate + Write-Host " found: $resolvedVersion" + break + } + $lastError = "npm view returned '$resolved' (no semver line matched pinned version '$depVersion')" + } else { + $lastError = "npm view exit $LASTEXITCODE for ${spec}: $resolved" + } + if ($preflightAttempt -lt $preflightAttempts) { + Write-Warning "$lastError. Retrying in $preflightDelaySeconds second(s)." + Start-Sleep -Seconds $preflightDelaySeconds + } + } + if (-not $resolvedVersion) { + Write-Warning "Preflight failed for ${spec}: $lastError" + $missing.Add($spec) | Out-Null + } + } + if ($missing.Count -gt 0) { + Write-Error ("Refusing to publish pointer package '$packageName@$packageVersion' because the following RID packages are not yet available on npm: " + ($missing -join ', ')) + exit 1 + } + Write-Host "All $($depNames.Count) RID packages are present on npm at the pointer's pinned versions. Safe to publish pointer." + } + finally { + if (Test-Path -LiteralPath $metadataRoot) { + Remove-Item -LiteralPath $metadataRoot -Recurse -Force + } + } + displayName: 'Verify npm RID Packages Present Before Pointer Publish' + # DryRun=true never reaches this step because the gating + # parent `if eq(parameters.SkipNpmPointerPublish, false)` + # is only entered in real publish runs (the pointer publish + # step lives directly below this one and is what the gate + # protects). + + - template: MicroBuild.Publish.yml@MicroBuildTemplate + parameters: + intent: 'PackageDistribution' + contentType: 'npm' + contentSource: 'Folder' + folderLocation: '$(Pipeline.Workspace)\npm\pointer-package' + waitForReleaseCompletion: true + owners: '${{ parameters.NpmPublishOwners }}' + approvers: '${{ parameters.NpmPublishApprovers }}' + + - ${{ if eq(parameters.SkipNpmPointerPublish, true) }}: + - powershell: | + Write-Host "Skipping npm pointer package publish because SkipNpmPointerPublish=true. Registry validation will still install the selected source build's pointer package version from npm." + displayName: 'Skip npm Pointer Publish (flagged)' + + - pwsh: | + $ErrorActionPreference = 'Stop' + $pointerPackagePath = "$(Pipeline.Workspace)/npm/pointer-package" + $pointerPackages = @(Get-ChildItem -Path $pointerPackagePath -Filter "*.tgz" -File) + if ($pointerPackages.Count -ne 1) { + Write-Error "Expected exactly one npm pointer package in '$pointerPackagePath', found $($pointerPackages.Count)." + exit 1 + } + + $metadataRoot = Join-Path "$(Agent.TempDirectory)" "npm-pointer-package-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $metadataRoot -Force | Out-Null + try { + tar -xzf $pointerPackages[0].FullName -C $metadataRoot + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to extract pointer package '$($pointerPackages[0].FullName)'." + exit 1 + } + + $packageJsonPath = Join-Path $metadataRoot 'package/package.json' + if (!(Test-Path -LiteralPath $packageJsonPath)) { + Write-Error "Pointer package '$($pointerPackages[0].Name)' is missing package/package.json." + exit 1 + } + + $packageJson = Get-Content -Raw -LiteralPath $packageJsonPath | ConvertFrom-Json + $packageName = "$($packageJson.name)" + $packageVersion = "$($packageJson.version)" + if ([string]::IsNullOrWhiteSpace($packageName) -or [string]::IsNullOrWhiteSpace($packageVersion)) { + Write-Error "Pointer package '$($pointerPackages[0].Name)' must include name and version in package.json." + exit 1 + } + + $packageSpec = "$packageName@$packageVersion" + } + finally { + if (Test-Path -LiteralPath $metadataRoot) { + Remove-Item -LiteralPath $metadataRoot -Recurse -Force + } + } + + $npmRoot = Join-Path "$(Agent.TempDirectory)" "npm-registry-smoke-$([Guid]::NewGuid().ToString('N'))" + $npmPrefix = Join-Path $npmRoot 'prefix' + $npmCache = Join-Path $npmRoot 'cache' + New-Item -ItemType Directory -Path $npmPrefix -Force | Out-Null + New-Item -ItemType Directory -Path $npmCache -Force | Out-Null + + try { + $env:npm_config_prefix = $npmPrefix + $env:npm_config_cache = $npmCache + $env:npm_config_registry = 'https://registry.npmjs.org/' + + Write-Host "node version: $(node --version)" + Write-Host "npm version: $(npm --version)" + Write-Host "Validating published npm package from registry: $packageSpec" + + $pingOutput = npm ping --registry=https://registry.npmjs.org/ --loglevel=warn 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host $pingOutput + Write-Error "Unable to reach https://registry.npmjs.org/ from the release job." + exit 1 + } + + $attempts = 10 + $delaySeconds = 30 + $available = $false + $lastViewOutput = $null + # Strict semver regex catches: 1.2.3, 1.2.3-prerelease, 1.2.3+build. + # `npm view --loglevel=warn` merges deprecation / peer-dep / + # EBADENGINE warnings onto stderr; combining stderr with + # stdout via `2>&1` and taking `Select-Object -First 1` + # could latch the warning line as the version, then burn + # all 10 retries and fail the release even though publish + # succeeded. Filter to the first line that matches a semver + # shape and only then compare to the published version. + $semverRegex = '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$' + for ($attempt = 1; $attempt -le $attempts; $attempt++) { + Write-Host "Checking npm registry metadata for $packageSpec (attempt $attempt/$attempts)..." + $lastViewOutput = npm view $packageSpec version --registry=https://registry.npmjs.org/ --loglevel=warn 2>&1 + if ($LASTEXITCODE -eq 0) { + $viewVersion = $lastViewOutput | ForEach-Object { "$_".Trim() } | Where-Object { $_ -match $semverRegex } | Select-Object -First 1 + if ($viewVersion -eq $packageVersion) { + $available = $true + break + } + } + + if ($attempt -lt $attempts) { + Write-Warning "npm registry has not returned $packageSpec yet. Retrying in $delaySeconds second(s)." + Start-Sleep -Seconds $delaySeconds + } + } + + if (!$available) { + Write-Host $lastViewOutput + Write-Error "npm registry did not return '$packageVersion' for '$packageSpec' after $attempts attempt(s)." + exit 1 + } + + $installed = $false + $lastInstallOutput = $null + for ($attempt = 1; $attempt -le $attempts; $attempt++) { + Write-Host "Installing $packageSpec from npm registry (attempt $attempt/$attempts)..." + $lastInstallOutput = npm install -g --foreground-scripts=true --no-audit --no-fund --loglevel=warn --registry=https://registry.npmjs.org/ $packageSpec 2>&1 + if ($LASTEXITCODE -eq 0) { + $installed = $true + break + } + + if ($attempt -lt $attempts) { + Write-Host $lastInstallOutput + Write-Warning "npm install failed while registry propagation may still be in progress. Retrying in $delaySeconds second(s)." + Start-Sleep -Seconds $delaySeconds + } + } + + if (!$installed) { + Write-Host $lastInstallOutput + Write-Error "Failed to install '$packageSpec' from npm registry after $attempts attempt(s)." + exit 1 + } + + Write-Host $lastInstallOutput + + $aspireCommand = if ($IsWindows) { + Join-Path $npmPrefix 'aspire.cmd' + } else { + Join-Path $npmPrefix 'bin/aspire' + } + + if (!(Test-Path -LiteralPath $aspireCommand)) { + Write-Error "npm install succeeded but the Aspire CLI command was not found at '$aspireCommand'." + exit 1 + } + + $versionOutput = & $aspireCommand --version 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host $versionOutput + Write-Error "Installed Aspire CLI failed to run --version." + exit 1 + } + + $versionLine = @($versionOutput | ForEach-Object { "$_".Trim() } | Where-Object { $_ } | Select-Object -First 1) + # `@(...)` wraps to an empty array when no non-empty lines + # are emitted; PowerShell's `-notmatch` against an empty + # array silently returns an empty array (falsy), which + # would let an `aspire --version` that exits 0 with empty + # stdout slip past the version-pattern check. Reject the + # empty case explicitly before the regex compare so the + # smoke test actually validates what it claims to. + if ($versionLine.Count -eq 0 -or [string]::IsNullOrWhiteSpace([string]$versionLine[0])) { + Write-Host $versionOutput + Write-Error "Installed Aspire CLI '$aspireCommand --version' produced no output." + exit 1 + } + $versionPattern = "^$([System.Text.RegularExpressions.Regex]::Escape($packageVersion))(\+.*)?$" + if ($versionLine -notmatch $versionPattern) { + Write-Host $versionOutput + Write-Error "Installed Aspire CLI version '$versionLine' did not match expected npm package version '$packageVersion'." + exit 1 + } + + Write-Host "aspire --version output matched the published npm package version: $versionLine" + } + finally { + if (Test-Path -LiteralPath $npmRoot) { + # Best-effort cleanup. On Windows, freshly extracted + # binaries may still be held open briefly by AV / file + # indexers, which can make Remove-Item throw EPERM / + # IOException. Swallow those so a successful smoke + # test isn't masked by a transient cleanup error. + try { + Remove-Item -LiteralPath $npmRoot -Recurse -Force -ErrorAction Stop + } catch { + Write-Warning "Best-effort cleanup of '$npmRoot' failed: $($_.Exception.Message)" + } + } + } + displayName: 'Validate Published npm Package from Registry' + + - ${{ if eq(parameters.SkipNpmPublish, true) }}: + - powershell: | + Write-Host "=== Skipping npm Publishing (SkipNpmPublish=true) ===" + displayName: 'Skip npm Publish (flagged)' + # ===== PROMOTE TO CHANNEL ===== - ${{ if eq(parameters.SkipChannelPromotion, false) }}: - powershell: | @@ -629,6 +1633,7 @@ extends: Write-Host "║ BAR Build ID: $(BarBuildId)" } Write-Host "║ GA Channel: ${{ parameters.GaChannelName }}" + Write-Host "║ Prerelease: ${{ parameters.IsPrerelease }}" Write-Host "║ Dry Run: ${{ parameters.DryRun }}" Write-Host "╠═══════════════════════════════════════════════════════════════╣" Write-Host "║ NuGet Publish: ${{ parameters.SkipNuGetPublish }}" -NoNewline @@ -637,6 +1642,20 @@ extends: } else { Write-Host " (EXECUTED)" } + Write-Host "║ npm Publish: ${{ parameters.SkipNpmPublish }}" -NoNewline + if ("${{ parameters.SkipNpmPublish }}" -eq "true") { + Write-Host " (SKIPPED)" + } elseif ("${{ parameters.DryRun }}" -eq "true") { + Write-Host " (DRY RUN)" + } elseif ("${{ parameters.IsPrerelease }}" -eq "true") { + Write-Host " (BLOCKED - prerelease requires non-latest npm dist-tag support)" + } elseif ("${{ parameters.SkipNpmPointerPublish }}" -eq "true") { + Write-Host " (PARTIAL - pointer publish skipped; registry smoke still ran)" + } else { + Write-Host " (EXECUTED)" + } + Write-Host "║ npm RID Skip: ${{ parameters.SkipNpmRidPublish }}" + Write-Host "║ npm Ptr Skip: ${{ parameters.SkipNpmPointerPublish }}" Write-Host "║ Channel Promo: ${{ parameters.SkipChannelPromotion }}" -NoNewline if ("${{ parameters.SkipChannelPromotion }}" -eq "true") { Write-Host " (SKIPPED)" @@ -688,6 +1707,12 @@ extends: templateContext: type: releaseJob isProduction: true + # No ESRP publish in this job (winget cli + git push to the WinGet community repo + # using a PAT). Skip the auto-injected MicroBuildAuthorizePublishPlugin@0 so it + # does not 401 against the devdiv MicroBuildToolset feed. + mb: + publish: + enabled: false inputs: - input: pipelineArtifact artifactName: winget-manifests-stable @@ -771,6 +1796,12 @@ extends: variables: # Pulled from the deriveReleaseVersion step in PrepareJob (PrepareArtifacts stage). ReleaseVersionEffective: $[ stageDependencies.PrepareArtifacts.PrepareJob.outputs['deriveReleaseVersion.releaseVersionEffective'] ] + templateContext: + # No ESRP publish in this job (dispatches a GitHub Actions workflow via GH App JWT + # and polls for completion). Skip the auto-injected publish auth task. + mb: + publish: + enabled: false steps: - checkout: self displayName: 'Checkout repo (for dispatch script)' @@ -866,6 +1897,12 @@ extends: # Mapped here independently of DispatchGitHubTasksJob so this job still gets the # value when SkipGitHubTasks=true causes its sibling to be skipped. ReleaseVersionEffective: $[ stageDependencies.PrepareArtifacts.PrepareJob.outputs['deriveReleaseVersion.releaseVersionEffective'] ] + templateContext: + # No ESRP publish in this job (gh CLI upload to GitHub Release via App-token auth). + # Skip the auto-injected publish auth task. + mb: + publish: + enabled: false steps: - checkout: self displayName: 'Checkout repo (for upload script)' @@ -1004,6 +2041,12 @@ extends: name: Azure Pipelines vmImage: macOS-latest-internal os: macOS + templateContext: + # No ESRP publish in this job (read-only brew audit / brew install of a public cask). + # Skip the auto-injected publish auth task. + mb: + publish: + enabled: false steps: - checkout: self displayName: 'Checkout repo (for validate-cask-artifact.sh)' diff --git a/eng/pipelines/templates/BuildAndTest.yml b/eng/pipelines/templates/BuildAndTest.yml index 11cbde39bbf..5154d51b140 100644 --- a/eng/pipelines/templates/BuildAndTest.yml +++ b/eng/pipelines/templates/BuildAndTest.yml @@ -55,6 +55,70 @@ steps: /p:BuildExtension=true displayName: 🟣Build + - pwsh: | + $ErrorActionPreference = 'Stop' + $shippingPackagesDir = '${{ parameters.repoArtifactsPath }}/packages/${{ parameters.buildConfig }}/Shipping' + + if (!(Test-Path $shippingPackagesDir)) { + Write-Error "Shipping packages directory not found: $shippingPackagesDir" + exit 1 + } + + $npmPackages = @(Get-ChildItem -Path $shippingPackagesDir -Filter "microsoft-aspire-cli*.tgz" -Recurse -File) + if ($npmPackages.Count -eq 0) { + Write-Error "No Aspire CLI npm packages were found under '$shippingPackagesDir'." + exit 1 + } + + $missingSignatures = @() + $invalidSignatures = @() + foreach ($package in $npmPackages) { + $signaturePath = "$($package.FullName).sig" + if (!(Test-Path $signaturePath)) { + $missingSignatures += $signaturePath + continue + } + + # Content sanity check: catch the most likely silent failure mode + # where Arcade/ESRP signing emits the sidecar file but the content + # is empty or garbage. A real PGP signature is hundreds of bytes + # and starts with either the ASCII-armored header + # `-----BEGIN PGP SIGNATURE-----` (RFC 9580 §6) or an OpenPGP + # binary signature packet (tag 2: old-format 0x88..0x8B or + # new-format 0xC2, RFC 9580 §4.3 / §5.2). + $signatureFile = Get-Item -LiteralPath $signaturePath + if ($signatureFile.Length -lt 64) { + $invalidSignatures += "$signaturePath (only $($signatureFile.Length) bytes)" + continue + } + $bytes = [System.IO.File]::ReadAllBytes($signaturePath)[0..63] + $asciiPreview = [System.Text.Encoding]::ASCII.GetString($bytes) + $looksArmored = $asciiPreview.Contains('-----BEGIN PGP SIGNATURE-----') + $firstByte = $bytes[0] + $looksBinarySignaturePacket = ($firstByte -ge 0x88 -and $firstByte -le 0x8B) -or $firstByte -eq 0xC2 + if (-not $looksArmored -and -not $looksBinarySignaturePacket) { + $hexPrefix = ($bytes[0..15] | ForEach-Object { '{0:x2}' -f $_ }) -join ' ' + $invalidSignatures += "$signaturePath (no PGP signature marker; first 16 bytes: $hexPrefix)" + continue + } + + Write-Host "Found npm package signature sidecar: $([System.IO.Path]::GetFileName($signaturePath))" + } + + if ($missingSignatures.Count -gt 0) { + Write-Error "Missing detached signature sidecar(s) for npm package(s): $($missingSignatures -join ', ')" + exit 1 + } + + if ($invalidSignatures.Count -gt 0) { + Write-Error "Detached signature sidecar(s) failed content sanity check: $($invalidSignatures -join '; ')" + exit 1 + } + + Write-Host "Validated $($npmPackages.Count) Aspire CLI npm package signature sidecar(s)." + displayName: 🟣Validate npm package signatures + condition: and(succeeded(), eq(variables['_SignType'], 'real')) + # Log MicroBuild environment for debugging # MicroBuildOutputFolderOverride is set by the MicroBuildSigningPlugin task in eng/common/templates-official/job/onelocbuild.yml # which is installed via the Arcade SDK's install-microbuild.yml template that runs before our build steps. diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index ac72bbde30d..eb2af1e8e34 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -210,6 +210,11 @@ jobs: chmod +x $(Build.SourcesDirectory)/artifacts/bin/Aspire.Managed/Release/net10.0/${{ targetRid }}/publish/aspire-managed displayName: 🟣Restore execute permission on aspire-managed + - task: NodeTool@0 + displayName: 🟣Install node.js + inputs: + versionSpec: '22.x' + - script: >- $(Build.SourcesDirectory)/$(dotnetScript) msbuild @@ -254,6 +259,21 @@ jobs: & "$(Build.SourcesDirectory)/eng/scripts/verify-cli-tool-nupkg.ps1" @verifyParams displayName: 🟣Verify CLI tool nupkg (${{ targetRid }}) + - pwsh: | + $ErrorActionPreference = 'Stop' + $verifyParams = @{ + PackagesDir = "$(Build.SourcesDirectory)/artifacts/packages/$(_BuildConfig)" + Rid = "${{ targetRid }}" + } + $archiveFilter = if ("${{ targetRid }}".StartsWith('win-')) { "aspire-cli-${{ targetRid }}-*.zip" } else { "aspire-cli-${{ targetRid }}-*.tar.gz" } + $archive = @(Get-ChildItem -Path $verifyParams.PackagesDir -Filter $archiveFilter -Recurse -File -ErrorAction SilentlyContinue) + if ($archive.Count -ne 1) { + throw "Expected exactly one CLI archive for ${{ targetRid }}, but found $($archive.Count): $($archive.FullName -join ', ')" + } + $verifyParams.ArchivePath = $archive[0].FullName + & "$(Build.SourcesDirectory)/eng/scripts/verify-cli-npm-package.ps1" @verifyParams + displayName: 🟣Verify CLI npm package (${{ targetRid }}) + # Verify the signed CLI archive can execute and create a project. # Only runs when the target RID architecture matches the build agent # (i.e. the binary can execute natively without emulation). diff --git a/eng/pipelines/templates/install-corepack.yml b/eng/pipelines/templates/install-corepack.yml new file mode 100644 index 00000000000..efbaa0b7359 --- /dev/null +++ b/eng/pipelines/templates/install-corepack.yml @@ -0,0 +1,55 @@ +parameters: + # Prefix prepended to step displayNames (e.g. the 🟣 emoji used by the + # internal-1ES jobs in azure-pipelines.yml / azure-pipelines-unofficial.yml). + # Public/CodeQL pipelines omit it. Kept as a parameter so the displayName + # convention of each calling pipeline is preserved. + - name: displayPrefix + type: string + default: '' + +steps: + - task: PowerShell@2 + displayName: ${{ parameters.displayPrefix }}Install Corepack + inputs: + targetType: 'inline' + script: | + # Pinned Corepack shim version, sourced from + # extension/scripts/corepack-version.txt so this script, + # extension/build.sh, extension/build.ps1, the GitHub Actions + # workflow, and every AzDO pipeline that references this template + # stay in sync. The Yarn release itself is pinned in + # extension/package.json via the `packageManager` field. + $CorepackVersion = (Get-Content -Raw -Path '$(Build.SourcesDirectory)/extension/scripts/corepack-version.txt').Trim() + $corepackHome = Join-Path '$(Agent.TempDirectory)' 'corepack' + $env:COREPACK_HOME = $corepackHome + Write-Host "##vso[task.setvariable variable=COREPACK_HOME]$corepackHome" + + # Hosted Windows images can already have yarn in npm's global prefix. + # The npm Corepack package owns that shim too, so force only in CI + # where the tool install is job-scoped. + npm install -g --force --registry "$env:NPM_REGISTRY" "corepack@$CorepackVersion" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + $npmGlobalBin = (npm prefix --global).Trim() + $env:PATH = "$npmGlobalBin$([IO.Path]::PathSeparator)$env:PATH" + Write-Host "##vso[task.prependpath]$npmGlobalBin" + + # Verify the version actually on PATH matches our pin. On Windows the + # Node.js installer registers a `corepack.cmd` under + # %ProgramFiles%\nodejs which may shadow the npm-global shim under + # %APPDATA%\npm, so a successful `npm install -g corepack@` + # does NOT guarantee that subsequent `corepack` calls resolve to it. + $installed = (corepack --version).Trim() + if ($installed -ne $CorepackVersion) { + Write-Error "corepack version mismatch: expected $CorepackVersion, got '$installed'. The bundled Corepack on PATH may be taking precedence over the npm-global install." + exit 1 + } + + corepack enable + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + node ./scripts/prepareCorepackYarn.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + corepack yarn --version + workingDirectory: '$(Build.SourcesDirectory)\extension' diff --git a/eng/pipelines/templates/prepare-npm-cli-packages.yml b/eng/pipelines/templates/prepare-npm-cli-packages.yml new file mode 100644 index 00000000000..7f559d3b7e4 --- /dev/null +++ b/eng/pipelines/templates/prepare-npm-cli-packages.yml @@ -0,0 +1,341 @@ +parameters: + - name: npmPackagesDir + type: string + default: '' + - name: expectedVersion + type: string + default: '' + - name: rid + type: string + default: 'linux-x64' + - name: validationSummaryArtifactName + type: string + default: 'npm-validation-summary' + # When true, the install test is allowed to run against just-built tarballs + # without contacting the npm registry. PR builds set this to true; release + # validation builds also set this to true. The variable exists so future + # registry-resolution probes can be gated on it. + - name: skipRegistryResolution + type: boolean + default: true + +steps: + - bash: | + set -euo pipefail + to_unix_path() { + local value="$1" + if command -v cygpath >/dev/null 2>&1 && [[ "$value" =~ ^[A-Za-z]:\\ ]]; then + cygpath -u "$value" + else + printf '%s\n' "$value" + fi + } + + packagesDir="$(to_unix_path '${{ parameters.npmPackagesDir }}')" + expectedVersion='${{ parameters.expectedVersion }}' + rid='${{ parameters.rid }}' + + if [ -z "$packagesDir" ] || [ ! -d "$packagesDir" ]; then + echo "##[error]npmPackagesDir parameter is required and must point to a directory containing the just-built microsoft-aspire-cli*.tgz files." + exit 1 + fi + + if [ -z "$expectedVersion" ]; then + echo "##[error]expectedVersion parameter is required." + exit 1 + fi + + if [ -z "$rid" ]; then + echo "##[error]rid parameter is required." + exit 1 + fi + + echo "##vso[task.setvariable variable=NpmPackagesDir]$packagesDir" + echo "##vso[task.setvariable variable=NpmExpectedVersion]$expectedVersion" + echo "##vso[task.setvariable variable=NpmTestRid]$rid" + displayName: 🟣Resolve npm install-test inputs + + - bash: | + set -euo pipefail + packagesDir="$(NpmPackagesDir)" + expectedVersion="$(NpmExpectedVersion)" + rid="$(NpmTestRid)" + + # Filename layout produced by eng/scripts/pack-cli-npm-package.ps1: + # microsoft-aspire-cli-.tgz (pointer package) + # microsoft-aspire-cli--.tgz (RID package) + # The pointer filename never embeds a RID, so distinguish them on that. + # + # IMPORTANT: This script must stay portable to Bash 3.2 because the + # AzDO Bash@3 task on the macOS image executes scripts with /bin/bash + # which is still 3.2.x. That means: no extended-glob shell options + # (those are 4+), no array-load builtins (those are 4+), no associative + # arrays (4+). We therefore enumerate the tarballs with `find` (always + # available) and read them into a regular indexed array with a + # portable `while read` loop that survives names containing spaces. + allPackages=() + while IFS= read -r pkg; do + [ -n "$pkg" ] && allPackages+=("$pkg") + done < <(find "$packagesDir" -type f -name "microsoft-aspire-cli*.tgz" | LC_ALL=C sort -u) + + if [ ${#allPackages[@]} -eq 0 ]; then + echo "##[error]No microsoft-aspire-cli*.tgz files found under $packagesDir" + exit 1 + fi + + pointer="" + ridTarball="" + ridFilename="microsoft-aspire-cli-${rid}-${expectedVersion}.tgz" + pointerFilename="microsoft-aspire-cli-${expectedVersion}.tgz" + + for pkg in "${allPackages[@]}"; do + base="$(basename "$pkg")" + if [ "$base" = "$pointerFilename" ]; then + pointer="$pkg" + elif [ "$base" = "$ridFilename" ]; then + ridTarball="$pkg" + fi + done + + if [ -z "$pointer" ]; then + echo "##[error]Pointer package $pointerFilename was not found under $packagesDir" + echo "Discovered files:" + printf ' %s\n' "${allPackages[@]}" + exit 1 + fi + + if [ -z "$ridTarball" ]; then + echo "##[error]RID package $ridFilename was not found under $packagesDir" + echo "Discovered files:" + printf ' %s\n' "${allPackages[@]}" + exit 1 + fi + + echo "Pointer package: $pointer" + echo "RID package: $ridTarball" + echo "##vso[task.setvariable variable=NpmPointerTarball]$pointer" + echo "##vso[task.setvariable variable=NpmRidTarball]$ridTarball" + + # Initialize every check status to 'failed' up front. If any subsequent + # step exits via 'set -e' before flipping one of these to 'passed', the + # summary written by the final step still reflects the real outcome + # instead of a literal '$(NpmCheckXxx)' AzDO variable expansion (which + # the release-side gate would still reject, but the diagnostic value + # of 'failed' is much higher). + echo "##vso[task.setvariable variable=NpmCheckInstall]failed" + echo "##vso[task.setvariable variable=NpmCheckVersion]failed" + echo "##vso[task.setvariable variable=NpmCheckLauncher]failed" + echo "##vso[task.setvariable variable=NpmCheckUninstall]failed" + displayName: 🟣Locate pointer and RID tarballs + + - bash: | + set -euo pipefail + pointer="$(NpmPointerTarball)" + ridTarball="$(NpmRidTarball)" + expectedVersion="$(NpmExpectedVersion)" + rid="$(NpmTestRid)" + + # Use a scratch npm prefix so the test never mutates the agent's + # global node_modules and so cleanup is a single rm -rf. + stagingDirectory="$(Build.StagingDirectory)" + if command -v cygpath >/dev/null 2>&1 && [[ "$stagingDirectory" =~ ^[A-Za-z]:\\ ]]; then + stagingDirectory="$(cygpath -u "$stagingDirectory")" + fi + + prefix="$stagingDirectory/npm-validate-prefix" + cache="$stagingDirectory/npm-validate-cache" + aspireCache="$stagingDirectory/npm-validate-aspire-cache" + rm -rf "$prefix" "$cache" "$aspireCache" + mkdir -p "$prefix" "$cache" "$aspireCache" + + export NPM_CONFIG_PREFIX="$prefix" + export NPM_CONFIG_CACHE="$cache" + export ASPIRE_NPM_CACHE_DIR="$aspireCache" + # Prefer the freshly-built launcher's PATH; do not poison existing aspire binaries. + # npm writes POSIX shims to /bin and Windows shims to . + export PATH="$prefix/bin:$prefix:$PATH" + + if command -v aspire >/dev/null 2>&1; then + echo "##[error]aspire is already on PATH before install ($(command -v aspire)) — test environment is not clean" + exit 1 + fi + + # Install the RID package first so it lands as a top-level sibling of the + # pointer package. Then install the pointer with --omit=optional so npm + # does not install the matching RID dep from the registry (the just-built + # version does not yet exist in the public registry during this validation + # step). + # + # IMPORTANT: --omit=optional only prevents the *install* of an optional + # dep; npm still resolves its metadata from the registry while building + # the dependency tree. The pointer declares ALL 7 supported RIDs as + # optionalDependencies pinned to the just-built version, so on a network + # path that doesn't get a fast 404 (some 1ES Linux/Windows pools, where + # the npm registry call gets blackholed by network isolation rules), each + # of the 7 lookups burns the full --fetch-timeout. That's how the pointer + # install took 9m on Linux while only 3s on macOS during dry-run build + # 2987581. Pair --omit=optional with --offline so npm never touches the + # network for this validation: optional deps are skipped without a + # resolution attempt and the local tarball is installed straight from + # disk. A short --fetch-timeout is set as belt-and-suspenders in case any + # code path bypasses --offline (it should not, but if it does we'd rather + # fail in seconds than minutes). NPM_CONFIG_CACHE points at a fresh empty + # directory above, so --offline cannot accidentally use a poisoned cache. + npmInstallArgs=( + --foreground-scripts=false + --no-audit + --no-fund + --loglevel=warn + --offline + --fetch-timeout=15000 + ) + + echo "Installing RID package..." + npm install -g "${npmInstallArgs[@]}" "$ridTarball" + + echo "Installing pointer package..." + npm install -g "${npmInstallArgs[@]}" --omit=optional "$pointer" + + echo "" + echo "Verifying aspire CLI is available on PATH..." + if ! command -v aspire >/dev/null 2>&1; then + echo "##[error]aspire command not found in PATH after npm install" + ls -la "$prefix/bin" || true + exit 1 + fi + echo " Path: $(command -v aspire)" + + # Capture the launcher's reported version. The CLI is set to skip its first-run + # update check in CI via DOTNET_CLI_TELEMETRY_OPTOUT and friends; here we just + # need a clean version line. Match the first line that looks like a semver-ish + # token so we are not thrown off by an incidental warn/info line printed before + # or after the version (System.CommandLine's VersionOption normally just prints + # the version and exits, but be defensive). + # + # The version we get from `aspire --version` is the InformationalVersion from + # the assembly, which the dotnet build embeds as SemVer 2.0 with build metadata + # appended after `+`. For example: + # 13.5.0-preview.1.26279.34+727fe3ca9dcecbcc6d10d8b4373ae6f5779b25b4 + # We accept this form and strip the `+` suffix before comparing to + # the npm package version, which intentionally drops build metadata because + # npm SemVer (npm/semver) doesn't honor it for equality. + # See https://semver.org/#spec-item-10 and https://github.com/npm/node-semver#advanced-range-syntax + # + # IMPORTANT: pipe the subprocess output through `tr -d '\r'` BEFORE capturing. + # On Windows runners this template's `bash:` step runs under Git Bash, which + # spawns `aspire.exe` as a Windows console process. System.CommandLine 2.x's + # VersionOption writes through Console.Out.WriteLine, which terminates lines + # with Environment.NewLine = "\r\n" on Windows. Bash command substitution + # `$(...)` strips trailing LF but NOT CR, so the captured variable ends with + # "\r". The semver regex below is anchored with `$`, which matches end-of-line + # but not the literal CR — so without the `tr -d '\r'`, the entire match + # silently fails on Windows and the step exits with + # "##[error]aspire --version reported '' but expected ''". + # Verified locally: `printf 'X\r\n' | grep -Eo '^X$'` produces NO match. + versionOutput="$(aspire --version 2>&1 | tr -d '\r')" + versionLine="$(printf '%s\n' "$versionOutput" | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9._-]+)?(\+[A-Za-z0-9._-]+)?$' | head -n 1 || true)" + # ${var%+*} strips the longest matching `+*` suffix. POSIX, works in Bash 3.2. + actualVersion="${versionLine%+*}" + echo " Raw output: $versionOutput" + echo " Matched semver line: $versionLine" + echo " Comparable version (no +buildmeta): $actualVersion" + if [ -z "$actualVersion" ] || [ "$actualVersion" != "$expectedVersion" ]; then + echo "##[error]aspire --version reported '$actualVersion' but expected '$expectedVersion'" + exit 1 + fi + + # The launcher copies the native binary into $ASPIRE_NPM_CACHE_DIR///bin/. + binaryName="aspire" + if [[ "$rid" == win-* ]]; then + binaryName="aspire.exe" + fi + + cachedBinary="$aspireCache/$expectedVersion/$rid/bin/$binaryName" + if [ ! -f "$cachedBinary" ]; then + echo "##[error]Expected cached binary at $cachedBinary was not created by the launcher" + find "$aspireCache" -maxdepth 6 -type f || true + exit 1 + fi + if [ ! -x "$cachedBinary" ]; then + echo "##[error]Cached binary at $cachedBinary is not executable" + ls -la "$cachedBinary" + exit 1 + fi + echo " Launcher cache layout verified at $cachedBinary" + + echo "" + echo "Uninstalling pointer and RID packages..." + # Reuse the offline/timeout-capped args so a stray audit/funding call + # on an isolated 1ES pool cannot hang the uninstall step the way it + # almost hung the install step. + npm uninstall -g "${npmInstallArgs[@]}" '@microsoft/aspire-cli' "@microsoft/aspire-cli-${rid}" + + if command -v aspire >/dev/null 2>&1; then + echo "##[error]aspire command still on PATH after uninstall ($(command -v aspire))" + exit 1 + fi + echo " Confirmed: aspire is no longer in PATH" + + # Surface every status to the summary step via task variables. + echo "##vso[task.setvariable variable=NpmCheckInstall]passed" + echo "##vso[task.setvariable variable=NpmCheckVersion]passed" + echo "##vso[task.setvariable variable=NpmCheckLauncher]passed" + echo "##vso[task.setvariable variable=NpmCheckUninstall]passed" + displayName: 🟣Install, verify, and uninstall @microsoft/aspire-cli + + - bash: | + set -euo pipefail + stagingDirectory="$(Build.StagingDirectory)" + if command -v cygpath >/dev/null 2>&1 && [[ "$stagingDirectory" =~ ^[A-Za-z]:\\ ]]; then + stagingDirectory="$(cygpath -u "$stagingDirectory")" + fi + + outputDir="$stagingDirectory/npm-validation-summary" + mkdir -p "$outputDir" + outputPath="$outputDir/validation-summary.json" + rid="$(NpmTestRid)" + expectedVersion="$(NpmExpectedVersion)" + skipRegistry='${{ parameters.skipRegistryResolution }}' + + # Lowercase booleans so the JSON is valid. + skipRegistry="$(printf '%s' "$skipRegistry" | tr '[:upper:]' '[:lower:]')" + + cat > "$outputPath" <.tgz && npm install -g --omit=optional .tgz" + }, + "version": { + "status": "$(NpmCheckVersion)", + "details": "aspire --version output matched the build version" + }, + "launcher": { + "status": "$(NpmCheckLauncher)", + "details": "Launcher cached native binary at ASPIRE_NPM_CACHE_DIR///bin/" + }, + "uninstall": { + "status": "$(NpmCheckUninstall)", + "details": "npm uninstall -g @microsoft/aspire-cli @microsoft/aspire-cli-$rid" + } + } + } + EOF + + echo "Wrote npm validation summary to $outputPath" + cat "$outputPath" + displayName: 🟣Write npm validation summary + condition: always() + + - task: 1ES.PublishBuildArtifacts@1 + displayName: 🟣Publish npm validation summary + condition: always() + inputs: + PathtoPublish: '$(Build.StagingDirectory)/npm-validation-summary' + ArtifactName: ${{ parameters.validationSummaryArtifactName }} diff --git a/eng/pipelines/templates/public-pipeline-template.yml b/eng/pipelines/templates/public-pipeline-template.yml index e560f93ed19..e200857acde 100644 --- a/eng/pipelines/templates/public-pipeline-template.yml +++ b/eng/pipelines/templates/public-pipeline-template.yml @@ -32,6 +32,15 @@ variables: - name: _InternalBuildArgs value: '' + # npm global installs and the Corepack Yarn cache seeder don't use the repo + # .npmrc, so pass this registry explicitly. + - name: NPM_REGISTRY + value: https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ + # Disable the interactive "Do you want to download yarn@x.y.z?" prompt so + # pipeline steps don't hang waiting for stdin. + - name: COREPACK_ENABLE_DOWNLOAD_PROMPT + value: '0' + # Set test variants based on parameter or build reason - ${{ if ne(parameters.testVariants, '') }}: - name: testVariants diff --git a/eng/scripts/pack-cli-npm-package.ps1 b/eng/scripts/pack-cli-npm-package.ps1 new file mode 100644 index 00000000000..0edf55cd01a --- /dev/null +++ b/eng/scripts/pack-cli-npm-package.ps1 @@ -0,0 +1,239 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Rid, + + [Parameter(Mandatory = $true)] + [string]$Version, + + [Parameter(Mandatory = $true)] + [string]$NativeBinaryPath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [Parameter(Mandatory = $true)] + [string]$StagingRoot, + + [string]$PackageName = '@microsoft/aspire-cli' +) + +$ErrorActionPreference = 'Stop' + +function New-StringList([string[]]$Values) { + # PowerShell enumerates single-item arrays when returning from functions. + # Returning a List keeps os/cpu/libc/files as JSON arrays for npm metadata. + $list = [System.Collections.Generic.List[string]]::new() + foreach ($value in $Values) { + $list.Add($value) + } + return ,$list +} + +function Get-RidPackageInfo([string]$PackageRid) { + # Keep npm platform metadata aligned with the RIDs produced by the native + # archive build. npm uses os/cpu/libc to install only the matching package. + switch ($PackageRid) { + 'win-x64' { + return [ordered]@{ + BinaryName = 'aspire.exe' + Os = New-StringList 'win32' + Cpu = New-StringList 'x64' + } + } + 'win-arm64' { + return [ordered]@{ + BinaryName = 'aspire.exe' + Os = New-StringList 'win32' + Cpu = New-StringList 'arm64' + } + } + 'linux-x64' { + return [ordered]@{ + BinaryName = 'aspire' + Os = New-StringList 'linux' + Cpu = New-StringList 'x64' + Libc = New-StringList 'glibc' + } + } + 'linux-arm64' { + return [ordered]@{ + BinaryName = 'aspire' + Os = New-StringList 'linux' + Cpu = New-StringList 'arm64' + Libc = New-StringList 'glibc' + } + } + 'linux-musl-x64' { + return [ordered]@{ + BinaryName = 'aspire' + Os = New-StringList 'linux' + Cpu = New-StringList 'x64' + Libc = New-StringList 'musl' + } + } + 'osx-x64' { + return [ordered]@{ + BinaryName = 'aspire' + Os = New-StringList 'darwin' + Cpu = New-StringList 'x64' + } + } + 'osx-arm64' { + return [ordered]@{ + BinaryName = 'aspire' + Os = New-StringList 'darwin' + Cpu = New-StringList 'arm64' + } + } + default { + throw "Unsupported Aspire CLI RID for npm packaging: $PackageRid" + } + } +} + +function Write-JsonFile([string]$Path, [object]$Value) { + $json = $Value | ConvertTo-Json -Depth 20 + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, "$json`n", $utf8NoBom) +} + +function Write-TextFile([string]$Path, [string]$Value) { + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText($Path, $Value, $utf8NoBom) +} + +function Invoke-NpmPack([string]$PackageDirectory, [string]$DestinationDirectory) { + Write-Host "Packing npm package from $PackageDirectory" + & npm pack $PackageDirectory --pack-destination $DestinationDirectory + if ($LASTEXITCODE -ne 0) { + throw "npm pack failed for $PackageDirectory with exit code $LASTEXITCODE." + } +} + +# MSBuild extracts this from the signed native CLI archive. This script only +# repackages that payload; it must not rebuild or substitute another binary. +if (-not (Test-Path -LiteralPath $NativeBinaryPath)) { + throw "Native binary path does not exist: $NativeBinaryPath" +} + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$launcherPath = Join-Path $repoRoot 'eng\clipack\npm\aspire.js' +if (-not (Test-Path -LiteralPath $launcherPath)) { + throw "Npm launcher path does not exist: $launcherPath" +} + +$ridInfo = Get-RidPackageInfo $Rid +$ridPackageName = "$PackageName-$Rid" +$supportedRids = @('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'osx-x64', 'osx-arm64') + +Remove-Item -LiteralPath $StagingRoot -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $StagingRoot -Force | Out-Null +New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null + +$ridPackageRoot = Join-Path $StagingRoot 'rid' +$ridPackageBin = Join-Path $ridPackageRoot 'bin' +New-Item -ItemType Directory -Path $ridPackageBin -Force | Out-Null +Copy-Item -LiteralPath $NativeBinaryPath -Destination (Join-Path $ridPackageBin $ridInfo.BinaryName) -Force + +$ridPackageJson = [ordered]@{ + name = $ridPackageName + version = $Version + description = "Native Aspire CLI binary for $Rid." + license = 'MIT' + repository = [ordered]@{ + type = 'git' + url = 'git+https://github.com/microsoft/aspire.git' + } + bugs = [ordered]@{ + url = 'https://github.com/microsoft/aspire/issues' + } + os = $ridInfo.Os + cpu = $ridInfo.Cpu + files = New-StringList @('bin', 'README.md') +} + +if ($ridInfo.Contains('Libc')) { + $ridPackageJson.libc = $ridInfo.Libc +} + +Write-JsonFile (Join-Path $ridPackageRoot 'package.json') $ridPackageJson +# Use a non-expanding here-string so the markdown backticks (`) survive verbatim. +# In a normal (double-quoted) here-string ` is the PowerShell escape character, which +# both swallows the backticks and suppresses $-interpolation; using @'...'@ and a +# manual -replace lets us emit literal `` code spans for $Rid / $PackageName. +$ridReadmeTemplate = @' +# __RID_PACKAGE_NAME__ + +Native Aspire CLI binary for `__RID__`. + +This package is installed as an optional dependency of `__PACKAGE_NAME__`. +'@ + +$ridReadme = $ridReadmeTemplate ` + -replace '__RID_PACKAGE_NAME__', $ridPackageName ` + -replace '__RID__', $Rid ` + -replace '__PACKAGE_NAME__', $PackageName + +Write-TextFile (Join-Path $ridPackageRoot 'README.md') $ridReadme + +$pointerPackageRoot = Join-Path $StagingRoot 'pointer' +$pointerPackageBin = Join-Path $pointerPackageRoot 'bin' +New-Item -ItemType Directory -Path $pointerPackageBin -Force | Out-Null +Copy-Item -LiteralPath $launcherPath -Destination (Join-Path $pointerPackageBin 'aspire.js') -Force + +if (-not [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + chmod +x (Join-Path $pointerPackageBin 'aspire.js') +} + +$optionalDependencies = [ordered]@{} +foreach ($supportedRid in $supportedRids) { + $optionalDependencies["$PackageName-$supportedRid"] = $Version +} + +# The launcher reads this map instead of hardcoding package names so package +# scope/name changes remain an MSBuild property. +$ridPackageMap = [ordered]@{} +foreach ($supportedRid in $supportedRids) { + $ridPackageMap[$supportedRid] = "$PackageName-$supportedRid" +} + +$pointerPackageJson = [ordered]@{ + name = $PackageName + version = $Version + description = 'Command line tool for Aspire developers.' + license = 'MIT' + repository = [ordered]@{ + type = 'git' + url = 'git+https://github.com/microsoft/aspire.git' + } + bugs = [ordered]@{ + url = 'https://github.com/microsoft/aspire/issues' + } + bin = [ordered]@{ + aspire = 'bin/aspire.js' + } + # Minimum Node 20: the launcher (`bin/aspire.js`) uses Error options-bag + # `new Error(msg, { cause: err })` which was added in Node 16.9.0. The + # `libc` selector in the per-RID optionalDependencies relies on + # npm >= 10.7 (ships with Node 20.10+). Node 18 reaches end-of-life + # 2025-04-30, so Node 20 is the lowest LTS we should support at GA. + # See: https://nodejs.org/en/about/previous-releases + engines = [ordered]@{ + node = '>=20' + } + optionalDependencies = $optionalDependencies + files = New-StringList @('bin', 'README.md') +} + +Write-JsonFile (Join-Path $pointerPackageRoot 'package.json') $pointerPackageJson +Write-JsonFile (Join-Path $pointerPackageBin 'aspire-package-map.json') $ridPackageMap +Write-TextFile (Join-Path $pointerPackageRoot 'README.md') @" +# $PackageName + +Npm package for the Aspire CLI. + +This package installs a small JavaScript launcher and resolves the matching native Aspire CLI package for the current platform. +"@ + +Invoke-NpmPack $ridPackageRoot $OutputPath +Invoke-NpmPack $pointerPackageRoot $OutputPath diff --git a/eng/scripts/stage-native-cli-tool-packages.ps1 b/eng/scripts/stage-native-cli-tool-packages.ps1 index 94ac98d0483..1779a293f33 100644 --- a/eng/scripts/stage-native-cli-tool-packages.ps1 +++ b/eng/scripts/stage-native-cli-tool-packages.ps1 @@ -5,36 +5,71 @@ param( [Parameter(Mandatory = $true)] [string]$ShippingDir, - [string]$CanonicalPointerArtifactName = 'native_archives_win_x64' + [string]$CanonicalPointerArtifactName = 'native_archives_win_x64', + + [string]$NpmPackageFilePrefix = 'microsoft-aspire-cli', + + [switch]$RequireNpmPackages ) $ErrorActionPreference = 'Stop' New-Item -ItemType Directory -Path $ShippingDir -Force | Out-Null -$packages = @(Get-ChildItem -Path $DownloadRoot -Filter "Aspire.Cli*.nupkg" -File -Recurse | - Where-Object { $_.Name -notmatch '\.symbols\.' } | - Sort-Object FullName) -if ($packages.Count -eq 0) { - throw "No native CLI tool packages were downloaded to $DownloadRoot." +$packageVersionPattern = '(?\d+(?:\.\d+){1,3}(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)' + +function Get-NativeArchiveArtifact { + param([System.IO.FileInfo]$Package) + + $pathParts = $Package.FullName.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) + $nativeArchiveRoot = @($pathParts | Where-Object { $_ -like 'native_archives_*' } | Select-Object -First 1) + + if ($nativeArchiveRoot.Count -eq 0) { + return $null + } + + $artifactName = $nativeArchiveRoot[0] + return [pscustomobject]@{ + Name = $artifactName + Rid = $artifactName.Substring('native_archives_'.Length).Replace('_', '-') + } } -$packageVersionPattern = '(?\d+(?:\.\d+){1,3}(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)' -$pointerPackagePattern = "^Aspire\.Cli\.$packageVersionPattern\.nupkg$" +function New-ClassifiedPackage { + param( + [System.IO.FileInfo]$Package, + [string]$PackageKind, + [string]$ArtifactName, + [string]$ArtifactRid, + [string]$PackageVersion, + [bool]$IsPointerPackage + ) + + [pscustomobject]@{ + File = $Package + PackageKind = $PackageKind + PackageName = $Package.Name + PackageVersion = $PackageVersion + ArtifactName = $ArtifactName + ArtifactRid = $ArtifactRid + IsPointerPackage = $IsPointerPackage + } +} -$classifiedPackages = @( - foreach ($package in $packages) { - $pathParts = $package.FullName.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) - $nativeArchiveRoot = @($pathParts | Where-Object { $_ -like 'native_archives_*' } | Select-Object -First 1) +function Get-ClassifiedNuGetPackages { + $packages = @(Get-ChildItem -Path $DownloadRoot -Filter "Aspire.Cli*.nupkg" -File -Recurse | + Where-Object { $_.Name -notmatch '\.symbols\.' } | + Sort-Object FullName) - if ($nativeArchiveRoot.Count -eq 0) { + foreach ($package in $packages) { + $artifact = Get-NativeArchiveArtifact $package + if ($null -eq $artifact) { Write-Warning "Skipping Aspire.Cli package outside native archive artifacts: $($package.FullName)" continue } - $artifactName = $nativeArchiveRoot[0] - $artifactRid = $artifactName.Substring('native_archives_'.Length).Replace('_', '-') - $artifactRidPattern = [System.Text.RegularExpressions.Regex]::Escape($artifactRid) + $artifactRidPattern = [System.Text.RegularExpressions.Regex]::Escape($artifact.Rid) + $pointerPackagePattern = "^Aspire\.Cli\.$packageVersionPattern\.nupkg$" $ridPackagePattern = "^Aspire\.Cli\.$artifactRidPattern\.$packageVersionPattern\.nupkg$" $isPointerPackage = $false @@ -48,49 +83,103 @@ $classifiedPackages = @( } if ($null -eq $packageVersion) { - throw "Unexpected Aspire.Cli package '$($package.FullName)' in artifact '$artifactName'. Expected either an Aspire.Cli pointer package or an Aspire.Cli.$artifactRid RID-specific package." + throw "Unexpected Aspire.Cli package '$($package.FullName)' in artifact '$($artifact.Name)'. Expected either an Aspire.Cli pointer package or an Aspire.Cli.$($artifact.Rid) RID-specific package." } - [pscustomobject]@{ - File = $package - PackageName = $package.Name - PackageVersion = $packageVersion - ArtifactName = $artifactName - ArtifactRid = $artifactRid - IsPointerPackage = $isPointerPackage - } + New-ClassifiedPackage $package 'NuGet' $artifact.Name $artifact.Rid $packageVersion $isPointerPackage } -) - -if ($classifiedPackages.Count -eq 0) { - throw "No native CLI tool packages were found under native_archives_ artifacts in $DownloadRoot." } -$canonicalPointerPackages = @($classifiedPackages | Where-Object { $_.IsPointerPackage -and $_.ArtifactName -eq $CanonicalPointerArtifactName }) -if ($canonicalPointerPackages.Count -ne 1) { - throw "Expected exactly one canonical Aspire.Cli pointer package from $CanonicalPointerArtifactName, but found $($canonicalPointerPackages.Count): $($canonicalPointerPackages.File.FullName -join ', ')" -} +function Get-ClassifiedNpmPackages { + $packages = @(Get-ChildItem -Path $DownloadRoot -Filter "$NpmPackageFilePrefix*.tgz" -File -Recurse | + Sort-Object FullName) + $escapedPrefix = [System.Text.RegularExpressions.Regex]::Escape($NpmPackageFilePrefix) + + foreach ($package in $packages) { + $artifact = Get-NativeArchiveArtifact $package + if ($null -eq $artifact) { + Write-Warning "Skipping Aspire CLI npm package outside native archive artifacts: $($package.FullName)" + continue + } + + $artifactRidPattern = [System.Text.RegularExpressions.Regex]::Escape($artifact.Rid) + $pointerPackagePattern = "^$escapedPrefix-$packageVersionPattern\.tgz$" + $ridPackagePattern = "^$escapedPrefix-$artifactRidPattern-$packageVersionPattern\.tgz$" + + $isPointerPackage = $false + $packageVersion = $null -$skippedPointerPackages = @($classifiedPackages | Where-Object { $_.IsPointerPackage -and $_.ArtifactName -ne $CanonicalPointerArtifactName }) -foreach ($package in $skippedPointerPackages) { - Write-Host "Skipping non-canonical Aspire.Cli pointer package from $($package.ArtifactName): $($package.File.FullName)" + if ($package.Name -match $pointerPackagePattern) { + $isPointerPackage = $true + $packageVersion = $Matches.Version + } elseif ($package.Name -match $ridPackagePattern) { + $packageVersion = $Matches.Version + } + + if ($null -eq $packageVersion) { + throw "Unexpected Aspire CLI npm package '$($package.FullName)' in artifact '$($artifact.Name)'. Expected either a $NpmPackageFilePrefix pointer package or a $NpmPackageFilePrefix-$($artifact.Rid) RID-specific package." + } + + New-ClassifiedPackage $package 'npm' $artifact.Name $artifact.Rid $packageVersion $isPointerPackage + } } -$canonicalPointerVersion = $canonicalPointerPackages[0].PackageVersion -$ridPackages = @($classifiedPackages | Where-Object { -not $_.IsPointerPackage }) -$duplicateRidPackageGroups = @($ridPackages | Group-Object ArtifactRid | Where-Object { $_.Count -gt 1 }) -if ($duplicateRidPackageGroups.Count -gt 0) { - $details = @($duplicateRidPackageGroups | ForEach-Object { "$($_.Name): $($_.Group.File.FullName -join ', ')" }) - throw "Expected exactly one Aspire.Cli RID-specific package per RID, but found duplicates: $($details -join '; ')" +function Get-PackagesToStage { + param( + [object[]]$ClassifiedPackages, + [string]$PointerPackageDescription, + [string]$RidPackageDescription, + [string]$NoPackagesDescription + ) + + if ($ClassifiedPackages.Count -eq 0) { + throw "No $NoPackagesDescription packages were found under native_archives_ artifacts in $DownloadRoot." + } + + $canonicalPointerPackages = @($ClassifiedPackages | Where-Object { $_.IsPointerPackage -and $_.ArtifactName -eq $CanonicalPointerArtifactName }) + if ($canonicalPointerPackages.Count -ne 1) { + throw "Expected exactly one canonical $PointerPackageDescription pointer package from $CanonicalPointerArtifactName, but found $($canonicalPointerPackages.Count): $($canonicalPointerPackages.File.FullName -join ', ')" + } + + # Pointer packages are produced for every RID build. Stage exactly one + # canonical pointer package and all RID-specific packages. + $skippedPointerPackages = @($ClassifiedPackages | Where-Object { $_.IsPointerPackage -and $_.ArtifactName -ne $CanonicalPointerArtifactName }) + foreach ($package in $skippedPointerPackages) { + Write-Host "Skipping non-canonical $PointerPackageDescription pointer package from $($package.ArtifactName): $($package.File.FullName)" + } + + $canonicalPointerVersion = $canonicalPointerPackages[0].PackageVersion + $ridPackages = @($ClassifiedPackages | Where-Object { -not $_.IsPointerPackage }) + $duplicateRidPackageGroups = @($ridPackages | Group-Object ArtifactRid | Where-Object { $_.Count -gt 1 }) + if ($duplicateRidPackageGroups.Count -gt 0) { + $details = @($duplicateRidPackageGroups | ForEach-Object { "$($_.Name): $($_.Group.File.FullName -join ', ')" }) + throw "Expected exactly one $RidPackageDescription RID-specific package per RID, but found duplicates: $($details -join '; ')" + } + + $ridPackageVersionMismatches = @($ridPackages | Where-Object { $_.PackageVersion -ne $canonicalPointerVersion }) + if ($ridPackageVersionMismatches.Count -gt 0) { + $details = @($ridPackageVersionMismatches | ForEach-Object { "$($_.PackageName) has version $($_.PackageVersion)" }) + throw "All $RidPackageDescription RID-specific packages must match canonical pointer package version $canonicalPointerVersion from $CanonicalPointerArtifactName, but found mismatches: $($details -join '; ')" + } + + return @($canonicalPointerPackages[0]) + $ridPackages } -$ridPackageVersionMismatches = @($ridPackages | Where-Object { $_.PackageVersion -ne $canonicalPointerVersion }) -if ($ridPackageVersionMismatches.Count -gt 0) { - $details = @($ridPackageVersionMismatches | ForEach-Object { "$($_.PackageName) has version $($_.PackageVersion)" }) - throw "All Aspire.Cli RID-specific packages must match canonical pointer package version $canonicalPointerVersion from $CanonicalPointerArtifactName, but found mismatches: $($details -join '; ')" +$nugetPackagesToStage = Get-PackagesToStage (Get-ClassifiedNuGetPackages) 'Aspire.Cli' 'Aspire.Cli' 'native CLI tool' +$classifiedNpmPackages = @(Get-ClassifiedNpmPackages) +$npmPackagesToStage = if ($classifiedNpmPackages.Count -eq 0) { + $message = "No Aspire CLI npm packages were found under native_archives_ artifacts in $DownloadRoot." + if ($RequireNpmPackages) { + throw $message + } + + Write-Warning $message + @() +} else { + Get-PackagesToStage $classifiedNpmPackages 'Aspire CLI npm' 'Aspire CLI npm' 'Aspire CLI npm' } +$packagesToStage = @($nugetPackagesToStage) + @($npmPackagesToStage) -$packagesToStage = @($canonicalPointerPackages[0]) + $ridPackages foreach ($packageToStage in $packagesToStage) { $package = $packageToStage.File Copy-Item -LiteralPath $package.FullName -Destination (Join-Path $ShippingDir $package.Name) -Force diff --git a/eng/scripts/verify-cli-npm-package.ps1 b/eng/scripts/verify-cli-npm-package.ps1 new file mode 100644 index 00000000000..f9791a8d95c --- /dev/null +++ b/eng/scripts/verify-cli-npm-package.ps1 @@ -0,0 +1,222 @@ +param( + [Parameter(Mandatory = $true)] + [string]$PackagesDir, + + [Parameter(Mandatory = $true)] + [string]$Rid, + + [string]$ArchivePath, + + [string]$PackageName = '@microsoft/aspire-cli' +) + +$ErrorActionPreference = 'Stop' + +function Write-Step([string]$Message) { + Write-Host "[verify-cli-npm-package] $Message" +} + +function Assert-SinglePackage([object[]]$Packages, [string]$Description) { + if (-not $Packages -or $Packages.Count -eq 0) { + throw "Could not find $Description." + } + + if ($Packages.Count -gt 1) { + throw "Found multiple packages for ${Description}: $($Packages.Name -join ', ')" + } + + return $Packages[0] +} + +function Expand-Tgz([string]$PackagePath, [string]$Destination) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + tar -xzf $PackagePath -C $Destination + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract $PackagePath." + } +} + +function Expand-CliArchive([string]$Archive, [string]$Destination) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + + if ($Archive.EndsWith('.zip', [System.StringComparison]::OrdinalIgnoreCase)) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + [System.IO.Compression.ZipFile]::ExtractToDirectory($Archive, $Destination) + return + } + + if ($Archive.EndsWith('.tar.gz', [System.StringComparison]::OrdinalIgnoreCase)) { + tar -xzf $Archive -C $Destination + if ($LASTEXITCODE -ne 0) { + throw "Failed to extract CLI archive $Archive." + } + return + } + + throw "Unsupported archive format: $Archive (expected .zip or .tar.gz)." +} + +function Test-FileContentEqual([string]$ExpectedPath, [string]$ActualPath) { + $expected = Get-Item -LiteralPath $ExpectedPath + $actual = Get-Item -LiteralPath $ActualPath + if ($expected.Length -ne $actual.Length) { + return $false + } + + $expectedStream = [System.IO.File]::OpenRead($expected.FullName) + $actualStream = [System.IO.File]::OpenRead($actual.FullName) + try { + $expectedBuffer = [byte[]]::new(1024 * 1024) + $actualBuffer = [byte[]]::new(1024 * 1024) + + while ($true) { + $expectedRead = $expectedStream.Read($expectedBuffer, 0, $expectedBuffer.Length) + $actualRead = $actualStream.Read($actualBuffer, 0, $actualBuffer.Length) + + if ($expectedRead -ne $actualRead) { + return $false + } + + if ($expectedRead -eq 0) { + return $true + } + + for ($i = 0; $i -lt $expectedRead; $i++) { + if ($expectedBuffer[$i] -ne $actualBuffer[$i]) { + return $false + } + } + } + } + finally { + $expectedStream.Dispose() + $actualStream.Dispose() + } +} + +function Get-NpmPackageFilePrefix([string]$NpmPackageName) { + return $NpmPackageName.TrimStart('@').Replace('/', '-') +} + +$effectiveDir = if (Test-Path (Join-Path $PackagesDir 'Shipping')) { + Join-Path $PackagesDir 'Shipping' +} else { + $PackagesDir +} + +Write-Step "PackagesDir: $effectiveDir" +Write-Step "RID: $Rid" + +if ($ArchivePath) { + if (-not (Test-Path -LiteralPath $ArchivePath)) { + throw "ArchivePath does not exist: $ArchivePath" + } + + $ArchivePath = (Resolve-Path -LiteralPath $ArchivePath).Path + Write-Step "Archive: $ArchivePath" +} + +# npm pack flattens scoped package names into tarball file names, e.g. +# @microsoft/aspire-cli -> microsoft-aspire-cli-1.0.0.tgz. +$packageFilePrefix = Get-NpmPackageFilePrefix $PackageName +$ridPackage = Assert-SinglePackage ` + (Get-ChildItem -Path $effectiveDir -Filter "$packageFilePrefix-$Rid-*.tgz" -ErrorAction SilentlyContinue) ` + "RID-specific npm package for $Rid" +$pointerPackage = Assert-SinglePackage ` + (Get-ChildItem -Path $effectiveDir -Filter "$packageFilePrefix-*.tgz" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch "^$([System.Text.RegularExpressions.Regex]::Escape($packageFilePrefix))-(win|linux|linux-musl|osx)-(x64|arm64)-" }) ` + 'Aspire CLI npm pointer package' + +Write-Step "RID package: $($ridPackage.Name)" +Write-Step "Pointer package: $($pointerPackage.Name)" + +$root = [System.IO.Directory]::CreateTempSubdirectory('aspire-cli-npm-package-').FullName +$ridExtract = Join-Path $root 'rid' +$pointerExtract = Join-Path $root 'pointer' +$archiveExtract = Join-Path $root 'archive' + +try { + Expand-Tgz $ridPackage.FullName $ridExtract + Expand-Tgz $pointerPackage.FullName $pointerExtract + + $binaryName = if ($Rid -like 'win-*') { 'aspire.exe' } else { 'aspire' } + $binaryPath = Join-Path $ridExtract "package/bin/$binaryName" + if (-not (Test-Path -LiteralPath $binaryPath)) { + throw "Could not find expected native binary at package/bin/$binaryName in $($ridPackage.Name)." + } + + if ($ArchivePath) { + Expand-CliArchive $ArchivePath $archiveExtract + $archiveBinaryPath = Join-Path $archiveExtract $binaryName + if (-not (Test-Path -LiteralPath $archiveBinaryPath)) { + throw "Could not find $binaryName in CLI archive $ArchivePath." + } + + # The signed native archive remains the canonical payload. The npm RID + # tarball must contain the exact same executable bytes. + if (-not (Test-FileContentEqual $archiveBinaryPath $binaryPath)) { + $archiveBinary = Get-Item -LiteralPath $archiveBinaryPath + $npmBinary = Get-Item -LiteralPath $binaryPath + throw "RID npm package binary does not match archive binary '$($archiveBinary.Name)'. Archive size: $($archiveBinary.Length) bytes; npm package size: $($npmBinary.Length) bytes." + } + + Write-Step "RID package binary matches CLI archive binary." + } + + $pointerPackageJsonPath = Join-Path $pointerExtract 'package/package.json' + if (-not (Test-Path -LiteralPath $pointerPackageJsonPath)) { + throw "Pointer package $($pointerPackage.Name) is missing package.json." + } + + $pointerPackageJson = Get-Content -Path $pointerPackageJsonPath -Raw | ConvertFrom-Json + $expectedRidPackageName = "$PackageName-$Rid" + if ($pointerPackageJson.name -ne $PackageName) { + throw "Pointer package name mismatch. Expected '$PackageName', got '$($pointerPackageJson.name)'." + } + + if (-not $pointerPackageJson.bin -or $pointerPackageJson.bin.aspire -ne 'bin/aspire.js') { + throw "Pointer package must expose the aspire bin at bin/aspire.js." + } + + if (-not (Test-Path -LiteralPath (Join-Path $pointerExtract 'package/bin/aspire.js'))) { + throw "Pointer package is missing bin/aspire.js." + } + + $packageMapPath = Join-Path $pointerExtract 'package/bin/aspire-package-map.json' + if (-not (Test-Path -LiteralPath $packageMapPath)) { + throw "Pointer package is missing bin/aspire-package-map.json." + } + + # The launcher depends on this generated map to resolve the selected RID + # package without hardcoding the package scope/name. + $packageMap = Get-Content -Path $packageMapPath -Raw | ConvertFrom-Json + if ($packageMap.$Rid -ne $expectedRidPackageName) { + throw "Pointer package map does not reference $expectedRidPackageName for $Rid." + } + + $optionalDependencyVersion = $pointerPackageJson.optionalDependencies.$expectedRidPackageName + if ($optionalDependencyVersion -ne $pointerPackageJson.version) { + throw "Pointer package optionalDependencies does not reference $expectedRidPackageName at version $($pointerPackageJson.version)." + } + + $ridPackageJsonPath = Join-Path $ridExtract 'package/package.json' + if (-not (Test-Path -LiteralPath $ridPackageJsonPath)) { + throw "RID package $($ridPackage.Name) is missing package.json." + } + + $ridPackageJson = Get-Content -Path $ridPackageJsonPath -Raw | ConvertFrom-Json + if ($ridPackageJson.name -ne $expectedRidPackageName) { + throw "RID package name mismatch. Expected '$expectedRidPackageName', got '$($ridPackageJson.name)'." + } + + if ($ridPackageJson.version -ne $pointerPackageJson.version) { + throw "RID package version '$($ridPackageJson.version)' does not match pointer package version '$($pointerPackageJson.version)'." + } + + Write-Step "CLI npm package verification passed." +} +finally { + if (Test-Path $root) { + Remove-Item -Path $root -Recurse -Force + } +} diff --git a/extension/CONTRIBUTING.MD b/extension/CONTRIBUTING.MD index 41e9a739728..905192acca0 100644 --- a/extension/CONTRIBUTING.MD +++ b/extension/CONTRIBUTING.MD @@ -2,8 +2,11 @@ ## Install Prerequisites -- Node.js (LTS version) -- Yarn accessible in the PATH +- Node.js (LTS version) — `npm` must be on the PATH (it ships with Node.js). The + build scripts (`build.sh` / `build.ps1`) install a pinned [Corepack](https://github.com/nodejs/corepack) + via `npm install -g corepack@` from the configured registry and seed + Corepack's cache with the Yarn release pinned by the `packageManager` field in + `extension/package.json`. You do **not** need to install Yarn yourself. - Visual Studio Code (latest) or Visual Studio Code Insiders - [Aspire CLI](https://aspire.dev/get-started/install-cli/) must be installed and available in the PATH @@ -26,12 +29,39 @@ You can use the `Aspire: Extension settings` command to open VS Code settings di ## Updating dependency overrides -The extension is built with **yarn**, so `package.json` uses `resolutions` for transitive dependency pins and `yarn.lock` is the authoritative lockfile. +The extension is built with **yarn**, pinned to the version recorded in `packageManager` of `package.json`. `package.json` uses `resolutions` for transitive dependency pins and `yarn.lock` is the authoritative lockfile. When pinning a transitive dependency (e.g. to address a security advisory), add the pin to `resolutions` and regenerate `yarn.lock` in the same change: ```bash -yarn install +corepack yarn install ``` -The build rejects public npm registry URLs in `yarn.lock`; ensure regenerated entries resolve through the internal `dotnet-public-npm` feed. \ No newline at end of file +The build rejects public npm registry URLs in `yarn.lock`; ensure regenerated entries resolve through the internal `dotnet-public-npm` feed. + +## Updating the Yarn version + +Edit the `"packageManager": "yarn@x.y.z"` field in `extension/package.json`. The next `build.sh` / `build.ps1` run seeds Corepack's cache with that version before calling `corepack yarn …`. No further changes are required in `build.sh`, `build.ps1`, or `extension/Extension.proj`. + +> **Heads up about the internal npm mirror.** The repo's `.npmrc` and the build scripts' `NPM_REGISTRY` default route npm package downloads through the dnceng `dotnet-public-npm` Azure Artifacts feed, which is a pull-through cache of npmjs.org. The first time anyone asks the feed for a version that has never been requested, the feed has to fetch it from npmjs.org — and anonymous pull-through fetches fail with HTTP 401. Subsequent anonymous reads work fine. If you bump the pinned Corepack or Yarn version, pre-seed the feed with credentials using `npm install --global --registry https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ corepack@` or `npm pack --registry https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/ yarn@`. Do not point `COREPACK_NPM_REGISTRY` at this Azure Artifacts feed for Yarn: Corepack requests the `//` npm metadata route, and Azure Artifacts returns 404 for that route even when the package and tarball exist. + +## Troubleshooting + +### `EACCES` from `npm install --global` on Linux/macOS + +The build scripts run `npm install --global corepack@` to pin the Corepack version. On systems where Node.js is installed from a package manager (apt, yum, the official `.pkg`), the npm global prefix is typically `/usr/lib/node_modules` or `/usr/local/lib/node_modules`, which is root-owned. The install will fail with `EACCES`. The cleanest fix is to use a Node version manager that puts the npm prefix in your home directory: + +- [`nvm`](https://github.com/nvm-sh/nvm) — installs Node and configures the npm prefix automatically. +- [`fnm`](https://github.com/Schniz/fnm), [`asdf`](https://asdf-vm.com/), [`volta`](https://volta.sh/) — same idea, different tradeoffs. + +Alternatively, point npm at a user-writable prefix without changing your Node install: + +```bash +mkdir -p ~/.npm-global +npm config set prefix ~/.npm-global +export PATH="$HOME/.npm-global/bin:$PATH" # add to ~/.bashrc or ~/.zshrc +``` + +### `corepack version mismatch` from `build.sh` / `build.ps1` + +This means the `corepack` resolved from `PATH` is not the one we just installed via `npm install -g`. Most often the system Node install (`/usr/bin/corepack`, `%ProgramFiles%\nodejs\corepack.cmd`) is sitting in front of the npm global bin directory. On Windows, ensure `%APPDATA%\npm` comes before `%ProgramFiles%\nodejs` on `PATH`. On Linux/macOS, follow the `EACCES` remediation above and the npm prefix will be on `PATH` ahead of the system Node directory. diff --git a/extension/Extension.proj b/extension/Extension.proj index ff8c1048973..c6e350aad2b 100644 --- a/extension/Extension.proj +++ b/extension/Extension.proj @@ -2,6 +2,31 @@ $(DefaultTargetFramework) $(MSBuildThisFileDirectory) + + <_CorepackHome Condition="'$(COREPACK_HOME)' != ''">$(COREPACK_HOME) + <_CorepackHome Condition="'$(_CorepackHome)' == ''">$([MSBuild]::NormalizePath($(ExtensionSrcDir), '.corepack-cache')) @@ -31,13 +56,14 @@ - - - + + @@ -70,6 +96,14 @@ <_YarnLockPath>$([MSBuild]::NormalizePath($(ExtensionSrcDir), 'yarn.lock')) + + <_AllowedNpmRegistryHost>pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm @@ -79,21 +113,28 @@ - <_PublicNpmRegistryLine Include="@(_YarnLockLine)" Condition="$([System.String]::Copy('%(_YarnLockLine.Identity)').Contains('registry.npmjs.org')) Or $([System.String]::Copy('%(_YarnLockLine.Identity)').Contains('registry.yarnpkg.com'))" /> + <_YarnLockResolvedLine Include="@(_YarnLockLine)" Condition="$([System.String]::Copy('%(_YarnLockLine.Identity)').TrimStart().StartsWith('resolved "'))" /> + <_DisallowedRegistryLine Include="@(_YarnLockResolvedLine)" Condition="!$([System.String]::Copy('%(_YarnLockResolvedLine.Identity)').Contains('$(_AllowedNpmRegistryHost)'))" /> + Text="extension/yarn.lock contains resolved entries outside the internal dotnet-public-npm feed. Regenerate it through the internal feed before building. Offending lines: @(_DisallowedRegistryLine, '; ')" + Condition="'@(_DisallowedRegistryLine)' != ''" /> - + + - + diff --git a/extension/build.ps1 b/extension/build.ps1 index 21069d7ff13..94436d77491 100644 --- a/extension/build.ps1 +++ b/extension/build.ps1 @@ -2,6 +2,54 @@ $ErrorActionPreference = "Stop" +# Ensure we run from the extension directory so corepack/yarn pick up +# extension/.npmrc and extension/package.json (which holds the packageManager pin). +Set-Location $PSScriptRoot + +# Pinned Corepack shim version. Node.js >= 16.10 bundles a Corepack, but the +# bundled version drifts with each Node release and Corepack is on track to be +# unbundled from Node entirely (see https://github.com/nodejs/node/issues/54647). +# Installing a pinned Corepack from npm makes the build reproducible regardless +# of which Node version a developer or CI runner happens to have. +# +# The version is stored in scripts/corepack-version.txt so that this script, the +# Bash build script, the GitHub Actions workflow, and the AzDO pipelines all +# read from a single source of truth. +$CorepackVersion = (Get-Content -Raw -Path (Join-Path $PSScriptRoot 'scripts/corepack-version.txt')).Trim() +if ([string]::IsNullOrWhiteSpace($CorepackVersion)) { + Write-Error "scripts/corepack-version.txt is empty or unreadable." + exit 1 +} + +# Yarn version is pinned in extension/package.json via the "packageManager" +# field, which scripts/prepareCorepackYarn.mjs uses to seed Corepack's cache. + +# Point npm at the dnceng internal npm mirror when installing the pinned Corepack +# shim and seeding Corepack's Yarn cache. npm global installs do not use the +# project .npmrc, so pass the registry explicitly. Override locally with +# `$env:NPM_REGISTRY = ''; ./build.ps1`. +if (-not $env:NPM_REGISTRY) { + $env:NPM_REGISTRY = "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/" +} +if (-not $env:COREPACK_ENABLE_DOWNLOAD_PROMPT) { + $env:COREPACK_ENABLE_DOWNLOAD_PROMPT = "0" +} + +# Pin Corepack's cache directory to a build-scoped location. Without this, every +# build shares the user's default cache (%LOCALAPPDATA%\node\corepack on +# Windows, ~/.cache/node/corepack on Linux, ~/Library/Caches/node/corepack on +# macOS). prepareCorepackYarn.mjs rewrites that cache in place +# (rmSync(installDirectory) followed by renameSync(staging, installDirectory)), +# so concurrent builds racing on the same Corepack home can corrupt each other's +# cache. The CI pipelines already scope this per-job (e.g. AzDO uses +# Agent.TempDirectory); do the same here so multi-worktree setups stay +# isolated. Concurrent builds in the *same* worktree still race on this shared +# directory — prepareCorepackYarn.mjs handles the EEXIST/ENOTEMPTY rename +# collision but is not a substitute for a lock. The directory is gitignored. +if (-not $env:COREPACK_HOME) { + $env:COREPACK_HOME = Join-Path $PSScriptRoot '.corepack-cache' +} + Write-Host "Checking prerequisites..." # Check for Node.js @@ -10,10 +58,10 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { exit 1 } -# Check for yarn -if (-not (Get-Command yarn -ErrorAction SilentlyContinue)) { - Write-Error "Error: yarn is not installed. Please install yarn first." - Write-Host "You can install yarn by running: npm install -g yarn" +# npm is required to install our pinned Corepack. It ships with every official +# Node.js distribution, so this should only fail on broken installs. +if (-not (Get-Command npm -ErrorAction SilentlyContinue)) { + Write-Error "Error: npm is not available. Reinstall Node.js so npm is on PATH." exit 1 } @@ -36,12 +84,65 @@ if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { Write-Host "All prerequisites satisfied." -# Ensure we run from the extension directory -Set-Location $PSScriptRoot +Write-Host "" +Write-Host "Installing pinned Corepack $CorepackVersion..." +# Reinstall every time so we overwrite any older Corepack shim that Node.js +# may have placed on PATH ahead of npm's global prefix. npm global installs do +# not use the project .npmrc, so pass the registry explicitly. +# +# --force is required because Corepack's npm package declares `yarn`, `yarnpkg`, +# `pnpm`, `pnpx`, and `corepack` as bin entries. Without --force, npm refuses to +# overwrite bins owned by a pre-existing global yarn or pnpm (a very common +# setup, and the state this repo itself was in before this build script existed) +# and aborts with EEXIST. The CI pipelines already pass --force for the same +# reason. +npm install --global --force --registry "$env:NPM_REGISTRY" "corepack@$CorepackVersion" + +if ($LASTEXITCODE -ne 0) { + Write-Error "npm install -g corepack@$CorepackVersion failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +# Verify the version actually on PATH matches our pin. On Windows the Node.js +# installer registers a `corepack.cmd` under %ProgramFiles%\nodejs which may +# shadow the npm-global shim under %APPDATA%\npm, so a successful +# `npm install -g corepack@` does NOT guarantee that subsequent +# `corepack` calls resolve to it. Fail loudly here so we don't silently run +# with the wrong tool. +$installedCorepack = $null +try { $installedCorepack = (corepack --version).Trim() } catch { } +if ($installedCorepack -ne $CorepackVersion) { + Write-Error @" +corepack version mismatch: expected $CorepackVersion, got '$installedCorepack'. +The bundled Corepack on PATH may be taking precedence over the npm-global install. +Ensure your npm global bin directory (typically %APPDATA%\npm on Windows) comes +before %ProgramFiles%\nodejs on PATH, or run `corepack disable` to remove the +bundled shim before re-running this script. +"@ + exit 1 +} + +Write-Host "" +Write-Host "Enabling Corepack package manager shims..." +corepack enable + +if ($LASTEXITCODE -ne 0) { + Write-Error "corepack enable failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "Preparing Yarn from packageManager pin in package.json..." +node ./scripts/prepareCorepackYarn.mjs + +if ($LASTEXITCODE -ne 0) { + Write-Error "Preparing Yarn for Corepack failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} Write-Host "" Write-Host "Running yarn install..." -yarn install --frozen-lockfile --non-interactive +corepack yarn install --frozen-lockfile --non-interactive if ($LASTEXITCODE -ne 0) { Write-Error "yarn install failed with exit code $LASTEXITCODE" @@ -50,7 +151,7 @@ if ($LASTEXITCODE -ne 0) { Write-Host "" Write-Host "Running yarn compile..." -yarn compile +corepack yarn compile if ($LASTEXITCODE -ne 0) { Write-Error "yarn compile failed with exit code $LASTEXITCODE" diff --git a/extension/build.sh b/extension/build.sh index 4b7c7e79bac..1fbb702d5ce 100755 --- a/extension/build.sh +++ b/extension/build.sh @@ -1,6 +1,55 @@ #!/bin/bash set -e +# Ensure we run from the extension directory so corepack/yarn pick up +# extension/.npmrc and extension/package.json (which holds the packageManager pin). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Pinned Corepack shim version. Node.js >= 16.10 bundles a Corepack, but the +# bundled version drifts with each Node release and Corepack is on track to be +# unbundled from Node entirely (see https://github.com/nodejs/node/issues/54647). +# Installing a pinned Corepack from npm makes the build reproducible regardless +# of which Node version a developer or CI runner happens to have. +# +# The version is stored in scripts/corepack-version.txt so that this script, the +# PowerShell build script, the GitHub Actions workflow, and the AzDO pipelines +# all read from a single source of truth. +COREPACK_VERSION="$(tr -d '[:space:]' < "$SCRIPT_DIR/scripts/corepack-version.txt")" +if [ -z "$COREPACK_VERSION" ]; then + echo "Error: scripts/corepack-version.txt is empty or unreadable." + exit 1 +fi + +# Yarn version is pinned in extension/package.json via the "packageManager" +# field, which scripts/prepareCorepackYarn.mjs uses to seed Corepack's cache. + +# Point npm at the dnceng internal npm mirror when installing the pinned Corepack +# shim and seeding Corepack's Yarn cache. npm global installs do not use the +# project .npmrc, so pass the registry explicitly. Override locally with +# `NPM_REGISTRY= ./build.sh`. +# Export NPM_REGISTRY so the child `node ./scripts/prepareCorepackYarn.mjs` +# process inherits it; without `export`, the script silently falls back to its +# own DefaultNpmRegistry constant and any user override of NPM_REGISTRY would +# be ignored when seeding Corepack's Yarn cache. +: "${NPM_REGISTRY:=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/}" +export NPM_REGISTRY +: "${COREPACK_ENABLE_DOWNLOAD_PROMPT:=0}" +export COREPACK_ENABLE_DOWNLOAD_PROMPT + +# Pin Corepack's cache directory to a build-scoped location. Without this, every +# build shares the user's default cache (~/.cache/node/corepack on Linux, +# ~/Library/Caches/node/corepack on macOS, %LOCALAPPDATA%\node\corepack on +# Windows). prepareCorepackYarn.mjs rewrites that cache in place +# (rmSync(installDirectory) followed by renameSync(staging, installDirectory)), +# so concurrent builds racing on the same Corepack home can corrupt each other's +# cache. The CI pipelines already scope this per-job (e.g. AzDO uses +# Agent.TempDirectory); do the same here so multi-worktree setups stay +# isolated. Concurrent builds in the *same* worktree still race on this shared +# directory — prepareCorepackYarn.mjs handles the EEXIST/ENOTEMPTY rename +# collision but is not a substitute for a lock. The directory is gitignored. +: "${COREPACK_HOME:=$SCRIPT_DIR/.corepack-cache}" +export COREPACK_HOME + echo "Checking prerequisites..." # Check for Node.js @@ -9,10 +58,10 @@ if ! command -v node &> /dev/null; then exit 1 fi -# Check for yarn -if ! command -v yarn &> /dev/null; then - echo "Error: yarn is not installed. Please install yarn first." - echo "You can install yarn by running: npm install -g yarn" +# npm is required to install our pinned Corepack. It ships with every official +# Node.js distribution, so this should only fail on broken installs. +if ! command -v npm &> /dev/null; then + echo "Error: npm is not available. Reinstall Node.js so npm is on PATH." exit 1 fi @@ -32,17 +81,51 @@ fi echo "All prerequisites satisfied." -# Ensure we run from the extension directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" +echo "" +echo "Installing pinned Corepack ${COREPACK_VERSION}..." +# Reinstall every time so we overwrite any older Corepack shim that Node.js +# may have placed on PATH ahead of npm's global prefix. npm global installs do +# not use the project .npmrc, so pass the registry explicitly. +# +# --force is required because Corepack's npm package declares `yarn`, `yarnpkg`, +# `pnpm`, `pnpx`, and `corepack` as bin entries. Without --force, npm refuses to +# overwrite bins owned by a pre-existing global yarn or pnpm (a very common +# setup, and the state this repo itself was in before this build script existed) +# and aborts with EEXIST. The CI pipelines already pass --force for the same +# reason. +npm install --global --force --registry "$NPM_REGISTRY" "corepack@${COREPACK_VERSION}" + +# Verify the version actually on PATH matches our pin. If a system-bundled +# Corepack shim shadows the npm-global install (common on Windows; possible on +# macOS/Linux when /usr/local/bin precedes the npm prefix), `npm install -g` +# can "succeed" while subsequent `corepack` calls still resolve to the bundled +# version. Fail loudly here so we don't silently run with the wrong tool. +installed_corepack=$(corepack --version 2>/dev/null || echo "") +if [ "$installed_corepack" != "$COREPACK_VERSION" ]; then + echo "Error: corepack version mismatch: expected $COREPACK_VERSION, got '$installed_corepack'." + echo "The bundled Corepack on PATH may be taking precedence over the npm-global install." + echo "Ensure your npm global bin directory comes before any other Node.js install on PATH," + echo "or use a Node version manager (nvm, asdf, fnm) that places the npm prefix appropriately." + exit 1 +fi + +echo "" +echo "Enabling Corepack package manager shims..." +corepack enable + +echo "" +echo "Preparing Yarn from packageManager pin in package.json..." +node ./scripts/prepareCorepackYarn.mjs + echo "" echo "Running yarn install..." -yarn install --frozen-lockfile --non-interactive +corepack yarn install --frozen-lockfile --non-interactive echo "" echo "Running yarn compile..." -yarn compile +corepack yarn compile echo "" echo "Building Aspire CLI..." diff --git a/extension/package.json b/extension/package.json index e3106820e0d..d23106bd664 100644 --- a/extension/package.json +++ b/extension/package.json @@ -13,6 +13,7 @@ "engines": { "vscode": "^1.98.0" }, + "packageManager": "yarn@1.22.22", "categories": [ "Programming Languages", "Debuggers", diff --git a/extension/scripts/corepack-version.txt b/extension/scripts/corepack-version.txt new file mode 100644 index 00000000000..6e5caff5f25 --- /dev/null +++ b/extension/scripts/corepack-version.txt @@ -0,0 +1 @@ +0.34.7 diff --git a/extension/scripts/prepareCorepackYarn.mjs b/extension/scripts/prepareCorepackYarn.mjs new file mode 100644 index 00000000000..4660862f9a0 --- /dev/null +++ b/extension/scripts/prepareCorepackYarn.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir, tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; + +const DefaultNpmRegistry = 'https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public-npm/npm/registry/'; +// Yarn 1.x `packageManager` strings can carry an integrity suffix when written +// by `corepack use yarn@` (the workflow CONTRIBUTING.MD points contributors +// at to update the pin), producing values like +// "yarn@1.22.22+sha512.f7062e6a5ee1f3aa…". +// The suffix is optional but its presence must not break us. Match an optional +// `+` suffix and ignore it; only the version is needed to seed the cache. +// +// Integrity note: when Corepack's `installVersion` finds an existing cache dir +// containing a `.corepack` file, it returns the recorded `hash`/`bin` +// immediately without re-hashing or signature-checking the install (see +// https://github.com/nodejs/corepack/blob/v0.34.7/sources/corepackUtils.ts). +// Its `Mismatch hashes` check fires only on the download path, which a +// pre-seeded cache never reaches. That means the `sha1.${packEntry.shasum}` we +// write to `.corepack` below is recorded for completeness but is never +// re-verified on reuse — trust on the seeded Yarn rests entirely on the +// `npm pack` fetch from `$NPM_REGISTRY` (the internal dnceng feed), which is +// the same authentication and integrity boundary npm uses for any other +// install through this feed. +// Spec: https://nodejs.org/api/packages.html#packagemanager +const PackageManagerPattern = /^yarn@(?\d+\.\d+\.\d+)(?:\+[\w.-]+)?$/; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const extensionDirectory = dirname(scriptDirectory); +const packageJsonPath = join(extensionDirectory, 'package.json'); + +const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); +const packageManager = packageJson.packageManager; +const match = typeof packageManager === 'string' ? PackageManagerPattern.exec(packageManager) : null; + +if (match?.groups?.version === undefined) { + fail(`Expected packageManager in ${packageJsonPath} to be an exact Yarn Classic version like "yarn@1.22.22", but found ${JSON.stringify(packageManager)}.`); +} + +const yarnVersion = match.groups.version; +const majorVersion = Number(yarnVersion.split('.')[0]); + +if (majorVersion >= 2) { + fail(`The Corepack cache seeding workaround only supports Yarn Classic (<2.0.0), but packageManager is ${packageManager}. Remove this workaround and use Corepack's native prepare/install flow for Yarn Berry.`); +} + +const registry = process.env.NPM_REGISTRY || DefaultNpmRegistry; +const corepackHome = getCorepackHome(); +const installDirectory = join(corepackHome, 'v1', 'yarn', yarnVersion); +const installParentDirectory = dirname(installDirectory); +const corepackMetadataPath = join(installDirectory, '.corepack'); + +if (existsSync(corepackMetadataPath)) { + console.log(`Corepack cache already contains yarn@${yarnVersion} at ${installDirectory}`); + process.exit(0); +} + +// Race-safe cleanup: only remove the install directory when it is stale +// (no `.corepack` metadata). The earlier existsSync at line 57 races against +// a concurrent winner whose renameSync could complete between that check and +// here; an unconditional rmSync would destroy that just-seeded cache. A +// narrow residual window remains between this re-check and the rmSync, but +// the rename at line 101 below would then fail with EEXIST/ENOTEMPTY and +// fall through to the existing concurrent-cache recovery path. +if (existsSync(installDirectory) && !existsSync(corepackMetadataPath)) { + rmSync(installDirectory, { recursive: true, force: true }); +} + +const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aspire-corepack-yarn-')); +mkdirSync(installParentDirectory, { recursive: true }); +const stagingDirectory = mkdtempSync(join(installParentDirectory, `.yarn-${yarnVersion}-`)); +let cacheSeeded = false; + +try { + console.log(`Packing yarn@${yarnVersion} from ${registry}`); + const npm = getNpmInvocation(); + const packResult = run(npm.command, [...npm.args, 'pack', '--json', '--registry', registry, `yarn@${yarnVersion}`], temporaryDirectory); + const packEntries = parseNpmPackJson(packResult.stdout); + const packEntry = packEntries[0]; + + if (packEntry === undefined || typeof packEntry.filename !== 'string' || typeof packEntry.shasum !== 'string') { + fail(`npm pack did not return the expected filename and shasum metadata. Output: ${packResult.stdout}`); + } + + const tarballPath = join(temporaryDirectory, packEntry.filename); + + // Corepack can use COREPACK_NPM_REGISTRY for npmjs.org, but Azure Artifacts + // does not implement the // metadata route Corepack calls. + // Seed the same cache shape Corepack writes, using npm pack because npm can + // resolve Yarn through the Azure Artifacts pull-through feed. + run('tar', ['-xzf', tarballPath, '-C', stagingDirectory, '--strip-components=1'], temporaryDirectory); + + writeFileSync(join(stagingDirectory, '.corepack'), JSON.stringify({ + locator: { + name: 'yarn', + reference: yarnVersion + }, + bin: { + yarn: './bin/yarn.js', + yarnpkg: './bin/yarn.js' + }, + hash: `sha1.${packEntry.shasum}` + })); + + try { + renameSync(stagingDirectory, installDirectory); + cacheSeeded = true; + } catch (error) { + // Lost a race with a concurrent build (same worktree, same COREPACK_HOME). + // The winner's renameSync atomically populated installDirectory; ours then + // fails because the destination already exists. Filesystem-level error + // codes vary: + // - Windows / macOS HFS+: EEXIST when the destination dir exists. + // - Linux / macOS APFS: ENOTEMPTY (rename(2) rejects renaming over a + // non-empty directory; see + // https://man7.org/linux/man-pages/man2/rename.2.html). + // Both mean the cache is already in place, so log and exit cleanly. Any + // other error code is a real failure (permission, ENOSPC, etc.) and is + // re-thrown. + if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY') { + console.log(`Corepack cache already contains yarn@${yarnVersion} at ${installDirectory}`); + } else { + throw error; + } + } + + if (cacheSeeded) { + console.log(`Seeded Corepack cache with yarn@${yarnVersion} at ${installDirectory}`); + } +} finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); + rmSync(stagingDirectory, { recursive: true, force: true }); +} + +function getCorepackHome() { + if (process.env.COREPACK_HOME) { + return process.env.COREPACK_HOME; + } + + // build.sh / build.ps1 / the GitHub Actions workflow / the AzDO pipelines all + // set COREPACK_HOME explicitly to a build-scoped directory. This fallback + // exists only for ad-hoc invocations of this script (e.g. local debugging, + // running `node ./scripts/prepareCorepackYarn.mjs` directly). It mirrors + // Corepack 0.34.x's own cache-path resolution so we seed the directory + // Corepack will later read from. + // Source: https://github.com/nodejs/corepack/blob/v0.34.7/sources/folderUtils.ts + const baseDirectory = process.env.XDG_CACHE_HOME + ?? process.env.LOCALAPPDATA + ?? join(homedir(), process.platform === 'win32' ? 'AppData/Local' : '.cache'); + + return join(baseDirectory, 'node', 'corepack'); +} + +function getNpmInvocation() { + if (process.platform !== 'win32') { + return { command: 'npm', args: [] }; + } + + const npmCliPath = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'); + + if (!existsSync(npmCliPath)) { + fail(`Could not find npm CLI at ${npmCliPath}. Corepack Yarn cache seeding requires the npm CLI that ships with Node.js.`); + } + + // Avoid spawning npm.cmd directly on Windows. Some hosted images reject the + // .cmd shim from child_process.spawnSync with EINVAL, while invoking the + // npm CLI through node.exe avoids shell/cmd parsing entirely. + return { command: process.execPath, args: [npmCliPath] }; +} + +function parseNpmPackJson(stdout) { + const jsonStart = stdout.indexOf('['); + + if (jsonStart === -1) { + fail(`npm pack did not emit JSON output. Output: ${stdout}`); + } + + return JSON.parse(stdout.slice(jsonStart)); +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + + if (result.status !== 0) { + if (result.stdout) { + process.stderr.write(result.stdout); + } + + if (result.stderr) { + process.stderr.write(result.stderr); + } + + const errorDetails = result.error ? ` (${result.error.message})` : ''; + fail(`${command} ${args.join(' ')} failed with exit code ${result.status ?? 'unknown'}${errorDetails}.`); + } + + if (result.stderr) { + process.stderr.write(result.stderr); + } + + return result; +} + +function fail(message) { + console.error(message); + process.exit(1); +} diff --git a/src/Aspire.Cli/Commands/UpdateCommand.cs b/src/Aspire.Cli/Commands/UpdateCommand.cs index 6103472f6c3..978f1d6cf84 100644 --- a/src/Aspire.Cli/Commands/UpdateCommand.cs +++ b/src/Aspire.Cli/Commands/UpdateCommand.cs @@ -110,6 +110,11 @@ public UpdateCommand( return DotNetToolDetection.GetDotNetToolUpdateCommand(); } + private static string? GetNpmUpdateCommand() + { + return NpmInstallDetection.GetNpmUpdateCommand(); + } + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { var isSelfUpdate = parseResult.GetValue(s_selfOption); @@ -126,6 +131,17 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.FromExitCode(0); } + // When running from a global npm install, defer to npm rather than overwriting + // npm-owned files with the GitHub-binary downloader. Detected via env vars the + // npm launcher (eng/clipack/npm/aspire.js) sets when spawning the native binary. + var npmUpdateCommand = GetNpmUpdateCommand(); + if (npmUpdateCommand is not null) + { + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.NpmSelfUpdateMessage); + InteractionService.DisplayPlainText($" {npmUpdateCommand}"); + return CommandResult.Success(); + } + if (_cliDownloader is null) { return CommandResult.Failure(CliExitCodes.InvalidCommand, "CLI self-update is not available in this environment."); @@ -340,6 +356,14 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.Success(); } + var npmUpdateCommand = GetNpmUpdateCommand(); + if (npmUpdateCommand is not null) + { + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.NpmSelfUpdateMessage); + InteractionService.DisplayPlainText($" {npmUpdateCommand}"); + return CommandResult.Success(); + } + // Use the same channel that was selected for the project update return await ExecuteSelfUpdateAsync(parseResult, cancellationToken, channel.Name); } @@ -362,8 +386,11 @@ protected override async Task ExecuteAsync(ParseResult parseResul // Check if this is a "no project found" error and prompt for self-update if (string.Equals(ex.Message, ErrorStrings.NoProjectFileFound, StringComparisons.CliInputOrOutput)) { - // Only prompt for self-update if not running as dotnet tool and downloader is available - if (GetDotNetToolUpdateCommand() is null && _cliDownloader is not null) + // Only prompt for self-update when we can actually perform it: not as a + // dotnet tool, not from an npm install, and the GitHub-binary downloader + // is wired up. Otherwise the downloader would overwrite package-manager-owned + // files instead of letting the package manager handle the update. + if (GetDotNetToolUpdateCommand() is null && GetNpmUpdateCommand() is null && _cliDownloader is not null) { var shouldUpdateCli = await InteractionService.PromptConfirmAsync( UpdateCommandStrings.NoAppHostFoundUpdateCliPrompt, @@ -430,6 +457,15 @@ protected override async Task ExecuteAsync(ParseResult parseResul return CommandResult.Success(); } + var npmUpdateCommand = GetNpmUpdateCommand(); + if (npmUpdateCommand is not null) + { + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.NpmSelfUpdateMessage); + InteractionService.DisplayPlainText($" {npmUpdateCommand}"); + InteractionService.DisplayMessage(KnownEmojis.Information, UpdateCommandStrings.ProjectUpdateSkippedAfterCliUpdateMessage); + return CommandResult.Success(); + } + var selfUpdateResult = await ExecuteSelfUpdateAsync(parseResult, cancellationToken, channel.Name); if (selfUpdateResult.ExitCode == CliExitCodes.Success) { diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs index 343f05a3c02..e96a24461ac 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.Designer.cs @@ -110,6 +110,7 @@ internal static string ProjectArgumentDescription { internal static string QualityOptionDescription => ResourceManager.GetString("QualityOptionDescription", resourceCulture); internal static string QualityOptionDescriptionWithStaging => ResourceManager.GetString("QualityOptionDescriptionWithStaging", resourceCulture); internal static string DotNetToolSelfUpdateMessage => ResourceManager.GetString("DotNetToolSelfUpdateMessage", resourceCulture); + internal static string NpmSelfUpdateMessage => ResourceManager.GetString("NpmSelfUpdateMessage", resourceCulture); internal static string ProjectUpdateSkippedAfterCliUpdateMessage => ResourceManager.GetString("ProjectUpdateSkippedAfterCliUpdateMessage", resourceCulture); internal static string MigratedToNewSdkFormat => ResourceManager.GetString("MigratedToNewSdkFormat", resourceCulture); internal static string RemovedObsoleteAppHostPackage => ResourceManager.GetString("RemovedObsoleteAppHostPackage", resourceCulture); diff --git a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx index 465d1663ed4..0b0b3dd3416 100644 --- a/src/Aspire.Cli/Resources/UpdateCommandStrings.resx +++ b/src/Aspire.Cli/Resources/UpdateCommandStrings.resx @@ -149,6 +149,9 @@ To update the Aspire CLI when installed as a .NET tool, run: + + To update the Aspire CLI when installed from npm, run: + Project update skipped. Update the Aspire CLI, then re-run `aspire update`. diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf index 5b6bc4fba22..4cb8d9a05d5 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.cs.xlf @@ -172,6 +172,11 @@ Nelze zapisovat do instalačního adresáře {0}. Spusťte prosím aktualizaci se zvýšenými oprávněními (např. pomocí sudo v Linuxu nebo macOS). + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf index 934071358d2..dcdb75c7b20 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.de.xlf @@ -172,6 +172,11 @@ Schreiben in das Installationsverzeichnis „{0}“ ist nicht möglich. Führen Sie das Update mit erhöhten Berechtigungen aus (zum Beispiel mit sudo unter Linux/macOS). + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf index ba8a15c222d..bdd27202d67 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.es.xlf @@ -172,6 +172,11 @@ No se puede escribir en el directorio de instalación '{0}'. Ejecute la actualización con permisos elevados (por ejemplo, con sudo en Linux o macOS). + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf index 3086557d2f6..5869e8c0bda 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.fr.xlf @@ -172,6 +172,11 @@ Impossible d’écrire dans le répertoire d’installation « {0} ». Exécutez la mise à jour avec des autorisations élevées (par exemple, en utilisant sudo sur Linux/macOS). + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf index 20350005f58..0811f04ec49 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.it.xlf @@ -172,6 +172,11 @@ Impossibile scrivere nella directory di installazione "{0}". Eseguire l'aggiornamento con autorizzazioni elevate, ad esempio usando sudo su Linux/macOS. + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf index f7fce6eaabb..99641c2dda3 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ja.xlf @@ -172,6 +172,11 @@ インストール ディレクトリ '{0}' に書き込めません。管理者権限で更新を実行してください (例: Linux/macOS で sudo を使用)。 + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf index 6545fde74f3..98310c72ef4 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ko.xlf @@ -172,6 +172,11 @@ 설치 디렉터리 '{0}'에 쓸 수 없습니다. 관리자 권한으로 업데이트를 실행하세요(예: Linux/macOS에서 sudo 사용). + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf index c623ee9f9d4..721290e2d1d 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pl.xlf @@ -172,6 +172,11 @@ Nie można zapisać w katalogu instalacyjnym „{0}”. Uruchom aktualizację z podniesionymi uprawnieniami (np. używając programu sudo w systemie Linux lub macOS). + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf index 82455f5175f..b00a38d706f 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.pt-BR.xlf @@ -172,6 +172,11 @@ Não é possível gravar no diretório de instalação '{0}'. Execute a atualização com permissões elevadas (por exemplo, usando sudo no Linux/macOS). + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf index 80fe9e2f427..e1304fdcb46 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.ru.xlf @@ -172,6 +172,11 @@ Не удается записать в каталог установки "{0}". Запустите обновление с повышенными правами (например, используя sudo в Linux или macOS). + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf index 8d351158d3c..ec65d25ef07 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.tr.xlf @@ -172,6 +172,11 @@ '{0}' yükleme dizinine yazılamıyor. Lütfen güncellemeyi yükseltilmiş izinlerle (örneğin Linux/macOS'ta sudo kullanarak) çalıştırın. + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf index 9cc534834e8..d855eeec68e 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hans.xlf @@ -172,6 +172,11 @@ 无法写入安装目录“{0}”。请使用提升的权限运行更新(例如,在 Linux/macOS 上使用 sudo)。 + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf index 252030ab8ca..e46ff98c583 100644 --- a/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/UpdateCommandStrings.zh-Hant.xlf @@ -172,6 +172,11 @@ 無法寫入安裝目錄 '{0}'。請使用提高的權限來執行更新 (例如,在Linux/macOS上使用 sudo)。 + + To update the Aspire CLI when installed from npm, run: + To update the Aspire CLI when installed from npm, run: + + Directory to create or update the NuGet.config file in Directory to create or update the NuGet.config file in diff --git a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs index 3a5c0be2c5c..cf743a3cc51 100644 --- a/src/Aspire.Cli/Utils/CliUpdateNotifier.cs +++ b/src/Aspire.Cli/Utils/CliUpdateNotifier.cs @@ -116,7 +116,11 @@ private CliVersionStatus GetCachedVersionStatus(string? updateCheckError = null) } var newerVersion = PackageUpdateHelpers.GetNewerVersion(logger, currentVersion, _availablePackages); - var updateCommand = newerVersion is null ? null : DotNetToolDetection.GetDotNetToolUpdateCommand() ?? "aspire update"; + var updateCommand = newerVersion is null + ? null + : DotNetToolDetection.GetDotNetToolUpdateCommand() + ?? NpmInstallDetection.GetNpmUpdateCommand() + ?? "aspire update"; // Derive the lane the recommendation comes from so doctor can show // 'Latest version is X (channel: stable)' vs '(channel: prerelease)'. // GetNewerVersion picks between newestStable and newestPrerelease diff --git a/src/Aspire.Cli/Utils/NpmInstallDetection.cs b/src/Aspire.Cli/Utils/NpmInstallDetection.cs new file mode 100644 index 00000000000..e8d50e73e13 --- /dev/null +++ b/src/Aspire.Cli/Utils/NpmInstallDetection.cs @@ -0,0 +1,109 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Cli.Utils; + +/// +/// Detects whether the Aspire CLI is running from a global npm install of the +/// @microsoft/aspire-cli package and provides the npm-equivalent +/// self-update command so the CLI surfaces the correct guidance instead of +/// attempting to overwrite npm-owned files with the GitHub-binary downloader. +/// +/// +/// The npm launcher (eng/clipack/npm/aspire.js) sets three environment +/// variables when it spawns the native CLI binary: ASPIRE_NPM_PACKAGE, +/// ASPIRE_NPM_PACKAGE_VERSION, and ASPIRE_NPM_PACKAGE_RID. +/// Presence of ASPIRE_NPM_PACKAGE with the expected package name is +/// treated as the authoritative signal that the CLI was launched by the npm +/// launcher; the other variables are surfaced via accessors for diagnostics. +/// +internal static class NpmInstallDetection +{ + internal const string PackageEnvironmentVariableName = "ASPIRE_NPM_PACKAGE"; + internal const string PackageVersionEnvironmentVariableName = "ASPIRE_NPM_PACKAGE_VERSION"; + internal const string PackageRidEnvironmentVariableName = "ASPIRE_NPM_PACKAGE_RID"; + + internal const string ExpectedPackageName = "@microsoft/aspire-cli"; + + private static readonly AsyncLocal s_environmentOverride = new(); + + internal static bool IsRunningFromNpm() + { + return GetNpmUpdateCommand() is not null; + } + + internal static string? GetNpmUpdateCommand() + { + var env = s_environmentOverride.Value ?? ProcessEnvironmentReader.Instance; + var packageName = env.GetEnvironmentVariable(PackageEnvironmentVariableName); + + if (string.IsNullOrWhiteSpace(packageName)) + { + return null; + } + + // The launcher always writes the canonical "@microsoft/aspire-cli" package name. + // Reject anything else so an unrelated env var collision does not flip the CLI + // into the npm self-update path. + if (!string.Equals(packageName, ExpectedPackageName, StringComparison.Ordinal)) + { + return null; + } + + return $"npm install -g {ExpectedPackageName}@latest"; + } + + internal static string? GetNpmPackageVersion() + { + var env = s_environmentOverride.Value ?? ProcessEnvironmentReader.Instance; + var version = env.GetEnvironmentVariable(PackageVersionEnvironmentVariableName); + + return string.IsNullOrWhiteSpace(version) ? null : version; + } + + internal static string? GetNpmPackageRid() + { + var env = s_environmentOverride.Value ?? ProcessEnvironmentReader.Instance; + var rid = env.GetEnvironmentVariable(PackageRidEnvironmentVariableName); + + return string.IsNullOrWhiteSpace(rid) ? null : rid; + } + + internal static IDisposable UseEnvironmentForTesting(IReadOnlyDictionary environment) + { + var previous = s_environmentOverride.Value; + s_environmentOverride.Value = new DictionaryEnvironmentReader(environment); + return new EnvironmentOverrideScope(previous); + } + + internal interface IEnvironmentReader + { + string? GetEnvironmentVariable(string name); + } + + private sealed class ProcessEnvironmentReader : IEnvironmentReader + { + public static readonly ProcessEnvironmentReader Instance = new(); + + public string? GetEnvironmentVariable(string name) + { + return Environment.GetEnvironmentVariable(name); + } + } + + private sealed class DictionaryEnvironmentReader(IReadOnlyDictionary environment) : IEnvironmentReader + { + public string? GetEnvironmentVariable(string name) + { + return environment.TryGetValue(name, out var value) ? value : null; + } + } + + private sealed class EnvironmentOverrideScope(IEnvironmentReader? previous) : IDisposable + { + public void Dispose() + { + s_environmentOverride.Value = previous; + } + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs index 17d26e209e7..cd7bd9c80d4 100644 --- a/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/UpdateCommandTests.cs @@ -631,6 +631,82 @@ public async Task UpdateCommand_WhenProjectUpdatedSuccessfullyAndRunningAsCustom Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains($"dotnet tool update --tool-path \"{toolPath}\" Aspire.Cli", StringComparison.Ordinal)); } + [Fact] + public async Task UpdateCommand_WhenProjectUpdatedSuccessfullyAndRunningFromNpm_DisplaysNpmUpdateCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var processPathScope = DotNetToolDetection.UseProcessPathForTesting("/home/test/.aspire/bin/aspire"); + using var npmScope = NpmInstallDetection.UseEnvironmentForTesting(CreateNpmInstallEnvironment()); + var interactionService = new TestInteractionService() + { + ConfirmCallback = (_, _) => true + }; + var downloaderInvoked = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.ProjectLocatorFactory = _ => new TestProjectLocator() + { + UseOrFindAppHostProjectFileAsyncCallback = (projectFile, _, _) => + { + return Task.FromResult(new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "AppHost.csproj"))); + } + }; + + options.InteractionServiceFactory = _ => interactionService; + options.DotNetCliRunnerFactory = _ => new TestDotNetCliRunner(); + + options.ProjectUpdaterFactory = _ => new TestProjectUpdater() + { + UpdateProjectAsyncCallback = (context, cancellationToken) => + { + return Task.FromResult(new ProjectUpdateResult { UpdatedApplied = true }); + } + }; + + options.PackagingServiceFactory = _ => new TestPackagingService() + { + GetChannelsAsyncCallback = (cancellationToken) => + { + var stableChannel = PackageChannel.CreateExplicitChannel( + "stable", + PackageChannelQuality.Stable, + new[] { new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json") }, + null!, + features: new TestFeatures(), + configureGlobalPackagesFolder: false, + cliDownloadBaseUrl: "https://aka.ms/dotnet/9/aspire/ga/daily"); + return Task.FromResult>(new[] { stableChannel }); + } + }; + + options.CliUpdateNotifierFactory = _ => new TestCliUpdateNotifier() + { + IsUpdateAvailableCallback = () => true + }; + + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (_, _) => + { + downloaderInvoked = true; + return Task.FromResult(string.Empty); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("update --apphost AppHost.csproj"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(downloaderInvoked, "Archive self-update should not be used for npm installs."); + Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("npm install -g @microsoft/aspire-cli@latest", StringComparison.Ordinal)); + Assert.DoesNotContain(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update", StringComparison.Ordinal)); + } + [Fact] public async Task UpdateCommand_WithoutAutoConfirmOption_UsesFalseConfirmationDefault() { @@ -811,6 +887,40 @@ public async Task UpdateCommand_SelfUpdate_WhenRunningAsNativeAotDotnetTool_Disp Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update -g Aspire.Cli", StringComparison.Ordinal)); } + [Fact] + public async Task UpdateCommand_SelfUpdate_WhenRunningFromNpm_DisplaysNpmUpdateCommand() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var processPathScope = DotNetToolDetection.UseProcessPathForTesting("/home/test/.aspire/bin/aspire"); + using var npmScope = NpmInstallDetection.UseEnvironmentForTesting(CreateNpmInstallEnvironment()); + var interactionService = new TestInteractionService(); + var downloaderInvoked = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.InteractionServiceFactory = _ => interactionService; + options.CliDownloaderFactory = _ => new TestCliDownloader(workspace.WorkspaceRoot) + { + DownloadLatestCliAsyncCallback = (_, _) => + { + downloaderInvoked = true; + return Task.FromResult(string.Empty); + } + }; + }); + + using var provider = services.BuildServiceProvider(); + var command = provider.GetRequiredService(); + var result = command.Parse("update --self"); + + var exitCode = await result.InvokeAsync().DefaultTimeout(); + + Assert.Equal(CliExitCodes.Success, exitCode); + Assert.False(downloaderInvoked, "Archive self-update should not be used for npm installs."); + Assert.Contains(interactionService.DisplayedPlainText, text => text.Contains("npm install -g @microsoft/aspire-cli@latest", StringComparison.Ordinal)); + Assert.DoesNotContain(interactionService.DisplayedPlainText, text => text.Contains("dotnet tool update", StringComparison.Ordinal)); + } + [Fact] public async Task UpdateCommand_SelfUpdate_WhenRunningAsCustomToolPathDotnetTool_DisplaysToolPathUpdateCommand() { @@ -2539,6 +2649,16 @@ private static string GetAspireExecutableName() return OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"; } + private static IReadOnlyDictionary CreateNpmInstallEnvironment() + { + return new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName, + [NpmInstallDetection.PackageVersionEnvironmentVariableName] = "9.4.0", + [NpmInstallDetection.PackageRidEnvironmentVariableName] = "linux-x64" + }; + } + // `aspire update --self` no longer mutates the global identity channel via // IConfigurationService. The freshly extracted binary already carries its own channel // via [AssemblyMetadata("AspireCliChannel")], so the global write is dead weight and diff --git a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs index 10cbbd35aeb..bbaf43295b1 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliUpdateNotificationServiceTests.cs @@ -331,6 +331,48 @@ public async Task NotifyIfUpdateAvailable_UsesAspireUpdateCommandForStandaloneAr Assert.Equal("aspire update", interactionService.LastVersionUpdateCommand); } + [Fact] + public async Task NotifyIfUpdateAvailable_UsesNpmCommandForNpmInstall() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + using var processPathScope = DotNetToolDetection.UseProcessPathForTesting("/home/test/.aspire/bin/aspire"); + using var npmScope = NpmInstallDetection.UseEnvironmentForTesting(CreateNpmInstallEnvironment()); + TestInteractionService? interactionService = null; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, configure => + { + configure.NuGetPackageCacheFactory = _ => new FakeNuGetPackageCache + { + GetCliPackagesAsyncCallback = (_, _, _, _) => Task.FromResult>([ + new NuGetPackage { Id = "Aspire.Cli", Version = "9.5.0", Source = "nuget.org" } + ]) + }; + + configure.InteractionServiceFactory = _ => + { + interactionService = new TestInteractionService(); + return interactionService; + }; + + configure.CliUpdateNotifierFactory = sp => + { + var logger = sp.GetRequiredService>(); + var nuGetPackageCache = sp.GetRequiredService(); + var service = sp.GetRequiredService(); + return new CliUpdateNotifierWithPackageVersionOverride("9.4.0", logger, nuGetPackageCache, service); + }; + }); + + using var provider = services.BuildServiceProvider(); + var notifier = provider.GetRequiredService(); + + await notifier.CheckForCliUpdatesAsync(workspace.WorkspaceRoot, CancellationToken.None).DefaultTimeout(); + notifier.NotifyIfUpdateAvailable(); + + Assert.NotNull(interactionService); + Assert.Equal("npm install -g @microsoft/aspire-cli@latest", interactionService.LastVersionUpdateCommand); + } + [Fact] public async Task StableWillNotRecommendUpdatingToPreview() { @@ -447,6 +489,16 @@ private static string GetAspireExecutableName() { return OperatingSystem.IsWindows() ? "aspire.exe" : "aspire"; } + + private static IReadOnlyDictionary CreateNpmInstallEnvironment() + { + return new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName, + [NpmInstallDetection.PackageVersionEnvironmentVariableName] = "9.4.0", + [NpmInstallDetection.PackageRidEnvironmentVariableName] = "linux-x64" + }; + } } internal sealed class CliUpdateNotifierWithPackageVersionOverride(string currentVersion, ILogger logger, INuGetPackageCache nuGetPackageCache, IInteractionService interactionService) : CliUpdateNotifier(logger, nuGetPackageCache, interactionService) diff --git a/tests/Aspire.Cli.Tests/Utils/NpmInstallDetectionTests.cs b/tests/Aspire.Cli.Tests/Utils/NpmInstallDetectionTests.cs new file mode 100644 index 00000000000..c0ebe865534 --- /dev/null +++ b/tests/Aspire.Cli.Tests/Utils/NpmInstallDetectionTests.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Cli.Utils; + +namespace Aspire.Cli.Tests.Utils; + +public class NpmInstallDetectionTests +{ + [Fact] + public void IsRunningFromNpm_ReturnsTrueWhenPackageEnvironmentVariableMatches() + { + using var scope = NpmInstallDetection.UseEnvironmentForTesting(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName, + [NpmInstallDetection.PackageVersionEnvironmentVariableName] = "9.4.0", + [NpmInstallDetection.PackageRidEnvironmentVariableName] = "linux-x64", + }); + + Assert.True(NpmInstallDetection.IsRunningFromNpm()); + Assert.Equal( + $"npm install -g {NpmInstallDetection.ExpectedPackageName}@latest", + NpmInstallDetection.GetNpmUpdateCommand()); + Assert.Equal("9.4.0", NpmInstallDetection.GetNpmPackageVersion()); + Assert.Equal("linux-x64", NpmInstallDetection.GetNpmPackageRid()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("@some-other-scope/aspire-cli")] + [InlineData("aspire-cli")] + [InlineData("@Microsoft/Aspire-Cli")] + public void IsRunningFromNpm_ReturnsFalseWhenPackageEnvironmentVariableMissingOrMismatched(string? packageName) + { + using var scope = NpmInstallDetection.UseEnvironmentForTesting(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = packageName, + [NpmInstallDetection.PackageVersionEnvironmentVariableName] = "9.4.0", + [NpmInstallDetection.PackageRidEnvironmentVariableName] = "linux-x64", + }); + + Assert.False(NpmInstallDetection.IsRunningFromNpm()); + Assert.Null(NpmInstallDetection.GetNpmUpdateCommand()); + } + + [Fact] + public void GetNpmPackageVersion_ReturnsNullWhenVariableMissing() + { + using var scope = NpmInstallDetection.UseEnvironmentForTesting(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName, + }); + + Assert.Null(NpmInstallDetection.GetNpmPackageVersion()); + Assert.Null(NpmInstallDetection.GetNpmPackageRid()); + } + + [Fact] + public void UseEnvironmentForTesting_RestoresPreviousValueOnDispose() + { + // Establish a known baseline that does NOT match, so the test result is + // independent of whatever ASPIRE_NPM_PACKAGE the test host may inherit. + using var baseline = NpmInstallDetection.UseEnvironmentForTesting(new Dictionary()); + Assert.False(NpmInstallDetection.IsRunningFromNpm()); + + using (NpmInstallDetection.UseEnvironmentForTesting(new Dictionary + { + [NpmInstallDetection.PackageEnvironmentVariableName] = NpmInstallDetection.ExpectedPackageName, + })) + { + Assert.True(NpmInstallDetection.IsRunningFromNpm()); + } + + Assert.False(NpmInstallDetection.IsRunningFromNpm()); + } +} diff --git a/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs b/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs new file mode 100644 index 00000000000..066bc228a1c --- /dev/null +++ b/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Infrastructure.Tests; + +public sealed class NpmCliPackageTests +{ + private readonly string _repoRoot = FindRepoRoot(); + + [Fact] + public async Task LauncherDetectsMuslArm64AndThrowsUnsupported() + { + var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js"); + + // libc-mismatched binaries crash at exec with cryptic dynamic-linker + // errors. Previously the launcher only checked musl for x64 and silently + // fell through to the glibc-linked `linux-arm64` binary on Alpine arm64. + // Assert the musl-arm64 case throws a friendly "Unsupported platform" + // error rather than silently resolving to a binary that will crash. + Assert.Contains("if (arch === 'arm64' && musl)", launcher); + Assert.Contains("Unsupported platform: ${platform} musl ${arch}", launcher); + } + + [Fact] + public async Task LauncherForwardsTerminatingSignalsToChild() + { + var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js"); + + // Programmatic `kill ` was orphaning the native CLI because + // the launcher only handled child exit and never forwarded inbound + // signals to the child. Especially bad for `aspire run` which keeps an + // AppHost alive. Verify all 4 standard termination signals are wired up + // and that we use `once` so a second signal can still kill the wrapper + // if the child ignores the first. + Assert.Contains("const forwardedSignals = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT']", launcher); + Assert.Contains("process.once(signal", launcher); + Assert.Contains("child.kill(signal)", launcher); + } + + [Fact] + public async Task LauncherLoadsRidPackageMapInsideErrorHandler() + { + var launcher = await ReadRepoFileAsync("eng/clipack/npm/aspire.js"); + + // A missing/corrupt aspire-package-map.json used to throw raw + // ENOENT/SyntaxError with a Node stack at module load — bypassing the + // launcher's top-level try/catch that produces friendly errors. Verify + // the map is loaded lazily inside main() and that read/parse failures + // produce a "corrupted ... Reinstall" message rather than a stack. + Assert.Contains("if (ridPackageNames === null)", launcher); + Assert.Contains("ridPackageNames = loadRidPackageNames()", launcher); + Assert.Contains("Aspire CLI installation is corrupted", launcher); + Assert.Contains("Reinstall @microsoft/aspire-cli", launcher); + } + + [Fact] + public async Task PackScriptUsesLiteralHereStringForRidReadme() + { + var packScript = await ReadRepoFileAsync("eng/scripts/pack-cli-npm-package.ps1"); + + // Previously the RID-package README used an expandable here-string with + // `$Rid` and `$PackageName`. In PowerShell expandable here-strings the + // backtick is the escape character, so the markdown code-span backticks + // were both stripped AND the $-interpolation was suppressed. Result: + // shipped READMEs read `Native Aspire CLI binary for $Rid.` with no + // backticks. Verify the script now uses a literal here-string (@'...'@) + // with explicit -replace placeholders so backticks survive verbatim. + Assert.Contains("$ridReadmeTemplate = @'", packScript); + Assert.Contains("Native Aspire CLI binary for `__RID__`.", packScript); + Assert.Contains("This package is installed as an optional dependency of `__PACKAGE_NAME__`.", packScript); + Assert.Contains("-replace '__RID__', $Rid", packScript); + Assert.Contains("-replace '__PACKAGE_NAME__', $PackageName", packScript); + + // The original broken sequence (expandable here-string with `$Rid`) must + // not be reintroduced. + Assert.DoesNotContain("Native Aspire CLI binary for `$Rid`.", packScript); + } + + [Fact] + public async Task PointerPackageRequiresNode20OrLater() + { + var packScript = await ReadRepoFileAsync("eng/scripts/pack-cli-npm-package.ps1"); + + // The launcher (`bin/aspire.js`) uses the Error options-bag + // `new Error(msg, { cause: err })` which was added in Node 16.9.0. + // Node 16.0–16.8.x would throw `TypeError: Unknown option 'cause'` + // at module load before the friendly "Aspire CLI installation is + // corrupted" message could be printed. + // The per-RID `libc` selector in optionalDependencies relies on + // npm >= 10.7 which ships with Node 20.10+. Node 18 reaches end + // of life on 2025-04-30, so Node 20 is the lowest LTS we should + // pin at GA. See https://nodejs.org/en/about/previous-releases. + // Guard against accidental regression to `>=16` (or any earlier + // version) which would let the launcher crash on supported Node + // engines. + Assert.Contains("node = '>=20'", packScript); + Assert.DoesNotContain("node = '>=16'", packScript); + Assert.DoesNotContain("node = '>=18'", packScript); + } + + private Task ReadRepoFileAsync(string relativePath) + => File.ReadAllTextAsync(Path.Combine(_repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + + private static string FindRepoRoot() + { + string? current = AppContext.BaseDirectory; + + while (current is not null) + { + if (File.Exists(Path.Combine(current, "Aspire.slnx"))) + { + return current; + } + + current = Directory.GetParent(current)?.FullName; + } + + throw new DirectoryNotFoundException("Could not find repository root containing Aspire.slnx"); + } +} diff --git a/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs b/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs new file mode 100644 index 00000000000..847eb9e6af8 --- /dev/null +++ b/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs @@ -0,0 +1,387 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Infrastructure.Tests; + +public sealed class ReleasePublishNugetPipelineTests +{ + private readonly string _repoRoot = FindRepoRoot(); + + [Fact] + public async Task ValidatesNpmPublishPreconditionsBeforeNuGetPublish() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + var nuGetPublishIndex = FindRequiredText(pipeline, "task: 1ES.PublishNuget@1"); + + AssertBefore( + pipeline, + "npm publishing is blocked for prerelease runs because the MicroBuild npm publish template does not yet expose a dist-tag parameter.", + nuGetPublishIndex); + + AssertBefore( + pipeline, + "NpmPublishOwners must be set before publishing npm packages.", + nuGetPublishIndex); + + AssertBefore( + pipeline, + "NpmPublishApprovers must be set before publishing npm packages.", + nuGetPublishIndex); + } + + [Fact] + public async Task UsesEsrpPublishTemplateForNpmPublishing() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // The MicroBuild. prefix is REQUIRED for ESRP-based publishing — it wires the MicroBuild + // signing/publish credential context so MicroBuild.Publish.yml and the auto-injected + // MicroBuildAuthorizePublishPlugin task can authenticate against the + // devdiv.pkgs.visualstudio.com/_packaging/MicroBuildToolset feed. + // Plain `1ES.Official.Publish.yml@MicroBuildTemplate` (no `MicroBuild.` prefix) injects + // the authorize task without supplying credentials, causing a 401. + // See microsoft/vscode-azuretools, microsoft/pyright, microsoft/vscode-python-environments. + Assert.Contains("template: azure-pipelines/MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate", pipeline); + Assert.DoesNotContain("template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates", pipeline); + // Guard against accidental regression to the plain template (without the MicroBuild. prefix) + Assert.DoesNotContain("template: azure-pipelines/1ES.Official.Publish.yml@MicroBuildTemplate", pipeline); + } + + [Fact] + public async Task DefinesTeamNameVariableForMicroBuildTelemetry() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate auto-injects MicroBuildCleanup@1 + // (displayName "🔩 MicroBuild Telemetry") at the END of every job. That task hard-requires + // a variable literally named `TeamName`; if absent the task fails with: + // "The TeamName variable is required to use MicroBuild. Please update your definition + // variables to include your team name in the 'TeamName' variable." + // common-variables.yml defines `_TeamName: dotnet-aspire` for Arcade conventions but + // MicroBuild reads the unprefixed name, so we must declare TeamName at pipeline scope. + Assert.Contains("- name: TeamName", pipeline); + Assert.Contains("value: dotnet-aspire", pipeline); + } + + [Fact] + public async Task RoutesMicroBuildPublishAuthPluginToDncengFeedOrDisablesIt() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate -> Stages/PublishStage.yml + // -> Jobs/PublishJob.yml auto-injects MicroBuildAuthorizePublishPlugin@0 at the START + // of every job. By default that task pulls its nuget package from + // `devdiv.pkgs.visualstudio.com/_packaging/MicroBuildToolset`, which is NOT accessible + // from the dnceng collection -> 401 -> stage fails before any customer step runs. + // Two valid escapes from MicroBuildTemplate are required: + // 1) templateContext.mb.publish.enabled: false (for jobs that don't ESRP-publish) + // 2) templateContext.mb.publish.feedSource: (for the publishing job) + // Both must be present in this pipeline: + // - non-publishing jobs (PrepareJob, WinGetJob, DispatchGitHubTasksJob, + // PublishReleaseAssetsJob, HomebrewValidateJob) -> enabled: false + // - ReleaseJob (the only job that actually publishes) -> feedSource = dnceng mirror + Assert.Contains("enabled: false", pipeline); + Assert.Contains( + "feedSource: 'https://pkgs.dev.azure.com/dnceng/_packaging/MicroBuildToolset/nuget/v3/index.json'", + pipeline); + } + + [Fact] + public async Task UsesRequiredNpmEsrpOwnersAndApprover() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + Assert.Contains("default: 'joperezr,ankj'", pipeline); + Assert.Contains("default: 'adamratzman'", pipeline); + Assert.Contains("$requiredNpmOwners = @('joperezr', 'ankj')", pipeline); + Assert.Contains("$requiredNpmApprovers = @('adamratzman')", pipeline); + Assert.Contains("NpmPublishOwners and NpmPublishApprovers must not contain the same alias(es)", pipeline); + } + + [Fact] + public async Task ValidatesPublishedNpmPackageFromRegistryAfterPublish() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + var pointerPublishIndex = FindRequiredText(pipeline, "folderLocation: '$(Pipeline.Workspace)\\npm\\pointer-package'"); + var registryValidationIndex = FindRequiredText(pipeline, "npm install -g --foreground-scripts=true --no-audit --no-fund --loglevel=warn --registry=https://registry.npmjs.org/ $packageSpec"); + var channelPromotionIndex = FindRequiredText(pipeline, "# ===== PROMOTE TO CHANNEL ====="); + var nodeToolIndex = FindRequiredText(pipeline, "task: NodeTool@0"); + var dryRunReachabilityIndex = FindRequiredText(pipeline, "Dry Run - Validate npm Registry Reachability"); + var pointerSkipIndex = FindRequiredText(pipeline, "SkipNpmPointerPublish"); + + Assert.True( + pointerPublishIndex < registryValidationIndex, + "Expected registry validation to happen after the npm pointer package is published."); + + Assert.True( + registryValidationIndex < channelPromotionIndex, + "Expected registry validation to happen before channel promotion."); + + Assert.True( + nodeToolIndex < registryValidationIndex, + "Expected Node.js to be installed before registry validation uses npm."); + + Assert.True( + dryRunReachabilityIndex < registryValidationIndex, + "Expected dry-run registry reachability validation to exercise npm before the actual publish-only install smoke."); + + Assert.True( + pointerSkipIndex < registryValidationIndex, + "Expected pointer package publishing to be independently skippable so registry validation can be retried without republishing."); + + Assert.Contains("aspire --version output matched the published npm package version", pipeline); + Assert.Contains("npm view $packageSpec version --registry=https://registry.npmjs.org/", pipeline); + Assert.Contains("Registry validation will still install the selected source build's pointer package version from npm.", pipeline); + } + + [Fact] + public async Task PrepareNpmCliPackagesScriptIsBash32Compatible() + { + var template = await ReadRepoFileAsync("eng/pipelines/templates/prepare-npm-cli-packages.yml"); + + // macOS AzDO runners execute bash@3 tasks with /bin/bash which is still + // Bash 3.2 on every shipping macOS release. These constructs are Bash 4+ + // and silently break the install/uninstall smoke that gates the npm release. + // See dry-run build 2987449 where `shopt: globstar: invalid shell option name` + // killed `🟣Locate pointer and RID tarballs` on macOS. + Assert.DoesNotContain("shopt -s globstar", template); + Assert.DoesNotContain("mapfile ", template); + Assert.DoesNotContain("readarray ", template); + // declare -A (associative arrays) is also Bash 4+. + Assert.DoesNotContain("declare -A", template); + } + + [Fact] + public async Task PrepareNpmCliPackagesScriptInstallsOfflineWithTimeout() + { + var template = await ReadRepoFileAsync("eng/pipelines/templates/prepare-npm-cli-packages.yml"); + + // The pointer package declares every supported RID as an optionalDependency + // pinned to the just-built version, which does not yet exist in the public + // npm registry. Even with --omit=optional, npm still resolves optional dep + // metadata while building the dep tree. In 1ES Linux/Windows pools the + // registry call is blackholed by network isolation rules and each of 7 + // lookups burns the full fetch-timeout — that's the 9-minute pointer install + // hang observed in dry-run build 2987581. Pair --omit=optional with --offline + // (no registry traffic at all) and cap any accidental fetch with a short + // --fetch-timeout. NPM_CONFIG_CACHE points at a fresh empty directory so + // --offline cannot reuse a poisoned cache. + Assert.Contains("--offline", template); + Assert.Contains("--fetch-timeout=", template); + } + + [Fact] + public async Task PointerPublishPreflightsRidPackagesAreOnRegistry() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // The pointer pins each RID package via optionalDependencies. If any + // RID dep is missing on npm at pointer-publish time (operator set + // SkipNpmRidPublish=true; only some RIDs landed in an earlier attempt; + // ESRP partial failure), end-user `npm install -g @microsoft/aspire-cli` + // succeeds but the launcher throws "The Aspire CLI native package '…' + // was not installed" on first invocation. The post-publish smoke only + // covers the publish-pool's own RID, so missing other-RID tarballs + // reach customers invisibly without this preflight. + Assert.Contains("Verify npm RID Packages Present Before Pointer Publish", pipeline); + Assert.Contains("Refusing to publish pointer package", pipeline); + + var preflightIndex = pipeline.IndexOf("Verify npm RID Packages Present Before Pointer Publish", StringComparison.Ordinal); + Assert.True(preflightIndex > 0); + + // The preflight must precede the actual pointer publish so it can gate + // submission. + var pointerPublishIndex = pipeline.IndexOf( + "folderLocation: '$(Pipeline.Workspace)\\npm\\pointer-package'", + StringComparison.Ordinal); + Assert.True(pointerPublishIndex > preflightIndex, + "Preflight RID-check must appear before the pointer-publish step."); + } + + [Fact] + public async Task PostPublishSmokeRejectsEmptyAspireVersionOutput() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // Without an explicit empty-stdout check, `@(...)` wraps an empty + // version line into an empty array and PowerShell's `-notmatch` + // against an empty array silently returns an empty array (falsy), + // letting an `aspire --version` that exits 0 with no output slip past + // the version-pattern check. Assert the explicit guard is present. + Assert.Contains("$versionLine.Count -eq 0", pipeline); + Assert.Contains("produced no output.", pipeline); + } + + [Fact] + public async Task PointerPreflightPinsPublicNpmRegistry() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // Every npm command in the publish flow MUST explicitly pin + // `--registry=https://registry.npmjs.org/`. The release agent's + // ambient registry is not guaranteed to be public npmjs — an + // internal mirror may be configured via .npmrc or + // npm_config_registry. Without the explicit pin, the preflight + // could (a) spuriously fail after a successful public publish + // if the mirror lacks the new package, or (b) pass against a + // stale mirror and let the pointer publish reference RIDs the + // public registry can't serve. Guard against future drift by + // asserting the preflight `npm view` is registry-pinned. + Assert.Contains( + "npm view $spec version --registry=https://registry.npmjs.org/", + pipeline); + } + + [Fact] + public async Task PointerPreflightRetriesForPropagationLag() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // The post-publish smoke uses 10×30s retry loops to ride out npm + // CDN propagation. The pre-pointer RID preflight must do the same + // because npm propagation of 7 freshly-published scoped tarballs + // can exceed the fixed NpmRegistryPropagationDelayMinutes wait. + // A single-shot preflight would fail closed AFTER all 7 RID + // packages are already published, forcing a manual re-run with + // SkipNpmRidPublish=true. Assert the preflight has its own + // retry loop. + Assert.Contains("$preflightAttempts = 10", pipeline); + Assert.Contains("$preflightDelaySeconds = 30", pipeline); + Assert.Contains("for ($preflightAttempt = 1; $preflightAttempt -le $preflightAttempts;", pipeline); + } + + [Fact] + public async Task NpmViewParsingFiltersToSemverShape() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // `npm view --loglevel=warn` merges deprecation / peer-dep / + // EBADENGINE warnings onto stderr. With `2>&1`, taking + // `Select-Object -First 1` could latch a warning line as the + // version, burn all 10 retries, and fail the release even though + // the publish succeeded. Both the preflight and post-publish + // smoke filter to lines that match a semver shape before + // comparing. + var semverRegexUses = System.Text.RegularExpressions.Regex.Matches( + pipeline, + @"\$semverRegex\s*=\s*'\^\\d\+\\\.\\d\+\\\.\\d\+"); + Assert.True( + semverRegexUses.Count >= 2, + $"Expected the semver regex to be defined in both the preflight and post-publish smoke; found {semverRegexUses.Count} occurrence(s)."); + } + + [Fact] + public async Task NpmSignatureSidecarsAreContentSanityChecked() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + var buildAndTest = await ReadRepoFileAsync("eng/pipelines/templates/BuildAndTest.yml"); + + // The earlier validation only checked that the `.tgz.sig` files + // EXIST. If Arcade SignTool silently produced an empty or garbage + // sidecar (signing service hiccup, plugin misconfiguration), the + // release would publish a tarball whose sidecar is unverifiable + // and nothing in CI would catch it. Full PGP verification would + // require importing the LinuxSign500180PGP public key and running + // gpg on every agent. As a low-risk middle ground, both source + // build (BuildAndTest.yml) and release pipeline assert each + // `.sig` is non-empty AND contains an OpenPGP signature marker + // (ASCII-armored "-----BEGIN PGP SIGNATURE-----" per RFC 9580 §6, + // OR a binary OpenPGP signature packet — tag 2, RFC 9580 §4.3 + // / §5.2 — starting with 0x88-0x8B (old format) or 0xC2 (new + // format)). + Assert.Contains("'-----BEGIN PGP SIGNATURE-----'", pipeline); + Assert.Contains("'-----BEGIN PGP SIGNATURE-----'", buildAndTest); + Assert.Contains("0x8B", pipeline); + Assert.Contains("0x8B", buildAndTest); + Assert.Contains("0xC2", pipeline); + Assert.Contains("0xC2", buildAndTest); + Assert.Contains("content sanity check", pipeline); + Assert.Contains("content sanity check", buildAndTest); + } + + [Fact] + public async Task AspireVersionCaptureStripsCarriageReturnForWindowsRunner() + { + var template = await ReadRepoFileAsync("eng/pipelines/templates/prepare-npm-cli-packages.yml"); + + // Regression guard for the CRLF-stripping fix surfaced by opus-4.7 review. + // + // On Windows runners the prepare-npm step runs under Git Bash, which + // launches `aspire.exe` as a Windows console process. System.CommandLine + // 2.x's VersionOption writes through Console.Out.WriteLine, which + // terminates lines with Environment.NewLine = "\r\n" on Windows. Bash + // command substitution `$(...)` strips trailing LF but NOT CR, so the + // captured variable ends with "\r". The semver capture regex used by + // the install validation is anchored with `$` (end-of-line), which does + // not match a literal CR — so without `tr -d '\r'` on the version + // capture, the entire install validation silently fails on Windows with + // "##[error]aspire --version reported '' but expected ''". + // + // Verified locally: `printf 'X\r\n' | grep -Eo '^X$'` produces NO match. + // + // This regressed in commit debf4ebf38 ("Harden npm prepare/publish + // validation against partial-failure leakage"), which replaced the + // earlier `tr -d '[:space:]'` form with a `grep -Eo`+`$` form. The dry + // run on 2987740 did NOT exercise this path because SkipNpmPublish=true + // skips the release-pipeline consumer that reads the win-x64 validation + // summary; the Monday real publish would have hit the bug at the + // first source-build Windows install validation. + Assert.Contains("aspire --version 2>&1 | tr -d '\\r'", template); + } + + [Fact] + public async Task PointerPreflightExplicitlyPinsRegistryOnSpecLine() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // The preflight that gates the pointer publish runs `npm view $spec ...` + // (note: `$spec`, not `$packageSpec` — the latter is the post-publish + // smoke). A separate test asserts the post-publish line is registry- + // pinned; this one asserts the preflight line is also pinned, so that + // a future refactor that drops `--registry=https://registry.npmjs.org/` + // from the preflight call would be caught at PR-time rather than + // silently letting a stale internal-mirror result decide whether to + // ship a broken pointer to npmjs. + Assert.Contains("npm view $spec version --registry=https://registry.npmjs.org/", pipeline); + } + + private static void AssertBefore(string contents, string text, int boundaryIndex) + { + var textIndex = FindRequiredText(contents, text); + + Assert.True( + textIndex < boundaryIndex, + $"Expected '{text}' to appear before 'task: 1ES.PublishNuget@1'."); + } + + private static int FindRequiredText(string contents, string text) + { + var index = contents.IndexOf(text, StringComparison.Ordinal); + + Assert.True(index >= 0, $"Expected to find '{text}'."); + + return index; + } + + private Task ReadRepoFileAsync(string relativePath) + => File.ReadAllTextAsync(Path.Combine(_repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + + private static string FindRepoRoot() + { + string? current = AppContext.BaseDirectory; + + while (current is not null) + { + if (File.Exists(Path.Combine(current, "Aspire.slnx"))) + { + return current; + } + + current = Directory.GetParent(current)?.FullName; + } + + throw new DirectoryNotFoundException("Could not find repository root containing Aspire.slnx"); + } +} diff --git a/tests/Infrastructure.Tests/PowerShellScripts/StageNativeCliToolPackagesTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/StageNativeCliToolPackagesTests.cs index 37deed78201..a8cec4c1207 100644 --- a/tests/Infrastructure.Tests/PowerShellScripts/StageNativeCliToolPackagesTests.cs +++ b/tests/Infrastructure.Tests/PowerShellScripts/StageNativeCliToolPackagesTests.cs @@ -68,6 +68,53 @@ public async Task StagesCanonicalPointerAndRidPackages() Assert.Contains("Skipping Aspire.Cli package outside native archive artifacts", result.Output); } + [Fact] + [RequiresTools(["pwsh"])] + public async Task StagesNpmPackagesWhenPresent() + { + var downloadRoot = CreateDownloadRoot(); + var shippingDir = CreateShippingDir(); + + CreateNativeArchivePackages(downloadRoot, "win-x64"); + CreateNativeArchivePackage(downloadRoot, "linux-x64", $"Aspire.Cli.linux-x64.{PackageVersion}.nupkg"); + CreateNativeArchiveNpmPackages(downloadRoot, "win-x64"); + CreateNativeArchiveNpmPackages(downloadRoot, "linux-x64"); + + var result = await RunScript(downloadRoot, shippingDir, "-RequireNpmPackages"); + + result.EnsureSuccessful(); + + var stagedPackageNames = GetStagedPackageNames(shippingDir); + Assert.Equal( + [ + $"Aspire.Cli.{PackageVersion}.nupkg", + $"Aspire.Cli.linux-x64.{PackageVersion}.nupkg", + $"Aspire.Cli.win-x64.{PackageVersion}.nupkg", + $"microsoft-aspire-cli-{PackageVersion}.tgz", + $"microsoft-aspire-cli-linux-x64-{PackageVersion}.tgz", + $"microsoft-aspire-cli-win-x64-{PackageVersion}.tgz" + ], + stagedPackageNames); + Assert.Contains("Skipping non-canonical Aspire CLI npm pointer package", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenRequiredNpmPackagesAreMissing() + { + var downloadRoot = CreateDownloadRoot(); + var shippingDir = CreateShippingDir(); + + CreateNativeArchivePackages(downloadRoot, "win-x64"); + CreateNativeArchivePackage(downloadRoot, "linux-x64", $"Aspire.Cli.linux-x64.{PackageVersion}.nupkg"); + + var result = await RunScript(downloadRoot, shippingDir, "-RequireNpmPackages"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("No Aspire CLI npm packages were found", result.Output); + Assert.Contains("native_archives_", result.Output); + } + [Fact] [RequiresTools(["pwsh"])] public async Task FailsWhenCanonicalPointerPackageIsMissing() @@ -268,6 +315,12 @@ private static void CreateNativeArchivePackages(string downloadRoot, string rid) CreateNativeArchivePackage(downloadRoot, rid, $"Aspire.Cli.{rid}.{PackageVersion}.nupkg"); } + private static void CreateNativeArchiveNpmPackages(string downloadRoot, string rid) + { + CreateNativeArchivePackage(downloadRoot, rid, $"microsoft-aspire-cli-{PackageVersion}.tgz"); + CreateNativeArchivePackage(downloadRoot, rid, $"microsoft-aspire-cli-{rid}-{PackageVersion}.tgz"); + } + private static void CreateNativeArchivePackage(string downloadRoot, string rid, string packageName) { CreatePackage(downloadRoot, $"native_archives_{rid.Replace('-', '_')}", "Release", "Shipping", packageName); @@ -282,7 +335,7 @@ private static void CreatePackage(string downloadRoot, params string[] pathParts private static string[] GetStagedPackageNames(string shippingDir) { - return Directory.GetFiles(shippingDir, "Aspire.Cli*.nupkg") + return Directory.GetFiles(shippingDir) .Select(Path.GetFileName) .Order(StringComparer.Ordinal) .ToArray()!;