From 3f9605d483600a1288e34b8aa0b7ea76f2bfc9e1 Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Fri, 10 Jul 2026 22:15:54 +0200 Subject: [PATCH 1/7] feat: add Azure Pipelines integration for setup-vp Introduce a reusable Azure step template with prepare/cache/finalize lifecycle, shared portable CI primitives extracted from the GitLab runtime, and a compiled dist/azure bundle for Linux, macOS, and Windows agents. Co-authored-by: Cursor --- .github/renovate.json | 2 +- .github/workflows/test.yml | 34 ++++ AGENTS.md | 9 +- README.md | 73 +++++++- azure/bootstrap.ps1 | 26 +++ azure/bootstrap.sh | 29 +++ azure/setup-vp.yml | 123 ++++++++++++ dist/azure/index.mjs | 2 + dist/gitlab/index.mjs | 2 +- dist/project-BPhRkgx0.mjs | 1 + rfcs/azure-pipelines-integration.md | 137 ++++++++++++++ src/azure/commands.test.ts | 37 ++++ src/azure/commands.ts | 37 ++++ src/azure/index.test.ts | 102 ++++++++++ src/azure/index.ts | 148 +++++++++++++++ src/azure/inputs.test.ts | 59 ++++++ src/azure/inputs.ts | 35 ++++ src/azure/install-viteplus.test.ts | 58 ++++++ src/azure/install-viteplus.ts | 124 ++++++++++++ src/azure/template.test.ts | 54 ++++++ src/ci/auth.ts | 45 +++++ src/ci/cache.ts | 107 +++++++++++ src/ci/install-sfw.ts | 219 ++++++++++++++++++++++ src/ci/process.ts | 39 ++++ src/ci/project.ts | 29 +++ src/ci/run-install.ts | 274 +++++++++++++++++++++++++++ src/ci/types.ts | 27 +++ src/ci/version.test.ts | 10 + src/ci/version.ts | 12 ++ src/gitlab/auth.ts | 41 +--- src/gitlab/install-sfw.test.ts | 5 +- src/gitlab/install-sfw.ts | 204 ++------------------ src/gitlab/run-install.ts | 281 +--------------------------- src/gitlab/shell.ts | 17 +- src/gitlab/types.ts | 20 +- src/gitlab/utils.ts | 28 +-- src/portable-bundles.test.ts | 29 +++ vite.config.ts | 1 + 38 files changed, 1916 insertions(+), 564 deletions(-) create mode 100644 azure/bootstrap.ps1 create mode 100644 azure/bootstrap.sh create mode 100644 azure/setup-vp.yml create mode 100644 dist/azure/index.mjs create mode 100644 dist/project-BPhRkgx0.mjs create mode 100644 rfcs/azure-pipelines-integration.md create mode 100644 src/azure/commands.test.ts create mode 100644 src/azure/commands.ts create mode 100644 src/azure/index.test.ts create mode 100644 src/azure/index.ts create mode 100644 src/azure/inputs.test.ts create mode 100644 src/azure/inputs.ts create mode 100644 src/azure/install-viteplus.test.ts create mode 100644 src/azure/install-viteplus.ts create mode 100644 src/azure/template.test.ts create mode 100644 src/ci/auth.ts create mode 100644 src/ci/cache.ts create mode 100644 src/ci/install-sfw.ts create mode 100644 src/ci/process.ts create mode 100644 src/ci/project.ts create mode 100644 src/ci/run-install.ts create mode 100644 src/ci/types.ts create mode 100644 src/ci/version.test.ts create mode 100644 src/ci/version.ts create mode 100644 src/portable-bundles.test.ts diff --git a/.github/renovate.json b/.github/renovate.json index 44ef473..a579acc 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -18,7 +18,7 @@ "customManagers": [ { "customType": "regex", - "fileMatch": ["^src/install-sfw\\.ts$", "^src/gitlab/install-sfw\\.ts$"], + "fileMatch": ["^src/install-sfw\\.ts$", "^src/ci/install-sfw\\.ts$"], "matchStrings": ["const SFW_VERSION = \"(?v[^\"]+)\";"], "depNameTemplate": "SocketDev/sfw-free", "datasourceTemplate": "github-releases" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e00b390..184b27a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -650,6 +650,40 @@ jobs: fi echo "OK: composed sfw blocked lodahs (exit $CODE, block-line found)" + test-azure-runtime: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Setup Vite+ + uses: ./ + with: + run-install: false + cache: false + + - name: Build portable bundles + run: vp run build + + - name: Verify Azure runtime invalid phase smoke test + shell: bash + run: | + node dist/azure/index.mjs invalid-phase + test $? -ne 0 + + - name: Verify bootstrap syntax (Unix) + if: runner.os != 'Windows' + run: bash -n azure/bootstrap.sh gitlab/bootstrap.sh + + - name: Verify bootstrap syntax (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + [scriptblock]::Create((Get-Content -Raw azure/bootstrap.ps1)) > $null + build: runs-on: ubuntu-latest # This job verifies the action's own artifacts (dist/ committed and up to diff --git a/AGENTS.md b/AGENTS.md index 6278ff5..a394df3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,11 @@ This file provides guidance to coding agents working in this repository. ## Guidelines - Do not commit changes automatically. Wait for an explicit user request to commit. -- Keep `dist/index.mjs` in sync with source changes by running `vp run build` before committing. +- Keep `dist/index.mjs`, `dist/gitlab/index.mjs`, and `dist/azure/index.mjs` in sync with source changes by running `vp run build` before committing. ## Project Overview -GitHub Action to set up [Vite+](https://viteplus.dev) (`vp`) with dependency caching support. The action installs Vite+ globally, can set up Node.js via `vp env use`, optionally configures registry auth, restores/saves dependency cache, and can run `vp install` with optional Socket Firewall Free (`sfw`) wrapping. +GitHub Action to set up [Vite+](https://viteplus.dev) (`vp`) with dependency caching support. The action installs Vite+ globally, can set up Node.js via `vp env use`, optionally configures registry auth, restores/saves dependency cache, and can run `vp install` with optional Socket Firewall Free (`sfw`) wrapping. GitLab and Azure Pipelines entry points reuse a dependency-light portable runtime under `src/ci/`. ## Commands @@ -60,6 +60,11 @@ The action has main and post execution phases. Both are served by `src/index.ts` - `src/run-install.ts` - Execute `vp install` entries with optional cwd/args. - `src/install-sfw.ts` - Install or reuse Socket Firewall Free for wrapped installs. - `src/utils.ts` - Lock file detection, package-manager cache paths, and shared helpers. +- `src/ci/*` - Shared dependency-light primitives for GitLab and Azure runtimes. +- `src/gitlab/*` - GitLab runtime adapters around `src/ci/*`. +- `src/azure/*` - Azure Pipelines runtime and logging-command adapters. +- `azure/setup-vp.yml` - Azure step template. +- `gitlab/setup-vp.yml` - GitLab remote template. ### Lock File Detection diff --git a/README.md b/README.md index ac81650..94f9f6f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # setup-vp -GitHub Action and GitLab CI/CD remote template to set up [Vite+](https://viteplus.dev) (`vp`). +GitHub Action, GitLab CI/CD remote template, and Azure Pipelines step template to set up [Vite+](https://viteplus.dev) (`vp`). ## Features @@ -11,6 +11,7 @@ GitHub Action and GitLab CI/CD remote template to set up [Vite+](https://viteplu - Optionally wrap `vp install` with [Socket Firewall Free (`sfw`)](https://docs.socket.dev/docs/socket-firewall-free) to block malicious dependencies - Support for all major package managers (npm, pnpm, yarn, bun) - GitLab CI/CD support through a reusable `include:remote` template +- Azure Pipelines support through a reusable step template and compiled runtime ## Usage @@ -494,6 +495,74 @@ test: - The GitLab template does not set up Node.js. Use a Node image such as `node:24`, or install Node.js before extending `.setup-vp`. - The GitLab template intentionally does not expose `cache` or `cache-dependency-path` inputs. GitLab restores job cache before `before_script`, so this template cannot compute cache paths during setup and restore them for the same job. Configure GitLab `cache:` directly on the job when needed. +## Azure Pipelines + +setup-vp also provides an Azure Pipelines step template hosted from this GitHub repository. Azure cannot execute the GitHub Action bundle directly, so the template downloads a compiled runtime (`dist/azure/index.mjs`) and runs it in `prepare` and `finalize` phases around Azure's native `Cache@2` task. + +See [Azure Pipelines integration notes](rfcs/azure-pipelines-integration.md) for the design background, parity table, and cache semantics. + +### Basic Azure Usage + +Create a GitHub service connection named `github`, then reference the template from this repository: + +```yaml +resources: + repositories: + - repository: setupVp + type: github + endpoint: github + name: voidzero-dev/setup-vp + ref: refs/tags/v1.16.0 + +pool: + vmImage: ubuntu-latest + +steps: + - checkout: self + + - template: azure/setup-vp.yml@setupVp + parameters: + setupRef: v1.16.0 + nodeVersion: 24.x + cache: true + runInstall: true + + - script: vp run test +``` + +Pin `ref` and `setupRef` to the same tag or commit SHA for strict reproducibility. The moving `v1` tag is supported for convenience but is not immutable. + +### Azure Parameters + +| Parameter | Default | Description | +| --------------------- | -------- | ------------------------------------------------------------------------- | +| `version` | `latest` | Vite+ version/dist-tag passed to the official installer. | +| `workingDirectory` | `.` | Project directory for lock detection and default `vp install`. | +| `runInstall` | `true` | Run `vp install`; accepts boolean or object/list with `cwd` and `args`. | +| `sfw` | `false` | Wrap `vp install` with Socket Firewall Free. | +| `registryUrl` | | Optional registry URL for a temporary `.npmrc`. | +| `scope` | | Optional npm registry scope. | +| `setupRef` | `v1` | Ref used to download bootstrap scripts and `dist/azure/index.mjs`. | +| `nodeVersion` | `24.x` | Passed to `UseNode@1`; an empty string skips Node setup. | +| `cache` | `false` | Enable Azure `Cache@2` around the package-manager cache directory. | +| `cacheDependencyPath` | | Explicit lock file relative to `workingDirectory`; otherwise auto-detect. | + +### Azure Job Variables + +| Variable | Purpose | +| ---------------------------- | --------------------------------------------------------------------- | +| `SETUP_VP_INSTALLED_VERSION` | Installed global Vite+ version (`unknown` when parsing fails). | +| `SETUP_VP_CACHE_HIT` | `true`, `inexact`, or `false` from `Cache@2` when caching is enabled. | + +`vp`, `NPM_CONFIG_USERCONFIG`, and `PNPM_CONFIG_USERCONFIG` are available to later steps in the same job. Map `NODE_AUTH_TOKEN` into later steps when private registry auth is required. + +### Azure Notes + +- The template supports Microsoft-hosted Linux, macOS, and Windows agents. +- `Cache@2` restores before `vp install` and saves automatically in a post-job step. +- Missing lock files or cache paths degrade to a warning and `SETUP_VP_CACHE_READY=false` instead of failing setup. +- For Azure Artifacts feeds, compose with Azure's `npmAuthenticate` task and/or pass `registryUrl` plus `NODE_AUTH_TOKEN`. + ## Example Workflow ```yaml @@ -550,7 +619,7 @@ vp install ### Before Committing - Run `vp run check:fix` and `vp run build` -- Generated files under `dist/` must be committed, including `dist/index.mjs` for the GitHub Action and `dist/gitlab/index.mjs` for the GitLab template +- Generated files under `dist/` must be committed, including `dist/index.mjs` for the GitHub Action, `dist/gitlab/index.mjs` for the GitLab template, and `dist/azure/index.mjs` for the Azure Pipelines runtime - Pre-commit hooks (via husky + lint-staged) will automatically run `vp check --fix` on staged files via `vpx lint-staged` ### Releasing diff --git a/azure/bootstrap.ps1 b/azure/bootstrap.ps1 new file mode 100644 index 0000000..e10ddf0 --- /dev/null +++ b/azure/bootstrap.ps1 @@ -0,0 +1,26 @@ +$ErrorActionPreference = 'Stop' + +# Azure step templates expand YAML from an external repository but do not check +# out bootstrap/runtime files onto the agent. Download the compiled runtime and +# execute its prepare phase, which installs Vite+ and emits cache metadata. + +function Setup-VpDownload { + param( + [string]$Url, + [string]$OutFile + ) + + Invoke-WebRequest -Uri $Url -OutFile $OutFile -TimeoutSec 60 +} + +$setupRef = if ($env:SETUP_VP_SETUP_REF) { $env:SETUP_VP_SETUP_REF } else { 'v1' } +$runtimeOut = if ($env:SETUP_VP_RUNTIME_OUT) { + $env:SETUP_VP_RUNTIME_OUT +} else { + Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure-runtime.mjs' +} + +$runtimeUrl = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/$setupRef/dist/azure/index.mjs" +Setup-VpDownload -Url $runtimeUrl -OutFile $runtimeOut + +& node $runtimeOut prepare diff --git a/azure/bootstrap.sh b/azure/bootstrap.sh new file mode 100644 index 0000000..7984fe9 --- /dev/null +++ b/azure/bootstrap.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Azure step templates expand YAML from an external repository but do not check +# out bootstrap/runtime files onto the agent. Download the compiled runtime and +# execute its prepare phase, which installs Vite+ and emits cache metadata. + +setup_vp_download() { + setup_vp_url="$1" + setup_vp_out="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL --connect-timeout 5 --max-time 60 "$setup_vp_url" -o "$setup_vp_out" + elif command -v wget >/dev/null 2>&1; then + wget -q -T 60 -t 2 -O "$setup_vp_out" "$setup_vp_url" + else + echo "setup-vp: curl or wget is required to download files." >&2 + return 127 + fi +} + +SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1}" +SETUP_VP_RUNTIME_OUT="${SETUP_VP_RUNTIME_OUT:-$(mktemp "${TMPDIR:-/tmp}/setup-vp-azure-runtime.XXXXXX.mjs")}" +chmod 600 "$SETUP_VP_RUNTIME_OUT" 2>/dev/null || true + +setup_vp_runtime_url="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${SETUP_VP_SETUP_REF}/dist/azure/index.mjs" +setup_vp_download "$setup_vp_runtime_url" "$SETUP_VP_RUNTIME_OUT" + +node "$SETUP_VP_RUNTIME_OUT" prepare diff --git a/azure/setup-vp.yml b/azure/setup-vp.yml new file mode 100644 index 0000000..b3cfc55 --- /dev/null +++ b/azure/setup-vp.yml @@ -0,0 +1,123 @@ +parameters: + - name: version + type: string + default: latest + - name: workingDirectory + type: string + default: . + - name: runInstall + type: object + default: true + - name: sfw + type: boolean + default: false + - name: registryUrl + type: string + default: "" + - name: scope + type: string + default: "" + - name: setupRef + type: string + default: v1 + - name: nodeVersion + type: string + default: 24.x + - name: cache + type: boolean + default: false + - name: cacheDependencyPath + type: string + default: "" + +steps: + - ${{ if ne(parameters.nodeVersion, '') }}: + - task: UseNode@1 + displayName: Use Node.js + inputs: + version: ${{ parameters.nodeVersion }} + + - ${{ if eq(variables['Agent.OS'], 'Windows_NT') }}: + - powershell: | + $ErrorActionPreference = 'Stop' + $bootstrap = Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure-bootstrap.ps1' + $runtime = Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure-runtime.mjs' + $base = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/${{ parameters.setupRef }}/azure" + Invoke-WebRequest -Uri "$base/bootstrap.ps1" -OutFile $bootstrap -TimeoutSec 60 + & $bootstrap + displayName: setup-vp prepare (Windows) + env: + SETUP_VP_VERSION: ${{ parameters.version }} + SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} + SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} + SETUP_VP_SFW: ${{ parameters.sfw }} + SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} + SETUP_VP_SCOPE: ${{ parameters.scope }} + SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} + SETUP_VP_CACHE: ${{ parameters.cache }} + SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} + SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)\setup-vp-azure-runtime.mjs + + - ${{ if ne(variables['Agent.OS'], 'Windows_NT') }}: + - bash: | + set -euo pipefail + bootstrap="$(Agent.TempDirectory)/setup-vp-azure-bootstrap.sh" + runtime="$(Agent.TempDirectory)/setup-vp-azure-runtime.mjs" + base="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${{ parameters.setupRef }}/azure" + curl -fsSL --connect-timeout 5 --max-time 60 "$base/bootstrap.sh" -o "$bootstrap" + chmod +x "$bootstrap" + bash "$bootstrap" + displayName: setup-vp prepare (Unix) + env: + SETUP_VP_VERSION: ${{ parameters.version }} + SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} + SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} + SETUP_VP_SFW: ${{ parameters.sfw }} + SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} + SETUP_VP_SCOPE: ${{ parameters.scope }} + SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} + SETUP_VP_CACHE: ${{ parameters.cache }} + SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} + SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)/setup-vp-azure-runtime.mjs + + - ${{ if eq(parameters.cache, true) }}: + - task: Cache@2 + displayName: Cache Vite+ package-manager data + condition: and(succeeded(), eq(variables['SETUP_VP_CACHE_READY'], 'true')) + inputs: + key: '"vite-plus" | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | "$(SETUP_VP_LOCK_TYPE)" | $(SETUP_VP_LOCK_FILE)' + restoreKeys: | + "vite-plus" | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | "$(SETUP_VP_LOCK_TYPE)" + "vite-plus" | "$(Agent.OS)" | "$(Agent.OSArchitecture)" + path: $(SETUP_VP_CACHE_PATH) + cacheHitVar: SETUP_VP_CACHE_HIT + + - ${{ if eq(variables['Agent.OS'], 'Windows_NT') }}: + - powershell: | + $ErrorActionPreference = 'Stop' + node "$(SETUP_VP_RUNTIME_PATH)" finalize + displayName: setup-vp finalize (Windows) + env: + SETUP_VP_VERSION: ${{ parameters.version }} + SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} + SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} + SETUP_VP_SFW: ${{ parameters.sfw }} + SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} + SETUP_VP_SCOPE: ${{ parameters.scope }} + SETUP_VP_CACHE: ${{ parameters.cache }} + SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} + + - ${{ if ne(variables['Agent.OS'], 'Windows_NT') }}: + - bash: | + set -euo pipefail + node "$(SETUP_VP_RUNTIME_PATH)" finalize + displayName: setup-vp finalize (Unix) + env: + SETUP_VP_VERSION: ${{ parameters.version }} + SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} + SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} + SETUP_VP_SFW: ${{ parameters.sfw }} + SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} + SETUP_VP_SCOPE: ${{ parameters.scope }} + SETUP_VP_CACHE: ${{ parameters.cache }} + SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} diff --git a/dist/azure/index.mjs b/dist/azure/index.mjs new file mode 100644 index 0000000..7240880 --- /dev/null +++ b/dist/azure/index.mjs @@ -0,0 +1,2 @@ +import{a as e,i as t,n,r,s as i,t as a}from"../project-BPhRkgx0.mjs";import{pathToFileURL as o}from"node:url";import s,{basename as c,isAbsolute as l,join as u}from"node:path";import{setTimeout as d}from"node:timers/promises";import{existsSync as f,readdirSync as p}from"node:fs";import{homedir as m}from"node:os";import{spawnSync as h}from"node:child_process";const g=[{filename:`pnpm-lock.yaml`,type:`pnpm`},{filename:`bun.lockb`,type:`bun`},{filename:`bun.lock`,type:`bun`},{filename:`package-lock.json`,type:`npm`},{filename:`npm-shrinkwrap.json`,type:`npm`},{filename:`yarn.lock`,type:`yarn`}];function resolvePath(e,t){return l(e)?e:u(t,e)}function inferLockFileType(e,t){return t.includes(`pnpm`)?{type:`pnpm`,path:e,filename:t}:t.includes(`yarn`)?{type:`yarn`,path:e,filename:t}:t.startsWith(`bun.`)?{type:`bun`,path:e,filename:t}:{type:`npm`,path:e,filename:t}}function detectLockFile(e,t){if(e){let n=resolvePath(e,t);if(f(n)){let e=c(n),t=g.find(t=>t.filename===e);return t?{type:t.type,path:n,filename:e}:inferLockFileType(n,e)}return}let n=p(t);for(let e of g)if(n.includes(e.filename)){let n=u(t,e.filename);return{type:e.type,path:n,filename:e.filename}}}function prepareCacheMetadata(t){let{projectDir:n,cacheDependencyPath:r,logWarning:i}=t,a=detectLockFile(r||void 0,n);if(!a)return i?.(`setup-vp: cache is enabled but no supported lock file was found; skipping cache.`),{ready:!1};let o=e(`vp`,[`pm`,`cache`,`dir`],{cwd:s.dirname(a.path)});return o?{ready:!0,cachePath:o,lockFile:a.path,lockType:a.type}:(i?.(`setup-vp: could not resolve package-manager cache directory for ${a.filename}; skipping cache.`),{ready:!1})}function parseInstalledVpVersion(e){return(e.match(/^\s*vp\s+v?(\d[^\s]*)/im)??e.match(/Global:\s*v?(\d[^\s]*)/i))?.[1]??`unknown`}function escapeLoggingCommandData(e){return e.replaceAll(`%`,`%25`).replaceAll(`\r`,`%0D`).replaceAll(` +`,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const _=[`https://viteplus.dev/install.sh`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh`],v=[`https://viteplus.dev/install.ps1`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.ps1`],y=2e3,b=/^0\.0\.0-commit\.([0-9a-f]{40})$/i;function getVitePlusHome(e=process.platform){return u(e===`win32`?process.env.USERPROFILE||m():process.env.HOME||m(),`.vite-plus`)}function pkgPrNewCommitSha(e){return e.match(b)?.[1]}function runInstallCommand(e,t,n=process.platform){if(n===`win32`){let n=h(`pwsh`,[`-Command`,`& ([scriptblock]::Create((irm -TimeoutSec 15 ${e})))`],{env:{...process.env,...t},stdio:`inherit`});if(n.error)throw n.error;return n.status??1}let r=h(`bash`,[`-c`,`set -o pipefail; curl -fsSL --connect-timeout 5 --max-time 15 ${e} | bash`],{env:{...process.env,...t},stdio:`inherit`});if(r.error)throw r.error;return r.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??d,o=t.runInstall??runInstallCommand,s=t.logWarningFn??logWarning,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=pkgPrNewCommitSha(e);l&&(c.VP_PR_VERSION=l);let f=n===`win32`?v:_,p=2*f.length,m=``,h=0;for(let e=0;e<2;e+=1)for(let e of f){h+=1;try{let t=o(e,c,n);if(t===0){let e=u(getVitePlusHome(n),`bin`);r.PATH?.includes(e)||(r.PATH=`${e}${n===`win32`?`;`:`:`}${r.PATH||``}`,i?.(e));return}m=`exit code ${t}`}catch(e){m=e instanceof Error?e.message:String(e)}ht.prependPath(e),logWarningFn:t.logWarning});let i=s.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,i,{isOutput:!0}),!n.cache)return;let a=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!a.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`,{isOutput:!0});return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`,{isOutput:!0}),a.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,a.cachePath,{isOutput:!0}),a.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,a.lockFile,{isOutput:!0}),a.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,a.lockType,{isOutput:!0})}async function runFinalize(e=process.env,t=x){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n,{isOutput:!0})});let i=t.parseRunInstall(n.runInstall),a=await t.setupSfw(i,{env:e,sfwEnabled:n.sfw,exportVariable:(e,n)=>{n!==void 0&&t.setVariable(e,n,{isOutput:!0})}});i.length>0&&t.runInstall(i,r,a,e);let o=t.getCommandOutput(`vp`,[`--version`])||``;t.logInfo(o);let s=t.parseInstalledVpVersion(o);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,s,{isOutput:!0})}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===o(s.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file diff --git a/dist/gitlab/index.mjs b/dist/gitlab/index.mjs index ef4fcad..bf768b2 100644 --- a/dist/gitlab/index.mjs +++ b/dist/gitlab/index.mjs @@ -1 +1 @@ -import{get as e}from"node:http";import{pathToFileURL as t}from"node:url";import n from"node:path";import{createWriteStream as r,existsSync as i,mkdtempSync as a,statSync as o,writeFileSync as s}from"node:fs";import{tmpdir as c}from"node:os";import{get as l}from"node:https";import{spawnSync as u}from"node:child_process";import{chmod as d,mkdtemp as f}from"node:fs/promises";function shellQuote(e){return`'${String(e).replaceAll(`'`,`'\\''`)}'`}function exportShellEnv(e,t,n=process.env){!n.SETUP_VP_ENV_FILE||t===void 0||s(n.SETUP_VP_ENV_FILE,`export ${e}=${shellQuote(t)}\n`,{encoding:`utf8`,flag:`a`})}function run(e,t,n={}){let r=u(e,t,{stdio:`inherit`,...n});if(r.error)throw r.error;r.status!==0&&process.exit(r.status??1)}function commandPath(e){let t=u(`sh`,[`-c`,`command -v "$1"`,`sh`,e],{encoding:`utf8`});if(t.status===0)return t.stdout.trim()}function configureAuth(e,t,r=process.env){if(!e)return;let i;try{i=new URL(e)}catch{throw Error(`Invalid registry-url: "${e}". Must be a valid URL.`)}let o=i.href.endsWith(`/`)?i.href:`${i.href}/`,l=``;t&&(l=`${(t.startsWith(`@`)?t:`@${t}`).toLowerCase()}:`);let u=o.replace(/^\w+:/,``).toLowerCase(),d=a(n.join(c(),`setup-vp-npmrc-`)),f=n.join(d,`.npmrc`);return s(f,`${u}:_authToken=\${NODE_AUTH_TOKEN}\n${l}registry=${o}\n`,{encoding:`utf8`,mode:384}),r.NPM_CONFIG_USERCONFIG=f,r.PNPM_CONFIG_USERCONFIG=f,r.NODE_AUTH_TOKEN=r.NODE_AUTH_TOKEN||`XXXXX-XXXXX-XXXXX-XXXXX`,r===process.env&&(exportShellEnv(`NPM_CONFIG_USERCONFIG`,r.NPM_CONFIG_USERCONFIG,r),exportShellEnv(`PNPM_CONFIG_USERCONFIG`,r.PNPM_CONFIG_USERCONFIG,r),exportShellEnv(`NODE_AUTH_TOKEN`,r.NODE_AUTH_TOKEN,r)),f}const p=`v1.12.0`,m=`https://github.com/SocketDev/sfw-free/releases/download/${p}`;function isMuslLinux(){if(process.platform!==`linux`)return!1;try{let e=process.report?.getReport();if(e?.header&&!e.header.glibcVersionRuntime)return!0}catch{}return i(`/etc/alpine-release`)}function getSfwAssetName(e,t,n){if(e===`darwin`){if(t===`x64`)return`sfw-free-macos-x86_64`;if(t===`arm64`)return`sfw-free-macos-arm64`}if(e===`linux`){if(t===`x64`)return n?`sfw-free-musl-linux-x86_64`:`sfw-free-linux-x86_64`;if(t===`arm64`)return n?`sfw-free-musl-linux-arm64`:`sfw-free-linux-arm64`}throw Error(`Unsupported platform/arch for sfw: ${e}/${t}${e===`linux`?` (${n?`musl`:`glibc`})`:``}`)}function downloadFileWithShell(e,t,n){let r=Math.max(1,Math.ceil(n/1e3)),i=commandPath(`curl`);if(i){let n=u(i,[`-fsSL`,`--connect-timeout`,`5`,`--max-time`,String(r),e,`-o`,t],{stdio:`ignore`});if(n.status===0)return;throw n.error||Error(`curl failed while downloading ${e}`)}let a=commandPath(`wget`);if(a){let n=u(a,[`-q`,`-T`,String(r),`-t`,`2`,`-O`,t,e],{stdio:`ignore`});if(n.status===0)return;throw n.error||Error(`wget failed while downloading ${e}`)}throw Error(`curl or wget is required to download sfw`)}function downloadFile(t,n,i=0,a=6e4,o){if(i>5)return Promise.reject(Error(`too many redirects while downloading ${t}`));if(!o)return downloadFileWithShell(t,n,a),Promise.resolve();let s=o||(t.startsWith(`https:`)?l:e);return new Promise((e,c)=>{let l=!1,finish=t=>{l||(l=!0,clearTimeout(d),t?c(t):e())},u=s(t,e=>{let s=e.statusCode??0,c=e.headers.location;if(s>=300&&s<400&&c){e.resume(),downloadFile(new URL(c,t).toString(),n,i+1,a,o).then(()=>finish(),finish);return}if(s!==200){e.resume(),finish(Error(`download failed with HTTP ${s}: ${t}`));return}let l=r(n);e.pipe(l),l.on(`finish`,()=>l.close(()=>finish())),l.on(`error`,finish)}),d=setTimeout(()=>{u.destroy(Error(`download timed out after ${a}ms: ${t}`))},a);u.on(`error`,finish)})}async function setupSfw(e,t=process.env){if(t.SETUP_VP_SFW!==`true`)return`vp`;if(e.length===0)return console.log(`setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.`),`vp`;let r=commandPath(`sfw`);if(r)return console.log(`setup-vp: using existing sfw on PATH: ${r}`),`sfw`;let i=isMuslLinux(),a;try{a=getSfwAssetName(process.platform,process.arch,i)}catch{a=void 0}if(!a)return console.error(`setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${process.platform}, process.arch=${process.arch}, musl=${i}) and none was found on PATH; falling back to plain vp install.`),`vp`;let o=await f(n.join(c(),`setup-vp-sfw-`)),s=n.join(o,`sfw`),l=`${m}/${a}`;for(let e=1;e<=2;e+=1)try{return console.log(`setup-vp: installing sfw ${p} from ${l}`),await downloadFile(l,s),await d(s,493),t.PATH=`${o}:${t.PATH||``}`,exportShellEnv(`PATH`,t.PATH,t),`sfw`}catch(t){if(e===2)throw t;await new Promise(e=>setTimeout(e,2e3))}throw Error(`failed to install sfw after retrying`)}function parseScalar(e){let t=String(e||``).trim();return t.startsWith(`"`)&&t.endsWith(`"`)||t.startsWith(`'`)&&t.endsWith(`'`)?t.slice(1,-1):t}function parseFlowArray(e){let t=String(e||``).trim();if(!t.startsWith(`[`)||!t.endsWith(`]`))throw Error(`args must be an array, got: ${e}`);let n=t.slice(1,-1).trim();if(!n)return[];let r=[],i=``,a=``,o=!1,pushCurrent=()=>{if(!i.trim())throw Error(`args flow array entries must be non-empty strings`);r.push(parseScalar(i)),i=``,o=!0};for(let e of n){if(a){e===a&&(a=``),i+=e;continue}if(e===`'`||e===`"`){a=e,i+=e;continue}if(e===`,`){pushCurrent();continue}i+=e}if(a)throw Error(`unterminated quoted string in args flow array`);if(i.trim())pushCurrent();else if(!o)throw Error(`args flow array entries must be non-empty strings`);return r}function parseKeyValue(e){let t=e.indexOf(`:`);if(!(t<0))return[e.slice(0,t).trim(),e.slice(t+1).trim()]}function countIndent(e){return e.length-e.trimStart().length}function parseBlockArray(e,t,n){let r=[],i=t;for(;itypeof e!=`string`))throw Error(`run-install.args must be an array of strings`);t.args=e.args}return t}function validateRunInstallInput(e){return e===null||typeof e==`boolean`?e:Array.isArray(e)?e.map(validateRunInstallEntry):validateRunInstallEntry(e)}function applyEntryLine(e,t,n,r,i,a){let o=parseKeyValue(t);if(!o)throw Error(`invalid run-install line: ${a}`);if(assignValue(e,o[0],o[1])){let t=parseBlockArray(n,r+1,i);return e.args=t.values,t.nextIndex-1}return r}function parseObject(e){let t={};for(let n=0;ne.trim()&&!e.trim().startsWith(`#`));if(t.length===0)return[];if(!t[0].trimStart().startsWith(`-`))return[parseObject(t)];let n=countIndent(t[0]),r=[],i;for(let e=0;eexportShellEnv(e,t,n):void 0)}async function setupSfw(t,n=process.env){return e(t,{env:n,exportVariable:(e,t)=>exportShellEnv(e,t,n)})}function resolveProjectDir(e=process.env){return a({workingDirectory:e.SETUP_VP_WORKING_DIRECTORY||`.`,workspaceRoot:e.CI_PROJECT_DIR||process.cwd()})}function fail(e){console.error(`setup-vp: ${e}`),process.exit(1)}async function main(){let e=resolveProjectDir(process.env);configureAuth(process.env.SETUP_VP_REGISTRY_URL||``,process.env.SETUP_VP_SCOPE||``);let i=t(process.env.SETUP_VP_RUN_INSTALL||`true`);r(i,e,await setupSfw(i)),n(`vp`,[`--version`])}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===o(s.resolve(e)).href)}if(isEntrypoint())try{await main()}catch(e){fail(e instanceof Error?e.message:String(e))}export{isEntrypoint,main}; \ No newline at end of file diff --git a/dist/project-BPhRkgx0.mjs b/dist/project-BPhRkgx0.mjs new file mode 100644 index 0000000..b920b20 --- /dev/null +++ b/dist/project-BPhRkgx0.mjs @@ -0,0 +1 @@ +import{get as e}from"node:http";import t from"node:path";import{createWriteStream as n,existsSync as r,mkdtempSync as i,statSync as a,writeFileSync as o}from"node:fs";import{tmpdir as s}from"node:os";import{get as c}from"node:https";import{spawnSync as l}from"node:child_process";import{chmod as u,mkdtemp as d}from"node:fs/promises";function configureAuth(e,n,r,a){if(!e)return;let c;try{c=new URL(e)}catch{throw Error(`Invalid registry-url: "${e}". Must be a valid URL.`)}let l=c.href.endsWith(`/`)?c.href:`${c.href}/`,u=``;n&&(u=`${(n.startsWith(`@`)?n:`@${n}`).toLowerCase()}:`);let d=l.replace(/^\w+:/,``).toLowerCase(),f=i(t.join(s(),`setup-vp-npmrc-`)),p=t.join(f,`.npmrc`);return o(p,`${d}:_authToken=\${NODE_AUTH_TOKEN}\n${u}registry=${l}\n`,{encoding:`utf8`,mode:384}),r.NPM_CONFIG_USERCONFIG=p,r.PNPM_CONFIG_USERCONFIG=p,r.NODE_AUTH_TOKEN=r.NODE_AUTH_TOKEN||`XXXXX-XXXXX-XXXXX-XXXXX`,a?.(`NPM_CONFIG_USERCONFIG`,r.NPM_CONFIG_USERCONFIG),a?.(`PNPM_CONFIG_USERCONFIG`,r.PNPM_CONFIG_USERCONFIG),a?.(`NODE_AUTH_TOKEN`,r.NODE_AUTH_TOKEN),p}function run(e,t,n={}){let r=l(e,t,{stdio:`inherit`,...n});if(r.error)throw r.error;r.status!==0&&process.exit(r.status??1)}function commandPath(e){if(process.platform===`win32`){let t=l(`where`,[e],{encoding:`utf8`});return t.status===0&&t.stdout.trim().split(/\r?\n/)[0]?.trim()||void 0}let t=l(`sh`,[`-c`,`command -v "$1"`,`sh`,e],{encoding:`utf8`});if(t.status===0)return t.stdout.trim()}function getCommandOutput(e,t,n){let r=l(e,t,{cwd:n?.cwd,encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]});if(r.status===0)return r.stdout.trim()}const f=`v1.12.0`,p=`https://github.com/SocketDev/sfw-free/releases/download/${f}`;function isMuslLinux(){if(process.platform!==`linux`)return!1;try{let e=process.report?.getReport();if(e?.header&&!e.header.glibcVersionRuntime)return!0}catch{}return r(`/etc/alpine-release`)}function getSfwAssetName(e,t,n){if(e===`darwin`){if(t===`arm64`)return`sfw-free-macos-arm64`;if(t===`x64`)return`sfw-free-macos-x86_64`}else if(e===`linux`){if(t===`arm64`)return n?`sfw-free-musl-linux-arm64`:`sfw-free-linux-arm64`;if(t===`x64`)return n?`sfw-free-musl-linux-x86_64`:`sfw-free-linux-x86_64`}else if(e===`win32`){if(t===`arm64`)return`sfw-free-windows-arm64.exe`;if(t===`x64`)return`sfw-free-windows-x86_64.exe`}throw Error(`Unsupported platform/arch for sfw: ${e}/${t}${e===`linux`?` (${n?`musl`:`glibc`})`:``}`)}function downloadFileWithShell(e,t,n){let r=Math.max(1,Math.ceil(n/1e3)),i=commandPath(`curl`);if(i){let n=l(i,[`-fsSL`,`--connect-timeout`,`5`,`--max-time`,String(r),e,`-o`,t],{stdio:`ignore`});if(n.status===0)return;throw n.error||Error(`curl failed while downloading ${e}`)}let a=commandPath(`wget`);if(a){let n=l(a,[`-q`,`-T`,String(r),`-t`,`2`,`-O`,t,e],{stdio:`ignore`});if(n.status===0)return;throw n.error||Error(`wget failed while downloading ${e}`)}throw Error(`curl or wget is required to download sfw`)}function downloadFile(t,r,i=0,a=6e4,o){if(i>5)return Promise.reject(Error(`too many redirects while downloading ${t}`));if(!o)return downloadFileWithShell(t,r,a),Promise.resolve();let s=o||(t.startsWith(`https:`)?c:e);return new Promise((e,c)=>{let l=!1,finish=t=>{l||(l=!0,clearTimeout(d),t?c(t):e())},u=s(t,e=>{let s=e.statusCode??0,c=e.headers.location;if(s>=300&&s<400&&c){e.resume(),downloadFile(new URL(c,t).toString(),r,i+1,a,o).then(()=>finish(),finish);return}if(s!==200){e.resume(),finish(Error(`download failed with HTTP ${s}: ${t}`));return}let l=n(r);e.pipe(l),l.on(`finish`,()=>l.close(()=>finish())),l.on(`error`,finish)}),d=setTimeout(()=>{u.destroy(Error(`download timed out after ${a}ms: ${t}`))},a);u.on(`error`,finish)})}async function setupSfw(e,n={}){let r=n.env??process.env,i=n.sfwEnabled??r.SETUP_VP_SFW===`true`,a=n.platform??process.platform,o=n.arch??process.arch,c=n.isMusl??isMuslLinux(),l=n.download??downloadFile;if(!i)return`vp`;if(e.length===0)return console.log(`setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.`),`vp`;let m=commandPath(`sfw`);if(m)return console.log(`setup-vp: using existing sfw on PATH: ${m}`),`sfw`;let h;try{h=getSfwAssetName(a,o,c)}catch{h=void 0}if(!h)return console.error(`setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${a}, process.arch=${o}, musl=${c}) and none was found on PATH; falling back to plain vp install.`),`vp`;let g=await d(t.join(s(),`setup-vp-sfw-`)),_=t.join(g,a===`win32`?`sfw.exe`:`sfw`),v=`${p}/${h}`;for(let e=1;e<=2;e+=1)try{return console.log(`setup-vp: installing sfw ${f} from ${v}`),await l(v,_),await u(_,493),r.PATH=`${g}${a===`win32`?`;`:`:`}${r.PATH||``}`,n.exportVariable?.(`PATH`,r.PATH),`sfw`}catch(t){if(e===2)throw t;await new Promise(e=>setTimeout(e,2e3))}throw Error(`failed to install sfw after retrying`)}function parseScalar(e){let t=String(e||``).trim();return t.startsWith(`"`)&&t.endsWith(`"`)||t.startsWith(`'`)&&t.endsWith(`'`)?t.slice(1,-1):t}function parseFlowArray(e){let t=String(e||``).trim();if(!t.startsWith(`[`)||!t.endsWith(`]`))throw Error(`args must be an array, got: ${e}`);let n=t.slice(1,-1).trim();if(!n)return[];let r=[],i=``,a=``,o=!1,pushCurrent=()=>{if(!i.trim())throw Error(`args flow array entries must be non-empty strings`);r.push(parseScalar(i)),i=``,o=!0};for(let e of n){if(a){e===a&&(a=``),i+=e;continue}if(e===`'`||e===`"`){a=e,i+=e;continue}if(e===`,`){pushCurrent();continue}i+=e}if(a)throw Error(`unterminated quoted string in args flow array`);if(i.trim())pushCurrent();else if(!o)throw Error(`args flow array entries must be non-empty strings`);return r}function parseKeyValue(e){let t=e.indexOf(`:`);if(!(t<0))return[e.slice(0,t).trim(),e.slice(t+1).trim()]}function countIndent(e){return e.length-e.trimStart().length}function parseBlockArray(e,t,n){let r=[],i=t;for(;itypeof e!=`string`))throw Error(`run-install.args must be an array of strings`);t.args=e.args}return t}function validateRunInstallInput(e){return e===null||typeof e==`boolean`?e:Array.isArray(e)?e.map(validateRunInstallEntry):validateRunInstallEntry(e)}function applyEntryLine(e,t,n,r,i,a){let o=parseKeyValue(t);if(!o)throw Error(`invalid run-install line: ${a}`);if(assignValue(e,o[0],o[1])){let t=parseBlockArray(n,r+1,i);return e.args=t.values,t.nextIndex-1}return r}function parseObject(e){let t={};for(let n=0;ne.trim()&&!e.trim().startsWith(`#`));if(t.length===0)return[];if(!t[0].trimStart().startsWith(`-`))return[parseObject(t)];let n=countIndent(t[0]),r=[],i;for(let e=0;e/azure/` into `Agent.TempDirectory`, then executes it. The bootstrap downloads `dist/azure/index.mjs` and runs `node prepare`. + +The template's finalize step reuses `SETUP_VP_RUNTIME_PATH` emitted during prepare. + +Pin `ref` and `setupRef` to the same immutable tag or commit SHA for strict reproducibility. + +### Execution Flow + +1. Optional `UseNode@1` when `nodeVersion` is non-empty. +2. `prepare`: install Vite+, prepend Vite+ bin to PATH, compute cache metadata. +3. Optional `Cache@2` when `cache: true` and `SETUP_VP_CACHE_READY=true`. +4. `finalize`: configure npm auth, install/reuse `sfw`, run `vp install`, emit `SETUP_VP_INSTALLED_VERSION`. + +### Shared Portable Runtime + +Provider-independent logic lives in `src/ci/` and is reused by GitLab (`src/gitlab/`) and Azure (`src/azure/`). Azure-specific logging uses `##vso[task.setvariable]` and `##vso[task.prependpath]`. + +## Public API + +| Parameter | Default | GitHub/GitLab equivalent | +| --------------------- | -------- | ------------------------ | +| `version` | `latest` | `version` | +| `workingDirectory` | `.` | `working-directory` | +| `runInstall` | `true` | `run-install` | +| `sfw` | `false` | `sfw` | +| `registryUrl` | | `registry-url` | +| `scope` | | `scope` | +| `setupRef` | `v1` | `setup-ref` | +| `nodeVersion` | `24.x` | `node-version` | +| `cache` | `false` | `cache` | +| `cacheDependencyPath` | | `cache-dependency-path` | + +Provider-visible variables: + +| Variable | Purpose | +| ---------------------------- | -------------------------------------------- | +| `SETUP_VP_INSTALLED_VERSION` | Installed global Vite+ version | +| `SETUP_VP_CACHE_HIT` | `true`, `inexact`, or `false` from `Cache@2` | + +## GitHub/GitLab/Azure Parity + +| Capability | GitHub Action | GitLab template | Azure template | +| ----------------------- | ------------- | --------------- | ----------------- | +| Install Vite+ | Yes | Yes | Yes | +| `node-version` | Yes | No | Yes (`UseNode@1`) | +| `node-version-file` | Yes | No | No | +| `version-file` | Yes | No | No | +| `working-directory` | Yes | Yes | Yes | +| `run-install` | Yes | Yes | Yes | +| `registry-url` | Yes | Yes | Yes | +| `scope` | Yes | Yes | Yes | +| `sfw` | Yes | Yes (Unix) | Yes (all OS) | +| `cache` | Yes | No | Yes (`Cache@2`) | +| `cache-dependency-path` | Yes | No | Yes | + +## Cache Design + +When `cache: true`, prepare detects the lock file, resolves `vp pm cache dir`, and emits: + +- `SETUP_VP_CACHE_READY` +- `SETUP_VP_CACHE_PATH` +- `SETUP_VP_LOCK_FILE` +- `SETUP_VP_LOCK_TYPE` + +`Cache@2` runs only when `SETUP_VP_CACHE_READY=true`. Missing lock files or cache paths log a warning and skip caching without failing setup. + +## Rollout + +1. Land the template, runtime, docs, and repository CI smoke coverage. +2. Validate on real Azure Pipelines agents before release. +3. After merge and tag, run one consumer pipeline using the public external-repository template plus matching immutable `setupRef`. diff --git a/src/azure/commands.test.ts b/src/azure/commands.test.ts new file mode 100644 index 0000000..b6c4a6d --- /dev/null +++ b/src/azure/commands.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vite-plus/test"; +import { escapeLoggingCommandData } from "./commands.js"; + +describe("escapeLoggingCommandData", () => { + it("escapes characters that could terminate logging commands", () => { + expect(escapeLoggingCommandData("plain/path")).toBe("plain/path"); + expect(escapeLoggingCommandData("C:\\Users\\dev")).toBe("C:\\Users\\dev"); + expect(escapeLoggingCommandData("a%b")).toBe("a%25b"); + expect(escapeLoggingCommandData("a;b")).toBe("a%3Bb"); + expect(escapeLoggingCommandData("a]b")).toBe("a%5Db"); + expect(escapeLoggingCommandData("a\nb")).toBe("a%0Ab"); + expect(escapeLoggingCommandData("a\rb")).toBe("a%0Db"); + }); +}); + +describe("Azure logging commands", () => { + it("renders prependpath and setvariable commands", async () => { + const { prependPath, setVariable } = await import("./commands.js"); + const stdout: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + try { + prependPath("/tmp/vp/bin"); + setVariable("SETUP_VP_INSTALLED_VERSION", "0.2.2", { isOutput: true }); + expect(stdout.join("")).toContain("##vso[task.prependpath]/tmp/vp/bin\n"); + expect(stdout.join("")).toContain( + "##vso[task.setvariable variable=SETUP_VP_INSTALLED_VERSION;isOutput=true]0.2.2\n", + ); + } finally { + process.stdout.write = originalWrite; + } + }); +}); diff --git a/src/azure/commands.ts b/src/azure/commands.ts new file mode 100644 index 0000000..5963916 --- /dev/null +++ b/src/azure/commands.ts @@ -0,0 +1,37 @@ +/** + * Escape data for Azure Pipelines logging commands. + * @see https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands + */ +export function escapeLoggingCommandData(value: string): string { + return value + .replaceAll("%", "%25") + .replaceAll("\r", "%0D") + .replaceAll("\n", "%0A") + .replaceAll(";", "%3B") + .replaceAll("]", "%5D"); +} + +export function prependPath(pathValue: string): void { + process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(pathValue)}\n`); +} + +export function setVariable( + name: string, + value: string, + options: { isOutput?: boolean; isReadonly?: boolean } = {}, +): void { + const parts = [`variable=${name}`]; + if (options.isOutput) parts.push("isOutput=true"); + if (options.isReadonly) parts.push("isReadonly=true"); + process.stdout.write( + `##vso[task.setvariable ${parts.join(";")}]${escapeLoggingCommandData(value)}\n`, + ); +} + +export function logWarning(message: string): void { + process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(message)}\n`); +} + +export function logInfo(message: string): void { + console.log(message); +} diff --git a/src/azure/index.test.ts b/src/azure/index.test.ts new file mode 100644 index 0000000..8c786a7 --- /dev/null +++ b/src/azure/index.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { runFinalize, runPrepare } from "./index.js"; + +describe("Azure lifecycle", () => { + it("runs prepare in install → cache order", async () => { + const calls: string[] = []; + await runPrepare( + { + SETUP_VP_VERSION: "latest", + SETUP_VP_CACHE: "true", + SYSTEM_DEFAULTWORKINGDIRECTORY: process.cwd(), + }, + { + installVitePlus: async () => { + calls.push("install"); + }, + prepareCacheMetadata: () => { + calls.push("cache"); + return { ready: false }; + }, + configureAuth: () => undefined, + setupSfw: async () => "vp", + parseRunInstall: () => [], + runInstall: () => undefined, + getCommandOutput: () => "vp v0.2.2", + parseInstalledVpVersion: () => "0.2.2", + prependPath: () => undefined, + setVariable: () => undefined, + logWarning: () => undefined, + logInfo: () => undefined, + }, + ); + + expect(calls).toEqual(["install", "cache"]); + }); + + it("runs finalize in auth → sfw → install → version order", async () => { + const calls: string[] = []; + await runFinalize( + { + SETUP_VP_RUN_INSTALL: "true", + SYSTEM_DEFAULTWORKINGDIRECTORY: process.cwd(), + }, + { + installVitePlus: async () => undefined, + prepareCacheMetadata: () => ({ ready: false }), + configureAuth: () => { + calls.push("auth"); + return undefined; + }, + setupSfw: async () => { + calls.push("sfw"); + return "vp"; + }, + parseRunInstall: () => { + calls.push("parse"); + return [{}]; + }, + runInstall: () => { + calls.push("install"); + }, + getCommandOutput: () => { + calls.push("version"); + return "vp v0.2.2"; + }, + parseInstalledVpVersion: () => "0.2.2", + prependPath: () => undefined, + setVariable: () => undefined, + logWarning: () => undefined, + logInfo: () => undefined, + }, + ); + + expect(calls).toEqual(["auth", "parse", "sfw", "install", "version"]); + }); + + it("skips install when runInstall is false", async () => { + const runInstall = vi.fn(); + await runFinalize( + { + SETUP_VP_RUN_INSTALL: "false", + SYSTEM_DEFAULTWORKINGDIRECTORY: process.cwd(), + }, + { + installVitePlus: async () => undefined, + prepareCacheMetadata: () => ({ ready: false }), + configureAuth: () => undefined, + setupSfw: async () => "vp", + parseRunInstall: () => [], + runInstall, + getCommandOutput: () => "vp v0.2.2", + parseInstalledVpVersion: () => "0.2.2", + prependPath: () => undefined, + setVariable: () => undefined, + logWarning: () => undefined, + logInfo: () => undefined, + }, + ); + + expect(runInstall).not.toHaveBeenCalled(); + }); +}); diff --git a/src/azure/index.ts b/src/azure/index.ts new file mode 100644 index 0000000..2724a1f --- /dev/null +++ b/src/azure/index.ts @@ -0,0 +1,148 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { configureAuth } from "../ci/auth.js"; +import { prepareCacheMetadata } from "../ci/cache.js"; +import { setupSfw } from "../ci/install-sfw.js"; +import { getCommandOutput } from "../ci/process.js"; +import { parseRunInstall, runInstall } from "../ci/run-install.js"; +import { parseInstalledVpVersion } from "../ci/version.js"; +import { logInfo, logWarning, prependPath, setVariable } from "./commands.js"; +import { installVitePlus } from "./install-viteplus.js"; +import { parseAzureInputs, resolveProjectDirFromInputs } from "./inputs.js"; + +export type AzurePhase = "prepare" | "finalize"; + +export interface AzurePorts { + installVitePlus: typeof installVitePlus; + prepareCacheMetadata: typeof prepareCacheMetadata; + configureAuth: typeof configureAuth; + setupSfw: typeof setupSfw; + parseRunInstall: typeof parseRunInstall; + runInstall: typeof runInstall; + getCommandOutput: typeof getCommandOutput; + parseInstalledVpVersion: typeof parseInstalledVpVersion; + prependPath: typeof prependPath; + setVariable: typeof setVariable; + logWarning: typeof logWarning; + logInfo: typeof logInfo; +} + +const defaultPorts: AzurePorts = { + installVitePlus, + prepareCacheMetadata, + configureAuth, + setupSfw, + parseRunInstall, + runInstall, + getCommandOutput, + parseInstalledVpVersion, + prependPath, + setVariable, + logWarning, + logInfo, +}; + +function fail(message: string): never { + console.error(`setup-vp: ${message}`); + process.exit(1); +} + +export async function runPrepare( + env: NodeJS.ProcessEnv = process.env, + ports: AzurePorts = defaultPorts, +): Promise { + const inputs = parseAzureInputs(env); + const projectDir = resolveProjectDirFromInputs(inputs); + + ports.setVariable("SETUP_VP_CACHE_HIT", "false", { isOutput: true }); + ports.setVariable("SETUP_VP_CACHE_READY", "false", { isOutput: true }); + + await ports.installVitePlus(inputs.version, { + env, + prependPath: (binDir) => ports.prependPath(binDir), + logWarningFn: ports.logWarning, + }); + + const runtimePath = path.resolve(process.argv[1] || ""); + ports.setVariable("SETUP_VP_RUNTIME_PATH", runtimePath, { isOutput: true }); + + if (!inputs.cache) return; + + const metadata = ports.prepareCacheMetadata({ + projectDir, + cacheDependencyPath: inputs.cacheDependencyPath || undefined, + logWarning: ports.logWarning, + }); + + if (!metadata.ready) { + ports.setVariable("SETUP_VP_CACHE_READY", "false", { isOutput: true }); + return; + } + + ports.setVariable("SETUP_VP_CACHE_READY", "true", { isOutput: true }); + if (metadata.cachePath) { + ports.setVariable("SETUP_VP_CACHE_PATH", metadata.cachePath, { isOutput: true }); + } + if (metadata.lockFile) { + ports.setVariable("SETUP_VP_LOCK_FILE", metadata.lockFile, { isOutput: true }); + } + if (metadata.lockType) { + ports.setVariable("SETUP_VP_LOCK_TYPE", metadata.lockType, { isOutput: true }); + } +} + +export async function runFinalize( + env: NodeJS.ProcessEnv = process.env, + ports: AzurePorts = defaultPorts, +): Promise { + const inputs = parseAzureInputs(env); + const projectDir = resolveProjectDirFromInputs(inputs); + + ports.configureAuth(inputs.registryUrl, inputs.scope, env, (name, value) => { + if (name === "NODE_AUTH_TOKEN") return; + if (value !== undefined) ports.setVariable(name, value, { isOutput: true }); + }); + + const runInstallEntries = ports.parseRunInstall(inputs.runInstall); + const installCommand = await ports.setupSfw(runInstallEntries, { + env, + sfwEnabled: inputs.sfw, + exportVariable: (name, value) => { + if (value !== undefined) ports.setVariable(name, value, { isOutput: true }); + }, + }); + if (runInstallEntries.length > 0) { + ports.runInstall(runInstallEntries, projectDir, installCommand, env); + } + + const versionOutput = ports.getCommandOutput("vp", ["--version"]) || ""; + ports.logInfo(versionOutput); + const installedVersion = ports.parseInstalledVpVersion(versionOutput); + ports.setVariable("SETUP_VP_INSTALLED_VERSION", installedVersion, { isOutput: true }); +} + +export async function main(phase: AzurePhase): Promise { + if (phase === "prepare") { + await runPrepare(); + return; + } + if (phase === "finalize") { + await runFinalize(); + return; + } + fail(`invalid phase "${String(phase)}"; expected "prepare" or "finalize"`); +} + +export function isEntrypoint(argvPath = process.argv[1], moduleUrl = import.meta.url): boolean { + return Boolean(argvPath && moduleUrl === pathToFileURL(path.resolve(argvPath)).href); +} + +if (isEntrypoint()) { + const phase = process.argv[2]; + if (!phase) fail('missing phase argument; expected "prepare" or "finalize"'); + try { + await main(phase as AzurePhase); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +} diff --git a/src/azure/inputs.test.ts b/src/azure/inputs.test.ts new file mode 100644 index 0000000..13c61e9 --- /dev/null +++ b/src/azure/inputs.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseAzureInputs, resolveProjectDirFromInputs } from "./inputs.js"; +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +describe("parseAzureInputs", () => { + it("maps SETUP_VP_* environment variables", () => { + const inputs = parseAzureInputs({ + SETUP_VP_VERSION: "1.2.3", + SETUP_VP_WORKING_DIRECTORY: "apps/web", + SETUP_VP_RUN_INSTALL: "false", + SETUP_VP_SFW: "true", + SETUP_VP_REGISTRY_URL: "https://registry.example/npm/", + SETUP_VP_SCOPE: "@acme", + SETUP_VP_CACHE: "true", + SETUP_VP_CACHE_DEPENDENCY_PATH: "pnpm-lock.yaml", + SYSTEM_DEFAULTWORKINGDIRECTORY: "/workspace", + }); + + expect(inputs).toEqual({ + version: "1.2.3", + workingDirectory: "apps/web", + runInstall: "false", + sfw: true, + registryUrl: "https://registry.example/npm/", + scope: "@acme", + cache: true, + cacheDependencyPath: "pnpm-lock.yaml", + workspaceRoot: "/workspace", + }); + }); +}); + +describe("resolveProjectDirFromInputs", () => { + const tempDirs: string[] = []; + + it("resolves workingDirectory against the Azure workspace root", () => { + const root = mkdtempSync(path.join(tmpdir(), "setup-vp-azure-")); + tempDirs.push(root); + const child = path.join(root, "apps", "web"); + mkdirSync(child, { recursive: true }); + + const projectDir = resolveProjectDirFromInputs({ + version: "latest", + workingDirectory: "apps/web", + runInstall: "true", + sfw: false, + registryUrl: "", + scope: "", + cache: false, + cacheDependencyPath: "", + workspaceRoot: root, + }); + + expect(projectDir).toBe(child); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/src/azure/inputs.ts b/src/azure/inputs.ts new file mode 100644 index 0000000..fb321bc --- /dev/null +++ b/src/azure/inputs.ts @@ -0,0 +1,35 @@ +import { resolveProjectDirectory } from "../ci/project.js"; +import type { RuntimeEnv } from "../ci/types.js"; + +export interface AzureInputs { + version: string; + workingDirectory: string; + runInstall: string; + sfw: boolean; + registryUrl: string; + scope: string; + cache: boolean; + cacheDependencyPath: string; + workspaceRoot: string; +} + +export function parseAzureInputs(env: RuntimeEnv): AzureInputs { + return { + version: env.SETUP_VP_VERSION || "latest", + workingDirectory: env.SETUP_VP_WORKING_DIRECTORY || ".", + runInstall: env.SETUP_VP_RUN_INSTALL ?? "true", + sfw: env.SETUP_VP_SFW === "true", + registryUrl: env.SETUP_VP_REGISTRY_URL || "", + scope: env.SETUP_VP_SCOPE || "", + cache: env.SETUP_VP_CACHE === "true", + cacheDependencyPath: env.SETUP_VP_CACHE_DEPENDENCY_PATH || "", + workspaceRoot: env.SYSTEM_DEFAULTWORKINGDIRECTORY || process.cwd(), + }; +} + +export function resolveProjectDirFromInputs(inputs: AzureInputs): string { + return resolveProjectDirectory({ + workingDirectory: inputs.workingDirectory, + workspaceRoot: inputs.workspaceRoot, + }); +} diff --git a/src/azure/install-viteplus.test.ts b/src/azure/install-viteplus.test.ts new file mode 100644 index 0000000..0d7570a --- /dev/null +++ b/src/azure/install-viteplus.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { installVitePlus } from "./install-viteplus.js"; + +describe("installVitePlus", () => { + it("uses PowerShell installers on Windows and bash installers on Unix", async () => { + const calls: NodeJS.Platform[] = []; + const runInstall = vi.fn( + ( + _url: string, + _env: Record, + platform: NodeJS.Platform = process.platform, + ) => { + calls.push(platform); + return 0; + }, + ); + + await installVitePlus("latest", { + platform: "win32", + env: { PATH: "" }, + prependPath: () => undefined, + sleep: async () => undefined, + runInstall, + logWarningFn: () => undefined, + }); + await installVitePlus("latest", { + platform: "linux", + env: { PATH: "" }, + prependPath: () => undefined, + sleep: async () => undefined, + runInstall, + logWarningFn: () => undefined, + }); + + expect(calls).toEqual(["win32", "linux"]); + expect(runInstall.mock.calls[0]?.[0]).toContain("install.ps1"); + expect(runInstall.mock.calls[1]?.[0]).toContain("install.sh"); + }); + + it("routes pkg.pr.new commit builds through VP_PR_VERSION", async () => { + const runInstall = vi.fn(() => 0); + const sha = "a".repeat(40); + + await installVitePlus(`0.0.0-commit.${sha}`, { + platform: "linux", + env: { PATH: "" }, + prependPath: () => undefined, + sleep: async () => undefined, + runInstall, + logWarningFn: () => undefined, + }); + + const installCalls = runInstall.mock.calls as unknown as Array< + [string, Record, NodeJS.Platform?] + >; + expect(installCalls[0]?.[1]?.VP_PR_VERSION).toBe(sha); + }); +}); diff --git a/src/azure/install-viteplus.ts b/src/azure/install-viteplus.ts new file mode 100644 index 0000000..cf2b2f1 --- /dev/null +++ b/src/azure/install-viteplus.ts @@ -0,0 +1,124 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import { spawnSync } from "node:child_process"; +import { logWarning } from "./commands.js"; + +const INSTALL_URLS_SH = [ + "https://viteplus.dev/install.sh", + "https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh", +]; +const INSTALL_URLS_PS1 = [ + "https://viteplus.dev/install.ps1", + "https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.ps1", +]; +const INSTALL_MAX_ROUNDS = 2; +const INSTALL_RETRY_DELAY_MS = 2000; +const CURL_TIMEOUT_FLAGS = "--connect-timeout 5 --max-time 15"; +const PWSH_TIMEOUT_SEC = 15; +const PKG_PR_NEW_COMMIT_RE = /^0\.0\.0-commit\.([0-9a-f]{40})$/i; + +export function getVitePlusHome(platform: NodeJS.Platform = process.platform): string { + const home = + platform === "win32" ? process.env.USERPROFILE || homedir() : process.env.HOME || homedir(); + return join(home, ".vite-plus"); +} + +function pkgPrNewCommitSha(version: string): string | undefined { + return version.match(PKG_PR_NEW_COMMIT_RE)?.[1]; +} + +function runInstallCommand( + url: string, + env: Record, + platform: NodeJS.Platform = process.platform, +): number { + if (platform === "win32") { + const result = spawnSync( + "pwsh", + ["-Command", `& ([scriptblock]::Create((irm -TimeoutSec ${PWSH_TIMEOUT_SEC} ${url})))`], + { env: { ...process.env, ...env }, stdio: "inherit" }, + ); + if (result.error) throw result.error; + return result.status ?? 1; + } + + const result = spawnSync( + "bash", + ["-c", `set -o pipefail; curl -fsSL ${CURL_TIMEOUT_FLAGS} ${url} | bash`], + { env: { ...process.env, ...env }, stdio: "inherit" }, + ); + if (result.error) throw result.error; + return result.status ?? 1; +} + +export async function installVitePlus( + version: string, + options: { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + prependPath?: (binDir: string) => void; + sleep?: (ms: number) => Promise; + runInstall?: typeof runInstallCommand; + logWarningFn?: (message: string) => void; + } = {}, +): Promise { + const platform = options.platform ?? process.platform; + const targetEnv = options.env ?? process.env; + const prependPath = options.prependPath; + const delay = options.sleep ?? sleep; + const runInstall = options.runInstall ?? runInstallCommand; + const warn = options.logWarningFn ?? logWarning; + + const env: Record = { + ...Object.fromEntries( + Object.entries(targetEnv).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ), + VP_VERSION: version, + VITE_PLUS_VERSION: version, + }; + + const prVersion = pkgPrNewCommitSha(version); + if (prVersion) { + env.VP_PR_VERSION = prVersion; + } + + const urls = platform === "win32" ? INSTALL_URLS_PS1 : INSTALL_URLS_SH; + const maxAttempts = INSTALL_MAX_ROUNDS * urls.length; + let failureReason = ""; + let attempt = 0; + + for (let round = 0; round < INSTALL_MAX_ROUNDS; round += 1) { + for (const url of urls) { + attempt += 1; + try { + const exitCode = runInstall(url, env, platform); + if (exitCode === 0) { + const binDir = join(getVitePlusHome(platform), "bin"); + if (!targetEnv.PATH?.includes(binDir)) { + const separator = platform === "win32" ? ";" : ":"; + targetEnv.PATH = `${binDir}${separator}${targetEnv.PATH || ""}`; + prependPath?.(binDir); + } + return; + } + failureReason = `exit code ${exitCode}`; + } catch (error) { + failureReason = error instanceof Error ? error.message : String(error); + } + + if (attempt < maxAttempts) { + warn( + `setup-vp: failed to install Vite+ from ${url} (${failureReason}). Retrying in ${INSTALL_RETRY_DELAY_MS}ms... (attempt ${attempt + 1}/${maxAttempts})`, + ); + await delay(INSTALL_RETRY_DELAY_MS); + } + } + } + + throw new Error( + `Failed to install Vite+ after ${maxAttempts} attempts across ${urls.length} URL(s): ${failureReason}`, + ); +} diff --git a/src/azure/template.test.ts b/src/azure/template.test.ts new file mode 100644 index 0000000..f7b9f34 --- /dev/null +++ b/src/azure/template.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; +import { parse as parseYaml } from "yaml"; + +const templatePath = fileURLToPath(new URL("../../azure/setup-vp.yml", import.meta.url)); +const template = readFileSync(templatePath, "utf8"); +const docs = parseYaml(template); + +describe("azure/setup-vp.yml", () => { + it("declares the documented parameters with defaults", () => { + const parameters = docs.parameters as Array<{ + name: string; + type: string; + default: unknown; + }>; + const byName = Object.fromEntries(parameters.map((entry) => [entry.name, entry])); + + expect(byName.version).toMatchObject({ type: "string", default: "latest" }); + expect(byName.workingDirectory).toMatchObject({ type: "string", default: "." }); + expect(byName.runInstall).toMatchObject({ type: "object", default: true }); + expect(byName.sfw).toMatchObject({ type: "boolean", default: false }); + expect(byName.registryUrl).toMatchObject({ type: "string", default: "" }); + expect(byName.scope).toMatchObject({ type: "string", default: "" }); + expect(byName.setupRef).toMatchObject({ type: "string", default: "v1" }); + expect(byName.nodeVersion).toMatchObject({ type: "string", default: "24.x" }); + expect(byName.cache).toMatchObject({ type: "boolean", default: false }); + expect(byName.cacheDependencyPath).toMatchObject({ type: "string", default: "" }); + }); + + it("orders UseNode, prepare, Cache@2, and finalize", () => { + const steps = docs.steps as Array>; + const flattened = JSON.stringify(steps); + expect(flattened).toContain("UseNode@1"); + expect(flattened).toContain("setup-vp prepare"); + expect(flattened).toContain("Cache@2"); + expect(flattened).toContain("setup-vp finalize"); + expect(flattened.indexOf("setup-vp prepare")).toBeLessThan(flattened.indexOf("Cache@2")); + expect(flattened.indexOf("Cache@2")).toBeLessThan(flattened.indexOf("setup-vp finalize")); + }); + + it("serializes runInstall with convertToJson and avoids main downloads", () => { + expect(template).toContain("${{ convertToJson(parameters.runInstall) }}"); + expect(template).not.toMatch(/setup-vp\/main\//); + expect(template).toContain("$(SETUP_VP_LOCK_FILE)"); + expect(template).toContain("cacheHitVar: SETUP_VP_CACHE_HIT"); + }); + + it("includes Unix and Windows branches", () => { + expect(template).toContain("Windows_NT"); + expect(template).toContain("bootstrap.sh"); + expect(template).toContain("bootstrap.ps1"); + }); +}); diff --git a/src/ci/auth.ts b/src/ci/auth.ts new file mode 100644 index 0000000..cfdee47 --- /dev/null +++ b/src/ci/auth.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { ExportVariable, RuntimeEnv } from "./types.js"; + +const NODE_AUTH_TOKEN_REF = "${NODE_AUTH_TOKEN}"; + +export function configureAuth( + registryUrlInput: string, + scopeInput: string, + targetEnv: RuntimeEnv, + exportVariable?: ExportVariable, +): string | undefined { + if (!registryUrlInput) return; + + let url: URL; + try { + url = new URL(registryUrlInput); + } catch { + throw new Error(`Invalid registry-url: "${registryUrlInput}". Must be a valid URL.`); + } + + const registryUrl = url.href.endsWith("/") ? url.href : `${url.href}/`; + let scopePrefix = ""; + if (scopeInput) { + const scope = scopeInput.startsWith("@") ? scopeInput : `@${scopeInput}`; + scopePrefix = `${scope.toLowerCase()}:`; + } + + const authUrl = registryUrl.replace(/^\w+:/, "").toLowerCase(); + const npmrcDir = mkdtempSync(path.join(tmpdir(), "setup-vp-npmrc-")); + const npmrc = path.join(npmrcDir, ".npmrc"); + const contents = `${authUrl}:_authToken=${NODE_AUTH_TOKEN_REF}\n${scopePrefix}registry=${registryUrl}\n`; + writeFileSync(npmrc, contents, { encoding: "utf8", mode: 0o600 }); + + targetEnv.NPM_CONFIG_USERCONFIG = npmrc; + targetEnv.PNPM_CONFIG_USERCONFIG = npmrc; + targetEnv.NODE_AUTH_TOKEN = targetEnv.NODE_AUTH_TOKEN || "XXXXX-XXXXX-XXXXX-XXXXX"; + + exportVariable?.("NPM_CONFIG_USERCONFIG", targetEnv.NPM_CONFIG_USERCONFIG); + exportVariable?.("PNPM_CONFIG_USERCONFIG", targetEnv.PNPM_CONFIG_USERCONFIG); + exportVariable?.("NODE_AUTH_TOKEN", targetEnv.NODE_AUTH_TOKEN); + + return npmrc; +} diff --git a/src/ci/cache.ts b/src/ci/cache.ts new file mode 100644 index 0000000..83eb75f --- /dev/null +++ b/src/ci/cache.ts @@ -0,0 +1,107 @@ +import path from "node:path"; +import { basename, isAbsolute, join } from "node:path"; +import { existsSync, readdirSync } from "node:fs"; +import { getCommandOutput } from "./process.js"; +import type { LockFileInfo, LockFileType, LogFn } from "./types.js"; +import { LockFileType as LockType } from "./types.js"; + +const LOCK_FILES: Array<{ filename: string; type: LockFileType }> = [ + { filename: "pnpm-lock.yaml", type: LockType.Pnpm }, + { filename: "bun.lockb", type: LockType.Bun }, + { filename: "bun.lock", type: LockType.Bun }, + { filename: "package-lock.json", type: LockType.Npm }, + { filename: "npm-shrinkwrap.json", type: LockType.Npm }, + { filename: "yarn.lock", type: LockType.Yarn }, +]; + +function resolvePath(filePath: string, baseDir: string): string { + return isAbsolute(filePath) ? filePath : join(baseDir, filePath); +} + +function inferLockFileType(fullPath: string, filename: string): LockFileInfo { + if (filename.includes("pnpm")) { + return { type: LockType.Pnpm, path: fullPath, filename }; + } + if (filename.includes("yarn")) { + return { type: LockType.Yarn, path: fullPath, filename }; + } + if (filename.startsWith("bun.")) { + return { type: LockType.Bun, path: fullPath, filename }; + } + return { type: LockType.Npm, path: fullPath, filename }; +} + +export function detectLockFile( + explicitPath: string | undefined, + workspace: string, +): LockFileInfo | undefined { + if (explicitPath) { + const fullPath = resolvePath(explicitPath, workspace); + if (existsSync(fullPath)) { + const filename = basename(fullPath); + const lockInfo = LOCK_FILES.find((entry) => entry.filename === filename); + if (lockInfo) { + return { + type: lockInfo.type, + path: fullPath, + filename, + }; + } + return inferLockFileType(fullPath, filename); + } + return undefined; + } + + const workspaceContents = readdirSync(workspace); + for (const lockInfo of LOCK_FILES) { + if (workspaceContents.includes(lockInfo.filename)) { + const fullPath = join(workspace, lockInfo.filename); + return { + type: lockInfo.type, + path: fullPath, + filename: lockInfo.filename, + }; + } + } + + return undefined; +} + +export interface CacheMetadata { + ready: boolean; + cachePath?: string; + lockFile?: string; + lockType?: string; +} + +export function prepareCacheMetadata(options: { + projectDir: string; + cacheDependencyPath?: string; + logWarning?: LogFn; +}): CacheMetadata { + const { projectDir, cacheDependencyPath, logWarning } = options; + const lockFile = detectLockFile(cacheDependencyPath || undefined, projectDir); + + if (!lockFile) { + logWarning?.( + "setup-vp: cache is enabled but no supported lock file was found; skipping cache.", + ); + return { ready: false }; + } + + const lockDir = path.dirname(lockFile.path); + const cachePath = getCommandOutput("vp", ["pm", "cache", "dir"], { cwd: lockDir }); + if (!cachePath) { + logWarning?.( + `setup-vp: could not resolve package-manager cache directory for ${lockFile.filename}; skipping cache.`, + ); + return { ready: false }; + } + + return { + ready: true, + cachePath, + lockFile: lockFile.path, + lockType: lockFile.type, + }; +} diff --git a/src/ci/install-sfw.ts b/src/ci/install-sfw.ts new file mode 100644 index 0000000..7d4ae13 --- /dev/null +++ b/src/ci/install-sfw.ts @@ -0,0 +1,219 @@ +import { createWriteStream, existsSync } from "node:fs"; +import { chmod, mkdtemp } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { get as httpGet } from "node:http"; +import { get as httpsGet } from "node:https"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { commandPath } from "./process.js"; +import type { ExportVariable, InstallCommand, RunInstallEntry } from "./types.js"; + +export const SFW_VERSION = "v1.12.0"; +const SFW_RELEASE_BASE = `https://github.com/SocketDev/sfw-free/releases/download/${SFW_VERSION}`; +const DOWNLOAD_TIMEOUT_MS = 60_000; +type DownloadClient = typeof httpGet; + +export function isMuslLinux(): boolean { + if (process.platform !== "linux") return false; + try { + const report = process.report?.getReport() as + | { header?: { glibcVersionRuntime?: string } } + | undefined; + if (report?.header && !report.header.glibcVersionRuntime) { + return true; + } + } catch { + // Fall through to filesystem fallback. + } + return existsSync("/etc/alpine-release"); +} + +/** + * Mirrors src/install-sfw.ts asset naming for portable CI runners. + */ +export function getSfwAssetName(platform: NodeJS.Platform, arch: string, isMusl: boolean): string { + if (platform === "darwin") { + if (arch === "arm64") return "sfw-free-macos-arm64"; + if (arch === "x64") return "sfw-free-macos-x86_64"; + } else if (platform === "linux") { + if (arch === "arm64") { + return isMusl ? "sfw-free-musl-linux-arm64" : "sfw-free-linux-arm64"; + } + if (arch === "x64") { + return isMusl ? "sfw-free-musl-linux-x86_64" : "sfw-free-linux-x86_64"; + } + } else if (platform === "win32") { + if (arch === "arm64") return "sfw-free-windows-arm64.exe"; + if (arch === "x64") return "sfw-free-windows-x86_64.exe"; + } + + const libcSuffix = platform === "linux" ? ` (${isMusl ? "musl" : "glibc"})` : ""; + throw new Error(`Unsupported platform/arch for sfw: ${platform}/${arch}${libcSuffix}`); +} + +function downloadFileWithShell(url: string, outputPath: string, timeoutMs: number): void { + const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000)); + const curl = commandPath("curl"); + if (curl) { + const result = spawnSync( + curl, + [ + "-fsSL", + "--connect-timeout", + "5", + "--max-time", + String(timeoutSeconds), + url, + "-o", + outputPath, + ], + { stdio: "ignore" }, + ); + if (result.status === 0) return; + throw result.error || new Error(`curl failed while downloading ${url}`); + } + + const wget = commandPath("wget"); + if (wget) { + const result = spawnSync( + wget, + ["-q", "-T", String(timeoutSeconds), "-t", "2", "-O", outputPath, url], + { stdio: "ignore" }, + ); + if (result.status === 0) return; + throw result.error || new Error(`wget failed while downloading ${url}`); + } + + throw new Error("curl or wget is required to download sfw"); +} + +export function downloadFile( + url: string, + outputPath: string, + redirects = 0, + timeoutMs = DOWNLOAD_TIMEOUT_MS, + clientOverride?: DownloadClient, +): Promise { + if (redirects > 5) { + return Promise.reject(new Error(`too many redirects while downloading ${url}`)); + } + + if (!clientOverride) { + downloadFileWithShell(url, outputPath, timeoutMs); + return Promise.resolve(); + } + + const client = clientOverride || (url.startsWith("https:") ? httpsGet : httpGet); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + const request = client(url, (response) => { + const statusCode = response.statusCode ?? 0; + const location = response.headers.location; + if (statusCode >= 300 && statusCode < 400 && location) { + response.resume(); + const nextUrl = new URL(location, url).toString(); + downloadFile(nextUrl, outputPath, redirects + 1, timeoutMs, clientOverride).then( + () => finish(), + finish, + ); + return; + } + + if (statusCode !== 200) { + response.resume(); + finish(new Error(`download failed with HTTP ${statusCode}: ${url}`)); + return; + } + + const file = createWriteStream(outputPath); + response.pipe(file); + file.on("finish", () => file.close(() => finish())); + file.on("error", finish); + }); + + const timeout = setTimeout(() => { + request.destroy(new Error(`download timed out after ${timeoutMs}ms: ${url}`)); + }, timeoutMs); + request.on("error", finish); + }); +} + +export async function setupSfw( + runInstallEntries: RunInstallEntry[], + options: { + env?: NodeJS.ProcessEnv; + sfwEnabled?: boolean; + exportVariable?: ExportVariable; + platform?: NodeJS.Platform; + arch?: string; + isMusl?: boolean; + download?: typeof downloadFile; + } = {}, +): Promise { + const env = options.env ?? process.env; + const sfwEnabled = options.sfwEnabled ?? env.SETUP_VP_SFW === "true"; + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const isMusl = options.isMusl ?? isMuslLinux(); + const download = options.download ?? downloadFile; + + if (!sfwEnabled) return "vp"; + + if (runInstallEntries.length === 0) { + console.log( + "setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.", + ); + return "vp"; + } + + const existing = commandPath("sfw"); + if (existing) { + console.log(`setup-vp: using existing sfw on PATH: ${existing}`); + return "sfw"; + } + + let asset: string | undefined; + try { + asset = getSfwAssetName(platform, arch, isMusl); + } catch { + asset = undefined; + } + if (!asset) { + console.error( + `setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${platform}, process.arch=${arch}, musl=${isMusl}) and none was found on PATH; falling back to plain vp install.`, + ); + return "vp"; + } + + const sfwDir = await mkdtemp(path.join(tmpdir(), "setup-vp-sfw-")); + const sfwBin = path.join(sfwDir, platform === "win32" ? "sfw.exe" : "sfw"); + const sfwUrl = `${SFW_RELEASE_BASE}/${asset}`; + + for (let round = 1; round <= 2; round += 1) { + try { + console.log(`setup-vp: installing sfw ${SFW_VERSION} from ${sfwUrl}`); + await download(sfwUrl, sfwBin); + await chmod(sfwBin, 0o755); + const pathSeparator = platform === "win32" ? ";" : ":"; + env.PATH = `${sfwDir}${pathSeparator}${env.PATH || ""}`; + options.exportVariable?.("PATH", env.PATH); + return "sfw"; + } catch (error) { + if (round === 2) throw error; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + + throw new Error("failed to install sfw after retrying"); +} diff --git a/src/ci/process.ts b/src/ci/process.ts new file mode 100644 index 0000000..0e1a40a --- /dev/null +++ b/src/ci/process.ts @@ -0,0 +1,39 @@ +import { spawnSync } from "node:child_process"; +import type { SpawnSyncOptions } from "node:child_process"; + +export function run(command: string, args: string[], options: SpawnSyncOptions = {}): void { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +export function commandPath(command: string): string | undefined { + if (process.platform === "win32") { + const result = spawnSync("where", [command], { encoding: "utf8" }); + if (result.status === 0) { + const line = result.stdout.trim().split(/\r?\n/)[0]?.trim(); + return line || undefined; + } + return undefined; + } + + const result = spawnSync("sh", ["-c", 'command -v "$1"', "sh", command], { + encoding: "utf8", + }); + if (result.status === 0) return result.stdout.trim(); + return undefined; +} + +export function getCommandOutput( + command: string, + args: string[], + options?: { cwd?: string }, +): string | undefined { + const result = spawnSync(command, args, { + cwd: options?.cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status === 0) return result.stdout.trim(); + return undefined; +} diff --git a/src/ci/project.ts b/src/ci/project.ts new file mode 100644 index 0000000..6625b09 --- /dev/null +++ b/src/ci/project.ts @@ -0,0 +1,29 @@ +import { statSync } from "node:fs"; +import path from "node:path"; + +export function resolveProjectDirectory(options: { + workingDirectory: string; + workspaceRoot: string; +}): string { + const { workingDirectory, workspaceRoot } = options; + const projectDir = path.isAbsolute(workingDirectory) + ? workingDirectory + : path.join(workspaceRoot, workingDirectory); + + try { + if (!statSync(projectDir).isDirectory()) { + throw new Error( + `working-directory is not a directory: ${workingDirectory} (resolved to ${projectDir})`, + ); + } + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + throw new Error( + `working-directory not found: ${workingDirectory} (resolved to ${projectDir})`, + ); + } + throw error; + } + + return projectDir; +} diff --git a/src/ci/run-install.ts b/src/ci/run-install.ts new file mode 100644 index 0000000..55f9337 --- /dev/null +++ b/src/ci/run-install.ts @@ -0,0 +1,274 @@ +import path from "node:path"; +import { run } from "./process.js"; +import type { InstallCommand, RunInstallEntry } from "./types.js"; + +function parseScalar(value: string): string { + const trimmed = String(value || "").trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +export function parseFlowArray(value: string): string[] { + const trimmed = String(value || "").trim(); + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) { + throw new Error(`args must be an array, got: ${value}`); + } + + const body = trimmed.slice(1, -1).trim(); + if (!body) return []; + + const result: string[] = []; + let current = ""; + let quote = ""; + let hasItem = false; + + const pushCurrent = (): void => { + if (!current.trim()) { + throw new Error("args flow array entries must be non-empty strings"); + } + result.push(parseScalar(current)); + current = ""; + hasItem = true; + }; + + for (const char of body) { + if (quote) { + if (char === quote) quote = ""; + current += char; + continue; + } + if (char === "'" || char === '"') { + quote = char; + current += char; + continue; + } + if (char === ",") { + pushCurrent(); + continue; + } + current += char; + } + + if (quote) { + throw new Error("unterminated quoted string in args flow array"); + } + + if (current.trim()) { + pushCurrent(); + } else if (!hasItem) { + throw new Error("args flow array entries must be non-empty strings"); + } + + return result; +} + +function parseKeyValue(line: string): [string, string] | undefined { + const index = line.indexOf(":"); + if (index < 0) return undefined; + return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; +} + +function countIndent(line: string): number { + return line.length - line.trimStart().length; +} + +function parseBlockArray( + lines: string[], + startIndex: number, + parentIndent: number, +): { values: string[]; nextIndex: number } { + const values: string[] = []; + let index = startIndex; + + while (index < lines.length) { + const rawLine = lines[index]; + const indent = countIndent(rawLine); + const trimmedStart = rawLine.trimStart(); + if (indent <= parentIndent) break; + if (!trimmedStart.startsWith("-")) { + throw new Error(`invalid args line: ${rawLine}`); + } + + const value = trimmedStart.slice(1).trim(); + if (!value) { + throw new Error(`args entries must be strings: ${rawLine}`); + } + values.push(parseScalar(value)); + index += 1; + } + + if (values.length === 0) { + throw new Error("args must be an array"); + } + + return { values, nextIndex: index }; +} + +function assignValue(target: RunInstallEntry, key: string, value: string): boolean { + if (key === "cwd") { + target.cwd = parseScalar(value); + return false; + } + if (key === "args") { + if (!value) return true; + target.args = parseFlowArray(value); + return false; + } + throw new Error(`unsupported run-install key: ${key}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function validateRunInstallEntry(value: unknown): RunInstallEntry { + if (!isRecord(value)) { + throw new Error("run-install entries must be objects"); + } + + for (const key of Object.keys(value)) { + if (key !== "cwd" && key !== "args") { + throw new Error(`unsupported run-install key: ${key}`); + } + } + + const entry: RunInstallEntry = {}; + if (value.cwd !== undefined) { + if (typeof value.cwd !== "string") { + throw new Error("run-install.cwd must be a string"); + } + entry.cwd = value.cwd; + } + + if (value.args !== undefined) { + if (!Array.isArray(value.args) || value.args.some((arg) => typeof arg !== "string")) { + throw new Error("run-install.args must be an array of strings"); + } + entry.args = value.args; + } + + return entry; +} + +function validateRunInstallInput(value: unknown): import("./types.js").RunInstallInput { + if (value === null || typeof value === "boolean") return value; + if (Array.isArray(value)) return value.map(validateRunInstallEntry); + return validateRunInstallEntry(value); +} + +function applyEntryLine( + target: RunInstallEntry, + text: string, + lines: string[], + index: number, + parentIndent: number, + rawLine: string, +): number { + const entry = parseKeyValue(text); + if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); + if (assignValue(target, entry[0], entry[1])) { + const parsed = parseBlockArray(lines, index + 1, parentIndent); + target.args = parsed.values; + return parsed.nextIndex - 1; + } + return index; +} + +function parseObject(lines: string[]): RunInstallEntry { + const item: RunInstallEntry = {}; + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index]; + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + index = applyEntryLine(item, line, lines, index, countIndent(rawLine), rawLine); + } + return item; +} + +export function parseYamlSubset(value: string): RunInstallEntry[] { + const lines = value.split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith("#")); + if (lines.length === 0) return []; + + if (!lines[0].trimStart().startsWith("-")) { + return [parseObject(lines)]; + } + + const topLevelIndent = countIndent(lines[0]); + const items: RunInstallEntry[] = []; + let current: RunInstallEntry | undefined = undefined; + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index]; + const indent = countIndent(rawLine); + const trimmedStart = rawLine.trimStart(); + if (indent === topLevelIndent && trimmedStart.startsWith("-")) { + if (current) items.push(current); + current = {}; + const rest = trimmedStart.slice(1).trim(); + if (rest) { + index = applyEntryLine(current, rest, lines, index, indent, rawLine); + } + continue; + } + + if (!current) throw new Error(`invalid run-install line: ${rawLine}`); + index = applyEntryLine(current, trimmedStart, lines, index, indent, rawLine); + } + if (current) items.push(current); + return items; +} + +export function parseRunInstall(value: string): RunInstallEntry[] { + const input = String(value || "").trim(); + if (!input) return []; + + const parsed = parseRunInstallInput(input); + return normalizeRunInstallInput(parsed); +} + +function parseRunInstallInput(input: string): import("./types.js").RunInstallInput { + try { + return validateRunInstallInput(JSON.parse(input)); + } catch (error) { + if (!(error instanceof SyntaxError)) throw formatRunInstallError(error); + } + + try { + return validateRunInstallInput(parseYamlSubset(input)); + } catch (error) { + throw formatRunInstallError(error); + } +} + +function normalizeRunInstallInput(input: import("./types.js").RunInstallInput): RunInstallEntry[] { + if (!input) return []; + if (input === true) return [{}]; + return Array.isArray(input) ? input : [input]; +} + +function formatRunInstallError(error: unknown): Error { + if (error instanceof Error) return error; + return new Error(String(error)); +} + +export function runInstall( + entries: RunInstallEntry[], + projectDir: string, + installCommand: InstallCommand, + env: NodeJS.ProcessEnv = process.env, +): void { + const installEnv = { ...env }; + delete installEnv.SETUP_VP_ENV_FILE; + + for (const entry of entries) { + const cwd = entry.cwd ? path.resolve(projectDir, entry.cwd) : projectDir; + const installArgs = ["install", ...(entry.args || [])]; + const args = installCommand === "sfw" ? ["vp", ...installArgs] : installArgs; + console.log(`setup-vp: running ${installCommand} ${args.join(" ")} in ${cwd}`); + run(installCommand, args, { cwd, env: installEnv }); + } +} diff --git a/src/ci/types.ts b/src/ci/types.ts new file mode 100644 index 0000000..038b959 --- /dev/null +++ b/src/ci/types.ts @@ -0,0 +1,27 @@ +export type RunInstallEntry = { + cwd?: string; + args?: string[]; +}; + +export type RunInstallInput = null | boolean | RunInstallEntry | RunInstallEntry[]; + +export type RuntimeEnv = Record; + +export type InstallCommand = "vp" | "sfw"; + +export enum LockFileType { + Npm = "npm", + Pnpm = "pnpm", + Yarn = "yarn", + Bun = "bun", +} + +export interface LockFileInfo { + type: LockFileType; + path: string; + filename: string; +} + +export type ExportVariable = (name: string, value: string | undefined) => void; + +export type LogFn = (message: string) => void; diff --git a/src/ci/version.test.ts b/src/ci/version.test.ts new file mode 100644 index 0000000..2c5b214 --- /dev/null +++ b/src/ci/version.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vite-plus/test"; +import { parseInstalledVpVersion } from "./version.js"; + +describe("parseInstalledVpVersion", () => { + it("parses current and legacy vp --version output", () => { + expect(parseInstalledVpVersion("vp v0.2.2\n")).toBe("0.2.2"); + expect(parseInstalledVpVersion("- Global: v0.2.0\n")).toBe("0.2.0"); + expect(parseInstalledVpVersion("unexpected")).toBe("unknown"); + }); +}); diff --git a/src/ci/version.ts b/src/ci/version.ts new file mode 100644 index 0000000..8f20ea1 --- /dev/null +++ b/src/ci/version.ts @@ -0,0 +1,12 @@ +/** + * Extract the installed global Vite+ version from `vp --version` output. + * + * vp >= 0.2 prints the global version as `vp v0.2.0` on the first line; older + * builds printed `- Global: v0.2.0`. Returns "unknown" when neither shape matches. + */ +export function parseInstalledVpVersion(versionOutput: string): string { + const match = + versionOutput.match(/^\s*vp\s+v?(\d[^\s]*)/im) ?? + versionOutput.match(/Global:\s*v?(\d[^\s]*)/i); + return match?.[1] ?? "unknown"; +} diff --git a/src/gitlab/auth.ts b/src/gitlab/auth.ts index 9678aad..d23415d 100644 --- a/src/gitlab/auth.ts +++ b/src/gitlab/auth.ts @@ -1,45 +1,16 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { configureAuth as configureAuthCore } from "../ci/auth.js"; import { exportShellEnv } from "./shell.js"; import type { RuntimeEnv } from "./types.js"; -const NODE_AUTH_TOKEN_REF = "${NODE_AUTH_TOKEN}"; - export function configureAuth( registryUrlInput: string, scopeInput: string, targetEnv: RuntimeEnv = process.env, ): string | undefined { - if (!registryUrlInput) return; - - let url: URL; - try { - url = new URL(registryUrlInput); - } catch { - throw new Error(`Invalid registry-url: "${registryUrlInput}". Must be a valid URL.`); - } - - const registryUrl = url.href.endsWith("/") ? url.href : `${url.href}/`; - let scopePrefix = ""; - if (scopeInput) { - const scope = scopeInput.startsWith("@") ? scopeInput : `@${scopeInput}`; - scopePrefix = `${scope.toLowerCase()}:`; - } - - const authUrl = registryUrl.replace(/^\w+:/, "").toLowerCase(); - const npmrcDir = mkdtempSync(path.join(tmpdir(), "setup-vp-npmrc-")); - const npmrc = path.join(npmrcDir, ".npmrc"); - const contents = `${authUrl}:_authToken=${NODE_AUTH_TOKEN_REF}\n${scopePrefix}registry=${registryUrl}\n`; - writeFileSync(npmrc, contents, { encoding: "utf8", mode: 0o600 }); + const exportVariable = + targetEnv === process.env + ? (name: string, value: string | undefined) => exportShellEnv(name, value, targetEnv) + : undefined; - targetEnv.NPM_CONFIG_USERCONFIG = npmrc; - targetEnv.PNPM_CONFIG_USERCONFIG = npmrc; - targetEnv.NODE_AUTH_TOKEN = targetEnv.NODE_AUTH_TOKEN || "XXXXX-XXXXX-XXXXX-XXXXX"; - if (targetEnv === process.env) { - exportShellEnv("NPM_CONFIG_USERCONFIG", targetEnv.NPM_CONFIG_USERCONFIG, targetEnv); - exportShellEnv("PNPM_CONFIG_USERCONFIG", targetEnv.PNPM_CONFIG_USERCONFIG, targetEnv); - exportShellEnv("NODE_AUTH_TOKEN", targetEnv.NODE_AUTH_TOKEN, targetEnv); - } - return npmrc; + return configureAuthCore(registryUrlInput, scopeInput, targetEnv, exportVariable); } diff --git a/src/gitlab/install-sfw.test.ts b/src/gitlab/install-sfw.test.ts index bce426f..23ac738 100644 --- a/src/gitlab/install-sfw.test.ts +++ b/src/gitlab/install-sfw.test.ts @@ -30,9 +30,8 @@ describe("GitLab sfw setup", () => { expect(getSfwAssetName("linux", "x64", true)).toBe("sfw-free-musl-linux-x86_64"); expect(getSfwAssetName("linux", "arm64", false)).toBe("sfw-free-linux-arm64"); expect(getSfwAssetName("darwin", "arm64", false)).toBe("sfw-free-macos-arm64"); - expect(() => getSfwAssetName("win32", "x64", false)).toThrow( - "Unsupported platform/arch for sfw", - ); + expect(getSfwAssetName("win32", "x64", false)).toBe("sfw-free-windows-x86_64.exe"); + expect(getSfwAssetName("win32", "arm64", false)).toBe("sfw-free-windows-arm64.exe"); }); it("does not set up sfw when disabled or run-install is disabled", async () => { diff --git a/src/gitlab/install-sfw.ts b/src/gitlab/install-sfw.ts index 293a034..2474b8a 100644 --- a/src/gitlab/install-sfw.ts +++ b/src/gitlab/install-sfw.ts @@ -1,199 +1,21 @@ -import { createWriteStream, existsSync } from "node:fs"; -import { chmod, mkdtemp } from "node:fs/promises"; -import { spawnSync } from "node:child_process"; -import { get as httpGet } from "node:http"; -import { get as httpsGet } from "node:https"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { commandPath, exportShellEnv } from "./shell.js"; +import { exportShellEnv } from "./shell.js"; +import { + downloadFile, + getSfwAssetName, + isMuslLinux, + setupSfw as setupSfwCore, + SFW_VERSION, +} from "../ci/install-sfw.js"; import type { InstallCommand, RunInstallEntry } from "./types.js"; -const SFW_VERSION = "v1.12.0"; -const SFW_RELEASE_BASE = `https://github.com/SocketDev/sfw-free/releases/download/${SFW_VERSION}`; -const DOWNLOAD_TIMEOUT_MS = 60_000; -type DownloadClient = typeof httpGet; - -export function isMuslLinux(): boolean { - if (process.platform !== "linux") return false; - try { - const report = process.report?.getReport() as - | { header?: { glibcVersionRuntime?: string } } - | undefined; - if (report?.header && !report.header.glibcVersionRuntime) { - return true; - } - } catch { - // Fall through to filesystem fallback. - } - return existsSync("/etc/alpine-release"); -} - -/** - * Mirrors src/install-sfw.ts asset naming for GitLab's supported Unix runners. - */ -export function getSfwAssetName(platform: NodeJS.Platform, arch: string, isMusl: boolean): string { - if (platform === "darwin") { - if (arch === "x64") return "sfw-free-macos-x86_64"; - if (arch === "arm64") return "sfw-free-macos-arm64"; - } - - if (platform === "linux") { - if (arch === "x64") return isMusl ? "sfw-free-musl-linux-x86_64" : "sfw-free-linux-x86_64"; - if (arch === "arm64") return isMusl ? "sfw-free-musl-linux-arm64" : "sfw-free-linux-arm64"; - } - - const libcSuffix = platform === "linux" ? ` (${isMusl ? "musl" : "glibc"})` : ""; - throw new Error(`Unsupported platform/arch for sfw: ${platform}/${arch}${libcSuffix}`); -} - -function downloadFileWithShell(url: string, outputPath: string, timeoutMs: number): void { - const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000)); - const curl = commandPath("curl"); - if (curl) { - const result = spawnSync( - curl, - [ - "-fsSL", - "--connect-timeout", - "5", - "--max-time", - String(timeoutSeconds), - url, - "-o", - outputPath, - ], - { stdio: "ignore" }, - ); - if (result.status === 0) return; - throw result.error || new Error(`curl failed while downloading ${url}`); - } - - const wget = commandPath("wget"); - if (wget) { - const result = spawnSync( - wget, - ["-q", "-T", String(timeoutSeconds), "-t", "2", "-O", outputPath, url], - { stdio: "ignore" }, - ); - if (result.status === 0) return; - throw result.error || new Error(`wget failed while downloading ${url}`); - } - - throw new Error("curl or wget is required to download sfw"); -} - -export function downloadFile( - url: string, - outputPath: string, - redirects = 0, - timeoutMs = DOWNLOAD_TIMEOUT_MS, - clientOverride?: DownloadClient, -): Promise { - if (redirects > 5) { - return Promise.reject(new Error(`too many redirects while downloading ${url}`)); - } - - if (!clientOverride) { - downloadFileWithShell(url, outputPath, timeoutMs); - return Promise.resolve(); - } - - const client = clientOverride || (url.startsWith("https:") ? httpsGet : httpGet); - return new Promise((resolve, reject) => { - let settled = false; - const finish = (error?: Error): void => { - if (settled) return; - settled = true; - clearTimeout(timeout); - if (error) { - reject(error); - } else { - resolve(); - } - }; - - const request = client(url, (response) => { - const statusCode = response.statusCode ?? 0; - const location = response.headers.location; - if (statusCode >= 300 && statusCode < 400 && location) { - response.resume(); - const nextUrl = new URL(location, url).toString(); - downloadFile(nextUrl, outputPath, redirects + 1, timeoutMs, clientOverride).then( - () => finish(), - finish, - ); - return; - } - - if (statusCode !== 200) { - response.resume(); - finish(new Error(`download failed with HTTP ${statusCode}: ${url}`)); - return; - } - - const file = createWriteStream(outputPath); - response.pipe(file); - file.on("finish", () => file.close(() => finish())); - file.on("error", finish); - }); - - const timeout = setTimeout(() => { - request.destroy(new Error(`download timed out after ${timeoutMs}ms: ${url}`)); - }, timeoutMs); - request.on("error", finish); - }); -} +export { downloadFile, getSfwAssetName, isMuslLinux, SFW_VERSION }; export async function setupSfw( runInstallEntries: RunInstallEntry[], env: NodeJS.ProcessEnv = process.env, ): Promise { - if (env.SETUP_VP_SFW !== "true") return "vp"; - - if (runInstallEntries.length === 0) { - console.log( - "setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.", - ); - return "vp"; - } - - const existing = commandPath("sfw"); - if (existing) { - console.log(`setup-vp: using existing sfw on PATH: ${existing}`); - return "sfw"; - } - - const musl = isMuslLinux(); - let asset: string | undefined; - try { - asset = getSfwAssetName(process.platform, process.arch, musl); - } catch { - asset = undefined; - } - if (!asset) { - console.error( - `setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${process.platform}, process.arch=${process.arch}, musl=${musl}) and none was found on PATH; falling back to plain vp install.`, - ); - return "vp"; - } - - const sfwDir = await mkdtemp(path.join(tmpdir(), "setup-vp-sfw-")); - const sfwBin = path.join(sfwDir, "sfw"); - const sfwUrl = `${SFW_RELEASE_BASE}/${asset}`; - - for (let round = 1; round <= 2; round += 1) { - try { - console.log(`setup-vp: installing sfw ${SFW_VERSION} from ${sfwUrl}`); - await downloadFile(sfwUrl, sfwBin); - await chmod(sfwBin, 0o755); - env.PATH = `${sfwDir}:${env.PATH || ""}`; - exportShellEnv("PATH", env.PATH, env); - return "sfw"; - } catch (error) { - if (round === 2) throw error; - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - } - - throw new Error("failed to install sfw after retrying"); + return setupSfwCore(runInstallEntries, { + env, + exportVariable: (name, value) => exportShellEnv(name, value, env), + }); } diff --git a/src/gitlab/run-install.ts b/src/gitlab/run-install.ts index d686057..7a08e13 100644 --- a/src/gitlab/run-install.ts +++ b/src/gitlab/run-install.ts @@ -1,280 +1 @@ -import path from "node:path"; -import { run } from "./shell.js"; -import type { InstallCommand, RunInstallEntry, RunInstallInput } from "./types.js"; - -function parseScalar(value: string): string { - const trimmed = String(value || "").trim(); - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1); - } - return trimmed; -} - -export function parseFlowArray(value: string): string[] { - const trimmed = String(value || "").trim(); - if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) { - throw new Error(`args must be an array, got: ${value}`); - } - - const body = trimmed.slice(1, -1).trim(); - if (!body) return []; - - const result: string[] = []; - let current = ""; - let quote = ""; - let hasItem = false; - - const pushCurrent = (): void => { - if (!current.trim()) { - throw new Error("args flow array entries must be non-empty strings"); - } - result.push(parseScalar(current)); - current = ""; - hasItem = true; - }; - - for (const char of body) { - if (quote) { - if (char === quote) quote = ""; - current += char; - continue; - } - if (char === "'" || char === '"') { - quote = char; - current += char; - continue; - } - if (char === ",") { - pushCurrent(); - continue; - } - current += char; - } - - if (quote) { - throw new Error("unterminated quoted string in args flow array"); - } - - if (current.trim()) { - pushCurrent(); - } else if (!hasItem) { - throw new Error("args flow array entries must be non-empty strings"); - } - - return result; -} - -function parseKeyValue(line: string): [string, string] | undefined { - const index = line.indexOf(":"); - if (index < 0) return undefined; - return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; -} - -function countIndent(line: string): number { - return line.length - line.trimStart().length; -} - -function parseBlockArray( - lines: string[], - startIndex: number, - parentIndent: number, -): { values: string[]; nextIndex: number } { - const values: string[] = []; - let index = startIndex; - - while (index < lines.length) { - const rawLine = lines[index]; - const indent = countIndent(rawLine); - const trimmedStart = rawLine.trimStart(); - if (indent <= parentIndent) break; - if (!trimmedStart.startsWith("-")) { - throw new Error(`invalid args line: ${rawLine}`); - } - - const value = trimmedStart.slice(1).trim(); - if (!value) { - throw new Error(`args entries must be strings: ${rawLine}`); - } - values.push(parseScalar(value)); - index += 1; - } - - if (values.length === 0) { - throw new Error("args must be an array"); - } - - return { values, nextIndex: index }; -} - -function assignValue(target: RunInstallEntry, key: string, value: string): boolean { - if (key === "cwd") { - target.cwd = parseScalar(value); - return false; - } - if (key === "args") { - if (!value) return true; - target.args = parseFlowArray(value); - return false; - } - throw new Error(`unsupported run-install key: ${key}`); -} - -// Validate run-install locally instead of reusing Zod + the `yaml` package. -// The bootstrap fetches this runtime (dist/gitlab/index.mjs) from GitHub on -// every job, so it is kept dependency-free and tiny (~9 KB); bundling zod and -// yaml would inflate it toward the ~1 MB GitHub Action bundle. -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function validateRunInstallEntry(value: unknown): RunInstallEntry { - if (!isRecord(value)) { - throw new Error("run-install entries must be objects"); - } - - for (const key of Object.keys(value)) { - if (key !== "cwd" && key !== "args") { - throw new Error(`unsupported run-install key: ${key}`); - } - } - - const entry: RunInstallEntry = {}; - if (value.cwd !== undefined) { - if (typeof value.cwd !== "string") { - throw new Error("run-install.cwd must be a string"); - } - entry.cwd = value.cwd; - } - - if (value.args !== undefined) { - if (!Array.isArray(value.args) || value.args.some((arg) => typeof arg !== "string")) { - throw new Error("run-install.args must be an array of strings"); - } - entry.args = value.args; - } - - return entry; -} - -function validateRunInstallInput(value: unknown): RunInstallInput { - if (value === null || typeof value === "boolean") return value; - if (Array.isArray(value)) return value.map(validateRunInstallEntry); - return validateRunInstallEntry(value); -} - -// Parse one `key: value` line into `target`. When the value spans a following -// block array, consume those lines and return the index of the last consumed -// line; otherwise return `index` unchanged. -function applyEntryLine( - target: RunInstallEntry, - text: string, - lines: string[], - index: number, - parentIndent: number, - rawLine: string, -): number { - const entry = parseKeyValue(text); - if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); - if (assignValue(target, entry[0], entry[1])) { - const parsed = parseBlockArray(lines, index + 1, parentIndent); - target.args = parsed.values; - return parsed.nextIndex - 1; - } - return index; -} - -function parseObject(lines: string[]): RunInstallEntry { - const item: RunInstallEntry = {}; - for (let index = 0; index < lines.length; index += 1) { - const rawLine = lines[index]; - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - index = applyEntryLine(item, line, lines, index, countIndent(rawLine), rawLine); - } - return item; -} - -export function parseYamlSubset(value: string): RunInstallEntry[] { - const lines = value.split(/\r?\n/).filter((line) => line.trim() && !line.trim().startsWith("#")); - if (lines.length === 0) return []; - - if (!lines[0].trimStart().startsWith("-")) { - return [parseObject(lines)]; - } - - const topLevelIndent = countIndent(lines[0]); - const items: RunInstallEntry[] = []; - let current: RunInstallEntry | undefined = undefined; - for (let index = 0; index < lines.length; index += 1) { - const rawLine = lines[index]; - const indent = countIndent(rawLine); - const trimmedStart = rawLine.trimStart(); - if (indent === topLevelIndent && trimmedStart.startsWith("-")) { - if (current) items.push(current); - current = {}; - const rest = trimmedStart.slice(1).trim(); - if (rest) { - index = applyEntryLine(current, rest, lines, index, indent, rawLine); - } - continue; - } - - if (!current) throw new Error(`invalid run-install line: ${rawLine}`); - index = applyEntryLine(current, trimmedStart, lines, index, indent, rawLine); - } - if (current) items.push(current); - return items; -} - -export function parseRunInstall(value: string): RunInstallEntry[] { - const input = String(value || "").trim(); - if (!input) return []; - - const parsed = parseRunInstallInput(input); - return normalizeRunInstallInput(parsed); -} - -function parseRunInstallInput(input: string): RunInstallInput { - try { - return validateRunInstallInput(JSON.parse(input)); - } catch (error) { - if (!(error instanceof SyntaxError)) throw formatRunInstallError(error); - } - - try { - return validateRunInstallInput(parseYamlSubset(input)); - } catch (error) { - throw formatRunInstallError(error); - } -} - -function normalizeRunInstallInput(input: RunInstallInput): RunInstallEntry[] { - if (!input) return []; - if (input === true) return [{}]; - return Array.isArray(input) ? input : [input]; -} - -function formatRunInstallError(error: unknown): Error { - if (error instanceof Error) return error; - return new Error(String(error)); -} - -export function runInstall( - entries: RunInstallEntry[], - projectDir: string, - installCommand: InstallCommand, -): void { - const installEnv = { ...process.env }; - delete installEnv.SETUP_VP_ENV_FILE; - - for (const entry of entries) { - const cwd = entry.cwd ? path.resolve(projectDir, entry.cwd) : projectDir; - const installArgs = ["install", ...(entry.args || [])]; - const args = installCommand === "sfw" ? ["vp", ...installArgs] : installArgs; - console.log(`setup-vp: running ${installCommand} ${args.join(" ")} in ${cwd}`); - run(installCommand, args, { cwd, env: installEnv }); - } -} +export { parseFlowArray, parseRunInstall, parseYamlSubset, runInstall } from "../ci/run-install.js"; diff --git a/src/gitlab/shell.ts b/src/gitlab/shell.ts index 2a31478..35e35cc 100644 --- a/src/gitlab/shell.ts +++ b/src/gitlab/shell.ts @@ -1,6 +1,5 @@ import { writeFileSync } from "node:fs"; -import { spawnSync } from "node:child_process"; -import type { SpawnSyncOptions } from "node:child_process"; +export { commandPath, run } from "../ci/process.js"; export function shellQuote(value: string): string { return `'${String(value).replaceAll("'", "'\\''")}'`; @@ -17,17 +16,3 @@ export function exportShellEnv( flag: "a", }); } - -export function run(command: string, args: string[], options: SpawnSyncOptions = {}): void { - const result = spawnSync(command, args, { stdio: "inherit", ...options }); - if (result.error) throw result.error; - if (result.status !== 0) process.exit(result.status ?? 1); -} - -export function commandPath(command: string): string | undefined { - const result = spawnSync("sh", ["-c", 'command -v "$1"', "sh", command], { - encoding: "utf8", - }); - if (result.status === 0) return result.stdout.trim(); - return undefined; -} diff --git a/src/gitlab/types.ts b/src/gitlab/types.ts index c2d87a5..4a90e25 100644 --- a/src/gitlab/types.ts +++ b/src/gitlab/types.ts @@ -1,10 +1,10 @@ -export type RunInstallEntry = { - cwd?: string; - args?: string[]; -}; - -export type RunInstallInput = null | boolean | RunInstallEntry | RunInstallEntry[]; - -export type RuntimeEnv = Record; - -export type InstallCommand = "vp" | "sfw"; +export type { + ExportVariable, + InstallCommand, + LockFileInfo, + LogFn, + RunInstallEntry, + RunInstallInput, + RuntimeEnv, +} from "../ci/types.js"; +export { LockFileType } from "../ci/types.js"; diff --git a/src/gitlab/utils.ts b/src/gitlab/utils.ts index b3e1145..ac0f81e 100644 --- a/src/gitlab/utils.ts +++ b/src/gitlab/utils.ts @@ -1,27 +1,9 @@ -import { statSync } from "node:fs"; -import path from "node:path"; +import { resolveProjectDirectory } from "../ci/project.js"; import type { RuntimeEnv } from "./types.js"; export function resolveProjectDir(runtimeEnv: RuntimeEnv = process.env): string { - const workingDirectory = runtimeEnv.SETUP_VP_WORKING_DIRECTORY || "."; - const projectDir = path.isAbsolute(workingDirectory) - ? workingDirectory - : path.join(runtimeEnv.CI_PROJECT_DIR || process.cwd(), workingDirectory); - - try { - if (!statSync(projectDir).isDirectory()) { - throw new Error( - `working-directory is not a directory: ${workingDirectory} (resolved to ${projectDir})`, - ); - } - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - throw new Error( - `working-directory not found: ${workingDirectory} (resolved to ${projectDir})`, - ); - } - throw error; - } - - return projectDir; + return resolveProjectDirectory({ + workingDirectory: runtimeEnv.SETUP_VP_WORKING_DIRECTORY || ".", + workspaceRoot: runtimeEnv.CI_PROJECT_DIR || process.cwd(), + }); } diff --git a/src/portable-bundles.test.ts b/src/portable-bundles.test.ts new file mode 100644 index 0000000..77e80c3 --- /dev/null +++ b/src/portable-bundles.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vite-plus/test"; +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const distRoot = fileURLToPath(new URL("../dist", import.meta.url)); +const azureDist = `${distRoot}/azure/index.mjs`; +const gitlabDist = `${distRoot}/gitlab/index.mjs`; +const actionDist = `${distRoot}/index.mjs`; + +describe("portable CI bundles", () => { + it("builds azure and gitlab bundles without @actions imports", () => { + expect(existsSync(azureDist)).toBe(true); + expect(existsSync(gitlabDist)).toBe(true); + const azure = readFileSync(azureDist, "utf8"); + const gitlab = readFileSync(gitlabDist, "utf8"); + expect(azure).not.toContain("@actions/"); + expect(gitlab).not.toContain("@actions/"); + expect(readFileSync(actionDist, "utf8")).toContain("@actions/"); + }); + + it("fails invalid azure phase with a controlled message", () => { + const result = spawnSync(process.execPath, [azureDist, "invalid-phase"], { + encoding: "utf8", + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('invalid phase "invalid-phase"'); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 6d14bd2..1336dbb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ entry: { index: "./src/index.ts", "gitlab/index": "./src/gitlab/index.ts", + "azure/index": "./src/azure/index.ts", }, format: ["esm"], outDir: "dist", From f38e15362191caac1fabcd41cd0778ca6e495577 Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Fri, 10 Jul 2026 23:54:52 +0200 Subject: [PATCH 2/7] fix: make Azure runtime reusable across steps --- azure/bootstrap.ps1 | 16 +++++++++++++++- azure/bootstrap.sh | 12 +++++++++++- azure/setup-vp.yml | 8 ++++---- dist/azure/index.mjs | 2 +- src/azure/index.ts | 22 +++++++++++----------- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/azure/bootstrap.ps1 b/azure/bootstrap.ps1 index e10ddf0..2773a4f 100644 --- a/azure/bootstrap.ps1 +++ b/azure/bootstrap.ps1 @@ -17,10 +17,24 @@ $setupRef = if ($env:SETUP_VP_SETUP_REF) { $env:SETUP_VP_SETUP_REF } else { 'v1' $runtimeOut = if ($env:SETUP_VP_RUNTIME_OUT) { $env:SETUP_VP_RUNTIME_OUT } else { - Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure-runtime.mjs' + Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure/dist/azure/index.mjs' } +$runtimeDir = Split-Path -Parent $runtimeOut +$chunkDir = Split-Path -Parent $runtimeDir +New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null +New-Item -ItemType Directory -Path $chunkDir -Force | Out-Null + $runtimeUrl = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/$setupRef/dist/azure/index.mjs" Setup-VpDownload -Url $runtimeUrl -OutFile $runtimeOut +$runtimeText = Get-Content -LiteralPath $runtimeOut -Raw +$chunkMatches = [regex]::Matches($runtimeText, '[''"]\.\./([^''"]+\.mjs)[''"]') +$chunkNames = @($chunkMatches | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) +foreach ($chunkName in $chunkNames) { + $chunkUrl = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/$setupRef/dist/$chunkName" + $chunkOut = Join-Path $chunkDir $chunkName + Setup-VpDownload -Url $chunkUrl -OutFile $chunkOut +} + & node $runtimeOut prepare diff --git a/azure/bootstrap.sh b/azure/bootstrap.sh index 7984fe9..abca655 100644 --- a/azure/bootstrap.sh +++ b/azure/bootstrap.sh @@ -20,10 +20,20 @@ setup_vp_download() { } SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1}" -SETUP_VP_RUNTIME_OUT="${SETUP_VP_RUNTIME_OUT:-$(mktemp "${TMPDIR:-/tmp}/setup-vp-azure-runtime.XXXXXX.mjs")}" +SETUP_VP_RUNTIME_OUT="${SETUP_VP_RUNTIME_OUT:-${TMPDIR:-/tmp}/setup-vp-azure/dist/azure/index.mjs}" +setup_vp_runtime_dir="$(dirname "$SETUP_VP_RUNTIME_OUT")" +setup_vp_chunk_dir="$(dirname "$setup_vp_runtime_dir")" +mkdir -p "$setup_vp_runtime_dir" "$setup_vp_chunk_dir" chmod 600 "$SETUP_VP_RUNTIME_OUT" 2>/dev/null || true setup_vp_runtime_url="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${SETUP_VP_SETUP_REF}/dist/azure/index.mjs" setup_vp_download "$setup_vp_runtime_url" "$SETUP_VP_RUNTIME_OUT" +while IFS= read -r setup_vp_chunk; do + setup_vp_chunk_name="${setup_vp_chunk#../}" + setup_vp_download \ + "https://raw.githubusercontent.com/voidzero-dev/setup-vp/${SETUP_VP_SETUP_REF}/dist/${setup_vp_chunk_name}" \ + "$setup_vp_chunk_dir/${setup_vp_chunk_name}" +done < <(grep -Eo "['\"]\.\./[^'\"]+\.mjs['\"]" "$SETUP_VP_RUNTIME_OUT" | tr -d "'\"" | sort -u) + node "$SETUP_VP_RUNTIME_OUT" prepare diff --git a/azure/setup-vp.yml b/azure/setup-vp.yml index b3cfc55..e8f4969 100644 --- a/azure/setup-vp.yml +++ b/azure/setup-vp.yml @@ -56,7 +56,7 @@ steps: SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} SETUP_VP_CACHE: ${{ parameters.cache }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} - SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)\setup-vp-azure-runtime.mjs + SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)\setup-vp-azure\dist\azure\index.mjs - ${{ if ne(variables['Agent.OS'], 'Windows_NT') }}: - bash: | @@ -78,7 +78,7 @@ steps: SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} SETUP_VP_CACHE: ${{ parameters.cache }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} - SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)/setup-vp-azure-runtime.mjs + SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs - ${{ if eq(parameters.cache, true) }}: - task: Cache@2 @@ -95,7 +95,7 @@ steps: - ${{ if eq(variables['Agent.OS'], 'Windows_NT') }}: - powershell: | $ErrorActionPreference = 'Stop' - node "$(SETUP_VP_RUNTIME_PATH)" finalize + node "$(Agent.TempDirectory)\setup-vp-azure\dist\azure\index.mjs" finalize displayName: setup-vp finalize (Windows) env: SETUP_VP_VERSION: ${{ parameters.version }} @@ -110,7 +110,7 @@ steps: - ${{ if ne(variables['Agent.OS'], 'Windows_NT') }}: - bash: | set -euo pipefail - node "$(SETUP_VP_RUNTIME_PATH)" finalize + node "$(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs" finalize displayName: setup-vp finalize (Unix) env: SETUP_VP_VERSION: ${{ parameters.version }} diff --git a/dist/azure/index.mjs b/dist/azure/index.mjs index 7240880..04d26e6 100644 --- a/dist/azure/index.mjs +++ b/dist/azure/index.mjs @@ -1,2 +1,2 @@ import{a as e,i as t,n,r,s as i,t as a}from"../project-BPhRkgx0.mjs";import{pathToFileURL as o}from"node:url";import s,{basename as c,isAbsolute as l,join as u}from"node:path";import{setTimeout as d}from"node:timers/promises";import{existsSync as f,readdirSync as p}from"node:fs";import{homedir as m}from"node:os";import{spawnSync as h}from"node:child_process";const g=[{filename:`pnpm-lock.yaml`,type:`pnpm`},{filename:`bun.lockb`,type:`bun`},{filename:`bun.lock`,type:`bun`},{filename:`package-lock.json`,type:`npm`},{filename:`npm-shrinkwrap.json`,type:`npm`},{filename:`yarn.lock`,type:`yarn`}];function resolvePath(e,t){return l(e)?e:u(t,e)}function inferLockFileType(e,t){return t.includes(`pnpm`)?{type:`pnpm`,path:e,filename:t}:t.includes(`yarn`)?{type:`yarn`,path:e,filename:t}:t.startsWith(`bun.`)?{type:`bun`,path:e,filename:t}:{type:`npm`,path:e,filename:t}}function detectLockFile(e,t){if(e){let n=resolvePath(e,t);if(f(n)){let e=c(n),t=g.find(t=>t.filename===e);return t?{type:t.type,path:n,filename:e}:inferLockFileType(n,e)}return}let n=p(t);for(let e of g)if(n.includes(e.filename)){let n=u(t,e.filename);return{type:e.type,path:n,filename:e.filename}}}function prepareCacheMetadata(t){let{projectDir:n,cacheDependencyPath:r,logWarning:i}=t,a=detectLockFile(r||void 0,n);if(!a)return i?.(`setup-vp: cache is enabled but no supported lock file was found; skipping cache.`),{ready:!1};let o=e(`vp`,[`pm`,`cache`,`dir`],{cwd:s.dirname(a.path)});return o?{ready:!0,cachePath:o,lockFile:a.path,lockType:a.type}:(i?.(`setup-vp: could not resolve package-manager cache directory for ${a.filename}; skipping cache.`),{ready:!1})}function parseInstalledVpVersion(e){return(e.match(/^\s*vp\s+v?(\d[^\s]*)/im)??e.match(/Global:\s*v?(\d[^\s]*)/i))?.[1]??`unknown`}function escapeLoggingCommandData(e){return e.replaceAll(`%`,`%25`).replaceAll(`\r`,`%0D`).replaceAll(` -`,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const _=[`https://viteplus.dev/install.sh`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh`],v=[`https://viteplus.dev/install.ps1`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.ps1`],y=2e3,b=/^0\.0\.0-commit\.([0-9a-f]{40})$/i;function getVitePlusHome(e=process.platform){return u(e===`win32`?process.env.USERPROFILE||m():process.env.HOME||m(),`.vite-plus`)}function pkgPrNewCommitSha(e){return e.match(b)?.[1]}function runInstallCommand(e,t,n=process.platform){if(n===`win32`){let n=h(`pwsh`,[`-Command`,`& ([scriptblock]::Create((irm -TimeoutSec 15 ${e})))`],{env:{...process.env,...t},stdio:`inherit`});if(n.error)throw n.error;return n.status??1}let r=h(`bash`,[`-c`,`set -o pipefail; curl -fsSL --connect-timeout 5 --max-time 15 ${e} | bash`],{env:{...process.env,...t},stdio:`inherit`});if(r.error)throw r.error;return r.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??d,o=t.runInstall??runInstallCommand,s=t.logWarningFn??logWarning,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=pkgPrNewCommitSha(e);l&&(c.VP_PR_VERSION=l);let f=n===`win32`?v:_,p=2*f.length,m=``,h=0;for(let e=0;e<2;e+=1)for(let e of f){h+=1;try{let t=o(e,c,n);if(t===0){let e=u(getVitePlusHome(n),`bin`);r.PATH?.includes(e)||(r.PATH=`${e}${n===`win32`?`;`:`:`}${r.PATH||``}`,i?.(e));return}m=`exit code ${t}`}catch(e){m=e instanceof Error?e.message:String(e)}ht.prependPath(e),logWarningFn:t.logWarning});let i=s.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,i,{isOutput:!0}),!n.cache)return;let a=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!a.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`,{isOutput:!0});return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`,{isOutput:!0}),a.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,a.cachePath,{isOutput:!0}),a.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,a.lockFile,{isOutput:!0}),a.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,a.lockType,{isOutput:!0})}async function runFinalize(e=process.env,t=x){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n,{isOutput:!0})});let i=t.parseRunInstall(n.runInstall),a=await t.setupSfw(i,{env:e,sfwEnabled:n.sfw,exportVariable:(e,n)=>{n!==void 0&&t.setVariable(e,n,{isOutput:!0})}});i.length>0&&t.runInstall(i,r,a,e);let o=t.getCommandOutput(`vp`,[`--version`])||``;t.logInfo(o);let s=t.parseInstalledVpVersion(o);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,s,{isOutput:!0})}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===o(s.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file +`,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const _=[`https://viteplus.dev/install.sh`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh`],v=[`https://viteplus.dev/install.ps1`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.ps1`],y=2e3,b=/^0\.0\.0-commit\.([0-9a-f]{40})$/i;function getVitePlusHome(e=process.platform){return u(e===`win32`?process.env.USERPROFILE||m():process.env.HOME||m(),`.vite-plus`)}function pkgPrNewCommitSha(e){return e.match(b)?.[1]}function runInstallCommand(e,t,n=process.platform){if(n===`win32`){let n=h(`pwsh`,[`-Command`,`& ([scriptblock]::Create((irm -TimeoutSec 15 ${e})))`],{env:{...process.env,...t},stdio:`inherit`});if(n.error)throw n.error;return n.status??1}let r=h(`bash`,[`-c`,`set -o pipefail; curl -fsSL --connect-timeout 5 --max-time 15 ${e} | bash`],{env:{...process.env,...t},stdio:`inherit`});if(r.error)throw r.error;return r.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??d,o=t.runInstall??runInstallCommand,s=t.logWarningFn??logWarning,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=pkgPrNewCommitSha(e);l&&(c.VP_PR_VERSION=l);let f=n===`win32`?v:_,p=2*f.length,m=``,h=0;for(let e=0;e<2;e+=1)for(let e of f){h+=1;try{let t=o(e,c,n);if(t===0){let e=u(getVitePlusHome(n),`bin`);r.PATH?.includes(e)||(r.PATH=`${e}${n===`win32`?`;`:`:`}${r.PATH||``}`,i?.(e));return}m=`exit code ${t}`}catch(e){m=e instanceof Error?e.message:String(e)}ht.prependPath(e),logWarningFn:t.logWarning});let i=s.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,i),!n.cache)return;let a=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!a.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`),a.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,a.cachePath),a.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,a.lockFile),a.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,a.lockType)}async function runFinalize(e=process.env,t=x){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n)});let i=t.parseRunInstall(n.runInstall),a=await t.setupSfw(i,{env:e,sfwEnabled:n.sfw,exportVariable:(e,n)=>{n!==void 0&&t.setVariable(e,n)}});i.length>0&&t.runInstall(i,r,a,e);let o=t.getCommandOutput(`vp`,[`--version`])||``;t.logInfo(o);let s=t.parseInstalledVpVersion(o);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,s)}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===o(s.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file diff --git a/src/azure/index.ts b/src/azure/index.ts index 2724a1f..d216018 100644 --- a/src/azure/index.ts +++ b/src/azure/index.ts @@ -54,8 +54,8 @@ export async function runPrepare( const inputs = parseAzureInputs(env); const projectDir = resolveProjectDirFromInputs(inputs); - ports.setVariable("SETUP_VP_CACHE_HIT", "false", { isOutput: true }); - ports.setVariable("SETUP_VP_CACHE_READY", "false", { isOutput: true }); + ports.setVariable("SETUP_VP_CACHE_HIT", "false"); + ports.setVariable("SETUP_VP_CACHE_READY", "false"); await ports.installVitePlus(inputs.version, { env, @@ -64,7 +64,7 @@ export async function runPrepare( }); const runtimePath = path.resolve(process.argv[1] || ""); - ports.setVariable("SETUP_VP_RUNTIME_PATH", runtimePath, { isOutput: true }); + ports.setVariable("SETUP_VP_RUNTIME_PATH", runtimePath); if (!inputs.cache) return; @@ -75,19 +75,19 @@ export async function runPrepare( }); if (!metadata.ready) { - ports.setVariable("SETUP_VP_CACHE_READY", "false", { isOutput: true }); + ports.setVariable("SETUP_VP_CACHE_READY", "false"); return; } - ports.setVariable("SETUP_VP_CACHE_READY", "true", { isOutput: true }); + ports.setVariable("SETUP_VP_CACHE_READY", "true"); if (metadata.cachePath) { - ports.setVariable("SETUP_VP_CACHE_PATH", metadata.cachePath, { isOutput: true }); + ports.setVariable("SETUP_VP_CACHE_PATH", metadata.cachePath); } if (metadata.lockFile) { - ports.setVariable("SETUP_VP_LOCK_FILE", metadata.lockFile, { isOutput: true }); + ports.setVariable("SETUP_VP_LOCK_FILE", metadata.lockFile); } if (metadata.lockType) { - ports.setVariable("SETUP_VP_LOCK_TYPE", metadata.lockType, { isOutput: true }); + ports.setVariable("SETUP_VP_LOCK_TYPE", metadata.lockType); } } @@ -100,7 +100,7 @@ export async function runFinalize( ports.configureAuth(inputs.registryUrl, inputs.scope, env, (name, value) => { if (name === "NODE_AUTH_TOKEN") return; - if (value !== undefined) ports.setVariable(name, value, { isOutput: true }); + if (value !== undefined) ports.setVariable(name, value); }); const runInstallEntries = ports.parseRunInstall(inputs.runInstall); @@ -108,7 +108,7 @@ export async function runFinalize( env, sfwEnabled: inputs.sfw, exportVariable: (name, value) => { - if (value !== undefined) ports.setVariable(name, value, { isOutput: true }); + if (value !== undefined) ports.setVariable(name, value); }, }); if (runInstallEntries.length > 0) { @@ -118,7 +118,7 @@ export async function runFinalize( const versionOutput = ports.getCommandOutput("vp", ["--version"]) || ""; ports.logInfo(versionOutput); const installedVersion = ports.parseInstalledVpVersion(versionOutput); - ports.setVariable("SETUP_VP_INSTALLED_VERSION", installedVersion, { isOutput: true }); + ports.setVariable("SETUP_VP_INSTALLED_VERSION", installedVersion); } export async function main(phase: AzurePhase): Promise { From 751948ad5b654630421c0b920685754d20e3f42f Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Sat, 11 Jul 2026 00:01:40 +0200 Subject: [PATCH 3/7] fix: parse Azure boolean env values case-insensitively --- dist/azure/index.mjs | 2 +- src/azure/inputs.test.ts | 10 ++++++++++ src/azure/inputs.ts | 8 ++++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/dist/azure/index.mjs b/dist/azure/index.mjs index 04d26e6..329f76e 100644 --- a/dist/azure/index.mjs +++ b/dist/azure/index.mjs @@ -1,2 +1,2 @@ import{a as e,i as t,n,r,s as i,t as a}from"../project-BPhRkgx0.mjs";import{pathToFileURL as o}from"node:url";import s,{basename as c,isAbsolute as l,join as u}from"node:path";import{setTimeout as d}from"node:timers/promises";import{existsSync as f,readdirSync as p}from"node:fs";import{homedir as m}from"node:os";import{spawnSync as h}from"node:child_process";const g=[{filename:`pnpm-lock.yaml`,type:`pnpm`},{filename:`bun.lockb`,type:`bun`},{filename:`bun.lock`,type:`bun`},{filename:`package-lock.json`,type:`npm`},{filename:`npm-shrinkwrap.json`,type:`npm`},{filename:`yarn.lock`,type:`yarn`}];function resolvePath(e,t){return l(e)?e:u(t,e)}function inferLockFileType(e,t){return t.includes(`pnpm`)?{type:`pnpm`,path:e,filename:t}:t.includes(`yarn`)?{type:`yarn`,path:e,filename:t}:t.startsWith(`bun.`)?{type:`bun`,path:e,filename:t}:{type:`npm`,path:e,filename:t}}function detectLockFile(e,t){if(e){let n=resolvePath(e,t);if(f(n)){let e=c(n),t=g.find(t=>t.filename===e);return t?{type:t.type,path:n,filename:e}:inferLockFileType(n,e)}return}let n=p(t);for(let e of g)if(n.includes(e.filename)){let n=u(t,e.filename);return{type:e.type,path:n,filename:e.filename}}}function prepareCacheMetadata(t){let{projectDir:n,cacheDependencyPath:r,logWarning:i}=t,a=detectLockFile(r||void 0,n);if(!a)return i?.(`setup-vp: cache is enabled but no supported lock file was found; skipping cache.`),{ready:!1};let o=e(`vp`,[`pm`,`cache`,`dir`],{cwd:s.dirname(a.path)});return o?{ready:!0,cachePath:o,lockFile:a.path,lockType:a.type}:(i?.(`setup-vp: could not resolve package-manager cache directory for ${a.filename}; skipping cache.`),{ready:!1})}function parseInstalledVpVersion(e){return(e.match(/^\s*vp\s+v?(\d[^\s]*)/im)??e.match(/Global:\s*v?(\d[^\s]*)/i))?.[1]??`unknown`}function escapeLoggingCommandData(e){return e.replaceAll(`%`,`%25`).replaceAll(`\r`,`%0D`).replaceAll(` -`,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const _=[`https://viteplus.dev/install.sh`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh`],v=[`https://viteplus.dev/install.ps1`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.ps1`],y=2e3,b=/^0\.0\.0-commit\.([0-9a-f]{40})$/i;function getVitePlusHome(e=process.platform){return u(e===`win32`?process.env.USERPROFILE||m():process.env.HOME||m(),`.vite-plus`)}function pkgPrNewCommitSha(e){return e.match(b)?.[1]}function runInstallCommand(e,t,n=process.platform){if(n===`win32`){let n=h(`pwsh`,[`-Command`,`& ([scriptblock]::Create((irm -TimeoutSec 15 ${e})))`],{env:{...process.env,...t},stdio:`inherit`});if(n.error)throw n.error;return n.status??1}let r=h(`bash`,[`-c`,`set -o pipefail; curl -fsSL --connect-timeout 5 --max-time 15 ${e} | bash`],{env:{...process.env,...t},stdio:`inherit`});if(r.error)throw r.error;return r.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??d,o=t.runInstall??runInstallCommand,s=t.logWarningFn??logWarning,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=pkgPrNewCommitSha(e);l&&(c.VP_PR_VERSION=l);let f=n===`win32`?v:_,p=2*f.length,m=``,h=0;for(let e=0;e<2;e+=1)for(let e of f){h+=1;try{let t=o(e,c,n);if(t===0){let e=u(getVitePlusHome(n),`bin`);r.PATH?.includes(e)||(r.PATH=`${e}${n===`win32`?`;`:`:`}${r.PATH||``}`,i?.(e));return}m=`exit code ${t}`}catch(e){m=e instanceof Error?e.message:String(e)}ht.prependPath(e),logWarningFn:t.logWarning});let i=s.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,i),!n.cache)return;let a=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!a.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`),a.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,a.cachePath),a.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,a.lockFile),a.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,a.lockType)}async function runFinalize(e=process.env,t=x){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n)});let i=t.parseRunInstall(n.runInstall),a=await t.setupSfw(i,{env:e,sfwEnabled:n.sfw,exportVariable:(e,n)=>{n!==void 0&&t.setVariable(e,n)}});i.length>0&&t.runInstall(i,r,a,e);let o=t.getCommandOutput(`vp`,[`--version`])||``;t.logInfo(o);let s=t.parseInstalledVpVersion(o);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,s)}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===o(s.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file +`,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const _=[`https://viteplus.dev/install.sh`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh`],v=[`https://viteplus.dev/install.ps1`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.ps1`],y=2e3,b=/^0\.0\.0-commit\.([0-9a-f]{40})$/i;function getVitePlusHome(e=process.platform){return u(e===`win32`?process.env.USERPROFILE||m():process.env.HOME||m(),`.vite-plus`)}function pkgPrNewCommitSha(e){return e.match(b)?.[1]}function runInstallCommand(e,t,n=process.platform){if(n===`win32`){let n=h(`pwsh`,[`-Command`,`& ([scriptblock]::Create((irm -TimeoutSec 15 ${e})))`],{env:{...process.env,...t},stdio:`inherit`});if(n.error)throw n.error;return n.status??1}let r=h(`bash`,[`-c`,`set -o pipefail; curl -fsSL --connect-timeout 5 --max-time 15 ${e} | bash`],{env:{...process.env,...t},stdio:`inherit`});if(r.error)throw r.error;return r.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??d,o=t.runInstall??runInstallCommand,s=t.logWarningFn??logWarning,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=pkgPrNewCommitSha(e);l&&(c.VP_PR_VERSION=l);let f=n===`win32`?v:_,p=2*f.length,m=``,h=0;for(let e=0;e<2;e+=1)for(let e of f){h+=1;try{let t=o(e,c,n);if(t===0){let e=u(getVitePlusHome(n),`bin`);r.PATH?.includes(e)||(r.PATH=`${e}${n===`win32`?`;`:`:`}${r.PATH||``}`,i?.(e));return}m=`exit code ${t}`}catch(e){m=e instanceof Error?e.message:String(e)}ht.prependPath(e),logWarningFn:t.logWarning});let i=s.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,i),!n.cache)return;let a=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!a.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`),a.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,a.cachePath),a.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,a.lockFile),a.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,a.lockType)}async function runFinalize(e=process.env,t=x){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n)});let i=t.parseRunInstall(n.runInstall),a=await t.setupSfw(i,{env:e,sfwEnabled:n.sfw,exportVariable:(e,n)=>{n!==void 0&&t.setVariable(e,n)}});i.length>0&&t.runInstall(i,r,a,e);let o=t.getCommandOutput(`vp`,[`--version`])||``;t.logInfo(o);let s=t.parseInstalledVpVersion(o);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,s)}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===o(s.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file diff --git a/src/azure/inputs.test.ts b/src/azure/inputs.test.ts index 13c61e9..0bebc37 100644 --- a/src/azure/inputs.test.ts +++ b/src/azure/inputs.test.ts @@ -30,6 +30,16 @@ describe("parseAzureInputs", () => { workspaceRoot: "/workspace", }); }); + + it("accepts Azure's title-cased boolean serialization", () => { + const inputs = parseAzureInputs({ + SETUP_VP_SFW: "True", + SETUP_VP_CACHE: "True", + }); + + expect(inputs.sfw).toBe(true); + expect(inputs.cache).toBe(true); + }); }); describe("resolveProjectDirFromInputs", () => { diff --git a/src/azure/inputs.ts b/src/azure/inputs.ts index fb321bc..1349806 100644 --- a/src/azure/inputs.ts +++ b/src/azure/inputs.ts @@ -13,15 +13,19 @@ export interface AzureInputs { workspaceRoot: string; } +function parseBoolean(value: string | undefined): boolean { + return value?.toLowerCase() === "true"; +} + export function parseAzureInputs(env: RuntimeEnv): AzureInputs { return { version: env.SETUP_VP_VERSION || "latest", workingDirectory: env.SETUP_VP_WORKING_DIRECTORY || ".", runInstall: env.SETUP_VP_RUN_INSTALL ?? "true", - sfw: env.SETUP_VP_SFW === "true", + sfw: parseBoolean(env.SETUP_VP_SFW), registryUrl: env.SETUP_VP_REGISTRY_URL || "", scope: env.SETUP_VP_SCOPE || "", - cache: env.SETUP_VP_CACHE === "true", + cache: parseBoolean(env.SETUP_VP_CACHE), cacheDependencyPath: env.SETUP_VP_CACHE_DEPENDENCY_PATH || "", workspaceRoot: env.SYSTEM_DEFAULTWORKINGDIRECTORY || process.cwd(), }; From 7807f4c54c9ed6ede3be5443c56f7690aeceffdc Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Sat, 11 Jul 2026 00:06:18 +0200 Subject: [PATCH 4/7] fix: serialize Azure boolean env values explicitly --- azure/setup-vp.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/azure/setup-vp.yml b/azure/setup-vp.yml index e8f4969..d8bbb99 100644 --- a/azure/setup-vp.yml +++ b/azure/setup-vp.yml @@ -50,11 +50,11 @@ steps: SETUP_VP_VERSION: ${{ parameters.version }} SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} - SETUP_VP_SFW: ${{ parameters.sfw }} + SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} SETUP_VP_SCOPE: ${{ parameters.scope }} SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} - SETUP_VP_CACHE: ${{ parameters.cache }} + SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)\setup-vp-azure\dist\azure\index.mjs @@ -72,11 +72,11 @@ steps: SETUP_VP_VERSION: ${{ parameters.version }} SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} - SETUP_VP_SFW: ${{ parameters.sfw }} + SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} SETUP_VP_SCOPE: ${{ parameters.scope }} SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} - SETUP_VP_CACHE: ${{ parameters.cache }} + SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs @@ -101,10 +101,10 @@ steps: SETUP_VP_VERSION: ${{ parameters.version }} SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} - SETUP_VP_SFW: ${{ parameters.sfw }} + SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} SETUP_VP_SCOPE: ${{ parameters.scope }} - SETUP_VP_CACHE: ${{ parameters.cache }} + SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} - ${{ if ne(variables['Agent.OS'], 'Windows_NT') }}: @@ -116,8 +116,8 @@ steps: SETUP_VP_VERSION: ${{ parameters.version }} SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} - SETUP_VP_SFW: ${{ parameters.sfw }} + SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} SETUP_VP_SCOPE: ${{ parameters.scope }} - SETUP_VP_CACHE: ${{ parameters.cache }} + SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} From f84c28dbd4c6a86eb1c1f8a768c633331f0182fe Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Sat, 11 Jul 2026 11:16:58 +0200 Subject: [PATCH 5/7] fix: select Azure shell at runtime --- azure/setup-vp.yml | 136 ++++++++++++++-------------- rfcs/azure-pipelines-integration.md | 5 +- src/azure/template.test.ts | 11 +++ 3 files changed, 82 insertions(+), 70 deletions(-) diff --git a/azure/setup-vp.yml b/azure/setup-vp.yml index d8bbb99..4dd15f4 100644 --- a/azure/setup-vp.yml +++ b/azure/setup-vp.yml @@ -37,48 +37,46 @@ steps: inputs: version: ${{ parameters.nodeVersion }} - - ${{ if eq(variables['Agent.OS'], 'Windows_NT') }}: - - powershell: | - $ErrorActionPreference = 'Stop' - $bootstrap = Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure-bootstrap.ps1' - $runtime = Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure-runtime.mjs' - $base = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/${{ parameters.setupRef }}/azure" - Invoke-WebRequest -Uri "$base/bootstrap.ps1" -OutFile $bootstrap -TimeoutSec 60 - & $bootstrap - displayName: setup-vp prepare (Windows) - env: - SETUP_VP_VERSION: ${{ parameters.version }} - SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} - SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} - SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} - SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} - SETUP_VP_SCOPE: ${{ parameters.scope }} - SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} - SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} - SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} - SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)\setup-vp-azure\dist\azure\index.mjs + - powershell: | + $ErrorActionPreference = 'Stop' + $bootstrap = Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure-bootstrap.ps1' + $base = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/${{ parameters.setupRef }}/azure" + Invoke-WebRequest -Uri "$base/bootstrap.ps1" -OutFile $bootstrap -TimeoutSec 60 + & $bootstrap + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + displayName: setup-vp prepare (Windows) + env: + SETUP_VP_VERSION: ${{ parameters.version }} + SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} + SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} + SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} + SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} + SETUP_VP_SCOPE: ${{ parameters.scope }} + SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} + SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} + SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} + SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)\setup-vp-azure\dist\azure\index.mjs - - ${{ if ne(variables['Agent.OS'], 'Windows_NT') }}: - - bash: | - set -euo pipefail - bootstrap="$(Agent.TempDirectory)/setup-vp-azure-bootstrap.sh" - runtime="$(Agent.TempDirectory)/setup-vp-azure-runtime.mjs" - base="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${{ parameters.setupRef }}/azure" - curl -fsSL --connect-timeout 5 --max-time 60 "$base/bootstrap.sh" -o "$bootstrap" - chmod +x "$bootstrap" - bash "$bootstrap" - displayName: setup-vp prepare (Unix) - env: - SETUP_VP_VERSION: ${{ parameters.version }} - SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} - SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} - SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} - SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} - SETUP_VP_SCOPE: ${{ parameters.scope }} - SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} - SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} - SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} - SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs + - bash: | + set -euo pipefail + bootstrap="$(Agent.TempDirectory)/setup-vp-azure-bootstrap.sh" + base="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${{ parameters.setupRef }}/azure" + curl -fsSL --connect-timeout 5 --max-time 60 "$base/bootstrap.sh" -o "$bootstrap" + chmod +x "$bootstrap" + bash "$bootstrap" + condition: and(succeeded(), ne(variables['Agent.OS'], 'Windows_NT')) + displayName: setup-vp prepare (Unix) + env: + SETUP_VP_VERSION: ${{ parameters.version }} + SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} + SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} + SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} + SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} + SETUP_VP_SCOPE: ${{ parameters.scope }} + SETUP_VP_SETUP_REF: ${{ parameters.setupRef }} + SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} + SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} + SETUP_VP_RUNTIME_OUT: $(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs - ${{ if eq(parameters.cache, true) }}: - task: Cache@2 @@ -92,32 +90,32 @@ steps: path: $(SETUP_VP_CACHE_PATH) cacheHitVar: SETUP_VP_CACHE_HIT - - ${{ if eq(variables['Agent.OS'], 'Windows_NT') }}: - - powershell: | - $ErrorActionPreference = 'Stop' - node "$(Agent.TempDirectory)\setup-vp-azure\dist\azure\index.mjs" finalize - displayName: setup-vp finalize (Windows) - env: - SETUP_VP_VERSION: ${{ parameters.version }} - SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} - SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} - SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} - SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} - SETUP_VP_SCOPE: ${{ parameters.scope }} - SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} - SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} + - powershell: | + $ErrorActionPreference = 'Stop' + node "$(Agent.TempDirectory)\setup-vp-azure\dist\azure\index.mjs" finalize + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + displayName: setup-vp finalize (Windows) + env: + SETUP_VP_VERSION: ${{ parameters.version }} + SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} + SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} + SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} + SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} + SETUP_VP_SCOPE: ${{ parameters.scope }} + SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} + SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} - - ${{ if ne(variables['Agent.OS'], 'Windows_NT') }}: - - bash: | - set -euo pipefail - node "$(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs" finalize - displayName: setup-vp finalize (Unix) - env: - SETUP_VP_VERSION: ${{ parameters.version }} - SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} - SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} - SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} - SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} - SETUP_VP_SCOPE: ${{ parameters.scope }} - SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} - SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} + - bash: | + set -euo pipefail + node "$(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs" finalize + condition: and(succeeded(), ne(variables['Agent.OS'], 'Windows_NT')) + displayName: setup-vp finalize (Unix) + env: + SETUP_VP_VERSION: ${{ parameters.version }} + SETUP_VP_WORKING_DIRECTORY: ${{ parameters.workingDirectory }} + SETUP_VP_RUN_INSTALL: ${{ convertToJson(parameters.runInstall) }} + SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} + SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} + SETUP_VP_SCOPE: ${{ parameters.scope }} + SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} + SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} diff --git a/rfcs/azure-pipelines-integration.md b/rfcs/azure-pipelines-integration.md index 227167e..bf8098d 100644 --- a/rfcs/azure-pipelines-integration.md +++ b/rfcs/azure-pipelines-integration.md @@ -66,7 +66,10 @@ The GitLab integration established the right pattern: a provider-native template Azure expands `azure/setup-vp.yml` from the external repository but does not check out bootstrap/runtime files onto the agent. Each prepare step downloads `azure/bootstrap.*` from `https://raw.githubusercontent.com/voidzero-dev/setup-vp//azure/` into `Agent.TempDirectory`, then executes it. The bootstrap downloads `dist/azure/index.mjs` and runs `node prepare`. -The template's finalize step reuses `SETUP_VP_RUNTIME_PATH` emitted during prepare. +The prepare and finalize steps use the same deterministic runtime path under +`$(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs`. The bootstrap +downloads the runtime and its generated sibling chunks into that directory, so +finalize does not depend on cross-step macro expansion of a runtime path. Pin `ref` and `setupRef` to the same immutable tag or commit SHA for strict reproducibility. diff --git a/src/azure/template.test.ts b/src/azure/template.test.ts index f7b9f34..fd4b91b 100644 --- a/src/azure/template.test.ts +++ b/src/azure/template.test.ts @@ -51,4 +51,15 @@ describe("azure/setup-vp.yml", () => { expect(template).toContain("bootstrap.sh"); expect(template).toContain("bootstrap.ps1"); }); + + it("selects the agent shell at runtime", () => { + expect(template).not.toContain("${{ if eq(variables['Agent.OS'], 'Windows_NT') }}"); + expect(template).not.toContain("${{ if ne(variables['Agent.OS'], 'Windows_NT') }}"); + expect(template).toContain( + "condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))", + ); + expect(template).toContain( + "condition: and(succeeded(), ne(variables['Agent.OS'], 'Windows_NT'))", + ); + }); }); From 9f5b303fd1a6e8d10e6f6021ed5467ea51b7a5ea Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Sat, 11 Jul 2026 17:50:23 +0200 Subject: [PATCH 6/7] fix: address setup-vp PR review feedback --- .github/workflows/test.yml | 6 ++- dist/azure/index.mjs | 4 +- dist/gitlab/index.mjs | 2 +- dist/project-BPhRkgx0.mjs | 1 - rfcs/azure-pipelines-integration.md | 8 ++-- src/azure/commands.test.ts | 3 +- src/azure/commands.ts | 2 +- src/portable-bundles.test.ts | 6 ++- vite.config.ts | 62 +++++++++++++++++++---------- 9 files changed, 61 insertions(+), 33 deletions(-) delete mode 100644 dist/project-BPhRkgx0.mjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 184b27a..efe87e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -671,8 +671,10 @@ jobs: - name: Verify Azure runtime invalid phase smoke test shell: bash run: | - node dist/azure/index.mjs invalid-phase - test $? -ne 0 + if node dist/azure/index.mjs invalid-phase; then + echo "::error::Azure runtime accepted an invalid phase" + exit 1 + fi - name: Verify bootstrap syntax (Unix) if: runner.os != 'Windows' diff --git a/dist/azure/index.mjs b/dist/azure/index.mjs index 329f76e..eddcdf6 100644 --- a/dist/azure/index.mjs +++ b/dist/azure/index.mjs @@ -1,2 +1,2 @@ -import{a as e,i as t,n,r,s as i,t as a}from"../project-BPhRkgx0.mjs";import{pathToFileURL as o}from"node:url";import s,{basename as c,isAbsolute as l,join as u}from"node:path";import{setTimeout as d}from"node:timers/promises";import{existsSync as f,readdirSync as p}from"node:fs";import{homedir as m}from"node:os";import{spawnSync as h}from"node:child_process";const g=[{filename:`pnpm-lock.yaml`,type:`pnpm`},{filename:`bun.lockb`,type:`bun`},{filename:`bun.lock`,type:`bun`},{filename:`package-lock.json`,type:`npm`},{filename:`npm-shrinkwrap.json`,type:`npm`},{filename:`yarn.lock`,type:`yarn`}];function resolvePath(e,t){return l(e)?e:u(t,e)}function inferLockFileType(e,t){return t.includes(`pnpm`)?{type:`pnpm`,path:e,filename:t}:t.includes(`yarn`)?{type:`yarn`,path:e,filename:t}:t.startsWith(`bun.`)?{type:`bun`,path:e,filename:t}:{type:`npm`,path:e,filename:t}}function detectLockFile(e,t){if(e){let n=resolvePath(e,t);if(f(n)){let e=c(n),t=g.find(t=>t.filename===e);return t?{type:t.type,path:n,filename:e}:inferLockFileType(n,e)}return}let n=p(t);for(let e of g)if(n.includes(e.filename)){let n=u(t,e.filename);return{type:e.type,path:n,filename:e.filename}}}function prepareCacheMetadata(t){let{projectDir:n,cacheDependencyPath:r,logWarning:i}=t,a=detectLockFile(r||void 0,n);if(!a)return i?.(`setup-vp: cache is enabled but no supported lock file was found; skipping cache.`),{ready:!1};let o=e(`vp`,[`pm`,`cache`,`dir`],{cwd:s.dirname(a.path)});return o?{ready:!0,cachePath:o,lockFile:a.path,lockType:a.type}:(i?.(`setup-vp: could not resolve package-manager cache directory for ${a.filename}; skipping cache.`),{ready:!1})}function parseInstalledVpVersion(e){return(e.match(/^\s*vp\s+v?(\d[^\s]*)/im)??e.match(/Global:\s*v?(\d[^\s]*)/i))?.[1]??`unknown`}function escapeLoggingCommandData(e){return e.replaceAll(`%`,`%25`).replaceAll(`\r`,`%0D`).replaceAll(` -`,`%0A`).replaceAll(`;`,`%3B`).replaceAll(`]`,`%5D`)}function prependPath(e){process.stdout.write(`##vso[task.prependpath]${escapeLoggingCommandData(e)}\n`)}function setVariable(e,t,n={}){let r=[`variable=${e}`];n.isOutput&&r.push(`isOutput=true`),n.isReadonly&&r.push(`isReadonly=true`),process.stdout.write(`##vso[task.setvariable ${r.join(`;`)}]${escapeLoggingCommandData(t)}\n`)}function logWarning(e){process.stdout.write(`##vso[task.logissue type=warning]${escapeLoggingCommandData(e)}\n`)}function logInfo(e){console.log(e)}const _=[`https://viteplus.dev/install.sh`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh`],v=[`https://viteplus.dev/install.ps1`,`https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.ps1`],y=2e3,b=/^0\.0\.0-commit\.([0-9a-f]{40})$/i;function getVitePlusHome(e=process.platform){return u(e===`win32`?process.env.USERPROFILE||m():process.env.HOME||m(),`.vite-plus`)}function pkgPrNewCommitSha(e){return e.match(b)?.[1]}function runInstallCommand(e,t,n=process.platform){if(n===`win32`){let n=h(`pwsh`,[`-Command`,`& ([scriptblock]::Create((irm -TimeoutSec 15 ${e})))`],{env:{...process.env,...t},stdio:`inherit`});if(n.error)throw n.error;return n.status??1}let r=h(`bash`,[`-c`,`set -o pipefail; curl -fsSL --connect-timeout 5 --max-time 15 ${e} | bash`],{env:{...process.env,...t},stdio:`inherit`});if(r.error)throw r.error;return r.status??1}async function installVitePlus(e,t={}){let n=t.platform??process.platform,r=t.env??process.env,i=t.prependPath,a=t.sleep??d,o=t.runInstall??runInstallCommand,s=t.logWarningFn??logWarning,c={...Object.fromEntries(Object.entries(r).filter(e=>e[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},l=pkgPrNewCommitSha(e);l&&(c.VP_PR_VERSION=l);let f=n===`win32`?v:_,p=2*f.length,m=``,h=0;for(let e=0;e<2;e+=1)for(let e of f){h+=1;try{let t=o(e,c,n);if(t===0){let e=u(getVitePlusHome(n),`bin`);r.PATH?.includes(e)||(r.PATH=`${e}${n===`win32`?`;`:`:`}${r.PATH||``}`,i?.(e));return}m=`exit code ${t}`}catch(e){m=e instanceof Error?e.message:String(e)}ht.prependPath(e),logWarningFn:t.logWarning});let i=s.resolve(process.argv[1]||``);if(t.setVariable(`SETUP_VP_RUNTIME_PATH`,i),!n.cache)return;let a=t.prepareCacheMetadata({projectDir:r,cacheDependencyPath:n.cacheDependencyPath||void 0,logWarning:t.logWarning});if(!a.ready){t.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}t.setVariable(`SETUP_VP_CACHE_READY`,`true`),a.cachePath&&t.setVariable(`SETUP_VP_CACHE_PATH`,a.cachePath),a.lockFile&&t.setVariable(`SETUP_VP_LOCK_FILE`,a.lockFile),a.lockType&&t.setVariable(`SETUP_VP_LOCK_TYPE`,a.lockType)}async function runFinalize(e=process.env,t=x){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n)});let i=t.parseRunInstall(n.runInstall),a=await t.setupSfw(i,{env:e,sfwEnabled:n.sfw,exportVariable:(e,n)=>{n!==void 0&&t.setVariable(e,n)}});i.length>0&&t.runInstall(i,r,a,e);let o=t.getCommandOutput(`vp`,[`--version`])||``;t.logInfo(o);let s=t.parseInstalledVpVersion(o);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,s)}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===o(s.resolve(e)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file +import e,{basename as t,isAbsolute as n,join as r}from"node:path";import{pathToFileURL as i}from"node:url";import{createWriteStream as a,existsSync as o,mkdtempSync as s,readdirSync as c,statSync as l,writeFileSync as u}from"node:fs";import{homedir as d,tmpdir as f}from"node:os";import{spawnSync as p}from"node:child_process";import{chmod as m,mkdtemp as h}from"node:fs/promises";import{get as g}from"node:http";import{get as _}from"node:https";import{setTimeout as v}from"node:timers/promises";function configureAuth(t,n,r,i){if(!t)return;let a;try{a=new URL(t)}catch{throw Error(`Invalid registry-url: "${t}". Must be a valid URL.`)}let o=a.href.endsWith(`/`)?a.href:`${a.href}/`,c=``;n&&(c=`${(n.startsWith(`@`)?n:`@${n}`).toLowerCase()}:`);let l=o.replace(/^\w+:/,``).toLowerCase(),d=s(e.join(f(),`setup-vp-npmrc-`)),p=e.join(d,`.npmrc`);return u(p,`${l}:_authToken=\${NODE_AUTH_TOKEN}\n${c}registry=${o}\n`,{encoding:`utf8`,mode:384}),r.NPM_CONFIG_USERCONFIG=p,r.PNPM_CONFIG_USERCONFIG=p,r.NODE_AUTH_TOKEN=r.NODE_AUTH_TOKEN||`XXXXX-XXXXX-XXXXX-XXXXX`,i?.(`NPM_CONFIG_USERCONFIG`,r.NPM_CONFIG_USERCONFIG),i?.(`PNPM_CONFIG_USERCONFIG`,r.PNPM_CONFIG_USERCONFIG),i?.(`NODE_AUTH_TOKEN`,r.NODE_AUTH_TOKEN),p}function run(e,t,n={}){let r=p(e,t,{stdio:`inherit`,...n});if(r.error)throw r.error;r.status!==0&&process.exit(r.status??1)}function commandPath(e){if(process.platform===`win32`){let t=p(`where`,[e],{encoding:`utf8`});return t.status===0&&t.stdout.trim().split(/\r?\n/)[0]?.trim()||void 0}let t=p(`sh`,[`-c`,`command -v "$1"`,`sh`,e],{encoding:`utf8`});if(t.status===0)return t.stdout.trim()}function getCommandOutput(e,t,n){let r=p(e,t,{cwd:n?.cwd,encoding:`utf8`,stdio:[`ignore`,`pipe`,`pipe`]});if(r.status===0)return r.stdout.trim()}const y=[{filename:`pnpm-lock.yaml`,type:`pnpm`},{filename:`bun.lockb`,type:`bun`},{filename:`bun.lock`,type:`bun`},{filename:`package-lock.json`,type:`npm`},{filename:`npm-shrinkwrap.json`,type:`npm`},{filename:`yarn.lock`,type:`yarn`}];function resolvePath(e,t){return n(e)?e:r(t,e)}function inferLockFileType(e,t){return t.includes(`pnpm`)?{type:`pnpm`,path:e,filename:t}:t.includes(`yarn`)?{type:`yarn`,path:e,filename:t}:t.startsWith(`bun.`)?{type:`bun`,path:e,filename:t}:{type:`npm`,path:e,filename:t}}function detectLockFile(e,n){if(e){let r=resolvePath(e,n);if(o(r)){let e=t(r),n=y.find(t=>t.filename===e);return n?{type:n.type,path:r,filename:e}:inferLockFileType(r,e)}return}let i=c(n);for(let e of y)if(i.includes(e.filename)){let t=r(n,e.filename);return{type:e.type,path:t,filename:e.filename}}}function prepareCacheMetadata(t){let{projectDir:n,cacheDependencyPath:r,logWarning:i}=t,a=detectLockFile(r||void 0,n);if(!a)return i?.(`setup-vp: cache is enabled but no supported lock file was found; skipping cache.`),{ready:!1};let o=getCommandOutput(`vp`,[`pm`,`cache`,`dir`],{cwd:e.dirname(a.path)});return o?{ready:!0,cachePath:o,lockFile:a.path,lockType:a.type}:(i?.(`setup-vp: could not resolve package-manager cache directory for ${a.filename}; skipping cache.`),{ready:!1})}const b=`v1.12.0`,x=`https://github.com/SocketDev/sfw-free/releases/download/${b}`;function isMuslLinux(){if(process.platform!==`linux`)return!1;try{let e=process.report?.getReport();if(e?.header&&!e.header.glibcVersionRuntime)return!0}catch{}return o(`/etc/alpine-release`)}function getSfwAssetName(e,t,n){if(e===`darwin`){if(t===`arm64`)return`sfw-free-macos-arm64`;if(t===`x64`)return`sfw-free-macos-x86_64`}else if(e===`linux`){if(t===`arm64`)return n?`sfw-free-musl-linux-arm64`:`sfw-free-linux-arm64`;if(t===`x64`)return n?`sfw-free-musl-linux-x86_64`:`sfw-free-linux-x86_64`}else if(e===`win32`){if(t===`arm64`)return`sfw-free-windows-arm64.exe`;if(t===`x64`)return`sfw-free-windows-x86_64.exe`}throw Error(`Unsupported platform/arch for sfw: ${e}/${t}${e===`linux`?` (${n?`musl`:`glibc`})`:``}`)}function downloadFileWithShell(e,t,n){let r=Math.max(1,Math.ceil(n/1e3)),i=commandPath(`curl`);if(i){let n=p(i,[`-fsSL`,`--connect-timeout`,`5`,`--max-time`,String(r),e,`-o`,t],{stdio:`ignore`});if(n.status===0)return;throw n.error||Error(`curl failed while downloading ${e}`)}let a=commandPath(`wget`);if(a){let n=p(a,[`-q`,`-T`,String(r),`-t`,`2`,`-O`,t,e],{stdio:`ignore`});if(n.status===0)return;throw n.error||Error(`wget failed while downloading ${e}`)}throw Error(`curl or wget is required to download sfw`)}function downloadFile(e,t,n=0,r=6e4,i){if(n>5)return Promise.reject(Error(`too many redirects while downloading ${e}`));if(!i)return downloadFileWithShell(e,t,r),Promise.resolve();let o=i||(e.startsWith(`https:`)?_:g);return new Promise((s,c)=>{let l=!1,finish=e=>{l||(l=!0,clearTimeout(d),e?c(e):s())},u=o(e,o=>{let s=o.statusCode??0,c=o.headers.location;if(s>=300&&s<400&&c){o.resume(),downloadFile(new URL(c,e).toString(),t,n+1,r,i).then(()=>finish(),finish);return}if(s!==200){o.resume(),finish(Error(`download failed with HTTP ${s}: ${e}`));return}let l=a(t);o.pipe(l),l.on(`finish`,()=>l.close(()=>finish())),l.on(`error`,finish)}),d=setTimeout(()=>{u.destroy(Error(`download timed out after ${r}ms: ${e}`))},r);u.on(`error`,finish)})}async function setupSfw(t,n={}){let r=n.env??process.env,i=n.sfwEnabled??r.SETUP_VP_SFW===`true`,a=n.platform??process.platform,o=n.arch??process.arch,s=n.isMusl??isMuslLinux(),c=n.download??downloadFile;if(!i)return`vp`;if(t.length===0)return console.log(`setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.`),`vp`;let l=commandPath(`sfw`);if(l)return console.log(`setup-vp: using existing sfw on PATH: ${l}`),`sfw`;let u;try{u=getSfwAssetName(a,o,s)}catch{u=void 0}if(!u)return console.error(`setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${a}, process.arch=${o}, musl=${s}) and none was found on PATH; falling back to plain vp install.`),`vp`;let d=await h(e.join(f(),`setup-vp-sfw-`)),p=e.join(d,a===`win32`?`sfw.exe`:`sfw`),g=`${x}/${u}`;for(let e=1;e<=2;e+=1)try{return console.log(`setup-vp: installing sfw ${b} from ${g}`),await c(g,p),await m(p,493),r.PATH=`${d}${a===`win32`?`;`:`:`}${r.PATH||``}`,n.exportVariable?.(`PATH`,r.PATH),`sfw`}catch(t){if(e===2)throw t;await new Promise(e=>setTimeout(e,2e3))}throw Error(`failed to install sfw after retrying`)}function parseScalar(e){let t=String(e||``).trim();return t.startsWith(`"`)&&t.endsWith(`"`)||t.startsWith(`'`)&&t.endsWith(`'`)?t.slice(1,-1):t}function parseFlowArray(e){let t=String(e||``).trim();if(!t.startsWith(`[`)||!t.endsWith(`]`))throw Error(`args must be an array, got: ${e}`);let n=t.slice(1,-1).trim();if(!n)return[];let r=[],i=``,a=``,o=!1,pushCurrent=()=>{if(!i.trim())throw Error(`args flow array entries must be non-empty strings`);r.push(parseScalar(i)),i=``,o=!0};for(let e of n){if(a){e===a&&(a=``),i+=e;continue}if(e===`'`||e===`"`){a=e,i+=e;continue}if(e===`,`){pushCurrent();continue}i+=e}if(a)throw Error(`unterminated quoted string in args flow array`);if(i.trim())pushCurrent();else if(!o)throw Error(`args flow array entries must be non-empty strings`);return r}function parseKeyValue(e){let t=e.indexOf(`:`);if(!(t<0))return[e.slice(0,t).trim(),e.slice(t+1).trim()]}function countIndent(e){return e.length-e.trimStart().length}function parseBlockArray(e,t,n){let r=[],i=t;for(;itypeof e!=`string`))throw Error(`run-install.args must be an array of strings`);t.args=e.args}return t}function validateRunInstallInput(e){return e===null||typeof e==`boolean`?e:Array.isArray(e)?e.map(validateRunInstallEntry):validateRunInstallEntry(e)}function applyEntryLine(e,t,n,r,i,a){let o=parseKeyValue(t);if(!o)throw Error(`invalid run-install line: ${a}`);if(assignValue(e,o[0],o[1])){let t=parseBlockArray(n,r+1,i);return e.args=t.values,t.nextIndex-1}return r}function parseObject(e){let t={};for(let n=0;ne.trim()&&!e.trim().startsWith(`#`));if(t.length===0)return[];if(!t[0].trimStart().startsWith(`-`))return[parseObject(t)];let n=countIndent(t[0]),r=[],i;for(let e=0;ee[1]!==void 0)),VP_VERSION:e,VITE_PLUS_VERSION:e},u=pkgPrNewCommitSha(e);u&&(l.VP_PR_VERSION=u);let d=n===`win32`?C:S,f=2*d.length,p=``,m=0;for(let e=0;e<2;e+=1)for(let e of d){m+=1;try{let t=s(e,l,n);if(t===0){let e=r(getVitePlusHome(n),`bin`);i.PATH?.includes(e)||(i.PATH=`${e}${n===`win32`?`;`:`:`}${i.PATH||``}`,a?.(e));return}p=`exit code ${t}`}catch(e){p=e instanceof Error?e.message:String(e)}mn.prependPath(e),logWarningFn:n.logWarning});let a=e.resolve(process.argv[1]||``);if(n.setVariable(`SETUP_VP_RUNTIME_PATH`,a),!r.cache)return;let o=n.prepareCacheMetadata({projectDir:i,cacheDependencyPath:r.cacheDependencyPath||void 0,logWarning:n.logWarning});if(!o.ready){n.setVariable(`SETUP_VP_CACHE_READY`,`false`);return}n.setVariable(`SETUP_VP_CACHE_READY`,`true`),o.cachePath&&n.setVariable(`SETUP_VP_CACHE_PATH`,o.cachePath),o.lockFile&&n.setVariable(`SETUP_VP_LOCK_FILE`,o.lockFile),o.lockType&&n.setVariable(`SETUP_VP_LOCK_TYPE`,o.lockType)}async function runFinalize(e=process.env,t=E){let n=parseAzureInputs(e),r=resolveProjectDirFromInputs(n);t.configureAuth(n.registryUrl,n.scope,e,(e,n)=>{e!==`NODE_AUTH_TOKEN`&&n!==void 0&&t.setVariable(e,n)});let i=t.parseRunInstall(n.runInstall),a=await t.setupSfw(i,{env:e,sfwEnabled:n.sfw,exportVariable:(e,n)=>{n!==void 0&&t.setVariable(e,n)}});i.length>0&&t.runInstall(i,r,a,e);let o=t.getCommandOutput(`vp`,[`--version`])||``;t.logInfo(o);let s=t.parseInstalledVpVersion(o);t.setVariable(`SETUP_VP_INSTALLED_VERSION`,s)}async function main(e){if(e===`prepare`){await runPrepare();return}if(e===`finalize`){await runFinalize();return}fail(`invalid phase "${String(e)}"; expected "prepare" or "finalize"`)}function isEntrypoint(t=process.argv[1],n=import.meta.url){return!!(t&&n===i(e.resolve(t)).href)}if(isEntrypoint()){let e=process.argv[2];e||fail(`missing phase argument; expected "prepare" or "finalize"`);try{await main(e)}catch(e){fail(e instanceof Error?e.message:String(e))}}export{isEntrypoint,main,runFinalize,runPrepare}; \ No newline at end of file diff --git a/dist/gitlab/index.mjs b/dist/gitlab/index.mjs index bf768b2..5151721 100644 --- a/dist/gitlab/index.mjs +++ b/dist/gitlab/index.mjs @@ -1 +1 @@ -import{i as e,n as t,o as n,r,s as i,t as a}from"../project-BPhRkgx0.mjs";import{pathToFileURL as o}from"node:url";import s from"node:path";import{writeFileSync as c}from"node:fs";function shellQuote(e){return`'${String(e).replaceAll(`'`,`'\\''`)}'`}function exportShellEnv(e,t,n=process.env){!n.SETUP_VP_ENV_FILE||t===void 0||c(n.SETUP_VP_ENV_FILE,`export ${e}=${shellQuote(t)}\n`,{encoding:`utf8`,flag:`a`})}function configureAuth(e,t,n=process.env){return i(e,t,n,n===process.env?(e,t)=>exportShellEnv(e,t,n):void 0)}async function setupSfw(t,n=process.env){return e(t,{env:n,exportVariable:(e,t)=>exportShellEnv(e,t,n)})}function resolveProjectDir(e=process.env){return a({workingDirectory:e.SETUP_VP_WORKING_DIRECTORY||`.`,workspaceRoot:e.CI_PROJECT_DIR||process.cwd()})}function fail(e){console.error(`setup-vp: ${e}`),process.exit(1)}async function main(){let e=resolveProjectDir(process.env);configureAuth(process.env.SETUP_VP_REGISTRY_URL||``,process.env.SETUP_VP_SCOPE||``);let i=t(process.env.SETUP_VP_RUN_INSTALL||`true`);r(i,e,await setupSfw(i)),n(`vp`,[`--version`])}function isEntrypoint(e=process.argv[1],t=import.meta.url){return!!(e&&t===o(s.resolve(e)).href)}if(isEntrypoint())try{await main()}catch(e){fail(e instanceof Error?e.message:String(e))}export{isEntrypoint,main}; \ No newline at end of file +import e from"node:path";import{pathToFileURL as t}from"node:url";import{createWriteStream as n,existsSync as r,mkdtempSync as i,statSync as a,writeFileSync as o}from"node:fs";import{tmpdir as s}from"node:os";import{spawnSync as c}from"node:child_process";import{chmod as l,mkdtemp as u}from"node:fs/promises";import{get as d}from"node:http";import{get as f}from"node:https";function configureAuth$1(t,n,r,a){if(!t)return;let c;try{c=new URL(t)}catch{throw Error(`Invalid registry-url: "${t}". Must be a valid URL.`)}let l=c.href.endsWith(`/`)?c.href:`${c.href}/`,u=``;n&&(u=`${(n.startsWith(`@`)?n:`@${n}`).toLowerCase()}:`);let d=l.replace(/^\w+:/,``).toLowerCase(),f=i(e.join(s(),`setup-vp-npmrc-`)),p=e.join(f,`.npmrc`);return o(p,`${d}:_authToken=\${NODE_AUTH_TOKEN}\n${u}registry=${l}\n`,{encoding:`utf8`,mode:384}),r.NPM_CONFIG_USERCONFIG=p,r.PNPM_CONFIG_USERCONFIG=p,r.NODE_AUTH_TOKEN=r.NODE_AUTH_TOKEN||`XXXXX-XXXXX-XXXXX-XXXXX`,a?.(`NPM_CONFIG_USERCONFIG`,r.NPM_CONFIG_USERCONFIG),a?.(`PNPM_CONFIG_USERCONFIG`,r.PNPM_CONFIG_USERCONFIG),a?.(`NODE_AUTH_TOKEN`,r.NODE_AUTH_TOKEN),p}function run(e,t,n={}){let r=c(e,t,{stdio:`inherit`,...n});if(r.error)throw r.error;r.status!==0&&process.exit(r.status??1)}function commandPath(e){if(process.platform===`win32`){let t=c(`where`,[e],{encoding:`utf8`});return t.status===0&&t.stdout.trim().split(/\r?\n/)[0]?.trim()||void 0}let t=c(`sh`,[`-c`,`command -v "$1"`,`sh`,e],{encoding:`utf8`});if(t.status===0)return t.stdout.trim()}function shellQuote(e){return`'${String(e).replaceAll(`'`,`'\\''`)}'`}function exportShellEnv(e,t,n=process.env){!n.SETUP_VP_ENV_FILE||t===void 0||o(n.SETUP_VP_ENV_FILE,`export ${e}=${shellQuote(t)}\n`,{encoding:`utf8`,flag:`a`})}function configureAuth(e,t,n=process.env){return configureAuth$1(e,t,n,n===process.env?(e,t)=>exportShellEnv(e,t,n):void 0)}const p=`v1.12.0`,m=`https://github.com/SocketDev/sfw-free/releases/download/${p}`;function isMuslLinux(){if(process.platform!==`linux`)return!1;try{let e=process.report?.getReport();if(e?.header&&!e.header.glibcVersionRuntime)return!0}catch{}return r(`/etc/alpine-release`)}function getSfwAssetName(e,t,n){if(e===`darwin`){if(t===`arm64`)return`sfw-free-macos-arm64`;if(t===`x64`)return`sfw-free-macos-x86_64`}else if(e===`linux`){if(t===`arm64`)return n?`sfw-free-musl-linux-arm64`:`sfw-free-linux-arm64`;if(t===`x64`)return n?`sfw-free-musl-linux-x86_64`:`sfw-free-linux-x86_64`}else if(e===`win32`){if(t===`arm64`)return`sfw-free-windows-arm64.exe`;if(t===`x64`)return`sfw-free-windows-x86_64.exe`}throw Error(`Unsupported platform/arch for sfw: ${e}/${t}${e===`linux`?` (${n?`musl`:`glibc`})`:``}`)}function downloadFileWithShell(e,t,n){let r=Math.max(1,Math.ceil(n/1e3)),i=commandPath(`curl`);if(i){let n=c(i,[`-fsSL`,`--connect-timeout`,`5`,`--max-time`,String(r),e,`-o`,t],{stdio:`ignore`});if(n.status===0)return;throw n.error||Error(`curl failed while downloading ${e}`)}let a=commandPath(`wget`);if(a){let n=c(a,[`-q`,`-T`,String(r),`-t`,`2`,`-O`,t,e],{stdio:`ignore`});if(n.status===0)return;throw n.error||Error(`wget failed while downloading ${e}`)}throw Error(`curl or wget is required to download sfw`)}function downloadFile(e,t,r=0,i=6e4,a){if(r>5)return Promise.reject(Error(`too many redirects while downloading ${e}`));if(!a)return downloadFileWithShell(e,t,i),Promise.resolve();let o=a||(e.startsWith(`https:`)?f:d);return new Promise((s,c)=>{let l=!1,finish=e=>{l||(l=!0,clearTimeout(d),e?c(e):s())},u=o(e,o=>{let s=o.statusCode??0,c=o.headers.location;if(s>=300&&s<400&&c){o.resume(),downloadFile(new URL(c,e).toString(),t,r+1,i,a).then(()=>finish(),finish);return}if(s!==200){o.resume(),finish(Error(`download failed with HTTP ${s}: ${e}`));return}let l=n(t);o.pipe(l),l.on(`finish`,()=>l.close(()=>finish())),l.on(`error`,finish)}),d=setTimeout(()=>{u.destroy(Error(`download timed out after ${i}ms: ${e}`))},i);u.on(`error`,finish)})}async function setupSfw$1(t,n={}){let r=n.env??process.env,i=n.sfwEnabled??r.SETUP_VP_SFW===`true`,a=n.platform??process.platform,o=n.arch??process.arch,c=n.isMusl??isMuslLinux(),d=n.download??downloadFile;if(!i)return`vp`;if(t.length===0)return console.log(`setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.`),`vp`;let f=commandPath(`sfw`);if(f)return console.log(`setup-vp: using existing sfw on PATH: ${f}`),`sfw`;let h;try{h=getSfwAssetName(a,o,c)}catch{h=void 0}if(!h)return console.error(`setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${a}, process.arch=${o}, musl=${c}) and none was found on PATH; falling back to plain vp install.`),`vp`;let g=await u(e.join(s(),`setup-vp-sfw-`)),_=e.join(g,a===`win32`?`sfw.exe`:`sfw`),v=`${m}/${h}`;for(let e=1;e<=2;e+=1)try{return console.log(`setup-vp: installing sfw ${p} from ${v}`),await d(v,_),await l(_,493),r.PATH=`${g}${a===`win32`?`;`:`:`}${r.PATH||``}`,n.exportVariable?.(`PATH`,r.PATH),`sfw`}catch(t){if(e===2)throw t;await new Promise(e=>setTimeout(e,2e3))}throw Error(`failed to install sfw after retrying`)}async function setupSfw(e,t=process.env){return setupSfw$1(e,{env:t,exportVariable:(e,n)=>exportShellEnv(e,n,t)})}function parseScalar(e){let t=String(e||``).trim();return t.startsWith(`"`)&&t.endsWith(`"`)||t.startsWith(`'`)&&t.endsWith(`'`)?t.slice(1,-1):t}function parseFlowArray(e){let t=String(e||``).trim();if(!t.startsWith(`[`)||!t.endsWith(`]`))throw Error(`args must be an array, got: ${e}`);let n=t.slice(1,-1).trim();if(!n)return[];let r=[],i=``,a=``,o=!1,pushCurrent=()=>{if(!i.trim())throw Error(`args flow array entries must be non-empty strings`);r.push(parseScalar(i)),i=``,o=!0};for(let e of n){if(a){e===a&&(a=``),i+=e;continue}if(e===`'`||e===`"`){a=e,i+=e;continue}if(e===`,`){pushCurrent();continue}i+=e}if(a)throw Error(`unterminated quoted string in args flow array`);if(i.trim())pushCurrent();else if(!o)throw Error(`args flow array entries must be non-empty strings`);return r}function parseKeyValue(e){let t=e.indexOf(`:`);if(!(t<0))return[e.slice(0,t).trim(),e.slice(t+1).trim()]}function countIndent(e){return e.length-e.trimStart().length}function parseBlockArray(e,t,n){let r=[],i=t;for(;itypeof e!=`string`))throw Error(`run-install.args must be an array of strings`);t.args=e.args}return t}function validateRunInstallInput(e){return e===null||typeof e==`boolean`?e:Array.isArray(e)?e.map(validateRunInstallEntry):validateRunInstallEntry(e)}function applyEntryLine(e,t,n,r,i,a){let o=parseKeyValue(t);if(!o)throw Error(`invalid run-install line: ${a}`);if(assignValue(e,o[0],o[1])){let t=parseBlockArray(n,r+1,i);return e.args=t.values,t.nextIndex-1}return r}function parseObject(e){let t={};for(let n=0;ne.trim()&&!e.trim().startsWith(`#`));if(t.length===0)return[];if(!t[0].trimStart().startsWith(`-`))return[parseObject(t)];let n=countIndent(t[0]),r=[],i;for(let e=0;e5)return Promise.reject(Error(`too many redirects while downloading ${t}`));if(!o)return downloadFileWithShell(t,r,a),Promise.resolve();let s=o||(t.startsWith(`https:`)?c:e);return new Promise((e,c)=>{let l=!1,finish=t=>{l||(l=!0,clearTimeout(d),t?c(t):e())},u=s(t,e=>{let s=e.statusCode??0,c=e.headers.location;if(s>=300&&s<400&&c){e.resume(),downloadFile(new URL(c,t).toString(),r,i+1,a,o).then(()=>finish(),finish);return}if(s!==200){e.resume(),finish(Error(`download failed with HTTP ${s}: ${t}`));return}let l=n(r);e.pipe(l),l.on(`finish`,()=>l.close(()=>finish())),l.on(`error`,finish)}),d=setTimeout(()=>{u.destroy(Error(`download timed out after ${a}ms: ${t}`))},a);u.on(`error`,finish)})}async function setupSfw(e,n={}){let r=n.env??process.env,i=n.sfwEnabled??r.SETUP_VP_SFW===`true`,a=n.platform??process.platform,o=n.arch??process.arch,c=n.isMusl??isMuslLinux(),l=n.download??downloadFile;if(!i)return`vp`;if(e.length===0)return console.log(`setup-vp: sfw was requested but run-install is disabled; sfw will not be invoked.`),`vp`;let m=commandPath(`sfw`);if(m)return console.log(`setup-vp: using existing sfw on PATH: ${m}`),`sfw`;let h;try{h=getSfwAssetName(a,o,c)}catch{h=void 0}if(!h)return console.error(`setup-vp: sfw has no published binary for this runner's platform/architecture (process.platform=${a}, process.arch=${o}, musl=${c}) and none was found on PATH; falling back to plain vp install.`),`vp`;let g=await d(t.join(s(),`setup-vp-sfw-`)),_=t.join(g,a===`win32`?`sfw.exe`:`sfw`),v=`${p}/${h}`;for(let e=1;e<=2;e+=1)try{return console.log(`setup-vp: installing sfw ${f} from ${v}`),await l(v,_),await u(_,493),r.PATH=`${g}${a===`win32`?`;`:`:`}${r.PATH||``}`,n.exportVariable?.(`PATH`,r.PATH),`sfw`}catch(t){if(e===2)throw t;await new Promise(e=>setTimeout(e,2e3))}throw Error(`failed to install sfw after retrying`)}function parseScalar(e){let t=String(e||``).trim();return t.startsWith(`"`)&&t.endsWith(`"`)||t.startsWith(`'`)&&t.endsWith(`'`)?t.slice(1,-1):t}function parseFlowArray(e){let t=String(e||``).trim();if(!t.startsWith(`[`)||!t.endsWith(`]`))throw Error(`args must be an array, got: ${e}`);let n=t.slice(1,-1).trim();if(!n)return[];let r=[],i=``,a=``,o=!1,pushCurrent=()=>{if(!i.trim())throw Error(`args flow array entries must be non-empty strings`);r.push(parseScalar(i)),i=``,o=!0};for(let e of n){if(a){e===a&&(a=``),i+=e;continue}if(e===`'`||e===`"`){a=e,i+=e;continue}if(e===`,`){pushCurrent();continue}i+=e}if(a)throw Error(`unterminated quoted string in args flow array`);if(i.trim())pushCurrent();else if(!o)throw Error(`args flow array entries must be non-empty strings`);return r}function parseKeyValue(e){let t=e.indexOf(`:`);if(!(t<0))return[e.slice(0,t).trim(),e.slice(t+1).trim()]}function countIndent(e){return e.length-e.trimStart().length}function parseBlockArray(e,t,n){let r=[],i=t;for(;itypeof e!=`string`))throw Error(`run-install.args must be an array of strings`);t.args=e.args}return t}function validateRunInstallInput(e){return e===null||typeof e==`boolean`?e:Array.isArray(e)?e.map(validateRunInstallEntry):validateRunInstallEntry(e)}function applyEntryLine(e,t,n,r,i,a){let o=parseKeyValue(t);if(!o)throw Error(`invalid run-install line: ${a}`);if(assignValue(e,o[0],o[1])){let t=parseBlockArray(n,r+1,i);return e.args=t.values,t.nextIndex-1}return r}function parseObject(e){let t={};for(let n=0;ne.trim()&&!e.trim().startsWith(`#`));if(t.length===0)return[];if(!t[0].trimStart().startsWith(`-`))return[parseObject(t)];let n=countIndent(t[0]),r=[],i;for(let e=0;e/azure/` into `Agent.TempDirectory`, then executes it. The bootstrap downloads `dist/azure/index.mjs` and runs `node prepare`. The prepare and finalize steps use the same deterministic runtime path under -`$(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs`. The bootstrap -downloads the runtime and its generated sibling chunks into that directory, so -finalize does not depend on cross-step macro expansion of a runtime path. +`$(Agent.TempDirectory)/setup-vp-azure/dist/azure/index.mjs`. The runtime is +built as a standalone entrypoint, so downloading that file is sufficient. The +bootstrap also recognizes generated sibling chunks for compatibility with older +refs, and places them beside the runtime when present; finalize therefore does +not depend on cross-step macro expansion of a runtime path. Pin `ref` and `setupRef` to the same immutable tag or commit SHA for strict reproducibility. diff --git a/src/azure/commands.test.ts b/src/azure/commands.test.ts index b6c4a6d..aa43abe 100644 --- a/src/azure/commands.test.ts +++ b/src/azure/commands.test.ts @@ -5,11 +5,12 @@ describe("escapeLoggingCommandData", () => { it("escapes characters that could terminate logging commands", () => { expect(escapeLoggingCommandData("plain/path")).toBe("plain/path"); expect(escapeLoggingCommandData("C:\\Users\\dev")).toBe("C:\\Users\\dev"); - expect(escapeLoggingCommandData("a%b")).toBe("a%25b"); + expect(escapeLoggingCommandData("a%b")).toBe("a%AZP25b"); expect(escapeLoggingCommandData("a;b")).toBe("a%3Bb"); expect(escapeLoggingCommandData("a]b")).toBe("a%5Db"); expect(escapeLoggingCommandData("a\nb")).toBe("a%0Ab"); expect(escapeLoggingCommandData("a\rb")).toBe("a%0Db"); + expect(escapeLoggingCommandData("a%;\n]b")).toBe("a%AZP25%3B%0A%5Db"); }); }); diff --git a/src/azure/commands.ts b/src/azure/commands.ts index 5963916..5e72b2d 100644 --- a/src/azure/commands.ts +++ b/src/azure/commands.ts @@ -4,7 +4,7 @@ */ export function escapeLoggingCommandData(value: string): string { return value - .replaceAll("%", "%25") + .replaceAll("%", "%AZP25") .replaceAll("\r", "%0D") .replaceAll("\n", "%0A") .replaceAll(";", "%3B") diff --git a/src/portable-bundles.test.ts b/src/portable-bundles.test.ts index 77e80c3..bed7a38 100644 --- a/src/portable-bundles.test.ts +++ b/src/portable-bundles.test.ts @@ -16,14 +16,18 @@ describe("portable CI bundles", () => { const gitlab = readFileSync(gitlabDist, "utf8"); expect(azure).not.toContain("@actions/"); expect(gitlab).not.toContain("@actions/"); + expect(azure).not.toMatch(/\bfrom["']\.\.\//); + expect(gitlab).not.toMatch(/\bfrom["']\.\.\//); expect(readFileSync(actionDist, "utf8")).toContain("@actions/"); }); it("fails invalid azure phase with a controlled message", () => { const result = spawnSync(process.execPath, [azureDist, "invalid-phase"], { encoding: "utf8", + timeout: 10_000, }); + expect(result.error).toBeUndefined(); expect(result.status).not.toBe(0); expect(result.stderr).toContain('invalid phase "invalid-phase"'); - }); + }, 15_000); }); diff --git a/vite.config.ts b/vite.config.ts index 1336dbb..c41edd3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,30 +7,50 @@ export default defineConfig({ staged: { "*": "vp check --fix", }, - pack: { - entry: { - index: "./src/index.ts", - "gitlab/index": "./src/gitlab/index.ts", - "azure/index": "./src/azure/index.ts", + // Build each entry independently so the portable CI runtimes remain + // standalone files. A shared chunk would not be present when GitLab or + // Azure downloads only its entrypoint from raw.githubusercontent.com. + pack: [ + { + entry: { index: "./src/index.ts" }, + format: ["esm"], + outDir: "dist", + deps: { alwaysBundle: [/.*/], onlyBundle: false }, + clean: true, + minify: { + compress: true, + mangle: { keepNames: { function: true, class: true } }, + }, }, - format: ["esm"], - outDir: "dist", - deps: { - alwaysBundle: [/.*/], - onlyBundle: false, + { + entry: { "gitlab/index": "./src/gitlab/index.ts" }, + format: ["esm"], + outDir: "dist", + deps: { alwaysBundle: [/.*/], onlyBundle: false }, + clean: false, + minify: { + compress: true, + mangle: { keepNames: { function: true, class: true } }, + }, }, - clean: true, - // Keep class/function names during minification. Bundled deps such as - // @actions/cache branch on `error.name === SomeError.name` (e.g. - // ReserveCacheError). Plain `minify: true` mangles the class binding, so - // `SomeError.name` becomes the mangled identifier and never matches the - // instance's preserved `this.name` literal — routing benign errors (like a - // cache reserve race in a build matrix) to core.warning instead of core.info. - minify: { - compress: true, - mangle: { keepNames: { function: true, class: true } }, + { + entry: { "azure/index": "./src/azure/index.ts" }, + format: ["esm"], + outDir: "dist", + deps: { alwaysBundle: [/.*/], onlyBundle: false }, + clean: false, + minify: { + compress: true, + mangle: { keepNames: { function: true, class: true } }, + }, }, - }, + ], + // Keep class/function names during minification. Bundled deps such as + // @actions/cache branch on `error.name === SomeError.name` (e.g. + // ReserveCacheError). Plain `minify: true` mangles the class binding, so + // `SomeError.name` becomes the mangled identifier and never matches the + // instance's preserved `this.name` literal — routing benign errors (like a + // cache reserve race in a build matrix) to core.warning instead of core.info. lint: { ignorePatterns: ["dist/**/*"], options: { From 6b7c66340585150daad7f385c3d139af4960b84a Mon Sep 17 00:00:00 2001 From: Kevin Beier Date: Sat, 11 Jul 2026 19:38:18 +0200 Subject: [PATCH 7/7] fix: pass Azure registry auth tokens to finalize --- README.md | 2 +- azure/setup-vp.yml | 2 ++ rfcs/azure-pipelines-integration.md | 4 ++++ src/azure/template.test.ts | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 94f9f6f..5d16566 100644 --- a/README.md +++ b/README.md @@ -554,7 +554,7 @@ Pin `ref` and `setupRef` to the same tag or commit SHA for strict reproducibilit | `SETUP_VP_INSTALLED_VERSION` | Installed global Vite+ version (`unknown` when parsing fails). | | `SETUP_VP_CACHE_HIT` | `true`, `inexact`, or `false` from `Cache@2` when caching is enabled. | -`vp`, `NPM_CONFIG_USERCONFIG`, and `PNPM_CONFIG_USERCONFIG` are available to later steps in the same job. Map `NODE_AUTH_TOKEN` into later steps when private registry auth is required. +`vp`, `NPM_CONFIG_USERCONFIG`, and `PNPM_CONFIG_USERCONFIG` are available to later steps in the same job. Define `NODE_AUTH_TOKEN` as an Azure secret pipeline variable when private registry auth is required; the template maps it into both finalize tasks. ### Azure Notes diff --git a/azure/setup-vp.yml b/azure/setup-vp.yml index 4dd15f4..76d8dfb 100644 --- a/azure/setup-vp.yml +++ b/azure/setup-vp.yml @@ -102,6 +102,7 @@ steps: SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} SETUP_VP_SCOPE: ${{ parameters.scope }} + NODE_AUTH_TOKEN: $(NODE_AUTH_TOKEN) SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} @@ -117,5 +118,6 @@ steps: SETUP_VP_SFW: ${{ iif(eq(parameters.sfw, true), 'true', 'false') }} SETUP_VP_REGISTRY_URL: ${{ parameters.registryUrl }} SETUP_VP_SCOPE: ${{ parameters.scope }} + NODE_AUTH_TOKEN: $(NODE_AUTH_TOKEN) SETUP_VP_CACHE: ${{ iif(eq(parameters.cache, true), 'true', 'false') }} SETUP_VP_CACHE_DEPENDENCY_PATH: ${{ parameters.cacheDependencyPath }} diff --git a/rfcs/azure-pipelines-integration.md b/rfcs/azure-pipelines-integration.md index bbb2045..7314c00 100644 --- a/rfcs/azure-pipelines-integration.md +++ b/rfcs/azure-pipelines-integration.md @@ -108,6 +108,10 @@ Provider-visible variables: | `SETUP_VP_INSTALLED_VERSION` | Installed global Vite+ version | | `SETUP_VP_CACHE_HIT` | `true`, `inexact`, or `false` from `Cache@2` | +For private registries, consumers define `NODE_AUTH_TOKEN` as an Azure secret +pipeline variable. Both finalize branches explicitly map that secret into the +runtime environment before configuring registry authentication. + ## GitHub/GitLab/Azure Parity | Capability | GitHub Action | GitLab template | Azure template | diff --git a/src/azure/template.test.ts b/src/azure/template.test.ts index fd4b91b..409d6fe 100644 --- a/src/azure/template.test.ts +++ b/src/azure/template.test.ts @@ -44,6 +44,7 @@ describe("azure/setup-vp.yml", () => { expect(template).not.toMatch(/setup-vp\/main\//); expect(template).toContain("$(SETUP_VP_LOCK_FILE)"); expect(template).toContain("cacheHitVar: SETUP_VP_CACHE_HIT"); + expect(template.match(/NODE_AUTH_TOKEN: \$\(NODE_AUTH_TOKEN\)/g) ?? []).toHaveLength(2); }); it("includes Unix and Windows branches", () => {