Skip to content
Draft
134 changes: 132 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# setup-vp

GitHub Action to set up [Vite+](https://viteplus.dev) (`vp`) with dependency caching support.
GitHub Action and GitLab CI/CD remote template to set up [Vite+](https://viteplus.dev) (`vp`).

## Features

Expand All @@ -10,6 +10,7 @@ GitHub Action to set up [Vite+](https://viteplus.dev) (`vp`) with dependency cac
- Optionally run `vp install` after setup
- 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

## Usage

Expand Down Expand Up @@ -253,6 +254,135 @@ When `working-directory` is set, lockfile auto-detection runs in that directory.

When `cache-dependency-path` points to a lock file in a subdirectory, the action resolves the package-manager cache directory from that lock file's directory.

## GitLab CI/CD

setup-vp also provides a GitLab CI/CD remote template hosted from this GitHub repository. Because this repository is not a GitLab CI/CD component project, GitLab users should load it with `include:remote` instead of `include:component`.

See [GitLab integration notes](rfcs/gitlab-integration.md) for the design background, constraints, and follow-up work.

### Basic GitLab Usage

```yaml
include:
- remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml"

test:
extends: .setup-vp
image: node:24
script:
- vp run test
```

### With GitLab Inputs

```yaml
include:
- remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml"
inputs:
version: "latest"
working-directory: "web"
run-install: "true"

test:
extends: .setup-vp
image: node:24
script:
- vp run test
```

### With Pinned GitLab Runtime

When using an immutable tag or commit SHA, pin `setup-ref` to the same ref so the bootstrap and compiled runtime are downloaded from the same version as the included template:

```yaml
include:
- remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1.0.0/gitlab/setup-vp.yml"
inputs:
setup-ref: "v1.0.0"

test:
extends: .setup-vp
image: node:24
script:
- vp run test
```

### Advanced GitLab Run Install

```yaml
include:
- remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml"
inputs:
run-install: |
- cwd: ./packages/app
args: ['--frozen-lockfile']
- cwd: ./packages/lib

test:
extends: .setup-vp
image: node:24
script:
- vp run test
```

### With GitLab Socket Firewall Free (sfw)

```yaml
include:
- remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml"
inputs:
sfw: true
run-install: "true"

test:
extends: .setup-vp
image: node:24
script:
- vp run test
```

### With Private Registry

Pass `NODE_AUTH_TOKEN` as a GitLab CI/CD variable and set `registry-url` when the job needs an authenticated npm registry:

```yaml
include:
- remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml"
inputs:
registry-url: "https://npm.pkg.github.com"
scope: "@myorg"

test:
extends: .setup-vp
image: node:24
variables:
NODE_AUTH_TOKEN: "$NPM_TOKEN"
script:
- vp run test
```

### GitLab Inputs

| Input | Description | Default |
| ------------------- | ------------------------------------------------------------------------------------------------ | -------- |
| `version` | Version of Vite+ to install | `latest` |
| `working-directory` | Project directory used for relative paths and default `vp install` execution | `.` |
| `run-install` | Run `vp install` after setup. Accepts boolean or YAML object with `cwd`/`args` | `true` |
| `sfw` | Wrap `vp install` with [Socket Firewall Free](https://docs.socket.dev/docs/socket-firewall-free) | `false` |
| `registry-url` | Optional registry URL to write to a temporary `.npmrc` | |
| `scope` | Optional scope for authenticating against scoped registries | |
| `setup-ref` | setup-vp ref used to download the GitLab bootstrap and compiled runtime | `v1` |

### GitLab Notes

- Use a tag such as `v1` or `v1.0.0` in the remote URL instead of `main`.
- Pin `setup-ref` to the same tag or commit SHA as the remote URL when strict reproducibility is required.
- GitLab 17.9+ users can add `integrity` to pin the remote file hash.
- The template expects a Unix-like runner image with Node.js, `bash`, and either `curl` or `wget`.
- The GitLab runtime source is TypeScript under `src/gitlab/`, but the template downloads and runs the `vp pack` generated JavaScript bundle from `dist/gitlab/index.mjs`.
- 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.

## Example Workflow

```yaml
Expand Down Expand Up @@ -309,7 +439,7 @@ vp install
### Before Committing

- Run `vp run check:fix` and `vp run build`
- The `dist/index.mjs` must be committed (it's the compiled action entry point)
- Generated files under `dist/` must be committed, including `dist/index.mjs` for the GitHub Action and `dist/gitlab/index.mjs` for the GitLab template
- Pre-commit hooks (via husky + lint-staged) will automatically run `vp check --fix` on staged files via `vpx lint-staged`

## Feedback
Expand Down
1 change: 1 addition & 0 deletions dist/gitlab/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +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,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 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 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){let t=l(`sh`,[`-c`,`command -v "${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 a=i.href.endsWith(`/`)?i.href:`${i.href}/`,c=``;t&&(c=`${(t.startsWith(`@`)?t:`@${t}`).toLowerCase()}:`);let l=a.replace(/^\w+:/,``).toLowerCase(),u=n.join(s(),`setup-vp-npmrc.${process.pid}`);return o(u,`${l}:_authToken=\${NODE_AUTH_TOKEN}\n${c}registry=${a}\n`,`utf8`),r.NPM_CONFIG_USERCONFIG=u,r.PNPM_CONFIG_USERCONFIG=u,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)),u}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 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 sfwAssetName(){try{return getSfwAssetName(process.platform,process.arch,isMuslLinux())}catch{return}}function sfwEnvironmentDescription(){return`process.platform=${process.platform}, process.arch=${process.arch}, musl=${isMuslLinux()}`}function downloadFile(t,n,i=0){if(i>5)return Promise.reject(Error(`too many redirects while downloading ${t}`));let a=t.startsWith(`https:`)?c:e;return new Promise((e,o)=>{a(t,a=>{let s=a.statusCode??0,c=a.headers.location;if(s>=300&&s<400&&c){a.resume(),downloadFile(new URL(c,t).toString(),n,i+1).then(()=>e(),o);return}if(s!==200){a.resume(),o(Error(`download failed with HTTP ${s}: ${t}`));return}let l=r(n);a.pipe(l),l.on(`finish`,()=>l.close(()=>e())),l.on(`error`,o)}).on(`error`,o)})}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=sfwAssetName();if(!i)return console.error(`setup-vp: sfw has no published binary for this runner's platform/architecture (${sfwEnvironmentDescription()}) and none was found on PATH; falling back to plain vp install.`),`vp`;let a=await d(n.join(s(),`setup-vp-sfw-`)),o=n.join(a,`sfw`),c=`${p}/${i}`;for(let e=1;e<=2;e+=1)try{return console.log(`setup-vp: installing sfw ${f} from ${c}`),await downloadFile(c,o),await u(o,493),t.PATH=`${a}:${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=``;for(let e of n){if(a){e===a&&(a=``),i+=e;continue}if(e===`'`||e===`"`){a=e,i+=e;continue}if(e===`,`){r.push(parseScalar(i)),i=``;continue}i+=e}return i.trim()&&r.push(parseScalar(i)),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(;i<e.length;){let t=e[i],a=countIndent(t),o=t.trimStart();if(a<=n)break;if(!o.startsWith(`-`))throw Error(`invalid args line: ${t}`);let s=o.slice(1).trim();if(!s)throw Error(`args entries must be strings: ${t}`);r.push(parseScalar(s)),i+=1}if(r.length===0)throw Error(`args must be an array`);return{values:r,nextIndex:i}}function assignValue(e,t,n){if(t===`cwd`)return e.cwd=parseScalar(n),!1;if(t===`args`)return n?(e.args=parseFlowArray(n),!1):!0;throw Error(`unsupported run-install key: ${t}`)}function isRecord(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function validateRunInstallEntry(e){if(!isRecord(e))throw Error(`run-install entries must be objects`);for(let t of Object.keys(e))if(t!==`cwd`&&t!==`args`)throw Error(`unsupported run-install key: ${t}`);let t={};if(e.cwd!==void 0){if(typeof e.cwd!=`string`)throw Error(`run-install.cwd must be a string`);t.cwd=e.cwd}if(e.args!==void 0){if(!Array.isArray(e.args)||e.args.some(e=>typeof 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 parseObject(e){let t={};for(let n=0;n<e.length;n+=1){let r=e[n],i=r.trim();if(!i||i.startsWith(`#`))continue;let a=parseKeyValue(i);if(!a)throw Error(`invalid run-install line: ${r}`);if(assignValue(t,a[0],a[1])){let i=parseBlockArray(e,n+1,countIndent(r));t.args=i.values,n=i.nextIndex-1}}return t}function parseYamlSubset(e){let t=e.split(/\r?\n/).filter(e=>e.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<t.length;e+=1){let a=t[e],o=countIndent(a),s=a.trimStart();if(o===n&&s.startsWith(`-`)){i&&r.push(i),i={};let n=s.slice(1).trim();if(n){let r=parseKeyValue(n);if(!r)throw Error(`invalid run-install line: ${a}`);if(assignValue(i,r[0],r[1])){let n=parseBlockArray(t,e+1,o);i.args=n.values,e=n.nextIndex-1}}continue}if(!i)throw Error(`invalid run-install line: ${a}`);let c=parseKeyValue(s);if(!c)throw Error(`invalid run-install line: ${a}`);if(assignValue(i,c[0],c[1])){let n=parseBlockArray(t,e+1,o);i.args=n.values,e=n.nextIndex-1}}return i&&r.push(i),r}function parseRunInstall(e){let t=String(e||``).trim();return t?normalizeRunInstallInput(parseRunInstallInput(t)):[]}function parseRunInstallInput(e){try{return validateRunInstallInput(JSON.parse(e))}catch(e){if(!(e instanceof SyntaxError))throw formatRunInstallError(e)}try{return validateRunInstallInput(parseYamlSubset(e))}catch(e){throw formatRunInstallError(e)}}function normalizeRunInstallInput(e){return e?e===!0?[{}]:Array.isArray(e)?e:[e]:[]}function formatRunInstallError(e){return e instanceof Error?e:Error(String(e))}function runInstall(e,t,r){for(let i of e){let e=i.cwd?n.resolve(t,i.cwd):t,a=[`install`,...i.args||[]],o=r===`sfw`?[`vp`,...a]:a;console.log(`setup-vp: running ${r} ${o.join(` `)} in ${e}`),run(r,o,{cwd:e})}}function resolveProjectDir(e=process.env){let t=e.SETUP_VP_WORKING_DIRECTORY||`.`,r=n.isAbsolute(t)?t:n.join(e.CI_PROJECT_DIR||process.cwd(),t);try{if(!a(r).isDirectory())throw Error(`working-directory is not a directory: ${t} (resolved to ${r})`)}catch(e){throw e instanceof Error&&`code`in e&&e.code===`ENOENT`?Error(`working-directory not found: ${t} (resolved to ${r})`):e}return r}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 t=parseRunInstall(process.env.SETUP_VP_RUN_INSTALL||`true`);runInstall(t,e,await setupSfw(t)),run(`vp`,[`--version`])}if(process.argv[1]&&import.meta.url===t(process.argv[1]).href)try{await main()}catch(e){fail(e instanceof Error?e.message:String(e))}export{main};
88 changes: 88 additions & 0 deletions gitlab/bootstrap.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -eu

# GitLab remote includes can only start from YAML, so setup-vp.yml downloads
# this bootstrap first. Keep this file as a thin shell entrypoint: install vp,
# export PATH for the rest of the job, verify Node.js is available in the
# runner image, then download and execute the compiled TypeScript runtime from
# dist/gitlab/index.mjs.

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 -qO "$setup_vp_out" "$setup_vp_url"
else
echo "setup-vp: curl or wget is required to download files." >&2
return 127
fi
}

setup_vp_shell_quote() {
printf "'%s'" "$(printf "%s" "$1" | sed "s/'/'\\\\''/g")"
}

setup_vp_export_env() {
if [ -z "${SETUP_VP_ENV_FILE:-}" ]; then
return 0
fi

setup_vp_name="$1"
setup_vp_value="$2"
printf "export %s=" "$setup_vp_name" >> "$SETUP_VP_ENV_FILE"
setup_vp_shell_quote "$setup_vp_value" >> "$SETUP_VP_ENV_FILE"
printf "\n" >> "$SETUP_VP_ENV_FILE"
}

setup_vp_install_viteplus_from() {
setup_vp_url="$1"
rm -f "$setup_vp_install_tmp"

setup_vp_download "$setup_vp_url" "$setup_vp_install_tmp"
VP_VERSION="$SETUP_VP_VERSION" VITE_PLUS_VERSION="$SETUP_VP_VERSION" bash "$setup_vp_install_tmp"
}

setup_vp_install_viteplus() {
setup_vp_round=1
while [ "$setup_vp_round" -le 2 ]; do
for setup_vp_url in \
"https://viteplus.dev/install.sh" \
"https://raw.githubusercontent.com/voidzero-dev/vite-plus/main/packages/cli/install.sh"
do
echo "setup-vp: installing Vite+ ${SETUP_VP_VERSION} from ${setup_vp_url}"
if setup_vp_install_viteplus_from "$setup_vp_url"; then
return 0
fi
echo "setup-vp: install attempt failed; retrying if another source is available." >&2
done
setup_vp_round=$((setup_vp_round + 1))
if [ "$setup_vp_round" -le 2 ]; then
sleep 2
fi
done

echo "setup-vp: failed to install Vite+ after retrying all installer URLs." >&2
return 1
}

SETUP_VP_VERSION="${SETUP_VP_VERSION:-latest}"
SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1}"
setup_vp_install_tmp="${TMPDIR:-/tmp}/setup-vp-install.$$"
setup_vp_runtime_tmp="${TMPDIR:-/tmp}/setup-vp-gitlab-runtime.$$.mjs"
trap 'rm -f "$setup_vp_install_tmp" "$setup_vp_runtime_tmp"' EXIT

setup_vp_install_viteplus
export PATH="$HOME/.vite-plus/bin:$PATH"
setup_vp_export_env PATH "$PATH"

if ! command -v node >/dev/null 2>&1; then
echo "setup-vp: Node.js is required in the GitLab runner image to execute the setup-vp runtime." >&2
return 127 2>/dev/null || exit 127
fi

setup_vp_runtime_url="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${SETUP_VP_SETUP_REF}/dist/gitlab/index.mjs"
setup_vp_download "$setup_vp_runtime_url" "$setup_vp_runtime_tmp"
node "$setup_vp_runtime_tmp"
Comment thread
naokihaba marked this conversation as resolved.
Loading
Loading