From 679e6fe40e66b3746b8f6b13f040091f5a999916 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 8 Apr 2026 10:07:00 +0900 Subject: [PATCH 1/4] fix: more safer vite-plus install and `vp upgrade` --- .../src/commands/upgrade/install.rs | 213 ++++++++++++++++-- .../src/commands/upgrade/mod.rs | 9 +- packages/cli/install.ps1 | 110 ++++++++- packages/cli/install.sh | 109 ++++++++- 4 files changed, 394 insertions(+), 47 deletions(-) diff --git a/crates/vite_global_cli/src/commands/upgrade/install.rs b/crates/vite_global_cli/src/commands/upgrade/install.rs index 569cf47562..875d201bcd 100644 --- a/crates/vite_global_cli/src/commands/upgrade/install.rs +++ b/crates/vite_global_cli/src/commands/upgrade/install.rs @@ -4,8 +4,9 @@ //! and version cleanup. use std::{ - io::{Cursor, Read as _}, + io::{Cursor, IsTerminal, Read as _, Write as _}, path::Path, + process::Output, }; use flate2::read::GzDecoder; @@ -122,6 +123,94 @@ pub async fn write_release_age_overrides(version_dir: &AbsolutePath) -> Result<( Ok(()) } +fn is_affirmative_response(input: &str) -> bool { + matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +fn should_prompt_release_age_override(silent: bool) -> bool { + !silent && std::io::stdin().is_terminal() && std::io::stderr().is_terminal() +} + +fn prompt_release_age_override(version: &str) -> bool { + eprintln!(); + eprintln!("warn: Your minimumReleaseAge setting prevented installing vite-plus@{version}."); + eprintln!("This setting helps protect against newly published compromised packages."); + eprintln!("Proceeding will disable this protection for this Vite+ install only."); + eprint!("Do you want to proceed? (y/N): "); + if std::io::stderr().flush().is_err() { + return false; + } + + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + return false; + } + + is_affirmative_response(&input) +} + +fn is_release_age_error(stdout: &[u8], stderr: &[u8]) -> bool { + let output = + format!("{}\n{}", String::from_utf8_lossy(stdout), String::from_utf8_lossy(stderr)); + let lower = output.to_ascii_lowercase(); + + // This wrapper install path is pinned to pnpm via packageManager, so this + // detection follows pnpm's resolver/reporter output rather than npm/yarn. + // + // pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so + // `NO_MATURE_MATCHING_VERSION` becomes `ERR_PNPM_NO_MATURE_MATCHING_VERSION` + // in CLI output. We still match the unprefixed code as a fallback in case + // future reporter/log output includes the raw internal code. + // https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 + // + // npm-resolver chooses NO_MATURE_MATCHING_VERSION when + // publishedBy/minimumReleaseAge rejects a matching version, and uses the + // "does not meet the minimumReleaseAge constraint" message. + // https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 + // + // default-reporter handles both ERR_PNPM_NO_MATURE_MATCHING_VERSION and + // ERR_PNPM_NO_MATCHING_VERSION, and may append guidance mentioning + // minimumReleaseAgeExclude when the error has an immatureVersion. + // https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 + // + // pnpm itself notes that NO_MATCHING_VERSION can also happen under + // minimumReleaseAge when all candidate versions are newer than the threshold. + // Because it is also used for real missing versions, we only treat it as + // release-age related when accompanied by the age-gate text below. + // https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 + // + // minimum-release-age is the pnpm .npmrc key; npm's min-release-age is + // intentionally not treated as a pnpm signal here. + // https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 + let has_release_age_text = output.contains("does not meet the minimumReleaseAge constraint") + || output.contains("minimumReleaseAge") + || output.contains("minimumReleaseAgeExclude") + || lower.contains("minimum release age") + || lower.contains("minimum-release-age"); + + output.contains("ERR_PNPM_NO_MATURE_MATCHING_VERSION") + || output.contains("NO_MATURE_MATCHING_VERSION") + || (output.contains("ERR_PNPM_NO_MATCHING_VERSION") && has_release_age_text) + || has_release_age_text +} + +fn format_install_failure_message( + exit_code: i32, + log_path: Option<&AbsolutePathBuf>, + release_age_blocked: bool, +) -> String { + let log_msg = log_path + .map_or_else(String::new, |p| format!(". See log for details: {}", p.as_path().display())); + + if release_age_blocked { + format!( + "Upgrade blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly{log_msg}" + ) + } else { + format!("Failed to install production dependencies (exit code: {exit_code}){log_msg}") + } +} + /// Write stdout and stderr from a failed install to `upgrade.log`. /// /// The log is written to the **parent** of `version_dir` (i.e. `~/.vite-plus/upgrade.log`) @@ -150,11 +239,13 @@ pub async fn write_upgrade_log( /// Install production dependencies using the new version's binary. /// -/// Spawns: `{version_dir}/bin/vp install --silent [--registry ]` with `CI=true`. +/// Spawns: `{version_dir}/bin/vp install [--registry ]` with `CI=true`. /// On failure, writes stdout+stderr to `{version_dir}/upgrade.log` for debugging. pub async fn install_production_deps( version_dir: &AbsolutePath, registry: Option<&str>, + silent: bool, + new_version: &str, ) -> Result<(), Error> { let vp_binary = version_dir.join("bin").join(if cfg!(windows) { "vp.exe" } else { "vp" }); @@ -166,39 +257,82 @@ pub async fn install_production_deps( tracing::debug!("Running vp install in {}", version_dir.as_path().display()); - let mut args = vec!["install", "--silent"]; + // Do not pass `--silent` to the inner install: pnpm suppresses the + // release-age error body in silent mode, which would leave upgrade.log + // empty and make the release-age gate impossible to detect. This outer + // process captures the output and only surfaces it through the log. + let mut args = vec!["install"]; if let Some(registry_url) = registry { args.push("--"); args.push("--registry"); args.push(registry_url); } - let output = tokio::process::Command::new(vp_binary.as_path()) - .args(&args) - .current_dir(version_dir) - .env("CI", "true") - .output() - .await?; + let output = run_vp_install(version_dir, &vp_binary, &args).await?; if !output.status.success() { let log_path = write_upgrade_log(version_dir, &output.stdout, &output.stderr).await; - let log_msg = log_path.map_or_else( - || String::new(), - |p| format!(". See log for details: {}", p.as_path().display()), - ); - return Err(Error::Upgrade( - format!( - "Failed to install production dependencies (exit code: {}){}", - output.status.code().unwrap_or(-1), - log_msg - ) - .into(), - )); + let release_age_blocked = is_release_age_error(&output.stdout, &output.stderr); + + if !release_age_blocked { + return Err(Error::Upgrade( + format_install_failure_message( + output.status.code().unwrap_or(-1), + log_path.as_ref(), + false, + ) + .into(), + )); + } + + if !should_prompt_release_age_override(silent) || !prompt_release_age_override(new_version) + { + return Err(Error::Upgrade( + format_install_failure_message( + output.status.code().unwrap_or(-1), + log_path.as_ref(), + true, + ) + .into(), + )); + } + + // Only create the local override after explicit consent. This preserves + // minimumReleaseAge protection for the default and non-interactive paths. + write_release_age_overrides(version_dir).await?; + let retry_output = run_vp_install(version_dir, &vp_binary, &args).await?; + if !retry_output.status.success() { + let retry_log_path = + write_upgrade_log(version_dir, &retry_output.stdout, &retry_output.stderr).await; + return Err(Error::Upgrade( + format_install_failure_message( + retry_output.status.code().unwrap_or(-1), + retry_log_path.as_ref(), + false, + ) + .into(), + )); + } } Ok(()) } +async fn run_vp_install( + version_dir: &AbsolutePath, + vp_binary: &AbsolutePath, + args: &[&str], +) -> Result { + let output = tokio::process::Command::new(vp_binary.as_path()) + .args(args) + .current_dir(version_dir) + .env("CI", "true") + .output() + .await?; + + Ok(output) +} + /// Save the current version before swapping, for rollback support. /// /// Reads the `current` symlink target and writes the version to `.previous-version`. @@ -545,4 +679,41 @@ mod tests { "bunfig.toml should not be created" ); } + + #[test] + fn test_is_release_age_error_detects_pnpm_no_mature_code() { + assert!(is_release_age_error( + b"", + b"ERR_PNPM_NO_MATURE_MATCHING_VERSION Version 0.1.16 of vite-plus does not meet the minimumReleaseAge constraint", + )); + } + + #[test] + fn test_is_release_age_error_detects_minimum_release_age_message() { + assert!(is_release_age_error( + b"", + b"Version 0.1.16 (released just now) of vite-plus does not meet the minimumReleaseAge constraint", + )); + } + + #[test] + fn test_is_release_age_error_detects_no_matching_with_release_age_context() { + assert!(is_release_age_error( + b"", + b"ERR_PNPM_NO_MATCHING_VERSION No matching version found. Add the package name to minimumReleaseAgeExclude if you want to ignore the time it was published.", + )); + } + + #[test] + fn test_is_release_age_error_ignores_plain_no_matching_version() { + assert!(!is_release_age_error( + b"", + b"ERR_PNPM_NO_MATCHING_VERSION No matching version found for vite-plus@999.999.999", + )); + } + + #[test] + fn test_is_release_age_error_ignores_npm_min_release_age() { + assert!(!is_release_age_error(b"", b"min-release-age prevented installing vite-plus",)); + } } diff --git a/crates/vite_global_cli/src/commands/upgrade/mod.rs b/crates/vite_global_cli/src/commands/upgrade/mod.rs index 6fbde07ff2..2435b5e61d 100644 --- a/crates/vite_global_cli/src/commands/upgrade/mod.rs +++ b/crates/vite_global_cli/src/commands/upgrade/mod.rs @@ -165,13 +165,8 @@ async fn install_platform_and_main( // Generate wrapper package.json that declares vite-plus as a dependency install::generate_wrapper_package_json(version_dir, new_version).await?; - // Isolate from user's global package manager config that may block - // installing recently-published packages (e.g. pnpm's minimumReleaseAge, - // yarn's npmMinimalAgeGate, bun's minimumReleaseAge) - install::write_release_age_overrides(version_dir).await?; - - // Install production dependencies (npm installs vite-plus + all transitive deps) - install::install_production_deps(version_dir, registry).await?; + // Install production dependencies (pnpm installs vite-plus + all transitive deps) + install::install_production_deps(version_dir, registry, silent, new_version).await?; // Save previous version for rollback let previous_version = install::save_previous_version(install_dir).await?; diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index 973d7f1794..e470ed2f28 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -46,6 +46,85 @@ function Write-Error-Exit { exit 1 } +function Test-ReleaseAgeError { + param([string]$LogPath) + if (-not (Test-Path $LogPath)) { + return $false + } + + $content = Get-Content -Path $LogPath -Raw + # This wrapper install path is pinned to pnpm via packageManager, so this + # detection follows pnpm's resolver/reporter output rather than npm/yarn. + # + # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so + # NO_MATURE_MATCHING_VERSION is normally printed as + # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the + # "does not meet the minimumReleaseAge constraint" message when + # publishedBy/minimumReleaseAge rejects a matching version. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 + # + # default-reporter may append guidance mentioning minimumReleaseAgeExclude + # when the error has an immatureVersion, so that token is also a useful + # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's + # min-release-age is intentionally not treated as a pnpm signal here. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 + $hasReleaseAgeText = $content -match "does not meet the minimumReleaseAge constraint" ` + -or $content -match "minimumReleaseAge" ` + -or $content -match "minimumReleaseAgeExclude" ` + -or $content -match "minimum release age" ` + -or $content -match "minimum-release-age" + + # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge + # filters out all candidates. That code is also used for real missing + # versions, so require age-gate context before prompting for a bypass. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 + return $content -match "ERR_PNPM_NO_MATURE_MATCHING_VERSION" ` + -or $content -match "NO_MATURE_MATCHING_VERSION" ` + -or (($content -match "ERR_PNPM_NO_MATCHING_VERSION") -and $hasReleaseAgeText) ` + -or $hasReleaseAgeText +} + +function Confirm-ReleaseAgeOverride { + if (-not [Environment]::UserInteractive) { + return $false + } + + Write-Host "" + Write-Warn "Your minimumReleaseAge setting prevented installing vite-plus@$ViteVersion." + Write-Host "This setting helps protect against newly published compromised packages." + Write-Host "Proceeding will disable this protection for this Vite+ install only." + $response = Read-Host "Do you want to proceed? (y/N)" + return $response -match "^(?i:y|yes)$" +} + +function Write-ReleaseAgeOverride { + Set-Content -Path (Join-Path $VersionDir ".npmrc") -Value "minimum-release-age=0" +} + +function Write-InstallFailure { + param([string]$LogPath) + if ($env:CI -eq "true") { + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host "Failed to install dependencies. Log output:" + Get-Content -Path $LogPath | ForEach-Object { Write-Host $_ } + } else { + Write-Error-Exit "Failed to install dependencies. See log for details: $LogPath" + } +} + +function Write-ReleaseAgeFailure { + param([string]$LogPath) + if ($env:CI -eq "true") { + Write-Host "error: " -ForegroundColor Red -NoNewline + Write-Host "Install blocked by your minimumReleaseAge setting. Log output:" + Get-Content -Path $LogPath | ForEach-Object { Write-Host $_ } + } else { + Write-Error-Exit "Install blocked by your minimumReleaseAge setting. Wait until the package is old enough or adjust your package manager configuration explicitly. See log for details: $LogPath" + } +} + function Get-Architecture { if ([Environment]::Is64BitOperatingSystem) { if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { @@ -368,10 +447,6 @@ function Main { } | ConvertTo-Json -Depth 10 Set-Content -Path (Join-Path $VersionDir "package.json") -Value $wrapperJson - # Isolate from pnpm's global config that may block installing - # recently-published packages (e.g. minimumReleaseAge). - Set-Content -Path (Join-Path $VersionDir ".npmrc") -Value "minimum-release-age=0" - # Install production dependencies (skip if VP_SKIP_DEPS_INSTALL is set, # e.g. during local dev where install-global-cli.ts handles deps separately) if (-not $env:VP_SKIP_DEPS_INSTALL) { @@ -380,12 +455,33 @@ function Main { try { # Use cmd /c so CI=true is scoped to the child process only, # avoiding leaking it into the user's shell session. - $output = cmd /c "set CI=true && `"$BinDir\vp.exe`" install --silent" 2>&1 + # Do not pass --silent to the inner install: pnpm suppresses the + # release-age error body in silent mode, which would leave + # install.log empty and make the release-age gate impossible to + # detect. Output is already captured to install.log here. + $output = cmd /c "set CI=true && `"$BinDir\vp.exe`" install" 2>&1 $installExitCode = $LASTEXITCODE $output | Out-File $installLog if ($installExitCode -ne 0) { - Write-Host "error: Failed to install dependencies. See log for details: $installLog" -ForegroundColor Red - exit 1 + if (Test-ReleaseAgeError $installLog) { + if (Confirm-ReleaseAgeOverride) { + # Write the override only after explicit consent, then retry once. + Write-ReleaseAgeOverride + $retryOutput = cmd /c "set CI=true && `"$BinDir\vp.exe`" install" 2>&1 + $retryExitCode = $LASTEXITCODE + $retryOutput | Out-File $installLog + if ($retryExitCode -ne 0) { + Write-InstallFailure $installLog + exit 1 + } + } else { + Write-ReleaseAgeFailure $installLog + exit 1 + } + } else { + Write-InstallFailure $installLog + exit 1 + } } } finally { Pop-Location diff --git a/packages/cli/install.sh b/packages/cli/install.sh index b8be70a286..f08ae0f73b 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -58,6 +58,84 @@ error() { exit 1 } +is_release_age_error() { + local log_file="$1" + [ -f "$log_file" ] || return 1 + + # This wrapper install path is pinned to pnpm via packageManager, so this + # detection follows pnpm's resolver/reporter output rather than npm/yarn. + # + # pnpm's PnpmError prefixes internal codes with ERR_PNPM_, so + # NO_MATURE_MATCHING_VERSION is normally printed as + # ERR_PNPM_NO_MATURE_MATCHING_VERSION. npm-resolver emits that code with the + # "does not meet the minimumReleaseAge constraint" message when + # publishedBy/minimumReleaseAge rejects a matching version. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/core/error/src/index.ts#L18-L20 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/resolving/npm-resolver/src/index.ts#L76-L84 + # + # default-reporter may append guidance mentioning minimumReleaseAgeExclude + # when the error has an immatureVersion, so that token is also a useful + # release-age signal. minimum-release-age is pnpm's .npmrc key; npm's + # min-release-age is intentionally not treated as a pnpm signal here. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/cli/default-reporter/src/reportError.ts#L163-L164 + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/config/reader/src/types.ts#L73-L74 + grep -Eqi 'ERR_PNPM_NO_MATURE_MATCHING_VERSION|NO_MATURE_MATCHING_VERSION|does not meet the minimumReleaseAge constraint|minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" && return 0 + + # pnpm can also surface ERR_PNPM_NO_MATCHING_VERSION when minimumReleaseAge + # filters out all candidates. That code is also used for real missing + # versions, so require age-gate context before prompting for a bypass. + # https://github.com/pnpm/pnpm/blob/16cfde66ec71125d692ea828eba2a5f9b3cc54fc/deps/inspection/outdated/src/createManifestGetter.ts#L66-L76 + if grep -Eq 'ERR_PNPM_NO_MATCHING_VERSION' "$log_file"; then + grep -Eqi 'minimumReleaseAge|minimumReleaseAgeExclude|minimum release age|minimum-release-age' "$log_file" + return $? + fi + + return 1 +} + +confirm_release_age_override() { + [ -e /dev/tty ] && [ -t 1 ] || return 1 + + echo "" > /dev/tty + echo -e "${YELLOW}warn${NC}: Your minimumReleaseAge setting prevented installing vite-plus@${VP_VERSION}." > /dev/tty + echo "This setting helps protect against newly published compromised packages." > /dev/tty + echo "Proceeding will disable this protection for this Vite+ install only." > /dev/tty + printf "Do you want to proceed? (y/N): " > /dev/tty + + local response + read -r response < /dev/tty || return 1 + case "$response" in + y|Y|yes|YES) return 0 ;; + *) return 1 ;; + esac +} + +write_release_age_override() { + cat > "$VERSION_DIR/.npmrc" < "$VERSION_DIR/.npmrc" < "$install_log" 2>&1); then - if [ "${CI:-}" = "true" ]; then - echo -e "${RED}error${NC}: Failed to install dependencies. Log output:" - cat "$install_log" + # Do not pass --silent to the inner install: pnpm suppresses the + # release-age error body in silent mode, which would leave install.log + # empty and make the release-age gate impossible to detect. Output is + # already redirected to install.log here. + if ! (cd "$VERSION_DIR" && CI=true "$vp_install_bin" install > "$install_log" 2>&1); then + if is_release_age_error "$install_log"; then + if confirm_release_age_override; then + # Write the override only after explicit consent, then retry once. + write_release_age_override + if ! (cd "$VERSION_DIR" && CI=true "$vp_install_bin" install > "$install_log" 2>&1); then + print_install_failure "$install_log" + exit 1 + fi + else + print_release_age_failure "$install_log" + exit 1 + fi else - echo -e "${RED}error${NC}: Failed to install dependencies. See log for details: $install_log" + print_install_failure "$install_log" + exit 1 fi - exit 1 fi fi From 064252251a0215bcd548d6fd8f8b20eaaaba3a01 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 8 Apr 2026 10:19:25 +0900 Subject: [PATCH 2/4] fix: add minimumReleaseAge test case for powsershell --- .github/workflows/test-standalone-install.yml | 73 +++++++++++++++++++ packages/cli/install.ps1 | 3 + 2 files changed, 76 insertions(+) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index b4b371a4a1..b19201c569 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -554,6 +554,79 @@ jobs: vp upgrade --rollback vp --version + test-install-ps1-release-age: + name: Test install.ps1 (minimum-release-age) + runs-on: windows-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify minimumReleaseAge blocks non-interactive install + shell: powershell + run: | + $ErrorActionPreference = "Stop" + $env:CI = "true" + $env:VP_NODE_MANAGER = "no" + $env:VP_HOME = Join-Path $env:RUNNER_TEMP "vite-plus-release-age" + + $npmrc = Join-Path $env:USERPROFILE ".npmrc" + $backup = Join-Path $env:RUNNER_TEMP ".npmrc.backup" + + if (Test-Path $npmrc) { + Move-Item -Path $npmrc -Destination $backup -Force + } + + try { + Set-Content -Path $npmrc -Value "minimum-release-age=10000000" + + $output = & powershell -NoProfile -ExecutionPolicy Bypass -File .\packages\cli\install.ps1 2>&1 + $exitCode = $LASTEXITCODE + $text = $output -join "`n" + Write-Host $text + + if ($exitCode -eq 0) { + Write-Error "Expected install.ps1 to fail when pnpm minimum-release-age blocks vite-plus" + exit 1 + } + + if ($text -notmatch "Install blocked by your minimumReleaseAge setting") { + Write-Error "Expected release-age block message in installer output" + exit 1 + } + + if ($text -notmatch "ERR_PNPM_NO_MATURE_MATCHING_VERSION|minimumReleaseAge") { + Write-Error "Expected pnpm release-age details in installer output" + exit 1 + } + + $installLog = Get-ChildItem -Path $env:VP_HOME -Recurse -Filter install.log | Select-Object -First 1 + if (-not $installLog) { + Write-Error "Expected install.log to be written under VP_HOME" + exit 1 + } + + $logText = Get-Content -Path $installLog.FullName -Raw + if ($logText -notmatch "ERR_PNPM_NO_MATURE_MATCHING_VERSION|minimumReleaseAge") { + Write-Error "Expected pnpm release-age details in install.log" + exit 1 + } + + $overrideFiles = Get-ChildItem -Path $env:VP_HOME -Recurse -Force -Filter .npmrc | + Where-Object { $_.FullName -notmatch "\\js_runtime\\" } + if ($overrideFiles) { + Write-Host "Unexpected override files:" + $overrideFiles | ForEach-Object { Write-Host $_.FullName } + Write-Error "Non-interactive install must not write minimum-release-age overrides" + exit 1 + } + } finally { + Remove-Item -Path $npmrc -Force -ErrorAction SilentlyContinue + if (Test-Path $backup) { + Move-Item -Path $backup -Destination $npmrc -Force + } + } + test-install-ps1: name: Test install.ps1 (Windows x64) runs-on: windows-latest diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index e470ed2f28..2961de290a 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -87,6 +87,9 @@ function Test-ReleaseAgeError { } function Confirm-ReleaseAgeOverride { + if ($env:CI -eq "true") { + return $false + } if (-not [Environment]::UserInteractive) { return $false } From b06a1c63192d1833f2c1fcdeb51c66adcec40859 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 8 Apr 2026 10:24:05 +0900 Subject: [PATCH 3/4] fix: add persist credentials --- .github/workflows/test-standalone-install.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index b19201c569..45566ce19b 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -561,6 +561,8 @@ jobs: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Verify minimumReleaseAge blocks non-interactive install shell: powershell From ef526de1135a1a5e262e286a58f78c27102b01f9 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 8 Apr 2026 10:33:59 +0900 Subject: [PATCH 4/4] fix: last exit code --- .github/workflows/test-standalone-install.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index 45566ce19b..302442821c 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -622,6 +622,11 @@ jobs: Write-Error "Non-interactive install must not write minimum-release-age overrides" exit 1 } + + # The child install.ps1 is expected to fail in this test. Reset the + # native command exit code so the GitHub Actions PowerShell wrapper + # does not fail the step after our assertions pass. + $global:LASTEXITCODE = 0 } finally { Remove-Item -Path $npmrc -Force -ErrorAction SilentlyContinue if (Test-Path $backup) {