From 54d06b97f5cac62dc6eafe3847e4e8cc6092e5db Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:44:42 +0900 Subject: [PATCH 01/10] docs: add GitLab integration RFC --- rfcs/gitlab-integration.md | 309 +++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 rfcs/gitlab-integration.md diff --git a/rfcs/gitlab-integration.md b/rfcs/gitlab-integration.md new file mode 100644 index 0000000..f08eccf --- /dev/null +++ b/rfcs/gitlab-integration.md @@ -0,0 +1,309 @@ +# 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+, set up Node.js through +`vp env use`, 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: + +```text +gitlab/setup-vp.yml +``` + +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 `node-version` and `node-version-file`. +6. Support the default `run-install: true` experience and advanced + `run-install` entries with `cwd` and `args`. +7. Support private registry auth through `registry-url`, `scope`, and + `NODE_AUTH_TOKEN`. +8. Support `sfw: true` for `vp install`. +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: + node-version: "22" +``` + +`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: + +1. `spec:inputs` for GitLab include inputs. +2. A hidden `.setup-vp` job that users extend from their jobs. + +```yaml +spec: + inputs: + version: + default: "latest" + node-version: + default: "lts" + node-version-file: + default: "" + working-directory: + default: "." + run-install: + default: "true" + sfw: + type: boolean + default: false + registry-url: + default: "" + scope: + default: "" +--- +.setup-vp: + before_script: + - | + # install Vite+, set up Node.js, auth, sfw, and optional vp install +``` + +### Execution Flow + +The hidden job runs in `before_script` so that the user's `script` can assume +`vp` is available. + +1. Resolve `working-directory`. +2. Install Vite+ from `https://viteplus.dev/install.sh`. +3. Fall back to the raw GitHub installer if the primary installer fails. +4. Add `~/.vite-plus/bin` to `PATH`. +5. Resolve `node-version-file` when provided. +6. Run `vp env use ` when a Node.js version is available. +7. Configure temporary npm auth when `registry-url` is set. +8. Install or detect `sfw` when `sfw: true`. +9. Run `vp install` when `run-install` is enabled. +10. Print `vp --version`. + +### Node.js Version Resolution + +`node-version` defaults to `lts`, matching the GitHub Action experience. + +```yaml +include: + - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" + inputs: + node-version: "lts" +``` + +`node-version-file` takes precedence when specified: + +```yaml +include: + - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" + inputs: + node-version-file: ".node-version" +``` + +Supported files: + +- `.nvmrc` +- `.node-version` +- `.tool-versions` +- `package.json` + +For `package.json`, the template reads `devEngines.runtime` for a `node` entry +first, then falls back to `engines.node`, matching the GitHub Action logic. + +There is one GitLab-specific caveat: because `spec:inputs` applies the +`node-version` default before the shell sees it, the template cannot distinguish +"the user omitted `node-version`" from "the user explicitly set `node-version: +lts`". The chosen behavior is simple: if `node-version-file` is set, the file +wins; otherwise `node-version` wins. + +### 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. | +| `node-version` | `lts` | Node.js version to install via `vp env use`. | +| `node-version-file` | | Path to `.nvmrc`, `.node-version`, `.tool-versions`, or `package.json`. | +| `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. | + +## GitHub Action Parity + +| Capability | GitHub Action | GitLab template | Notes | +| ----------------------- | ------------- | --------------- | -------------------------------------------- | +| Install Vite+ | Yes | Yes | GitLab uses shell in `before_script`. | +| `node-version` | Yes | Yes | Default is `lts` in both. | +| `node-version-file` | Yes | Yes | Includes `package.json`. | +| `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. + +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 this RFC under `docs/`. +3. Document GitLab usage in `README.md`. +4. Validate YAML parsing and shell syntax locally. +5. Validate the remote include through GitLab CI Lint before release. +6. Release under `v1` and an immutable semver tag. From 024fde06c5d9ce7b176f052fc1f9f0126a280851 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:04:13 +0900 Subject: [PATCH 02/10] feat: add GitLab runtime template and bootstrap flow --- gitlab/setup-vp.mjs | 676 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 676 insertions(+) create mode 100644 gitlab/setup-vp.mjs diff --git a/gitlab/setup-vp.mjs b/gitlab/setup-vp.mjs new file mode 100644 index 0000000..991c22d --- /dev/null +++ b/gitlab/setup-vp.mjs @@ -0,0 +1,676 @@ +/// +// @ts-check +import { createWriteStream, existsSync, readFileSync, statSync, writeFileSync } 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 { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const env = process.env; +const NODE_AUTH_TOKEN_REF = "${NODE_AUTH_TOKEN}"; +const SFW_VERSION = "v1.12.0"; +const SFW_RELEASE_BASE = `https://github.com/SocketDev/sfw-free/releases/download/${SFW_VERSION}`; + +/** + * @typedef {{ cwd?: string, args?: string[] }} RunInstallEntry + * @typedef {string | string[] | number | boolean | null | object | undefined} RunInstallField + * @typedef {{ [key: string]: string | undefined }} RuntimeEnv + */ + +/** + * @param {string} message + * @returns {never} + */ +function fail(message) { + console.error(`setup-vp: ${message}`); + process.exit(1); +} + +/** + * @param {string} value + * @returns {string} + */ +export function shellQuote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +/** + * @param {string} name + * @param {string | undefined} value + */ +function exportShellEnv(name, value) { + if (!env.SETUP_VP_ENV_FILE || value === undefined) return; + writeFileSync(env.SETUP_VP_ENV_FILE, `export ${name}=${shellQuote(value)}\n`, { + encoding: "utf8", + flag: "a", + }); +} + +/** + * @param {string} command + * @param {string[]} args + * @param {import("node:child_process").SpawnSyncOptions} [options] + */ +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +/** + * @param {string | undefined | null} version + * @returns {string} + */ +export function normalizeNodeVersion(version) { + let normalized = String(version || "").replace(/^[vV]/, ""); + const lower = normalized.toLowerCase(); + if (lower === "node" || lower === "stable") normalized = "latest"; + return normalized; +} + +/** + * @param {string} filePath + * @returns {string | undefined} + */ +export function parsePlainNodeVersionFile(filePath) { + for (const rawLine of readFileSync(filePath, "utf8").split(/\r?\n/)) { + const line = rawLine.replace(/#.*$/, "").trim(); + if (line) return normalizeNodeVersion(line); + } + return undefined; +} + +/** + * @param {string} filePath + * @returns {string | undefined} + */ +export function parseToolVersionsNode(filePath) { + for (const rawLine of readFileSync(filePath, "utf8").split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const [tool, ...versions] = line.split(/\s+/); + if (tool !== "nodejs" && tool !== "node") continue; + + for (const version of versions) { + if ( + version && + version !== "system" && + !version.startsWith("ref:") && + !version.startsWith("path:") + ) { + return normalizeNodeVersion(version); + } + } + } + return undefined; +} + +/** + * @param {object | null} value + * @returns {value is { devEngines?: { runtime?: { name?: string, version?: string } | { name?: string, version?: string }[] }, engines?: { node?: string } }} + */ +function isPackageJsonLike(value) { + return !!value && typeof value === "object"; +} + +/** + * @param {string} filePath + * @returns {string | undefined} + */ +export function parsePackageJsonNode(filePath) { + let pkg; + try { + pkg = JSON.parse(readFileSync(filePath, "utf8")); + } catch { + throw new Error("Failed to parse package.json: invalid JSON"); + } + + if (!isPackageJsonLike(pkg)) return undefined; + + const runtime = pkg.devEngines?.runtime; + const entries = Array.isArray(runtime) ? runtime : [runtime]; + for (const entry of entries) { + if (entry?.name === "node" && typeof entry.version === "string") { + return normalizeNodeVersion(entry.version); + } + } + + if (typeof pkg.engines?.node === "string") { + return normalizeNodeVersion(pkg.engines.node); + } + + return undefined; +} + +/** + * @param {string} inputPath + * @param {string} projectDir + * @returns {string} + */ +export function resolveNodeVersionFile(inputPath, projectDir) { + const filePath = path.isAbsolute(inputPath) ? inputPath : path.join(projectDir, inputPath); + let version; + + try { + const filename = path.basename(filePath); + if (filename === ".tool-versions") { + version = parseToolVersionsNode(filePath); + } else if (filename === "package.json") { + version = parsePackageJsonNode(filePath); + } else { + version = parsePlainNodeVersionFile(filePath); + } + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + throw new Error(`node-version-file not found: ${filePath}`); + } + throw error; + } + + if (!version) throw new Error(`No Node.js version found in ${inputPath}`); + return version; +} + +/** + * @param {string} value + * @returns {string} + */ +function parseScalar(value) { + const trimmed = String(value || "").trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +/** + * @param {string} value + * @returns {string[]} + */ +export function parseFlowArray(value) { + 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 = []; + 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; +} + +/** + * @param {string} line + * @returns {[string, string] | undefined} + */ +function parseKeyValue(line) { + const index = line.indexOf(":"); + if (index < 0) return undefined; + return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; +} + +/** + * @param {RunInstallEntry} target + * @param {string} key + * @param {string} value + */ +function assignValue(target, key, value) { + if (key === "cwd") { + target.cwd = parseScalar(value); + return; + } + if (key === "args") { + target.args = parseFlowArray(value); + return; + } + throw new Error(`unsupported run-install key: ${key}`); +} + +/** + * @param {string[]} lines + * @returns {RunInstallEntry} + */ +function parseObject(lines) { + /** @type {RunInstallEntry} */ + const item = {}; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const entry = parseKeyValue(line); + if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); + assignValue(item, entry[0], entry[1]); + } + return item; +} + +/** + * @param {string} value + * @returns {RunInstallEntry[]} + */ +export function parseYamlSubset(value) { + 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)]; + } + + /** @type {RunInstallEntry[]} */ + const items = []; + /** @type {RunInstallEntry | undefined} */ + let current = undefined; + for (const rawLine of lines) { + const trimmedStart = rawLine.trimStart(); + if (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}`); + assignValue(current, entry[0], entry[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}`); + assignValue(current, entry[0], entry[1]); + } + if (current) items.push(current); + return items; +} + +/** + * @param {string | undefined | null} value + * @returns {RunInstallEntry[]} + */ +function normalizeRunInstall(value) { + const input = String(value || "").trim(); + if (!input || input === "false" || input === "null") return []; + if (input === "true") return [{}]; + + let parsed; + try { + parsed = JSON.parse(input); + } catch { + return parseYamlSubset(input); + } + + if (parsed === null || parsed === false) return []; + if (parsed === true) return [{}]; + + const entries = Array.isArray(parsed) ? parsed : [parsed]; + return entries.map(normalizeRunInstallItem); +} + +/** + * @param {object | boolean | null} value + * @returns {object} + */ +function asRunInstallRecord(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("run-install entries must be objects with optional cwd and args"); + } + return value; +} + +/** + * @param {object} record + * @param {"cwd" | "args"} key + * @returns {RunInstallField} + */ +function getRunInstallField(record, key) { + return Object.prototype.hasOwnProperty.call(record, key) ? Reflect.get(record, key) : undefined; +} + +/** + * @param {RunInstallField} value + * @returns {value is string[]} + */ +function isStringArray(value) { + return Array.isArray(value) && value.every((arg) => typeof arg === "string"); +} + +/** + * @param {object | boolean | null} item + * @returns {RunInstallEntry} + */ +export function normalizeRunInstallItem(item) { + const candidate = asRunInstallRecord(item); + /** @type {RunInstallEntry} */ + const normalized = {}; + const cwd = getRunInstallField(candidate, "cwd"); + if (cwd !== undefined) { + if (typeof cwd !== "string") throw new Error("run-install.cwd must be a string"); + normalized.cwd = cwd; + } + const args = getRunInstallField(candidate, "args"); + if (args !== undefined) { + if (!isStringArray(args)) { + throw new Error("run-install.args must be an array of strings"); + } + normalized.args = args; + } + return normalized; +} + +/** + * @param {string} value + * @returns {RunInstallEntry[]} + */ +export function parseRunInstall(value) { + return normalizeRunInstall(value).map(normalizeRunInstallItem); +} + +/** + * @param {string} registryUrlInput + * @param {string} scopeInput + * @param {RuntimeEnv} targetEnv + * @returns {string | undefined} + */ +export function configureAuth(registryUrlInput, scopeInput, targetEnv = env) { + if (!registryUrlInput) return; + + let 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 === env) { + exportShellEnv("NPM_CONFIG_USERCONFIG", targetEnv.NPM_CONFIG_USERCONFIG); + exportShellEnv("PNPM_CONFIG_USERCONFIG", targetEnv.PNPM_CONFIG_USERCONFIG); + exportShellEnv("NODE_AUTH_TOKEN", targetEnv.NODE_AUTH_TOKEN); + } + return npmrc; +} + +export function isMuslLinux() { + if (process.platform !== "linux") return false; + try { + const report = /** @type {{ header?: { glibcVersionRuntime?: string } } | undefined} */ ( + process.report?.getReport() + ); + 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. + * + * @param {NodeJS.Platform} platform + * @param {string} arch + * @param {boolean} isMusl + * @returns {string | undefined} + */ +export function getSfwAssetName(platform, arch, isMusl) { + 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}`); +} + +/** + * @returns {string | undefined} + */ +export function sfwAssetName() { + try { + return getSfwAssetName(process.platform, process.arch, isMuslLinux()); + } catch { + return undefined; + } +} + +/** + * @returns {boolean} + */ +export function isSfwSupported() { + return !!sfwAssetName(); +} + +/** + * @returns {string} + */ +function sfwEnvironmentDescription() { + return `process.platform=${process.platform}, process.arch=${process.arch}, musl=${isMuslLinux()}`; +} + +/** + * @param {string} command + * @returns {string | undefined} + */ +function commandPath(command) { + const result = spawnSync("sh", ["-c", `command -v "${command}"`], { encoding: "utf8" }); + if (result.status === 0) return result.stdout.trim(); + return undefined; +} + +/** + * @param {string} url + * @param {string} outputPath + * @param {number} [redirects] + * @returns {Promise} + */ +function downloadFile(url, outputPath, redirects = 0) { + 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); + }); +} + +/** + * @param {RunInstallEntry[]} runInstallEntries + * @returns {Promise<"vp" | "sfw">} + */ +async function setupSfw(runInstallEntries) { + 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); + 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"); +} + +/** + * @param {RunInstallEntry[]} entries + * @param {string} projectDir + * @param {"vp" | "sfw"} installCommand + */ +function runInstall(entries, projectDir, installCommand) { + 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 }); + } +} + +/** + * @param {RuntimeEnv} runtimeEnv + * @returns {string} + */ +export function resolveProjectDir(runtimeEnv = env) { + 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; +} + +/** + * @returns {Promise} + */ +export async function main() { + const nodeVersion = env.SETUP_VP_NODE_VERSION || "lts"; + const nodeVersionFile = env.SETUP_VP_NODE_VERSION_FILE || ""; + const projectDir = resolveProjectDir(env); + + let effectiveNodeVersion = nodeVersion; + if (nodeVersionFile) { + effectiveNodeVersion = resolveNodeVersionFile(nodeVersionFile, projectDir); + console.log( + `setup-vp: resolved Node.js version ${effectiveNodeVersion} from ${nodeVersionFile}`, + ); + } + + if (effectiveNodeVersion) { + run("vp", ["env", "use", effectiveNodeVersion]); + } + + configureAuth(env.SETUP_VP_REGISTRY_URL || "", env.SETUP_VP_SCOPE || "", env); + + const runInstallEntries = parseRunInstall(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)); + } +} From f222ba2108356341c635b9ada0cf1f0ccb600e3e Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:04:55 +0900 Subject: [PATCH 03/10] chore: include gitlab runtime tests in test config --- vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 2f911ec..474a40c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.ts", "gitlab/**/*.test.mjs"], }, staged: { "*": "vp check --fix", From 894abf166c2252538a23ed722aaf180d6f34f0d7 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:05:16 +0900 Subject: [PATCH 04/10] docs: document GitLab integration usage and rollout details --- README.md | 166 ++++++++++++++++++++++++++++++++++++- rfcs/gitlab-integration.md | 89 +++++++++++++++----- 2 files changed, 233 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2901bc1..dd5dc81 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,169 @@ 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" + node-version: "22" + 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 Node 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: + node-version: "lts" + run-install: | + - cwd: ./packages/app + args: ['--frozen-lockfile'] + - cwd: ./packages/lib + +test: + extends: .setup-vp + image: node:24 + script: + - vp run test +``` + +### With GitLab Node.js Version + +```yaml +include: + - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" + inputs: + node-version: "lts" + +test: + extends: .setup-vp + image: node:24 + script: + - vp run test +``` + +### With GitLab Node.js Version File + +```yaml +include: + - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" + inputs: + node-version-file: ".node-version" + +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` | +| `node-version` | Node.js version to install via `vp env use` | `lts` | +| `node-version-file` | Path to `.nvmrc`, `.node-version`, `.tool-versions`, or `package.json` | | +| `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 Node 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 `bash` and either `curl` or `wget`; Node.js does not need to be preinstalled. +- `node-version-file` takes precedence over `node-version` when both are specified. +- The GitLab template supports `.nvmrc`, `.node-version`, `.tool-versions`, and `package.json` for `node-version-file`. +- 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 diff --git a/rfcs/gitlab-integration.md b/rfcs/gitlab-integration.md index f08eccf..6bb000a 100644 --- a/rfcs/gitlab-integration.md +++ b/rfcs/gitlab-integration.md @@ -7,10 +7,12 @@ The template lets GitLab users install Vite+, set up Node.js through `vp env use`, 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: +The template is published as a plain YAML file plus two runtime files: ```text gitlab/setup-vp.yml +gitlab/bootstrap.sh +gitlab/setup-vp.mjs ``` GitLab users load it with `include:remote`: @@ -57,7 +59,8 @@ Relevant GitLab documentation: 7. Support private registry auth through `registry-url`, `scope`, and `NODE_AUTH_TOKEN`. 8. Support `sfw: true` for `vp install`. -9. Document where GitLab behavior cannot match GitHub Actions. +9. Avoid requiring users to provide Node.js before setup starts. +10. Document where GitLab behavior cannot match GitHub Actions. ## Non-Goals @@ -96,10 +99,11 @@ components resolved from a GitLab component project, which conflicts with the ### Template Shape -`gitlab/setup-vp.yml` defines two YAML documents: +`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 users extend from their jobs. +2. A hidden `.setup-vp` job that exports inputs, downloads `bootstrap.sh`, and + executes it. ```yaml spec: @@ -121,11 +125,43 @@ spec: default: "" scope: default: "" + setup-ref: + default: "v1" --- .setup-vp: before_script: - | - # install Vite+, set up Node.js, auth, sfw, and optional vp install + # 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+, ensures a bootstrap Node is available + through `vp env use` when `node` is not already on `PATH`, downloads + `setup-vp.mjs`, and runs it. +- `gitlab/setup-vp.mjs` handles maintainable logic: node-version-file parsing, + registry auth, `sfw`, `run-install` parsing, install execution, and final + version output. + +This avoids requiring users to choose a Node image before using setup-vp. If the +runner already has Node, bootstrap reuses it. If not, bootstrap installs Vite+ +first and uses `vp env use ` only to make enough Node available to +run `setup-vp.mjs`. When `node-version-file` later resolves to a different +version, the Node runtime runs `vp env use` again with the final version. + +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" ``` ### Execution Flow @@ -133,16 +169,21 @@ spec: The hidden job runs in `before_script` so that the user's `script` can assume `vp` is available. -1. Resolve `working-directory`. -2. Install Vite+ from `https://viteplus.dev/install.sh`. -3. Fall back to the raw GitHub installer if the primary installer fails. -4. Add `~/.vite-plus/bin` to `PATH`. -5. Resolve `node-version-file` when provided. -6. Run `vp env use ` when a Node.js version is available. -7. Configure temporary npm auth when `registry-url` is set. -8. Install or detect `sfw` when `sfw: true`. -9. Run `vp install` when `run-install` is enabled. -10. Print `vp --version`. +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. Install bootstrap Node with `vp env use ` only when `node` is + missing. +7. Download and execute `setup-vp.mjs` from `setup-ref`. +8. Resolve `working-directory`. +9. Resolve `node-version-file` when provided. +10. Run `vp env use ` when a Node.js version is available. +11. Configure temporary npm auth when `registry-url` is set. +12. Install or detect `sfw` when `sfw: true`. +13. Run `vp install` when `run-install` is enabled. +14. Print `vp --version`. ### Node.js Version Resolution @@ -171,7 +212,7 @@ Supported files: - `.tool-versions` - `package.json` -For `package.json`, the template reads `devEngines.runtime` for a `node` entry +For `package.json`, the Node runtime reads `devEngines.runtime` for a `node` entry first, then falls back to `engines.node`, matching the GitHub Action logic. There is one GitLab-specific caveat: because `spec:inputs` applies the @@ -239,6 +280,7 @@ back to plain `vp install`. | `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 `setup-vp.mjs`. | ## GitHub Action Parity @@ -293,6 +335,9 @@ 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 Node 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 @@ -302,8 +347,10 @@ before extending `.setup-vp`; the template will reuse `sfw` from `PATH`. ## Rollout 1. Add `gitlab/setup-vp.yml`. -2. Add this RFC under `docs/`. -3. Document GitLab usage in `README.md`. -4. Validate YAML parsing and shell syntax locally. -5. Validate the remote include through GitLab CI Lint before release. -6. Release under `v1` and an immutable semver tag. +2. Add `gitlab/bootstrap.sh`. +3. Add `gitlab/setup-vp.mjs`. +4. Add this RFC under `rfcs/`. +5. Document GitLab usage in `README.md`. +6. Validate YAML parsing and shell/Node syntax locally. +7. Validate the remote include through GitLab CI Lint before release. +8. Release under `v1` and an immutable semver tag. From 155cbb7a85df16c9651e82ad22a98ac401fc4766 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:05:31 +0900 Subject: [PATCH 05/10] feat: add GitLab template bootstrap and documentation page --- gitlab/bootstrap.sh | 82 +++ gitlab/setup-vp-runtime-explained.html | 717 +++++++++++++++++++++++++ gitlab/setup-vp.test.mjs | 170 ++++++ gitlab/setup-vp.yml | 73 +++ 4 files changed, 1042 insertions(+) create mode 100644 gitlab/bootstrap.sh create mode 100644 gitlab/setup-vp-runtime-explained.html create mode 100644 gitlab/setup-vp.test.mjs create mode 100644 gitlab/setup-vp.yml diff --git a/gitlab/bootstrap.sh b/gitlab/bootstrap.sh new file mode 100644 index 0000000..5dbd72d --- /dev/null +++ b/gitlab/bootstrap.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +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 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_NODE_VERSION="${SETUP_VP_NODE_VERSION:-lts}" +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 + vp env use "$SETUP_VP_NODE_VERSION" +fi + +setup_vp_runtime_url="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${SETUP_VP_SETUP_REF}/gitlab/setup-vp.mjs" +setup_vp_download "$setup_vp_runtime_url" "$setup_vp_runtime_tmp" +node "$setup_vp_runtime_tmp" diff --git a/gitlab/setup-vp-runtime-explained.html b/gitlab/setup-vp-runtime-explained.html new file mode 100644 index 0000000..ad8921b --- /dev/null +++ b/gitlab/setup-vp-runtime-explained.html @@ -0,0 +1,717 @@ + + + + + + gitlab/setup-vp.mjs 読解ガイド + + + + +
+ + +
+
+

