ci: use lockfile installs for Studio npm paths

PR #5604 committed three npm lockfiles (studio/, studio/frontend/,
studio/backend/core/data_recipe/oxc-validator/) but the install
paths still ran `npm install` -- mutates the lockfile, ignores
integrity hashes. Flip them to `npm ci`, which is non-mutating
and refuses to run unless the committed package-lock.json matches
the dependency tree byte-for-byte. This closes the gap between
"the lockfile passes the audit" and "the install respected that
lockfile".

For bun: gate every `bun install` on `[ -f bun.lock ]` (Test-Path
on Windows) plus `command -v bun` (Get-Command), and add
`--frozen-lockfile --no-progress` to the bun invocation. No
bun.lock is committed in this PR, so the gate fails closed today
and execution falls through to `npm ci`. When the eventual
bun.lock PR lands, the gate auto-activates with no further
script edits.

Existing bun infrastructure (cache-corruption retry +
critical-binary verification + npm fallback) is kept intact so
the auto-upgrade is clean. Deliberately NOT introducing
_BUN_PIN_VERSION or the 3-attempt cache-corruption recovery
ladder; both require committed bun.lock to be useful.

Touches three files only:
- build.sh
- studio/setup.sh
- studio/setup.ps1
This commit is contained in:
Daniel Han 2026-05-19 14:07:13 +00:00
commit 8dce77f80d
3 changed files with 89 additions and 39 deletions

View file

@ -33,18 +33,24 @@ _restore_gitignores() {
}
trap _restore_gitignores EXIT
# Use bun for install if available (faster), fall back to npm.
# Lockfile-pinned install. Prefer bun when a bun.lock is checked in AND
# bun is on PATH; otherwise use `npm ci`, which is non-mutating and
# refuses to run unless the committed package-lock.json matches the
# dependency tree byte-for-byte. The `[ -f bun.lock ]` gate keeps bun
# off the path until a bun.lock is also committed -- without it,
# `bun install --frozen-lockfile` would fail and the script would
# still fall through to npm ci, so the gate just skips the noise.
_install_ok=false
if command -v bun &>/dev/null; then
if bun install; then
if [ -f bun.lock ] && command -v bun &>/dev/null; then
if bun install --frozen-lockfile --no-progress; then
_install_ok=true
else
echo "⚠ bun install failed, falling back to npm"
echo "⚠ bun install --frozen-lockfile failed, falling back to npm ci"
rm -rf node_modules
fi
fi
if [ "$_install_ok" != "true" ]; then
if ! npm install; then
if ! npm ci --no-fund --no-audit; then
echo "❌ ERROR: package install failed" >&2
exit 1
fi

View file

