Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,a=6e4,o){if(i>5)return Promise.reject(Error(`too many redirects while downloading ${t}`));let s=o||(t.startsWith(`https:`)?c:e);return new Promise((e,o)=>{let c=!1,finish=t=>{c||(c=!0,clearTimeout(u),t?o(t):e())},l=s(t,e=>{let o=e.statusCode??0,s=e.headers.location;if(o>=300&&o<400&&s){e.resume(),downloadFile(new URL(s,t).toString(),n,i+1,a).then(()=>finish(),finish);return}if(o!==200){e.resume(),finish(Error(`download failed with HTTP ${o}: ${t}`));return}let c=r(n);e.pipe(c),c.on(`finish`,()=>c.close(()=>finish())),c.on(`error`,finish)}),u=setTimeout(()=>{l.destroy(Error(`download timed out after ${a}ms: ${t}`))},a);l.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=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=``,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(;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`])}function isEntrypoint(e=process.argv[1],r=import.meta.url){return!!(e&&r===t(n.resolve(e)).href)}if(isEntrypoint())try{await main()}catch(e){fail(e instanceof Error?e.message:String(e))}export{isEntrypoint,main};
Loading