gitlab/setup-vp.mjs

+

GitLab runtime 読解ガイド

+

+ このHTMLは gitlab/setup-vp.mjs を読むための道案内です。 + まず実行順を押さえ、そのあと周辺の + parser、auth、sfw、環境変数の引き継ぎを読む構成にしています。 +

+
+ +
+

全体像

+

+ setup-vp.mjs は GitLab template の Node.js runtime です。 + gitlab/bootstrap.sh が Vite+ と Node + を最低限用意したあと、このファイルを実行します。 +

+
+
+

責務

+
    +
  • node-version-file を読む
  • +
  • vp env use を実行する
  • +
  • registry auth 用の .npmrc を作る
  • +
  • run-install を解釈して vp install を実行する
  • +
  • 必要なら sfw vp install に切り替える
  • +
+
+
+

入口

+

+ 最後の direct-execution guard から main() が呼ばれます。 + 読むときは末尾の main() から戻るのが一番追いやすいです。 +

+

setup-vp.mjs を開く

+
+
+

テスト

+

+ parser と auth の主要挙動は + gitlab/setup-vp.test.mjs にまとまっています。 + 読解で迷ったらテストの期待値を先に見ると早いです。 +

+

setup-vp.test.mjs を開く

+
+
+
+ +
+

入力と状態

