diff --git a/README.md b/README.md index 2901bc1..7676670 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/dist/gitlab/index.mjs b/dist/gitlab/index.mjs new file mode 100644 index 0000000..90a3186 --- /dev/null +++ b/dist/gitlab/index.mjs @@ -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(;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 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/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" diff --git a/gitlab/setup-vp.yml b/gitlab/setup-vp.yml new file mode 100644 index 0000000..2c4969d --- /dev/null +++ b/gitlab/setup-vp.yml @@ -0,0 +1,65 @@ +spec: + inputs: + version: + description: "Version of Vite+ to install" + default: "latest" + working-directory: + description: "Project directory used for relative paths and default `vp install` execution." + default: "." + run-install: + description: "Run `vp install` after setup. Accepts boolean or YAML object with cwd/args." + default: "true" + sfw: + description: "Wrap `vp install` with Socket Firewall Free (`sfw`) to block malicious dependency fetches." + type: boolean + default: false + registry-url: + description: "Optional registry URL to write to a temporary .npmrc. Auth token is read from NODE_AUTH_TOKEN." + default: "" + scope: + description: "Optional scope for authenticating against scoped registries." + default: "" + setup-ref: + description: "setup-vp ref used to download the GitLab bootstrap and compiled runtime. Pin this to the same tag or commit as the remote template for strict reproducibility." + default: "v1" +--- +.setup-vp: + before_script: + - | + set -eu + + 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 the GitLab bootstrap." >&2 + return 127 + fi + } + + export SETUP_VP_VERSION="$[[ inputs.version ]]" + export SETUP_VP_WORKING_DIRECTORY="$[[ inputs.working-directory ]]" + SETUP_VP_RUN_INSTALL="$(cat <<'SETUP_VP_RUN_INSTALL_EOF' + $[[ inputs.run-install ]] + SETUP_VP_RUN_INSTALL_EOF + )" + export SETUP_VP_RUN_INSTALL + export SETUP_VP_SFW="$[[ inputs.sfw ]]" + export SETUP_VP_REGISTRY_URL="$[[ inputs.registry-url ]]" + export SETUP_VP_SCOPE="$[[ inputs.scope ]]" + export SETUP_VP_SETUP_REF="$[[ inputs.setup-ref ]]" + + setup_vp_bootstrap_tmp="${TMPDIR:-/tmp}/setup-vp-gitlab-bootstrap.$$" + setup_vp_env_tmp="${TMPDIR:-/tmp}/setup-vp-gitlab-env.$$" + : > "$setup_vp_env_tmp" + export SETUP_VP_ENV_FILE="$setup_vp_env_tmp" + trap 'rm -f "$setup_vp_bootstrap_tmp" "$setup_vp_env_tmp"' EXIT + setup_vp_bootstrap_url="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${SETUP_VP_SETUP_REF}/gitlab/bootstrap.sh" + setup_vp_download "$setup_vp_bootstrap_url" "$setup_vp_bootstrap_tmp" + bash "$setup_vp_bootstrap_tmp" + . "$setup_vp_env_tmp" diff --git a/rfcs/assets/gitlab-runtime-flow.svg b/rfcs/assets/gitlab-runtime-flow.svg new file mode 100644 index 0000000..adbda7b --- /dev/null +++ b/rfcs/assets/gitlab-runtime-flow.svg @@ -0,0 +1,119 @@ + + setup-vp GitLab integration overview + Repository files and GitLab job runtime flow for setup-vp. The remote YAML template starts the job, bootstrap.sh installs Vite+ and runs the compiled GitLab runtime, and src/gitlab modules provide the runtime behavior. + + + + + + + + + + + + + + gitlab job runtime + repository files + typescript runtime responsibilities + + + GitLab Job Runtime Flow + The remote template starts in YAML, then hands off to shell, then to the compiled Node.js runtime. + + + Remote template + GitLab include:remote + + + bootstrap.sh + install vp, check node + + + GitLab runtime + node dist/gitlab/index.mjs + + + vp commands + install, version + + + + + + Shell stays small: download, install, PATH export, runtime handoff. + TypeScript handles the behavior that would be hard to maintain in shell. + + + Repository files that make the template work + GitLab downloads generated/runtime files from the same setup-ref as the remote template. + + + gitlab/setup-vp.yml + remote include entry, inputs, before_script + + + gitlab/bootstrap.sh + installs Vite+, exports PATH, downloads runtime + + + src/gitlab/*.ts + maintainable TypeScript source for GitLab behavior + + + dist/gitlab/index.mjs + compiled bundle executed by Node.js in GitLab + + + dist/index.mjs + separate GitHub Actions bundle + + + src/gitlab modules + split by runtime responsibility + + + auth + + run-install + + install-sfw + + shell / utils + + + GitHub Actions path + GitHub reads action.yml and runs + dist/index.mjs with using: node24. + + + + + + + GitLab support is a GitLab-native entrypoint, not the GitHub Action bundle running inside GitLab. + diff --git a/rfcs/gitlab-integration.md b/rfcs/gitlab-integration.md new file mode 100644 index 0000000..c5fa4a9 --- /dev/null +++ b/rfcs/gitlab-integration.md @@ -0,0 +1,339 @@ +# RFC: setup-vp GitLab CI/CD Remote Template + +## Summary + +This RFC proposes a GitLab CI/CD remote template for `voidzero-dev/setup-vp`. +The template lets GitLab users install Vite+, configure registry auth, and +optionally run `vp install` while keeping the source of truth in this GitHub +repository. + +The template is published as a plain YAML file plus a shell bootstrap. The +maintainable runtime is TypeScript under `src/gitlab/` and is distributed as a +precompiled JavaScript bundle under `dist/gitlab/`. + +```text +gitlab/setup-vp.yml +gitlab/bootstrap.sh +src/gitlab/*.ts +dist/gitlab/index.mjs +``` + +GitLab users load it with `include:remote`: + +```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 +``` + +## Motivation + +`setup-vp` is currently a GitHub Action. Its TypeScript implementation depends +on the GitHub Actions runtime, including action inputs, path management, state, +outputs, and post-action cache behavior. GitLab CI/CD cannot execute that action +directly with equivalent semantics. + +The goal is to provide a GitLab-native entry point without creating a separate +GitLab project or mirror. GitLab supports remote YAML includes, so a template +hosted from GitHub can be reused directly by GitLab pipelines. + +Relevant GitLab documentation: + +- https://docs.gitlab.com/ci/yaml/#includeremote +- https://docs.gitlab.com/ci/yaml/#includeinputs +- https://docs.gitlab.com/ci/yaml/#includeintegrity +- https://docs.gitlab.com/ci/caching/ +- https://docs.gitlab.com/ci/migration/github_actions/ + +## Goals + +1. Provide a GitLab CI/CD template from this GitHub repository only. +2. Support `include:remote` with `spec:inputs`. +3. Keep GitLab input names as close as possible to the GitHub Action inputs. +4. Install Vite+ from the official installer with retry and fallback URLs. +5. Support the default `run-install: true` experience and advanced + `run-install` entries with `cwd` and `args`. +6. Support private registry auth through `registry-url`, `scope`, and + `NODE_AUTH_TOKEN`. +7. Support `sfw: true` for `vp install`. +8. Require Node.js in the selected GitLab runner image, matching the model that + the runtime script is executed by Node.js. +9. Document where GitLab behavior cannot match GitHub Actions. + +## Non-Goals + +1. Do not create or require a GitLab project. +2. Do not publish a GitLab CI/CD component. +3. Do not use `include:component` for the initial design. +4. Do not run the GitHub Action bundle (`dist/index.mjs`) inside GitLab. +5. Do not implement GitHub Actions cache semantics inside the GitLab template. +6. Do not provide Windows runner support in the initial template. + +## Design + +### Distribution Model + +The template is stored in this repository and referenced by raw GitHub URL. + +```yaml +include: + - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" +``` + +Consumers should pin a tag or commit instead of `main`. GitLab 17.9+ users can +also use `include:integrity` when they want to pin the remote file hash. + +```yaml +include: + - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1.0.0/gitlab/setup-vp.yml" + integrity: "sha256-..." + inputs: + run-install: "true" +``` + +`include:component` is intentionally not used. It is designed for GitLab CI/CD +components resolved from a GitLab component project, which conflicts with the +"GitHub repository only" constraint. + +### Template Shape + +`gitlab/setup-vp.yml` defines two YAML documents and intentionally stays thin: + +1. `spec:inputs` for GitLab include inputs. +2. A hidden `.setup-vp` job that exports inputs, downloads `bootstrap.sh`, and + executes it. + +```yaml +spec: + inputs: + version: + default: "latest" + working-directory: + default: "." + run-install: + default: "true" + sfw: + type: boolean + default: false + registry-url: + default: "" + scope: + default: "" + setup-ref: + default: "v1" +--- +.setup-vp: + before_script: + - | + # export inputs, download bootstrap.sh, and execute it +``` + +### Bootstrap And Runtime + +Implementation logic is split to keep shell small: + +- `gitlab/setup-vp.yml` handles GitLab inputs and downloads `bootstrap.sh`. +- `gitlab/bootstrap.sh` installs Vite+, checks that Node.js is available, + downloads `dist/gitlab/index.mjs`, and runs it. +- `src/gitlab/*.ts` handles maintainable logic split by responsibility: + registry auth, `sfw`, `run-install` parsing, install execution, shell helpers, + path resolution, and final orchestration. +- `dist/gitlab/index.mjs` is generated from `src/gitlab/index.ts` by + `vp pack`, mirroring how the GitHub Action runs `dist/index.mjs` generated + from TypeScript. + +This intentionally requires users to choose a runner image that already contains +Node.js, such as `node:24`. GitLab remote templates run inside the user-selected +image, so requiring Node.js keeps the template simple and avoids bootstrapping a +runtime before the compiled JavaScript can execute. + +Remote includes do not provide a portable way for the included YAML to discover +the exact Git ref used in the `include:remote` URL. For that reason the template +has a `setup-ref` input. The default points at `v1`, and users who need strict +reproducibility should pass the same tag or commit SHA as the template include. + +```yaml +include: + - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1.0.0/gitlab/setup-vp.yml" + inputs: + setup-ref: "v1.0.0" +``` + +The runtime handoff is intentionally explicit: + +![GitLab setup-vp runtime flow](./assets/gitlab-runtime-flow.svg) + +### Execution Flow + +The hidden job runs in `before_script` so that the user's `script` can assume +`vp` is available. + +1. Export GitLab inputs into `SETUP_VP_*` environment variables. +2. Download and execute `bootstrap.sh` from `setup-ref`. +3. Install Vite+ from `https://viteplus.dev/install.sh`. +4. Fall back to the raw GitHub installer if the primary installer fails. +5. Add `~/.vite-plus/bin` to `PATH`. +6. Verify that `node` is available in the runner image. +7. Download and execute `dist/gitlab/index.mjs` from `setup-ref`. +8. Resolve `working-directory`. +9. Configure temporary npm auth when `registry-url` is set. +10. Install or detect `sfw` when `sfw: true`. +11. Run `vp install` when `run-install` is enabled. +12. Print `vp --version`. + +### Node.js Runtime Requirement + +The GitLab template does not expose `node-version` or `node-version-file`. +Instead, jobs must select an image or environment where Node.js is already +available: + +```yaml +test: + extends: .setup-vp + image: node:24 + script: + - vp run test +``` + +This differs from the GitHub Action, where GitHub provides a built-in Node.js +runtime for actions. In both cases, TypeScript source is not executed directly: +GitHub Actions runs `dist/index.mjs`, and the GitLab template runs +`dist/gitlab/index.mjs`. + +### Run Install + +The default matches GitHub Actions: + +```yaml +run-install: "true" +``` + +The GitLab template also supports multiple install entries: + +```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 +``` + +This is intentionally modeled after the GitHub Action's structured +`run-install` input rather than adding a separate `install-args` input. Keeping +one input avoids diverging user experience between GitHub and GitLab. + +### Socket Firewall Free + +`sfw: true` wraps install commands as `sfw vp install ...`. + +```yaml +include: + - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" + inputs: + sfw: true + run-install: "true" +``` + +If `sfw` is already on `PATH`, the template reuses it. Otherwise it downloads a +pinned `sfw-free` release for Linux or macOS when a matching binary exists. If +the runner architecture is unsupported, the template logs a warning and falls +back to plain `vp install`. + +## Public API + +| Input | Default | Description | +| ------------------- | -------- | ----------------------------------------------------------------------------- | +| `version` | `latest` | Version of Vite+ to install. | +| `working-directory` | `.` | Project directory used for relative paths and default `vp install` execution. | +| `run-install` | `true` | Run `vp install`; accepts boolean or YAML object/array with `cwd` and `args`. | +| `sfw` | `false` | Wrap `vp install` with Socket Firewall Free. | +| `registry-url` | | Optional registry URL to write to a temporary `.npmrc`. | +| `scope` | | Optional scope for authenticating against scoped registries. | +| `setup-ref` | `v1` | Ref used to download `bootstrap.sh` and `dist/gitlab/index.mjs`. | + +## GitHub Action Parity + +| Capability | GitHub Action | GitLab template | Notes | +| ----------------------- | ------------- | --------------- | -------------------------------------------- | +| Install Vite+ | Yes | Yes | GitLab uses shell in `before_script`. | +| `node-version` | Yes | No | GitLab requires Node.js in the runner image. | +| `node-version-file` | Yes | No | GitLab requires Node.js in the runner image. | +| `working-directory` | Yes | Yes | Used for relative paths and default install. | +| `run-install` | Yes | Yes | Structured `cwd` and `args` are supported. | +| `registry-url` | Yes | Yes | GitLab requires `NODE_AUTH_TOKEN` variable. | +| `scope` | Yes | Yes | Same input name. | +| `sfw` | Yes | Yes | GitLab supports Unix-like runners only. | +| `cache` | Yes | No | GitLab cache is job-level YAML behavior. | +| `cache-dependency-path` | Yes | No | See cache section below. | + +## Cache Design + +The GitLab template does not expose `cache` or `cache-dependency-path` inputs. +This is an intentional difference. + +The GitHub Action restores cache during the action's main phase and saves cache +during the action's post phase. GitLab cache is configured as a job keyword and +is restored by the runner before `before_script` starts. A remote template +running shell commands inside `before_script` cannot compute dynamic cache paths +and then ask GitLab to restore those paths for the same job. + +GitLab users should configure `cache:` on their jobs directly: + +```yaml +test: + extends: .setup-vp + image: node:24 + cache: + key: + files: + - pnpm-lock.yaml + paths: + - .pnpm-store/ + script: + - vp run test +``` + +Follow-up cache work should happen separately after deciding whether Vite+ should +support a stable project-local package manager cache directory for GitLab. + +## Security + +Remote includes execute as CI configuration, so examples should recommend +pinning: + +- Prefer `v1`, an immutable version tag such as `v1.0.0`, or a commit SHA. +- Avoid `main` in production pipelines. +- Use `include:integrity` where available for stricter remote file validation. +- Pin `setup-ref` to the same immutable tag or commit SHA when strict + reproducibility is required. `include:integrity` validates the included YAML, + not the bootstrap or compiled runtime downloaded by that YAML. + +The template downloads installers and optional `sfw` binaries at runtime. The +downloaded `sfw` version is pinned in the template for reproducibility. Users +who need stronger supply-chain guarantees can install a SHA-pinned `sfw` binary +before extending `.setup-vp`; the template will reuse `sfw` from `PATH`. + +## Rollout + +1. Add `gitlab/setup-vp.yml`. +2. Add `gitlab/bootstrap.sh`. +3. Add the `src/gitlab/` TypeScript runtime modules. +4. Generate `dist/gitlab/index.mjs` with `vp pack`. +5. Add this RFC under `rfcs/`. +6. Document GitLab usage in `README.md`. +7. Validate YAML parsing and shell/Node syntax locally. +8. Validate the remote include through GitLab CI Lint before release. +9. Release under `v1` and an immutable semver tag. diff --git a/src/gitlab/auth.test.ts b/src/gitlab/auth.test.ts new file mode 100644 index 0000000..c6f17f1 --- /dev/null +++ b/src/gitlab/auth.test.ts @@ -0,0 +1,82 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { configureAuth } from "./auth.js"; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "setup-vp-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("configureAuth", () => { + it("writes registry auth config and updates the provided env object", () => { + const targetEnv: Record = {}; + const npmrc = configureAuth("https://npm.pkg.github.com", "MyOrg", targetEnv); + + expect(npmrc).toBeTruthy(); + if (!npmrc) throw new Error("expected configureAuth to return an npmrc path"); + expect(targetEnv.NPM_CONFIG_USERCONFIG).toBe(npmrc); + expect(targetEnv.PNPM_CONFIG_USERCONFIG).toBe(npmrc); + expect(targetEnv.NODE_AUTH_TOKEN).toBe("XXXXX-XXXXX-XXXXX-XXXXX"); + expect(readFileSync(npmrc, "utf8")).toBe( + "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}\n@myorg:registry=https://npm.pkg.github.com/\n", + ); + }); + + it("writes registry auth exports for the GitLab job shell", () => { + const dir = tempDir(); + const envFile = path.join(dir, "env.sh"); + writeFileSync(envFile, "", "utf8"); + + const previousEnvFile = process.env.SETUP_VP_ENV_FILE; + const previousNodeAuthToken = process.env.NODE_AUTH_TOKEN; + const previousNpmConfig = process.env.NPM_CONFIG_USERCONFIG; + const previousPnpmConfig = process.env.PNPM_CONFIG_USERCONFIG; + + try { + process.env.SETUP_VP_ENV_FILE = envFile; + delete process.env.NODE_AUTH_TOKEN; + configureAuth("https://npm.pkg.github.com", "MyOrg"); + + const exports = readFileSync(envFile, "utf8"); + expect(exports).toContain("export NPM_CONFIG_USERCONFIG="); + expect(exports).toContain("export PNPM_CONFIG_USERCONFIG="); + expect(exports).toContain("export NODE_AUTH_TOKEN='XXXXX-XXXXX-XXXXX-XXXXX'"); + } finally { + if (previousEnvFile === undefined) delete process.env.SETUP_VP_ENV_FILE; + else process.env.SETUP_VP_ENV_FILE = previousEnvFile; + if (previousNodeAuthToken === undefined) delete process.env.NODE_AUTH_TOKEN; + else process.env.NODE_AUTH_TOKEN = previousNodeAuthToken; + if (previousNpmConfig === undefined) delete process.env.NPM_CONFIG_USERCONFIG; + else process.env.NPM_CONFIG_USERCONFIG = previousNpmConfig; + if (previousPnpmConfig === undefined) delete process.env.PNPM_CONFIG_USERCONFIG; + else process.env.PNPM_CONFIG_USERCONFIG = previousPnpmConfig; + } + }); + + it("rejects invalid registry URLs", () => { + expect(() => configureAuth("not-a-url", "", {})).toThrow("Invalid registry-url"); + }); + + it("skips registry auth when no registry URL is configured", () => { + const targetEnv: Record = {}; + expect(configureAuth("", "", targetEnv)).toBeUndefined(); + expect(targetEnv).toEqual({}); + }); + + it("keeps an existing NODE_AUTH_TOKEN when configuring auth", () => { + const targetEnv: Record = { NODE_AUTH_TOKEN: "real-token" }; + configureAuth("https://registry.example.test/npm", "", targetEnv); + expect(targetEnv.NODE_AUTH_TOKEN).toBe("real-token"); + }); +}); diff --git a/src/gitlab/auth.ts b/src/gitlab/auth.ts new file mode 100644 index 0000000..e7f34d9 --- /dev/null +++ b/src/gitlab/auth.ts @@ -0,0 +1,44 @@ +import { writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +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 npmrc = path.join(tmpdir(), `setup-vp-npmrc.${process.pid}`); + const contents = `${authUrl}:_authToken=${NODE_AUTH_TOKEN_REF}\n${scopePrefix}registry=${registryUrl}\n`; + writeFileSync(npmrc, contents, "utf8"); + + 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; +} diff --git a/src/gitlab/index.test.ts b/src/gitlab/index.test.ts new file mode 100644 index 0000000..d88f1f3 --- /dev/null +++ b/src/gitlab/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vite-plus/test"; +import { main } from "./index.js"; + +describe("GitLab entrypoint", () => { + it("exports the GitLab runtime main function", () => { + expect(main).toBeTypeOf("function"); + }); +}); diff --git a/src/gitlab/index.ts b/src/gitlab/index.ts new file mode 100644 index 0000000..602190c --- /dev/null +++ b/src/gitlab/index.ts @@ -0,0 +1,32 @@ +import { pathToFileURL } from "node:url"; +import { configureAuth } from "./auth.js"; +import { setupSfw } from "./install-sfw.js"; +import { parseRunInstall, runInstall } from "./run-install.js"; +import { run } from "./shell.js"; +import { resolveProjectDir } from "./utils.js"; + +function fail(message: string): never { + console.error(`setup-vp: ${message}`); + process.exit(1); +} + +export async function main(): Promise { + const projectDir = resolveProjectDir(process.env); + + configureAuth(process.env.SETUP_VP_REGISTRY_URL || "", process.env.SETUP_VP_SCOPE || ""); + + const runInstallEntries = parseRunInstall(process.env.SETUP_VP_RUN_INSTALL || "true"); + + const installCommand = await setupSfw(runInstallEntries); + runInstall(runInstallEntries, projectDir, installCommand); + + run("vp", ["--version"]); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + await main(); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +} diff --git a/src/gitlab/install-sfw.test.ts b/src/gitlab/install-sfw.test.ts new file mode 100644 index 0000000..e865606 --- /dev/null +++ b/src/gitlab/install-sfw.test.ts @@ -0,0 +1,53 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { getSfwAssetName, setupSfw } from "./install-sfw.js"; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "setup-vp-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("GitLab sfw setup", () => { + it("maps supported sfw release asset names", () => { + expect(getSfwAssetName("linux", "x64", false)).toBe("sfw-free-linux-x86_64"); + 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", + ); + }); + + it("does not set up sfw when disabled or run-install is disabled", async () => { + expect(await setupSfw([{}], { SETUP_VP_SFW: "false" })).toBe("vp"); + expect(await setupSfw([], { SETUP_VP_SFW: "true" })).toBe("vp"); + }); + + it("uses an existing sfw command from PATH", async () => { + const dir = tempDir(); + const binDir = path.join(dir, "bin"); + mkdirSync(binDir); + const sfwBin = path.join(binDir, "sfw"); + writeFileSync(sfwBin, "#!/usr/bin/env sh\nexit 0\n", "utf8"); + chmodSync(sfwBin, 0o755); + + const previousPath = process.env.PATH; + try { + process.env.PATH = `${binDir}:${previousPath || ""}`; + expect(await setupSfw([{}], { SETUP_VP_SFW: "true", PATH: process.env.PATH })).toBe("sfw"); + } finally { + process.env.PATH = previousPath; + } + }); +}); diff --git a/src/gitlab/install-sfw.ts b/src/gitlab/install-sfw.ts new file mode 100644 index 0000000..ad705c4 --- /dev/null +++ b/src/gitlab/install-sfw.ts @@ -0,0 +1,144 @@ +import { createWriteStream, existsSync } from "node:fs"; +import { chmod, mkdtemp } from "node:fs/promises"; +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 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}`; + +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 | undefined { + 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}`); +} + +export function sfwAssetName(): string | undefined { + try { + return getSfwAssetName(process.platform, process.arch, isMuslLinux()); + } catch { + return undefined; + } +} + +export function isSfwSupported(): boolean { + return !!sfwAssetName(); +} + +function sfwEnvironmentDescription(): string { + return `process.platform=${process.platform}, process.arch=${process.arch}, musl=${isMuslLinux()}`; +} + +function downloadFile(url: string, outputPath: string, redirects = 0): Promise { + if (redirects > 5) { + return Promise.reject(new Error(`too many redirects while downloading ${url}`)); + } + + const client = url.startsWith("https:") ? httpsGet : httpGet; + return new Promise((resolve, reject) => { + 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).then(() => resolve(), reject); + return; + } + + if (statusCode !== 200) { + response.resume(); + reject(new Error(`download failed with HTTP ${statusCode}: ${url}`)); + return; + } + + const file = createWriteStream(outputPath); + response.pipe(file); + file.on("finish", () => file.close(() => resolve())); + file.on("error", reject); + }); + request.on("error", reject); + }); +} + +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 asset = sfwAssetName(); + if (!asset) { + 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.`, + ); + 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"); +} diff --git a/src/gitlab/run-install.test.ts b/src/gitlab/run-install.test.ts new file mode 100644 index 0000000..8387ff2 --- /dev/null +++ b/src/gitlab/run-install.test.ts @@ -0,0 +1,125 @@ +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { parseFlowArray, parseRunInstall, runInstall } from "./run-install.js"; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "setup-vp-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("GitLab run-install parsing", () => { + it("parses booleans, JSON, and the supported YAML subset", () => { + expect(parseRunInstall("false")).toEqual([]); + expect(parseRunInstall("null")).toEqual([]); + expect(parseRunInstall("true")).toEqual([{}]); + expect(parseRunInstall('[{"cwd":"app"},{"args":["--prod"]}]')).toEqual([ + { cwd: "app" }, + { args: ["--prod"] }, + ]); + expect(parseRunInstall('{"cwd":"app","args":["--frozen-lockfile"]}')).toEqual([ + { cwd: "app", args: ["--frozen-lockfile"] }, + ]); + expect(parseRunInstall("- cwd: ./app\n args: ['--frozen-lockfile']\n- cwd: ./lib")).toEqual([ + { cwd: "./app", args: ["--frozen-lockfile"] }, + { cwd: "./lib" }, + ]); + }); + + it("parses block YAML args", () => { + expect(parseRunInstall("cwd: ./packages/app\nargs:\n - --frozen-lockfile")).toEqual([ + { cwd: "./packages/app", args: ["--frozen-lockfile"] }, + ]); + expect( + parseRunInstall("- cwd: ./app\n args:\n - --frozen-lockfile\n - --prefer-offline"), + ).toEqual([{ cwd: "./app", args: ["--frozen-lockfile", "--prefer-offline"] }]); + }); + + it("rejects unsupported keys", () => { + expect(() => parseRunInstall("command: install")).toThrow( + "unsupported run-install key: command", + ); + expect(() => parseRunInstall('{"command":"install"}')).toThrow( + "unsupported run-install key: command", + ); + }); + + it("rejects invalid JSON entry fields", () => { + expect(() => parseRunInstall('{"cwd":1}')).toThrow("run-install.cwd must be a string"); + expect(() => parseRunInstall('{"args":[1]}')).toThrow( + "run-install.args must be an array of strings", + ); + }); + + it("parses quoted flow array items", () => { + expect(parseFlowArray("['--filter', \"@scope/app\"]")).toEqual(["--filter", "@scope/app"]); + }); +}); + +describe("GitLab run-install execution", () => { + it("runs install entries with cwd and args", () => { + const dir = tempDir(); + const binDir = path.join(dir, "bin"); + const appDir = path.join(dir, "app"); + const logFile = path.join(dir, "run.log"); + mkdirSync(binDir); + mkdirSync(appDir); + const vpBin = path.join(binDir, "vp"); + writeFileSync( + vpBin, + `#!/usr/bin/env sh\nprintf '%s\\n%s\\n' "$PWD" "$*" > "${logFile}"\n`, + "utf8", + ); + chmodSync(vpBin, 0o755); + + const previousPath = process.env.PATH; + try { + process.env.PATH = `${binDir}:${previousPath || ""}`; + runInstall([{ cwd: "app", args: ["--frozen-lockfile"] }], dir, "vp"); + } finally { + process.env.PATH = previousPath; + } + + expect(readFileSync(logFile, "utf8")).toBe( + `${realpathSync(appDir)}\ninstall --frozen-lockfile\n`, + ); + }); + + it("runs install entries through sfw when requested", () => { + const dir = tempDir(); + const binDir = path.join(dir, "bin"); + const logFile = path.join(dir, "sfw.log"); + mkdirSync(binDir); + const sfwBin = path.join(binDir, "sfw"); + writeFileSync(sfwBin, `#!/usr/bin/env sh\nprintf '%s\\n' "$*" > "${logFile}"\n`, "utf8"); + chmodSync(sfwBin, 0o755); + + const previousPath = process.env.PATH; + try { + process.env.PATH = `${binDir}:${previousPath || ""}`; + runInstall([{}], dir, "sfw"); + } finally { + process.env.PATH = previousPath; + } + + expect(readFileSync(logFile, "utf8")).toBe("vp install\n"); + }); +}); diff --git a/src/gitlab/run-install.ts b/src/gitlab/run-install.ts new file mode 100644 index 0000000..fa0e986 --- /dev/null +++ b/src/gitlab/run-install.ts @@ -0,0 +1,258 @@ +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 = ""; + + for (const char of body) { + if (quote) { + if (char === quote) quote = ""; + current += char; + continue; + } + if (char === "'" || char === '"') { + quote = char; + current += char; + continue; + } + if (char === ",") { + result.push(parseScalar(current)); + current = ""; + continue; + } + current += char; + } + + if (current.trim()) result.push(parseScalar(current)); + 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}`); +} + +// Keep GitLab runtime validation local instead of using Zod. The bootstrap +// downloads and runs one generated .mjs file from /tmp, so shared chunks such +// as dist/schemas-*.mjs would break relative imports at runtime. +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); +} + +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; + const entry = parseKeyValue(line); + if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); + const expectsBlockArray = assignValue(item, entry[0], entry[1]); + if (expectsBlockArray) { + const parsed = parseBlockArray(lines, index + 1, countIndent(rawLine)); + item.args = parsed.values; + index = parsed.nextIndex - 1; + } + } + 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) { + const entry = parseKeyValue(rest); + if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); + const expectsBlockArray = assignValue(current, entry[0], entry[1]); + if (expectsBlockArray) { + const parsed = parseBlockArray(lines, index + 1, indent); + current.args = parsed.values; + index = parsed.nextIndex - 1; + } + } + continue; + } + + if (!current) throw new Error(`invalid run-install line: ${rawLine}`); + const entry = parseKeyValue(trimmedStart); + if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); + const expectsBlockArray = assignValue(current, entry[0], entry[1]); + if (expectsBlockArray) { + const parsed = parseBlockArray(lines, index + 1, indent); + current.args = parsed.values; + index = parsed.nextIndex - 1; + } + } + 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 { + 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 }); + } +} diff --git a/src/gitlab/shell.test.ts b/src/gitlab/shell.test.ts new file mode 100644 index 0000000..55a05cc --- /dev/null +++ b/src/gitlab/shell.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { commandPath, exportShellEnv, shellQuote } from "./shell.js"; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "setup-vp-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("GitLab shell helpers", () => { + it("quotes shell environment values", () => { + expect(shellQuote("plain")).toBe("'plain'"); + expect(shellQuote("has spaces")).toBe("'has spaces'"); + expect(shellQuote("it's ok")).toBe("'it'\\''s ok'"); + }); + + it("appends shell exports when an env file is configured", () => { + const dir = tempDir(); + const envFile = path.join(dir, "env.sh"); + writeFileSync(envFile, "", "utf8"); + + exportShellEnv("SETUP_VP_TEST", "value with ' quote", { SETUP_VP_ENV_FILE: envFile }); + + expect(readFileSync(envFile, "utf8")).toBe("export SETUP_VP_TEST='value with '\\'' quote'\n"); + }); + + it("does not write shell exports without an env file or value", () => { + exportShellEnv("SETUP_VP_TEST", "value", {}); + exportShellEnv("SETUP_VP_TEST", undefined, { SETUP_VP_ENV_FILE: "unused" }); + }); + + it("finds commands on PATH", () => { + expect(commandPath("sh")).toBeTruthy(); + expect(commandPath("setup-vp-command-that-should-not-exist")).toBeUndefined(); + }); +}); diff --git a/src/gitlab/shell.ts b/src/gitlab/shell.ts new file mode 100644 index 0000000..a745532 --- /dev/null +++ b/src/gitlab/shell.ts @@ -0,0 +1,31 @@ +import { writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import type { SpawnSyncOptions } from "node:child_process"; + +export function shellQuote(value: string): string { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +export function exportShellEnv( + name: string, + value: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): void { + if (!env.SETUP_VP_ENV_FILE || value === undefined) return; + writeFileSync(env.SETUP_VP_ENV_FILE, `export ${name}=${shellQuote(value)}\n`, { + encoding: "utf8", + 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 "${command}"`], { encoding: "utf8" }); + if (result.status === 0) return result.stdout.trim(); + return undefined; +} diff --git a/src/gitlab/types.test.ts b/src/gitlab/types.test.ts new file mode 100644 index 0000000..9aa741c --- /dev/null +++ b/src/gitlab/types.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { InstallCommand, RunInstallEntry, RunInstallInput, RuntimeEnv } from "./types.js"; + +function acceptInstallCommand(command: InstallCommand): InstallCommand { + return command; +} + +describe("GitLab runtime types", () => { + it("keeps the runtime type contracts narrow", () => { + const entries: RunInstallEntry[] = [{ cwd: "app", args: ["--prod"] }]; + const input: RunInstallInput = entries; + const env: RuntimeEnv = { SETUP_VP_SFW: "true" }; + + expect(acceptInstallCommand("vp")).toBe("vp"); + expect(acceptInstallCommand("sfw")).toBe("sfw"); + expect(input).toEqual(entries); + expect(env.SETUP_VP_SFW).toBe("true"); + }); +}); diff --git a/src/gitlab/types.ts b/src/gitlab/types.ts new file mode 100644 index 0000000..c2d87a5 --- /dev/null +++ b/src/gitlab/types.ts @@ -0,0 +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"; diff --git a/src/gitlab/utils.test.ts b/src/gitlab/utils.test.ts new file mode 100644 index 0000000..1c89666 --- /dev/null +++ b/src/gitlab/utils.test.ts @@ -0,0 +1,57 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { resolveProjectDir } from "./utils.js"; + +const tempDirs: string[] = []; + +function tempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "setup-vp-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("resolveProjectDir", () => { + it("resolves relative working directories from CI_PROJECT_DIR", () => { + const root = tempDir(); + mkdirSync(path.join(root, "web")); + + expect( + resolveProjectDir({ + CI_PROJECT_DIR: root, + SETUP_VP_WORKING_DIRECTORY: "web", + }), + ).toBe(path.join(root, "web")); + }); + + it("resolves absolute working directories", () => { + const root = tempDir(); + expect(resolveProjectDir({ SETUP_VP_WORKING_DIRECTORY: root })).toBe(root); + }); + + it("rejects missing and file working directories", () => { + const root = tempDir(); + const file = path.join(root, "package.json"); + writeFileSync(file, "{}", "utf8"); + + expect(() => + resolveProjectDir({ + CI_PROJECT_DIR: root, + SETUP_VP_WORKING_DIRECTORY: "missing", + }), + ).toThrow("working-directory not found"); + expect(() => + resolveProjectDir({ + CI_PROJECT_DIR: root, + SETUP_VP_WORKING_DIRECTORY: "package.json", + }), + ).toThrow("working-directory is not a directory"); + }); +}); diff --git a/src/gitlab/utils.ts b/src/gitlab/utils.ts new file mode 100644 index 0000000..b3e1145 --- /dev/null +++ b/src/gitlab/utils.ts @@ -0,0 +1,27 @@ +import { statSync } from "node:fs"; +import path from "node:path"; +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; +} diff --git a/vite.config.ts b/vite.config.ts index 2f911ec..6d14bd2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,7 +8,10 @@ export default defineConfig({ "*": "vp check --fix", }, pack: { - entry: ["./src/index.ts"], + entry: { + index: "./src/index.ts", + "gitlab/index": "./src/gitlab/index.ts", + }, format: ["esm"], outDir: "dist", deps: {