@ -1321,13 +1321,19 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
$WalkDir = Split-Path $WalkDir -Parent
}
# Use bun if available (faster install), fall back to npm.
# Bun is used only as package manager; Node runs the actual build (Vite 8).
# Lockfile-pinned install. Prefer bun ONLY when a bun.lock is checked
# in AND bun is on PATH; otherwise use `npm ci`, which is non-mutating
# and refuses to run unless the committed package-lock.json matches
# the dependency tree byte-for-byte. The `Test-Path "bun.lock"` gate
# keeps bun off the path until a bun.lock is also committed -- the
# bun-fallback wiring (cache-clear retry + critical-binary
# verification) is kept intact so this script auto-upgrades when
# bun.lock eventually lands.
$prevEAP_npm = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Push-Location $FrontendDir
$UseBun = $null -ne (Get-Command bun -ErrorAction SilentlyContinue)
$UseBun = (Test-Path "bun.lock") -and ($null -ne (Get-Command bun -ErrorAction SilentlyContinue))
# bun's package cache can become corrupt -- packages get stored with only
# metadata but no actual content (bin/, lib/). When this happens bun install
@ -1335,7 +1341,7 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
# the cache + retry once before falling back to npm.
if ($UseBun) {
Write-Host " Using bun for package install (faster)" -ForegroundColor DarkGray
$bunExit = Invoke-SetupCommand { bun install }
$bunExit = Invoke-SetupCommand { bun install --frozen-lockfile --no-progress }
# On Windows, .bin/ entries vary by package manager:
# npm → tsc, tsc.cmd, tsc.ps1
# bun → tsc.exe, tsc.bunx
@ -1349,18 +1355,18 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
}
Invoke-SetupCommand { bun pm cache rm } | Out-Null
$bunExit = Invoke-SetupCommand { bun install }
$bunExit = Invoke-SetupCommand { bun install --frozen-lockfile --no-progress }
$hasTsc = (Test-Path "node_modules\.bin\tsc") -or (Test-Path "node_modules\.bin\tsc.cmd") -or (Test-Path "node_modules\.bin\tsc.exe") -or (Test-Path "node_modules\.bin\tsc.bunx")
$hasVite = (Test-Path "node_modules\.bin\vite") -or (Test-Path "node_modules\.bin\vite.cmd") -or (Test-Path "node_modules\.bin\vite.exe") -or (Test-Path "node_modules\.bin\vite.bunx")
if ($bunExit -ne 0 -or -not $hasTsc -or -not $hasVite) {
Write-Host " bun retry failed, falling back to npm" -ForegroundColor Yellow
Write-Host " bun retry failed, falling back to npm ci" -ForegroundColor Yellow
if (Test-Path "node_modules") {
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
}
$UseBun = $false
}
} else {
substep "bun install failed (exit $bunExit), falling back to npm" "Yellow"
substep "bun install --frozen-lockfile failed (exit $bunExit), falling back to npm ci" "Yellow"
if (Test-Path "node_modules") {
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
}
@ -1368,13 +1374,13 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
}
}
if (-not $UseBun) {
$npmExit = Invoke-SetupCommand { npm install }
$npmExit = Invoke-SetupCommand { npm ci --no-fund --no-audit }
if ($npmExit -ne 0) {
Pop-Location
$ErrorActionPreference = $prevEAP_npm
foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue }
Write-Host "[ERROR] npm install failed (exit code $npmExit)" -ForegroundColor Red
Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow
Write-Host "[ERROR] npm ci failed (exit code $npmExit)" -ForegroundColor Red
Write-Host " Try running 'npm ci' manually in frontend/ to see errors" -ForegroundColor Yellow
exit 1
}
}
@ -1411,12 +1417,31 @@ if (Test-Path $OxcValidatorDir) {
$prevEAP_oxc = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Push-Location $OxcValidatorDir
$oxcInstallExit = Invoke-SetupCommand { npm install }
if ($oxcInstallExit -ne 0) {
Pop-Location
$ErrorActionPreference = $prevEAP_oxc
Write-Host "[ERROR] OXC validator npm install failed (exit code $oxcInstallExit)" -ForegroundColor Red
exit 1
# Same lockfile-pinned shape as the frontend block above: bun only
# when a bun.lock is checked in next to package.json (currently not
# the case), otherwise `npm ci` against the committed
# package-lock.json (shipped in #5604).
$UseOxcBun = (Test-Path "bun.lock") -and ($null -ne (Get-Command bun -ErrorAction SilentlyContinue))
$oxcInstallOk = $false
if ($UseOxcBun) {
$oxcBunExit = Invoke-SetupCommand { bun install --frozen-lockfile --no-progress }
if ($oxcBunExit -eq 0) {
$oxcInstallOk = $true
} else {
if (Test-Path "node_modules") {
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
if (-not $oxcInstallOk) {
$oxcInstallExit = Invoke-SetupCommand { npm ci --no-fund --no-audit }
if ($oxcInstallExit -ne 0) {
Pop-Location
$ErrorActionPreference = $prevEAP_oxc
Write-Host "[ERROR] OXC validator npm ci failed (exit code $oxcInstallExit)" -ForegroundColor Red
exit 1
}
}
Pop-Location
$ErrorActionPreference = $prevEAP_oxc

View file

@ -329,21 +329,23 @@ _restore_gitignores() {
}
trap _restore_gitignores EXIT
# Use bun for install if available (faster), fall back to npm.
# Build always uses npm (Node runtime -- avoids bun runtime issues on some platforms).
# NOTE: We intentionally avoid run_quiet for the bun install attempt because
# run_quiet calls exit on failure, which would kill the script before the npm
# fallback can run. Instead we capture output manually and only show it on failure.
# Lockfile-pinned install. Prefer bun ONLY when a bun.lock is checked in
# AND bun is on PATH; otherwise use `npm ci`, which is non-mutating and
# refuses to run unless the committed package-lock.json matches the
# dependency tree byte-for-byte. The `[ -f bun.lock ]` gate keeps bun
# off the path until a bun.lock is also committed -- the bun-fallback
# wiring (cache-clear retry + critical-binary verification) is kept
# intact so this script auto-upgrades when bun.lock eventually lands.
#
# IMPORTANT: bun's package cache can become corrupt -- packages get stored
# with only metadata (package.json, README) but no actual content (bin/,
# lib/). When this happens bun install exits 0 but leaves binaries missing.
# We verify critical binaries after install. If missing, we clear the cache
# and retry once before falling back to npm.
# IMPORTANT: bun's package cache can become corrupt -- packages get
# stored with only metadata (package.json, README) but no actual content
# (bin/, lib/). When this happens bun install exits 0 but leaves
# binaries missing. We verify critical binaries after install. If
# missing, we clear the cache and retry once before falling back to npm.
_try_bun_install() {
local _log _exit_code=0
_log=$(mktemp)
bun install >"$_log" 2>&1 || _exit_code=$?
bun install --frozen-lockfile --no-progress >"$_log" 2>&1 || _exit_code=$?
# bun may create .exe shims on Windows (Git Bash / MSYS2) instead of plain scripts
if [ "$_exit_code" -eq 0 ] \
@ -355,7 +357,7 @@ _try_bun_install() {
# Either bun install failed or it exited 0 but left packages missing
if [ "$_exit_code" -ne 0 ]; then
echo " bun install failed (exit code $_exit_code):"
echo " bun install --frozen-lockfile failed (exit code $_exit_code):"
else
echo " bun install exited 0 but critical binaries are missing:"
fi
@ -366,7 +368,7 @@ _try_bun_install() {
}
_bun_install_ok=false
if command -v bun &>/dev/null; then
if [ -f bun.lock ] && command -v bun &>/dev/null; then
substep "using bun for package install (faster)"
if _try_bun_install; then
_bun_install_ok=true
@ -381,7 +383,10 @@ if command -v bun &>/dev/null; then
fi
fi
if [ "$_bun_install_ok" = false ]; then
run_quiet_no_exit "npm install" npm install --no-fund --no-audit --loglevel=error
# npm ci enforces the committed package-lock.json (refuses to install
# otherwise); --no-fund / --no-audit / --loglevel=error keep the
# output quiet during normal installs.
run_quiet_no_exit "npm ci" npm ci --no-fund --no-audit --loglevel=error
_npm_install_rc=$?
if [ "$_npm_install_rc" -ne 0 ]; then
exit "$_npm_install_rc"
@ -406,12 +411,26 @@ cd "$SCRIPT_DIR"
fi # end frontend build check
# ── oxc-validator runtime ──
# Same lockfile-pinned shape as the frontend block above: bun only when
# a bun.lock is checked in next to package.json (currently not the
# case), otherwise `npm ci` against the committed package-lock.json
# (shipped in #5604).
if [ -d "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator" ] && command -v npm &>/dev/null; then
cd "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator"
run_quiet_no_exit "npm install (oxc validator runtime)" npm install --no-fund --no-audit --loglevel=error
_oxc_install_rc=$?
if [ "$_oxc_install_rc" -ne 0 ]; then
exit "$_oxc_install_rc"
_oxc_install_ok=false
if [ -f bun.lock ] && command -v bun &>/dev/null; then
if run_quiet_no_exit "bun install (oxc validator runtime)" bun install --frozen-lockfile --no-progress; then
_oxc_install_ok=true
else
rm -rf node_modules
fi
fi
if [ "$_oxc_install_ok" = false ]; then
run_quiet_no_exit "npm ci (oxc validator runtime)" npm ci --no-fund --no-audit --loglevel=error
_oxc_install_rc=$?
if [ "$_oxc_install_rc" -ne 0 ]; then
exit "$_oxc_install_rc"
fi
fi
cd "$SCRIPT_DIR"
fi