+

+ この runtime は GitLab include input を直接読むのではなく、setup-vp.yml が + export した SETUP_VP_* 環境変数を読みます。 +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 主な環境変数 +
変数意味
SETUP_VP_NODE_VERSION + vp env use に渡す Node.js version。未指定時は lts。 +
SETUP_VP_NODE_VERSION_FILE + 指定されると version file の内容が node-version より優先されます。 +
SETUP_VP_RUN_INSTALLtruefalse、JSON、または限定的な YAML subset。
SETUP_VP_REGISTRY_URL指定されると一時 .npmrc を作ります。
SETUP_VP_ENV_FILEGitLab job shell に戻す export 文を書き込むファイル。
+
+ +
+

main の流れ

+

まずここを読んでください。細かい helper はあとで戻れば十分です。

+
+
+ 1 +
+ resolveProjectDir(env) + GitLab の CI_PROJECT_DIR と + working-directory から実作業ディレクトリを決めます。 +
+
+
+ 2 +
+ resolveNodeVersionFile(...) + node-version-file があれば、ファイルから Node.js version + を解決します。 +
+
+
+ 3 +
+ run("vp", ["env", "use", version]) + 最終的に決まった Node.js version を Vite+ に設定させます。 +
+
+
+ 4 +
+ configureAuth(...) + private registry 用の npm/pnpm 設定を作ります。未指定なら何もしません。 +
+
+
+ 5 +
+ parseRunInstall(...) + run-install 入力を install 対象の配列に変換します。 +
+
+
+ 6 +
+ setupSfw(...) and runInstall(...) + sfw が有効なら準備し、vp install または + sfw vp install を実行します。 +
+
+
+ 7 +
+ run("vp", ["--version"]) + 最後にインストール結果をログへ出します。 +
+
+
+
+ +
+

Node version 解決

+

+ このブロックは GitHub Action 側の + src/node-version-file.ts に近い考え方です。 対応ファイルごとに parser + が分かれています。 +

+
    +
  • .nvmrc
  • +
  • .node-version
  • +
  • .tool-versions
  • +
  • package.json
  • +
+
+ 読むポイント +
    +
  • + normalizeNodeVersion() は先頭の v を取り、node + / stablelatest に変換します。 +
  • +
  • + parsePlainNodeVersionFile() + は空行とコメントを無視して最初の値を使います。 +
  • +
  • + parseToolVersionsNode()nodejs または + node の行だけを見ます。 +
  • +
  • + parsePackageJsonNode() は + devEngines.runtime を優先し、その後 + engines.node を見ます。 +
  • +
+
+
+ +
+

run-install 解釈

+

+ run-install は GitLab input では文字列として runtime に渡ってきます。 + そのため true / false、JSON、限定的な YAML + をこのファイル内で解釈しています。 +

+
run-install: true
+run-install: false
+run-install: {"cwd":"app","args":["--frozen-lockfile"]}
+run-install: |
+  - cwd: ./packages/app
+    args: ['--frozen-lockfile']
+  - cwd: ./packages/lib
+
+ parser の読み方 +
    +
  1. parseRunInstall() から入ります。
  2. +
  3. normalizeRunInstall() が boolean 文字列と JSON を処理します。
  4. +
  5. JSON ではない場合だけ parseYamlSubset() に落ちます。
  6. +
  7. cwdargs 以外の key は明示的に reject します。
  8. +
+
+

+ ここは汎用 YAML parser ではありません。GitLab input として必要な subset + だけを受け付ける実装です。 +

+
+ +
+

registry auth

+

+ configureAuth()registry-url があるときだけ動きます。 一時 + .npmrc を作り、npm と pnpm がそのファイルを見るように環境変数を設定します。 +

+
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
+@myorg:registry=https://npm.pkg.github.com/
+
+ 重要な設計 +
    +
  • + ${NODE_AUTH_TOKEN} はファイルに literal + として書きます。実値を直接書かないためです。 +
  • +
  • + NODE_AUTH_TOKEN が未設定なら placeholder を入れ、package manager 側の + env 展開エラーを避けます。 +
  • +
  • + GitLab job の後続 script: でも同じ auth を使えるように、必要な export + を SETUP_VP_ENV_FILE へ追記します。 +
  • +
+
+
+ +
+

sfw

+

+ setupSfw()sfw: true かつ + run-install が有効なときだけ、 vp install を + sfw vp install に切り替える準備をします。 +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ sfw の分岐 +
条件結果
SETUP_VP_SFW !== "true"通常の vp install
run-install が空sfw は準備しません。
既存の sfwPATH にあるそれを再利用します。
対応 platform / archSocketDev の pinned release から binary を落として使います。
未対応 platform / archwarning を出して通常の vp install に戻します。
+
+ +
+

GitLab shell への引き継ぎ

+

+ この runtime は child process として実行されるため、Node.js 内で変更した + process.env は そのままでは GitLab job の + script: に戻りません。そこで SETUP_VP_ENV_FILE を使います。 +

+
+
+ 1 +
+ setup-vp.yml + 一時 env ファイルを作り、SETUP_VP_ENV_FILE として bootstrap/runtime + に渡します。 +
+
+
+ 2 +
+ bootstrap.sh + Vite+ の bin directory を追加した PATH を env + ファイルへ書きます。 +
+
+
+ 3 +
+ setup-vp.mjs + auth 変数や、download した sfw の PATH を env + ファイルへ追記します。 +
+
+
+ 4 +
+ setup-vp.yml + bootstrap 終了後に env ファイルを source し、ユーザーの + script: へ設定を残します。 +
+
+
+
export PATH='/home/gitlab/.vite-plus/bin:...'
+export NPM_CONFIG_USERCONFIG='/tmp/setup-vp-npmrc.12345'
+export PNPM_CONFIG_USERCONFIG='/tmp/setup-vp-npmrc.12345'
+export NODE_AUTH_TOKEN='XXXXX-XXXXX-XXXXX-XXXXX'
+
+ +
+

読解チェック

+

一通り読んだあと、次の問いに答えられれば十分に理解できています。

+
    +
  1. + node-version-file と + node-version が両方あると、どちらが使われるか。 +
  2. +
  3. run-install: false のとき、sfw はどう扱われるか。
  4. +
  5. + registry-url 指定時に、実 token が + .npmrc へ直接書かれない理由。 +
  6. +
  7. + download した sfwPATH を GitLab job shell + に戻す必要がある理由。 +
  8. +
  9. run() が non-zero exit を受けたときに、後続処理がどうなるか。
  10. +
+

+ 迷ったら main() に戻り、呼び出される helper + を一つずつ読むのが一番安定します。 +

+
+
+
+ + diff --git a/gitlab/setup-vp.test.mjs b/gitlab/setup-vp.test.mjs new file mode 100644 index 0000000..ebe68b4 --- /dev/null +++ b/gitlab/setup-vp.test.mjs @@ -0,0 +1,170 @@ +import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { + configureAuth, + normalizeNodeVersion, + parseFlowArray, + parsePackageJsonNode, + parsePlainNodeVersionFile, + parseRunInstall, + shellQuote, + parseToolVersionsNode, + resolveNodeVersionFile, + resolveProjectDir, +} from "./setup-vp.mjs"; + +/** @type {string[]} */ +const tempDirs = []; + +function tempDir() { + 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 setup runtime", () => { + 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("normalizes node aliases and v-prefixed versions", () => { + expect(normalizeNodeVersion("v22.11.0")).toBe("22.11.0"); + expect(normalizeNodeVersion("V20")).toBe("20"); + expect(normalizeNodeVersion("node")).toBe("latest"); + expect(normalizeNodeVersion("Node")).toBe("latest"); + expect(normalizeNodeVersion("stable")).toBe("latest"); + expect(normalizeNodeVersion("Stable")).toBe("latest"); + }); + + it("parses plain node version files", () => { + const dir = tempDir(); + const file = path.join(dir, ".node-version"); + writeFileSync(file, "\n# comment\nv24.1.0 # inline\n", "utf8"); + + expect(parsePlainNodeVersionFile(file)).toBe("24.1.0"); + }); + + it("parses .tool-versions node entries", () => { + const dir = tempDir(); + const file = path.join(dir, ".tool-versions"); + writeFileSync(file, "ruby 3.4.0\nnodejs system ref:test 22.3.0\n", "utf8"); + + expect(parseToolVersionsNode(file)).toBe("22.3.0"); + }); + + it("prefers package.json devEngines.runtime over engines.node", () => { + const dir = tempDir(); + const file = path.join(dir, "package.json"); + writeFileSync( + file, + JSON.stringify({ + devEngines: { runtime: [{ name: "node", version: "v24.0.0" }] }, + engines: { node: "22" }, + }), + "utf8", + ); + + expect(parsePackageJsonNode(file)).toBe("24.0.0"); + }); + + it("resolves node version files relative to the project directory", () => { + const dir = tempDir(); + writeFileSync(path.join(dir, ".nvmrc"), "stable\n", "utf8"); + + expect(resolveNodeVersionFile(".nvmrc", dir)).toBe("latest"); + }); + + it("parses run-install booleans, JSON, and the supported YAML subset", () => { + expect(parseRunInstall("false")).toEqual([]); + expect(parseRunInstall("true")).toEqual([{}]); + 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("rejects unsupported run-install keys", () => { + expect(() => parseRunInstall("command: install")).toThrow( + "unsupported run-install key: command", + ); + }); + + it("parses quoted flow array items", () => { + expect(parseFlowArray("['--filter', \"@scope/app\"]")).toEqual(["--filter", "@scope/app"]); + }); + + it("writes registry auth config and updates the provided env object", () => { + /** @type {{ [key: string]: string | undefined }} */ + const targetEnv = {}; + 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("resolves and validates the configured project directory", () => { + const root = tempDir(); + mkdirSync(path.join(root, "web")); + + expect( + resolveProjectDir({ + CI_PROJECT_DIR: root, + SETUP_VP_WORKING_DIRECTORY: "web", + }), + ).toBe(path.join(root, "web")); + }); +}); diff --git a/gitlab/setup-vp.yml b/gitlab/setup-vp.yml new file mode 100644 index 0000000..8fb9b53 --- /dev/null +++ b/gitlab/setup-vp.yml @@ -0,0 +1,73 @@ +spec: + inputs: + version: + description: "Version of Vite+ to install" + default: "latest" + node-version: + description: "Node.js version to install via `vp env use`." + default: "lts" + node-version-file: + description: "Path to file containing the Node.js version spec (.nvmrc, .node-version, .tool-versions, package.json). Takes precedence over node-version when specified." + default: "" + 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 Node 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_NODE_VERSION="$[[ inputs.node-version ]]" + export SETUP_VP_NODE_VERSION_FILE="$[[ inputs.node-version-file ]]" + 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" From 277acafbdc5e21f50109f1237223f81024820842 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:06:29 +0900 Subject: [PATCH 06/10] chore: remove generated GitLab runtime explanation page --- gitlab/setup-vp-runtime-explained.html | 717 ------------------------- 1 file changed, 717 deletions(-) delete mode 100644 gitlab/setup-vp-runtime-explained.html diff --git a/gitlab/setup-vp-runtime-explained.html b/gitlab/setup-vp-runtime-explained.html deleted file mode 100644 index ad8921b..0000000 --- a/gitlab/setup-vp-runtime-explained.html +++ /dev/null @@ -1,717 +0,0 @@ - - - - - - gitlab/setup-vp.mjs 読解ガイド - - - - -
- - -
-
-

gitlab/setup-vp.mjs

-

GitLab runtime 読解ガイド

-

- このHTMLは gitlab/setup-vp.mjs を読むための道案内です。 - まず実行順を押さえ、そのあと周辺の - parser、auth、sfw、環境変数の引き継ぎを読む構成にしています。 -

-
- -
-

全体像

-

- setup-vp.mjs は GitLab template の Node.js runtime です。 - gitlab/bootstrap.sh が Vite+ と Node - を最低限用意したあと、このファイルを実行します。 -

-
-
-

責務

-
    -
  • node-version-file を読む
  • -
  • vp env use を実行する
  • -
  • registry auth 用の .npmrc を作る
  • -
  • run-install を解釈して vp install を実行する
  • -
  • 必要なら sfw vp install に切り替える
  • -
-
-
-

入口

-

- 最後の direct-execution guard から main() が呼ばれます。 - 読むときは末尾の main() から戻るのが一番追いやすいです。 -

-

setup-vp.mjs を開く

-
-
-

テスト

-

- parser と auth の主要挙動は - gitlab/setup-vp.test.mjs にまとまっています。 - 読解で迷ったらテストの期待値を先に見ると早いです。 -

-

setup-vp.test.mjs を開く

-
-
-
- -
-

入力と状態

-

- この runtime は GitLab include input を直接読むのではなく、setup-vp.yml が - export した SETUP_VP_* 環境変数を読みます。 -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- 主な環境変数 -
変数意味
SETUP_VP_NODE_VERSION - vp env use に渡す Node.js version。未指定時は lts。 -
SETUP_VP_NODE_VERSION_FILE - 指定されると version file の内容が node-version より優先されます。 -
SETUP_VP_RUN_INSTALLtruefalse、JSON、または限定的な YAML subset。
SETUP_VP_REGISTRY_URL指定されると一時 .npmrc を作ります。
SETUP_VP_ENV_FILEGitLab job shell に戻す export 文を書き込むファイル。
-
- -
-

main の流れ

-

まずここを読んでください。細かい helper はあとで戻れば十分です。

-
-
- 1 -
- resolveProjectDir(env) - GitLab の CI_PROJECT_DIR と - working-directory から実作業ディレクトリを決めます。 -
-
-
- 2 -
- resolveNodeVersionFile(...) - node-version-file があれば、ファイルから Node.js version - を解決します。 -
-
-
- 3 -
- run("vp", ["env", "use", version]) - 最終的に決まった Node.js version を Vite+ に設定させます。 -
-
-
- 4 -
- configureAuth(...) - private registry 用の npm/pnpm 設定を作ります。未指定なら何もしません。 -
-
-
- 5 -
- parseRunInstall(...) - run-install 入力を install 対象の配列に変換します。 -
-
-
- 6 -
- setupSfw(...) and runInstall(...) - sfw が有効なら準備し、vp install または - sfw vp install を実行します。 -
-
-
- 7 -
- run("vp", ["--version"]) - 最後にインストール結果をログへ出します。 -
-
-
-
- -
-

Node version 解決

-

- このブロックは GitHub Action 側の - src/node-version-file.ts に近い考え方です。 対応ファイルごとに parser - が分かれています。 -

-
    -
  • .nvmrc
  • -
  • .node-version
  • -
  • .tool-versions
  • -
  • package.json
  • -
-
- 読むポイント -
    -
  • - normalizeNodeVersion() は先頭の v を取り、node - / stablelatest に変換します。 -
  • -
  • - parsePlainNodeVersionFile() - は空行とコメントを無視して最初の値を使います。 -
  • -
  • - parseToolVersionsNode()nodejs または - node の行だけを見ます。 -
  • -
  • - parsePackageJsonNode() は - devEngines.runtime を優先し、その後 - engines.node を見ます。 -
  • -
-
-
- -
-

run-install 解釈

-

- run-install は GitLab input では文字列として runtime に渡ってきます。 - そのため true / false、JSON、限定的な YAML - をこのファイル内で解釈しています。 -

-
run-install: true
-run-install: false
-run-install: {"cwd":"app","args":["--frozen-lockfile"]}
-run-install: |
-  - cwd: ./packages/app
-    args: ['--frozen-lockfile']
-  - cwd: ./packages/lib
-
- parser の読み方 -
    -
  1. parseRunInstall() から入ります。
  2. -
  3. normalizeRunInstall() が boolean 文字列と JSON を処理します。
  4. -
  5. JSON ではない場合だけ parseYamlSubset() に落ちます。
  6. -
  7. cwdargs 以外の key は明示的に reject します。
  8. -
-
-

- ここは汎用 YAML parser ではありません。GitLab input として必要な subset - だけを受け付ける実装です。 -

-
- -
-

registry auth

-

- configureAuth()registry-url があるときだけ動きます。 一時 - .npmrc を作り、npm と pnpm がそのファイルを見るように環境変数を設定します。 -

-
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
-@myorg:registry=https://npm.pkg.github.com/
-
- 重要な設計 -
    -
  • - ${NODE_AUTH_TOKEN} はファイルに literal - として書きます。実値を直接書かないためです。 -
  • -
  • - NODE_AUTH_TOKEN が未設定なら placeholder を入れ、package manager 側の - env 展開エラーを避けます。 -
  • -
  • - GitLab job の後続 script: でも同じ auth を使えるように、必要な export - を SETUP_VP_ENV_FILE へ追記します。 -
  • -
-
-
- -
-

sfw

-

- setupSfw()sfw: true かつ - run-install が有効なときだけ、 vp install を - sfw vp install に切り替える準備をします。 -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- sfw の分岐 -
条件結果
SETUP_VP_SFW !== "true"通常の vp install
run-install が空sfw は準備しません。
既存の sfwPATH にあるそれを再利用します。
対応 platform / archSocketDev の pinned release から binary を落として使います。
未対応 platform / archwarning を出して通常の vp install に戻します。
-
- -
-

GitLab shell への引き継ぎ

-

- この runtime は child process として実行されるため、Node.js 内で変更した - process.env は そのままでは GitLab job の - script: に戻りません。そこで SETUP_VP_ENV_FILE を使います。 -

-
-
- 1 -
- setup-vp.yml - 一時 env ファイルを作り、SETUP_VP_ENV_FILE として bootstrap/runtime - に渡します。 -
-
-
- 2 -
- bootstrap.sh - Vite+ の bin directory を追加した PATH を env - ファイルへ書きます。 -
-
-
- 3 -
- setup-vp.mjs - auth 変数や、download した sfw の PATH を env - ファイルへ追記します。 -
-
-
- 4 -
- setup-vp.yml - bootstrap 終了後に env ファイルを source し、ユーザーの - script: へ設定を残します。 -
-
-
-
export PATH='/home/gitlab/.vite-plus/bin:...'
-export NPM_CONFIG_USERCONFIG='/tmp/setup-vp-npmrc.12345'
-export PNPM_CONFIG_USERCONFIG='/tmp/setup-vp-npmrc.12345'
-export NODE_AUTH_TOKEN='XXXXX-XXXXX-XXXXX-XXXXX'
-
- -
-

読解チェック

-

一通り読んだあと、次の問いに答えられれば十分に理解できています。

-
    -
  1. - node-version-file と - node-version が両方あると、どちらが使われるか。 -
  2. -
  3. run-install: false のとき、sfw はどう扱われるか。
  4. -
  5. - registry-url 指定時に、実 token が - .npmrc へ直接書かれない理由。 -
  6. -
  7. - download した sfwPATH を GitLab job shell - に戻す必要がある理由。 -
  8. -
  9. run() が non-zero exit を受けたときに、後続処理がどうなるか。
  10. -
-

- 迷ったら main() に戻り、呼び出される helper - を一つずつ読むのが一番安定します。 -

-
-
-
- - From a2cc7ceeb8aabb364a540c637141b20fdf7d8557 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:39:45 +0900 Subject: [PATCH 07/10] fix: align GitLab runtime bootstrap behavior --- README.md | 4 ++-- gitlab/bootstrap.sh | 4 +--- rfcs/gitlab-integration.md | 23 ++++++++++++----------- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index dd5dc81..f93b885 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ include: version: "latest" node-version: "22" working-directory: "web" - run-install: true + run-install: "true" test: extends: .setup-vp @@ -364,7 +364,7 @@ include: - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" inputs: sfw: true - run-install: true + run-install: "true" test: extends: .setup-vp diff --git a/gitlab/bootstrap.sh b/gitlab/bootstrap.sh index 5dbd72d..a49c25a 100644 --- a/gitlab/bootstrap.sh +++ b/gitlab/bootstrap.sh @@ -73,9 +73,7 @@ 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 - vp env use "$SETUP_VP_NODE_VERSION" -fi +vp env use "$SETUP_VP_NODE_VERSION" setup_vp_runtime_url="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${SETUP_VP_SETUP_REF}/gitlab/setup-vp.mjs" setup_vp_download "$setup_vp_runtime_url" "$setup_vp_runtime_tmp" diff --git a/rfcs/gitlab-integration.md b/rfcs/gitlab-integration.md index 6bb000a..a036e61 100644 --- a/rfcs/gitlab-integration.md +++ b/rfcs/gitlab-integration.md @@ -139,18 +139,19 @@ spec: Implementation logic is split to keep shell small: - `gitlab/setup-vp.yml` handles GitLab inputs and downloads `bootstrap.sh`. -- `gitlab/bootstrap.sh` installs Vite+, ensures a bootstrap Node is available - through `vp env use` when `node` is not already on `PATH`, downloads +- `gitlab/bootstrap.sh` installs Vite+, runs `vp env use ` to + ensure the runtime starts with the requested bootstrap Node, downloads `setup-vp.mjs`, and runs it. - `gitlab/setup-vp.mjs` handles maintainable logic: node-version-file parsing, registry auth, `sfw`, `run-install` parsing, install execution, and final version output. -This avoids requiring users to choose a Node image before using setup-vp. If the -runner already has Node, bootstrap reuses it. If not, bootstrap installs Vite+ -first and uses `vp env use ` only to make enough Node available to -run `setup-vp.mjs`. When `node-version-file` later resolves to a different -version, the Node runtime runs `vp env use` again with the final version. +This avoids requiring users to choose a Node image before using setup-vp. +Bootstrap installs Vite+ first and uses `vp env use ` to make +enough requested Node available to run `setup-vp.mjs`, even when the runner +image already contains an older `node` binary. When `node-version-file` later +resolves to a different version, the Node runtime runs `vp env use` again with +the final version. 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 @@ -174,8 +175,8 @@ The hidden job runs in `before_script` so that the user's `script` can assume 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. Install bootstrap Node with `vp env use ` only when `node` is - missing. +6. Install bootstrap Node with `vp env use ` before starting the + Node runtime. 7. Download and execute `setup-vp.mjs` from `setup-ref`. 8. Resolve `working-directory`. 9. Resolve `node-version-file` when provided. @@ -226,7 +227,7 @@ wins; otherwise `node-version` wins. The default matches GitHub Actions: ```yaml -run-install: true +run-install: "true" ``` The GitLab template also supports multiple install entries: @@ -260,7 +261,7 @@ include: - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" inputs: sfw: true - run-install: true + run-install: "true" ``` If `sfw` is already on `PATH`, the template reuses it. Otherwise it downloads a From ea2c661c5509a5a8d3ebeead0117f1818cd94458 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:56:46 +0900 Subject: [PATCH 08/10] refactor: compile GitLab runtime from TypeScript --- dist/gitlab/index.mjs | 1 + gitlab/bootstrap.sh | 14 +- gitlab/setup-vp.mjs | 676 --------------------------------- gitlab/setup-vp.test.mjs | 170 --------- gitlab/setup-vp.yml | 10 +- src/gitlab/auth.test.ts | 82 ++++ src/gitlab/auth.ts | 44 +++ src/gitlab/index.test.ts | 8 + src/gitlab/index.ts | 32 ++ src/gitlab/install-sfw.test.ts | 53 +++ src/gitlab/install-sfw.ts | 144 +++++++ src/gitlab/run-install.test.ts | 116 ++++++ src/gitlab/run-install.ts | 202 ++++++++++ src/gitlab/shell.test.ts | 47 +++ src/gitlab/shell.ts | 31 ++ src/gitlab/types.test.ts | 19 + src/gitlab/types.ts | 10 + src/gitlab/utils.test.ts | 57 +++ src/gitlab/utils.ts | 27 ++ vite.config.ts | 7 +- 20 files changed, 890 insertions(+), 860 deletions(-) create mode 100644 dist/gitlab/index.mjs delete mode 100644 gitlab/setup-vp.mjs delete mode 100644 gitlab/setup-vp.test.mjs create mode 100644 src/gitlab/auth.test.ts create mode 100644 src/gitlab/auth.ts create mode 100644 src/gitlab/index.test.ts create mode 100644 src/gitlab/index.ts create mode 100644 src/gitlab/install-sfw.test.ts create mode 100644 src/gitlab/install-sfw.ts create mode 100644 src/gitlab/run-install.test.ts create mode 100644 src/gitlab/run-install.ts create mode 100644 src/gitlab/shell.test.ts create mode 100644 src/gitlab/shell.ts create mode 100644 src/gitlab/types.test.ts create mode 100644 src/gitlab/types.ts create mode 100644 src/gitlab/utils.test.ts create mode 100644 src/gitlab/utils.ts diff --git a/dist/gitlab/index.mjs b/dist/gitlab/index.mjs new file mode 100644 index 0000000..8bbe767 --- /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 assignValue(e,t,n){if(t===`cwd`){e.cwd=parseScalar(n);return}if(t===`args`){e.args=parseFlowArray(n);return}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 of e){let e=n.trim();if(!e||e.startsWith(`#`))continue;let r=parseKeyValue(e);if(!r)throw Error(`invalid run-install line: ${n}`);assignValue(t,r[0],r[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=[],r;for(let e of t){let t=e.trimStart();if(t.startsWith(`-`)){r&&n.push(r),r={};let i=t.slice(1).trim();if(i){let t=parseKeyValue(i);if(!t)throw Error(`invalid run-install line: ${e}`);assignValue(r,t[0],t[1])}continue}if(!r)throw Error(`invalid run-install line: ${e}`);let i=parseKeyValue(t);if(!i)throw Error(`invalid run-install line: ${e}`);assignValue(r,i[0],i[1])}return r&&n.push(r),n}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}; \ No newline at end of file diff --git a/gitlab/bootstrap.sh b/gitlab/bootstrap.sh index a49c25a..da83ace 100644 --- a/gitlab/bootstrap.sh +++ b/gitlab/bootstrap.sh @@ -1,6 +1,12 @@ #!/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" @@ -63,7 +69,6 @@ setup_vp_install_viteplus() { } SETUP_VP_VERSION="${SETUP_VP_VERSION:-latest}" -SETUP_VP_NODE_VERSION="${SETUP_VP_NODE_VERSION:-lts}" 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" @@ -73,8 +78,11 @@ setup_vp_install_viteplus export PATH="$HOME/.vite-plus/bin:$PATH" setup_vp_export_env PATH "$PATH" -vp env use "$SETUP_VP_NODE_VERSION" +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}/gitlab/setup-vp.mjs" +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.mjs b/gitlab/setup-vp.mjs deleted file mode 100644 index 991c22d..0000000 --- a/gitlab/setup-vp.mjs +++ /dev/null @@ -1,676 +0,0 @@ -/// -// @ts-check -import { createWriteStream, existsSync, readFileSync, statSync, writeFileSync } 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 { spawnSync } from "node:child_process"; -import { pathToFileURL } from "node:url"; - -const env = process.env; -const NODE_AUTH_TOKEN_REF = "${NODE_AUTH_TOKEN}"; -const SFW_VERSION = "v1.12.0"; -const SFW_RELEASE_BASE = `https://github.com/SocketDev/sfw-free/releases/download/${SFW_VERSION}`; - -/** - * @typedef {{ cwd?: string, args?: string[] }} RunInstallEntry - * @typedef {string | string[] | number | boolean | null | object | undefined} RunInstallField - * @typedef {{ [key: string]: string | undefined }} RuntimeEnv - */ - -/** - * @param {string} message - * @returns {never} - */ -function fail(message) { - console.error(`setup-vp: ${message}`); - process.exit(1); -} - -/** - * @param {string} value - * @returns {string} - */ -export function shellQuote(value) { - return `'${String(value).replaceAll("'", "'\\''")}'`; -} - -/** - * @param {string} name - * @param {string | undefined} value - */ -function exportShellEnv(name, value) { - if (!env.SETUP_VP_ENV_FILE || value === undefined) return; - writeFileSync(env.SETUP_VP_ENV_FILE, `export ${name}=${shellQuote(value)}\n`, { - encoding: "utf8", - flag: "a", - }); -} - -/** - * @param {string} command - * @param {string[]} args - * @param {import("node:child_process").SpawnSyncOptions} [options] - */ -function run(command, args, options = {}) { - const result = spawnSync(command, args, { stdio: "inherit", ...options }); - if (result.error) throw result.error; - if (result.status !== 0) process.exit(result.status ?? 1); -} - -/** - * @param {string | undefined | null} version - * @returns {string} - */ -export function normalizeNodeVersion(version) { - let normalized = String(version || "").replace(/^[vV]/, ""); - const lower = normalized.toLowerCase(); - if (lower === "node" || lower === "stable") normalized = "latest"; - return normalized; -} - -/** - * @param {string} filePath - * @returns {string | undefined} - */ -export function parsePlainNodeVersionFile(filePath) { - for (const rawLine of readFileSync(filePath, "utf8").split(/\r?\n/)) { - const line = rawLine.replace(/#.*$/, "").trim(); - if (line) return normalizeNodeVersion(line); - } - return undefined; -} - -/** - * @param {string} filePath - * @returns {string | undefined} - */ -export function parseToolVersionsNode(filePath) { - for (const rawLine of readFileSync(filePath, "utf8").split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - - const [tool, ...versions] = line.split(/\s+/); - if (tool !== "nodejs" && tool !== "node") continue; - - for (const version of versions) { - if ( - version && - version !== "system" && - !version.startsWith("ref:") && - !version.startsWith("path:") - ) { - return normalizeNodeVersion(version); - } - } - } - return undefined; -} - -/** - * @param {object | null} value - * @returns {value is { devEngines?: { runtime?: { name?: string, version?: string } | { name?: string, version?: string }[] }, engines?: { node?: string } }} - */ -function isPackageJsonLike(value) { - return !!value && typeof value === "object"; -} - -/** - * @param {string} filePath - * @returns {string | undefined} - */ -export function parsePackageJsonNode(filePath) { - let pkg; - try { - pkg = JSON.parse(readFileSync(filePath, "utf8")); - } catch { - throw new Error("Failed to parse package.json: invalid JSON"); - } - - if (!isPackageJsonLike(pkg)) return undefined; - - const runtime = pkg.devEngines?.runtime; - const entries = Array.isArray(runtime) ? runtime : [runtime]; - for (const entry of entries) { - if (entry?.name === "node" && typeof entry.version === "string") { - return normalizeNodeVersion(entry.version); - } - } - - if (typeof pkg.engines?.node === "string") { - return normalizeNodeVersion(pkg.engines.node); - } - - return undefined; -} - -/** - * @param {string} inputPath - * @param {string} projectDir - * @returns {string} - */ -export function resolveNodeVersionFile(inputPath, projectDir) { - const filePath = path.isAbsolute(inputPath) ? inputPath : path.join(projectDir, inputPath); - let version; - - try { - const filename = path.basename(filePath); - if (filename === ".tool-versions") { - version = parseToolVersionsNode(filePath); - } else if (filename === "package.json") { - version = parsePackageJsonNode(filePath); - } else { - version = parsePlainNodeVersionFile(filePath); - } - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - throw new Error(`node-version-file not found: ${filePath}`); - } - throw error; - } - - if (!version) throw new Error(`No Node.js version found in ${inputPath}`); - return version; -} - -/** - * @param {string} value - * @returns {string} - */ -function parseScalar(value) { - const trimmed = String(value || "").trim(); - if ( - (trimmed.startsWith('"') && trimmed.endsWith('"')) || - (trimmed.startsWith("'") && trimmed.endsWith("'")) - ) { - return trimmed.slice(1, -1); - } - return trimmed; -} - -/** - * @param {string} value - * @returns {string[]} - */ -export function parseFlowArray(value) { - 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 = []; - 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; -} - -/** - * @param {string} line - * @returns {[string, string] | undefined} - */ -function parseKeyValue(line) { - const index = line.indexOf(":"); - if (index < 0) return undefined; - return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; -} - -/** - * @param {RunInstallEntry} target - * @param {string} key - * @param {string} value - */ -function assignValue(target, key, value) { - if (key === "cwd") { - target.cwd = parseScalar(value); - return; - } - if (key === "args") { - target.args = parseFlowArray(value); - return; - } - throw new Error(`unsupported run-install key: ${key}`); -} - -/** - * @param {string[]} lines - * @returns {RunInstallEntry} - */ -function parseObject(lines) { - /** @type {RunInstallEntry} */ - const item = {}; - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - const entry = parseKeyValue(line); - if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); - assignValue(item, entry[0], entry[1]); - } - return item; -} - -/** - * @param {string} value - * @returns {RunInstallEntry[]} - */ -export function parseYamlSubset(value) { - 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)]; - } - - /** @type {RunInstallEntry[]} */ - const items = []; - /** @type {RunInstallEntry | undefined} */ - let current = undefined; - for (const rawLine of lines) { - const trimmedStart = rawLine.trimStart(); - if (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}`); - assignValue(current, entry[0], entry[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}`); - assignValue(current, entry[0], entry[1]); - } - if (current) items.push(current); - return items; -} - -/** - * @param {string | undefined | null} value - * @returns {RunInstallEntry[]} - */ -function normalizeRunInstall(value) { - const input = String(value || "").trim(); - if (!input || input === "false" || input === "null") return []; - if (input === "true") return [{}]; - - let parsed; - try { - parsed = JSON.parse(input); - } catch { - return parseYamlSubset(input); - } - - if (parsed === null || parsed === false) return []; - if (parsed === true) return [{}]; - - const entries = Array.isArray(parsed) ? parsed : [parsed]; - return entries.map(normalizeRunInstallItem); -} - -/** - * @param {object | boolean | null} value - * @returns {object} - */ -function asRunInstallRecord(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("run-install entries must be objects with optional cwd and args"); - } - return value; -} - -/** - * @param {object} record - * @param {"cwd" | "args"} key - * @returns {RunInstallField} - */ -function getRunInstallField(record, key) { - return Object.prototype.hasOwnProperty.call(record, key) ? Reflect.get(record, key) : undefined; -} - -/** - * @param {RunInstallField} value - * @returns {value is string[]} - */ -function isStringArray(value) { - return Array.isArray(value) && value.every((arg) => typeof arg === "string"); -} - -/** - * @param {object | boolean | null} item - * @returns {RunInstallEntry} - */ -export function normalizeRunInstallItem(item) { - const candidate = asRunInstallRecord(item); - /** @type {RunInstallEntry} */ - const normalized = {}; - const cwd = getRunInstallField(candidate, "cwd"); - if (cwd !== undefined) { - if (typeof cwd !== "string") throw new Error("run-install.cwd must be a string"); - normalized.cwd = cwd; - } - const args = getRunInstallField(candidate, "args"); - if (args !== undefined) { - if (!isStringArray(args)) { - throw new Error("run-install.args must be an array of strings"); - } - normalized.args = args; - } - return normalized; -} - -/** - * @param {string} value - * @returns {RunInstallEntry[]} - */ -export function parseRunInstall(value) { - return normalizeRunInstall(value).map(normalizeRunInstallItem); -} - -/** - * @param {string} registryUrlInput - * @param {string} scopeInput - * @param {RuntimeEnv} targetEnv - * @returns {string | undefined} - */ -export function configureAuth(registryUrlInput, scopeInput, targetEnv = env) { - if (!registryUrlInput) return; - - let 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 === env) { - exportShellEnv("NPM_CONFIG_USERCONFIG", targetEnv.NPM_CONFIG_USERCONFIG); - exportShellEnv("PNPM_CONFIG_USERCONFIG", targetEnv.PNPM_CONFIG_USERCONFIG); - exportShellEnv("NODE_AUTH_TOKEN", targetEnv.NODE_AUTH_TOKEN); - } - return npmrc; -} - -export function isMuslLinux() { - if (process.platform !== "linux") return false; - try { - const report = /** @type {{ header?: { glibcVersionRuntime?: string } } | undefined} */ ( - process.report?.getReport() - ); - 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. - * - * @param {NodeJS.Platform} platform - * @param {string} arch - * @param {boolean} isMusl - * @returns {string | undefined} - */ -export function getSfwAssetName(platform, arch, isMusl) { - 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}`); -} - -/** - * @returns {string | undefined} - */ -export function sfwAssetName() { - try { - return getSfwAssetName(process.platform, process.arch, isMuslLinux()); - } catch { - return undefined; - } -} - -/** - * @returns {boolean} - */ -export function isSfwSupported() { - return !!sfwAssetName(); -} - -/** - * @returns {string} - */ -function sfwEnvironmentDescription() { - return `process.platform=${process.platform}, process.arch=${process.arch}, musl=${isMuslLinux()}`; -} - -/** - * @param {string} command - * @returns {string | undefined} - */ -function commandPath(command) { - const result = spawnSync("sh", ["-c", `command -v "${command}"`], { encoding: "utf8" }); - if (result.status === 0) return result.stdout.trim(); - return undefined; -} - -/** - * @param {string} url - * @param {string} outputPath - * @param {number} [redirects] - * @returns {Promise} - */ -function downloadFile(url, outputPath, redirects = 0) { - 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); - }); -} - -/** - * @param {RunInstallEntry[]} runInstallEntries - * @returns {Promise<"vp" | "sfw">} - */ -async function setupSfw(runInstallEntries) { - 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); - 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"); -} - -/** - * @param {RunInstallEntry[]} entries - * @param {string} projectDir - * @param {"vp" | "sfw"} installCommand - */ -function runInstall(entries, projectDir, installCommand) { - 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 }); - } -} - -/** - * @param {RuntimeEnv} runtimeEnv - * @returns {string} - */ -export function resolveProjectDir(runtimeEnv = env) { - 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; -} - -/** - * @returns {Promise} - */ -export async function main() { - const nodeVersion = env.SETUP_VP_NODE_VERSION || "lts"; - const nodeVersionFile = env.SETUP_VP_NODE_VERSION_FILE || ""; - const projectDir = resolveProjectDir(env); - - let effectiveNodeVersion = nodeVersion; - if (nodeVersionFile) { - effectiveNodeVersion = resolveNodeVersionFile(nodeVersionFile, projectDir); - console.log( - `setup-vp: resolved Node.js version ${effectiveNodeVersion} from ${nodeVersionFile}`, - ); - } - - if (effectiveNodeVersion) { - run("vp", ["env", "use", effectiveNodeVersion]); - } - - configureAuth(env.SETUP_VP_REGISTRY_URL || "", env.SETUP_VP_SCOPE || "", env); - - const runInstallEntries = parseRunInstall(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/gitlab/setup-vp.test.mjs b/gitlab/setup-vp.test.mjs deleted file mode 100644 index ebe68b4..0000000 --- a/gitlab/setup-vp.test.mjs +++ /dev/null @@ -1,170 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vite-plus/test"; -import { - configureAuth, - normalizeNodeVersion, - parseFlowArray, - parsePackageJsonNode, - parsePlainNodeVersionFile, - parseRunInstall, - shellQuote, - parseToolVersionsNode, - resolveNodeVersionFile, - resolveProjectDir, -} from "./setup-vp.mjs"; - -/** @type {string[]} */ -const tempDirs = []; - -function tempDir() { - 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 setup runtime", () => { - 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("normalizes node aliases and v-prefixed versions", () => { - expect(normalizeNodeVersion("v22.11.0")).toBe("22.11.0"); - expect(normalizeNodeVersion("V20")).toBe("20"); - expect(normalizeNodeVersion("node")).toBe("latest"); - expect(normalizeNodeVersion("Node")).toBe("latest"); - expect(normalizeNodeVersion("stable")).toBe("latest"); - expect(normalizeNodeVersion("Stable")).toBe("latest"); - }); - - it("parses plain node version files", () => { - const dir = tempDir(); - const file = path.join(dir, ".node-version"); - writeFileSync(file, "\n# comment\nv24.1.0 # inline\n", "utf8"); - - expect(parsePlainNodeVersionFile(file)).toBe("24.1.0"); - }); - - it("parses .tool-versions node entries", () => { - const dir = tempDir(); - const file = path.join(dir, ".tool-versions"); - writeFileSync(file, "ruby 3.4.0\nnodejs system ref:test 22.3.0\n", "utf8"); - - expect(parseToolVersionsNode(file)).toBe("22.3.0"); - }); - - it("prefers package.json devEngines.runtime over engines.node", () => { - const dir = tempDir(); - const file = path.join(dir, "package.json"); - writeFileSync( - file, - JSON.stringify({ - devEngines: { runtime: [{ name: "node", version: "v24.0.0" }] }, - engines: { node: "22" }, - }), - "utf8", - ); - - expect(parsePackageJsonNode(file)).toBe("24.0.0"); - }); - - it("resolves node version files relative to the project directory", () => { - const dir = tempDir(); - writeFileSync(path.join(dir, ".nvmrc"), "stable\n", "utf8"); - - expect(resolveNodeVersionFile(".nvmrc", dir)).toBe("latest"); - }); - - it("parses run-install booleans, JSON, and the supported YAML subset", () => { - expect(parseRunInstall("false")).toEqual([]); - expect(parseRunInstall("true")).toEqual([{}]); - 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("rejects unsupported run-install keys", () => { - expect(() => parseRunInstall("command: install")).toThrow( - "unsupported run-install key: command", - ); - }); - - it("parses quoted flow array items", () => { - expect(parseFlowArray("['--filter', \"@scope/app\"]")).toEqual(["--filter", "@scope/app"]); - }); - - it("writes registry auth config and updates the provided env object", () => { - /** @type {{ [key: string]: string | undefined }} */ - const targetEnv = {}; - 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("resolves and validates the configured project directory", () => { - const root = tempDir(); - mkdirSync(path.join(root, "web")); - - expect( - resolveProjectDir({ - CI_PROJECT_DIR: root, - SETUP_VP_WORKING_DIRECTORY: "web", - }), - ).toBe(path.join(root, "web")); - }); -}); diff --git a/gitlab/setup-vp.yml b/gitlab/setup-vp.yml index 8fb9b53..2c4969d 100644 --- a/gitlab/setup-vp.yml +++ b/gitlab/setup-vp.yml @@ -3,12 +3,6 @@ spec: version: description: "Version of Vite+ to install" default: "latest" - node-version: - description: "Node.js version to install via `vp env use`." - default: "lts" - node-version-file: - description: "Path to file containing the Node.js version spec (.nvmrc, .node-version, .tool-versions, package.json). Takes precedence over node-version when specified." - default: "" working-directory: description: "Project directory used for relative paths and default `vp install` execution." default: "." @@ -26,7 +20,7 @@ spec: description: "Optional scope for authenticating against scoped registries." default: "" setup-ref: - description: "setup-vp ref used to download the GitLab bootstrap and Node runtime. Pin this to the same tag or commit as the remote template for strict reproducibility." + 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: @@ -49,8 +43,6 @@ spec: } export SETUP_VP_VERSION="$[[ inputs.version ]]" - export SETUP_VP_NODE_VERSION="$[[ inputs.node-version ]]" - export SETUP_VP_NODE_VERSION_FILE="$[[ inputs.node-version-file ]]" export SETUP_VP_WORKING_DIRECTORY="$[[ inputs.working-directory ]]" SETUP_VP_RUN_INSTALL="$(cat <<'SETUP_VP_RUN_INSTALL_EOF' $[[ inputs.run-install ]] 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..1c4705f --- /dev/null +++ b/src/gitlab/run-install.test.ts @@ -0,0 +1,116 @@ +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("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..27b1640 --- /dev/null +++ b/src/gitlab/run-install.ts @@ -0,0 +1,202 @@ +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 assignValue(target: RunInstallEntry, key: string, value: string): void { + if (key === "cwd") { + target.cwd = parseScalar(value); + return; + } + if (key === "args") { + target.args = parseFlowArray(value); + return; + } + 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 (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const entry = parseKeyValue(line); + if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); + assignValue(item, entry[0], entry[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 items: RunInstallEntry[] = []; + let current: RunInstallEntry | undefined = undefined; + for (const rawLine of lines) { + const trimmedStart = rawLine.trimStart(); + if (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}`); + assignValue(current, entry[0], entry[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}`); + assignValue(current, entry[0], entry[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 474a40c..6d14bd2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,13 +2,16 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ test: { - include: ["src/**/*.test.ts", "gitlab/**/*.test.mjs"], + include: ["src/**/*.test.ts"], }, staged: { "*": "vp check --fix", }, pack: { - entry: ["./src/index.ts"], + entry: { + index: "./src/index.ts", + "gitlab/index": "./src/gitlab/index.ts", + }, format: ["esm"], outDir: "dist", deps: { From 45e42bf9852cc4a01e0dbf33ebfaa8286d60b336 Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:57:33 +0900 Subject: [PATCH 09/10] docs: document GitLab runtime architecture --- README.md | 46 ++------- rfcs/assets/gitlab-runtime-flow.svg | 119 +++++++++++++++++++++++ rfcs/gitlab-integration.md | 142 ++++++++++++---------------- 3 files changed, 187 insertions(+), 120 deletions(-) create mode 100644 rfcs/assets/gitlab-runtime-flow.svg diff --git a/README.md b/README.md index f93b885..7676670 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,6 @@ include: - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" inputs: version: "latest" - node-version: "22" working-directory: "web" run-install: "true" @@ -293,7 +292,7 @@ test: ### With Pinned GitLab Runtime -When using an immutable tag or commit SHA, pin `setup-ref` to the same ref so the bootstrap and Node runtime are downloaded from the same version as the included template: +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: @@ -314,7 +313,6 @@ test: include: - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" inputs: - node-version: "lts" run-install: | - cwd: ./packages/app args: ['--frozen-lockfile'] @@ -327,36 +325,6 @@ test: - vp run test ``` -### With GitLab Node.js Version - -```yaml -include: - - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" - inputs: - node-version: "lts" - -test: - extends: .setup-vp - image: node:24 - script: - - vp run test -``` - -### With GitLab Node.js Version File - -```yaml -include: - - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" - inputs: - node-version-file: ".node-version" - -test: - extends: .setup-vp - image: node:24 - script: - - vp run test -``` - ### With GitLab Socket Firewall Free (sfw) ```yaml @@ -398,23 +366,21 @@ test: | Input | Description | Default | | ------------------- | ------------------------------------------------------------------------------------------------ | -------- | | `version` | Version of Vite+ to install | `latest` | -| `node-version` | Node.js version to install via `vp env use` | `lts` | -| `node-version-file` | Path to `.nvmrc`, `.node-version`, `.tool-versions`, or `package.json` | | | `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 Node runtime | `v1` | +| `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 `bash` and either `curl` or `wget`; Node.js does not need to be preinstalled. -- `node-version-file` takes precedence over `node-version` when both are specified. -- The GitLab template supports `.nvmrc`, `.node-version`, `.tool-versions`, and `package.json` for `node-version-file`. +- 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 @@ -473,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/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 index a036e61..c5fa4a9 100644 --- a/rfcs/gitlab-integration.md +++ b/rfcs/gitlab-integration.md @@ -3,16 +3,19 @@ ## Summary This RFC proposes a GitLab CI/CD remote template for `voidzero-dev/setup-vp`. -The template lets GitLab users install Vite+, set up Node.js through -`vp env use`, configure registry auth, and optionally run `vp install` while -keeping the source of truth in this GitHub repository. +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 two runtime files: +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 -gitlab/setup-vp.mjs +src/gitlab/*.ts +dist/gitlab/index.mjs ``` GitLab users load it with `include:remote`: @@ -53,14 +56,14 @@ Relevant GitLab documentation: 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 `node-version` and `node-version-file`. -6. Support the default `run-install: true` experience and advanced +5. Support the default `run-install: true` experience and advanced `run-install` entries with `cwd` and `args`. -7. Support private registry auth through `registry-url`, `scope`, and +6. Support private registry auth through `registry-url`, `scope`, and `NODE_AUTH_TOKEN`. -8. Support `sfw: true` for `vp install`. -9. Avoid requiring users to provide Node.js before setup starts. -10. Document where GitLab behavior cannot match GitHub Actions. +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 @@ -90,7 +93,7 @@ include: - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1.0.0/gitlab/setup-vp.yml" integrity: "sha256-..." inputs: - node-version: "22" + run-install: "true" ``` `include:component` is intentionally not used. It is designed for GitLab CI/CD @@ -110,10 +113,6 @@ spec: inputs: version: default: "latest" - node-version: - default: "lts" - node-version-file: - default: "" working-directory: default: "." run-install: @@ -139,19 +138,19 @@ spec: Implementation logic is split to keep shell small: - `gitlab/setup-vp.yml` handles GitLab inputs and downloads `bootstrap.sh`. -- `gitlab/bootstrap.sh` installs Vite+, runs `vp env use ` to - ensure the runtime starts with the requested bootstrap Node, downloads - `setup-vp.mjs`, and runs it. -- `gitlab/setup-vp.mjs` handles maintainable logic: node-version-file parsing, - registry auth, `sfw`, `run-install` parsing, install execution, and final - version output. - -This avoids requiring users to choose a Node image before using setup-vp. -Bootstrap installs Vite+ first and uses `vp env use ` to make -enough requested Node available to run `setup-vp.mjs`, even when the runner -image already contains an older `node` binary. When `node-version-file` later -resolves to a different version, the Node runtime runs `vp env use` again with -the final version. +- `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 @@ -165,6 +164,10 @@ include: 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 @@ -175,52 +178,32 @@ The hidden job runs in `before_script` so that the user's `script` can assume 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. Install bootstrap Node with `vp env use ` before starting the - Node runtime. -7. Download and execute `setup-vp.mjs` from `setup-ref`. +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. Resolve `node-version-file` when provided. -10. Run `vp env use ` when a Node.js version is available. -11. Configure temporary npm auth when `registry-url` is set. -12. Install or detect `sfw` when `sfw: true`. -13. Run `vp install` when `run-install` is enabled. -14. Print `vp --version`. - -### Node.js Version Resolution - -`node-version` defaults to `lts`, matching the GitHub Action experience. +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`. -```yaml -include: - - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" - inputs: - node-version: "lts" -``` +### Node.js Runtime Requirement -`node-version-file` takes precedence when specified: +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 -include: - - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1/gitlab/setup-vp.yml" - inputs: - node-version-file: ".node-version" +test: + extends: .setup-vp + image: node:24 + script: + - vp run test ``` -Supported files: - -- `.nvmrc` -- `.node-version` -- `.tool-versions` -- `package.json` - -For `package.json`, the Node runtime reads `devEngines.runtime` for a `node` entry -first, then falls back to `engines.node`, matching the GitHub Action logic. - -There is one GitLab-specific caveat: because `spec:inputs` applies the -`node-version` default before the shell sees it, the template cannot distinguish -"the user omitted `node-version`" from "the user explicitly set `node-version: -lts`". The chosen behavior is simple: if `node-version-file` is set, the file -wins; otherwise `node-version` wins. +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 @@ -274,22 +257,20 @@ back to plain `vp install`. | Input | Default | Description | | ------------------- | -------- | ----------------------------------------------------------------------------- | | `version` | `latest` | Version of Vite+ to install. | -| `node-version` | `lts` | Node.js version to install via `vp env use`. | -| `node-version-file` | | Path to `.nvmrc`, `.node-version`, `.tool-versions`, or `package.json`. | | `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 `setup-vp.mjs`. | +| `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 | Yes | Default is `lts` in both. | -| `node-version-file` | Yes | Yes | Includes `package.json`. | +| `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. | @@ -338,7 +319,7 @@ pinning: - 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 Node runtime downloaded by that 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 @@ -349,9 +330,10 @@ before extending `.setup-vp`; the template will reuse `sfw` from `PATH`. 1. Add `gitlab/setup-vp.yml`. 2. Add `gitlab/bootstrap.sh`. -3. Add `gitlab/setup-vp.mjs`. -4. Add this RFC under `rfcs/`. -5. Document GitLab usage in `README.md`. -6. Validate YAML parsing and shell/Node syntax locally. -7. Validate the remote include through GitLab CI Lint before release. -8. Release under `v1` and an immutable semver tag. +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. From 23b47009687b4169fd38de61f8d607aed144bffc Mon Sep 17 00:00:00 2001 From: naokihaba <59875779+naokihaba@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:18:16 +0900 Subject: [PATCH 10/10] fix: parse GitLab block YAML install args --- dist/gitlab/index.mjs | 2 +- src/gitlab/run-install.test.ts | 9 +++++ src/gitlab/run-install.ts | 74 +++++++++++++++++++++++++++++----- 3 files changed, 75 insertions(+), 10 deletions(-) diff --git a/dist/gitlab/index.mjs b/dist/gitlab/index.mjs index 8bbe767..90a3186 100644 --- a/dist/gitlab/index.mjs +++ b/dist/gitlab/index.mjs @@ -1 +1 @@ -import{get as e}from"node:http";import{pathToFileURL as t}from"node:url";import n from"node:path";import{createWriteStream as r,existsSync as i,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 assignValue(e,t,n){if(t===`cwd`){e.cwd=parseScalar(n);return}if(t===`args`){e.args=parseFlowArray(n);return}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 of e){let e=n.trim();if(!e||e.startsWith(`#`))continue;let r=parseKeyValue(e);if(!r)throw Error(`invalid run-install line: ${n}`);assignValue(t,r[0],r[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=[],r;for(let e of t){let t=e.trimStart();if(t.startsWith(`-`)){r&&n.push(r),r={};let i=t.slice(1).trim();if(i){let t=parseKeyValue(i);if(!t)throw Error(`invalid run-install line: ${e}`);assignValue(r,t[0],t[1])}continue}if(!r)throw Error(`invalid run-install line: ${e}`);let i=parseKeyValue(t);if(!i)throw Error(`invalid run-install line: ${e}`);assignValue(r,i[0],i[1])}return r&&n.push(r),n}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}; \ No newline at end of file +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 { ]); }); + 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", diff --git a/src/gitlab/run-install.ts b/src/gitlab/run-install.ts index 27b1640..fa0e986 100644 --- a/src/gitlab/run-install.ts +++ b/src/gitlab/run-install.ts @@ -55,14 +55,51 @@ function parseKeyValue(line: string): [string, string] | undefined { return [line.slice(0, index).trim(), line.slice(index + 1).trim()]; } -function assignValue(target: RunInstallEntry, key: string, value: string): void { +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; + return false; } if (key === "args") { + if (!value) return true; target.args = parseFlowArray(value); - return; + return false; } throw new Error(`unsupported run-install key: ${key}`); } @@ -111,12 +148,18 @@ function validateRunInstallInput(value: unknown): RunInstallInput { function parseObject(lines: string[]): RunInstallEntry { const item: RunInstallEntry = {}; - for (const rawLine of lines) { + 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}`); - assignValue(item, entry[0], entry[1]); + 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; } @@ -129,18 +172,26 @@ export function parseYamlSubset(value: string): RunInstallEntry[] { return [parseObject(lines)]; } + const topLevelIndent = countIndent(lines[0]); const items: RunInstallEntry[] = []; let current: RunInstallEntry | undefined = undefined; - for (const rawLine of lines) { + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index]; + const indent = countIndent(rawLine); const trimmedStart = rawLine.trimStart(); - if (trimmedStart.startsWith("-")) { + 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}`); - assignValue(current, entry[0], entry[1]); + 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; } @@ -148,7 +199,12 @@ export function parseYamlSubset(value: string): RunInstallEntry[] { if (!current) throw new Error(`invalid run-install line: ${rawLine}`); const entry = parseKeyValue(trimmedStart); if (!entry) throw new Error(`invalid run-install line: ${rawLine}`); - assignValue(current, entry[0], entry[1]); + 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;