From 7bc3ddde4a7b5d102e5632f92ec245b56d7dee22 Mon Sep 17 00:00:00 2001 From: MK Date: Fri, 9 Oct 2026 00:02:37 +0800 Subject: [PATCH] fix: pass GitLab inputs as literal environment data --- README.md | 2 + gitlab/setup-vp-windows.yml | 92 +++++++++++--------- gitlab/setup-vp.yml | 118 ++++++++++++-------------- src/gitlab/template.test.ts | 164 +++++++++++++++++++++++++++++++++++- 4 files changed, 269 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 79d3974..f0a2235 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,8 @@ test: ### With GitLab Inputs +String inputs are passed as literal environment values. Quotes, dollar signs, and multiline values do not become shell commands. Inputs take precedence over the corresponding `SETUP_VP_*` variables when you extend `.setup-vp` or `.setup-vp-cached`; use `.setup-vp-bootstrap` directly to configure setup through variables instead. + `setup-ref` accepts ASCII letters, digits, underscores, and hyphens, with single dots or slashes between these groups (for example, `v1.21.1` or `refs/tags/v1.21.1`). The templates and bootstrap scripts reject other values before downloading executable files. The same validation applies to `SETUP_VP_SETUP_REF` when using `.setup-vp-bootstrap` directly. Keep this value under maintainer control because it selects code that runs on the runner. ```yaml diff --git a/gitlab/setup-vp-windows.yml b/gitlab/setup-vp-windows.yml index 8d5a7f4..a2df0bc 100644 --- a/gitlab/setup-vp-windows.yml +++ b/gitlab/setup-vp-windows.yml @@ -73,47 +73,61 @@ spec: } .setup-vp: + # Keep string inputs out of shell source and disable variable expansion. + variables: + SETUP_VP_INPUT_VERSION: + value: $[[ inputs.version ]] + expand: false + SETUP_VP_INPUT_VERSION_FILE: + value: $[[ inputs.version-file ]] + expand: false + SETUP_VP_INPUT_NODE_VERSION: + value: $[[ inputs.node-version ]] + expand: false + SETUP_VP_INPUT_NODE_VERSION_FILE: + value: $[[ inputs.node-version-file ]] + expand: false + SETUP_VP_INPUT_CACHE_DEPENDENCY_PATH: + value: $[[ inputs.cache-dependency-path ]] + expand: false + SETUP_VP_INPUT_WORKING_DIRECTORY: + value: $[[ inputs.working-directory ]] + expand: false + SETUP_VP_INPUT_RUN_INSTALL: + value: $[[ inputs.run-install ]] + expand: false + SETUP_VP_INPUT_PACKAGE_MANAGER: + value: $[[ inputs.package-manager ]] + expand: false + SETUP_VP_INPUT_NODE_MANAGER: + value: $[[ inputs.node-manager ]] + expand: false + SETUP_VP_INPUT_REGISTRY_URL: + value: $[[ inputs.registry-url ]] + expand: false + SETUP_VP_INPUT_SCOPE: + value: $[[ inputs.scope ]] + expand: false + SETUP_VP_INPUT_SETUP_REF: + value: $[[ inputs.setup-ref ]] + expand: false before_script: - | - $env:SETUP_VP_VERSION = @' - $[[ inputs.version ]] - '@ - $env:SETUP_VP_VERSION_FILE = @' - $[[ inputs.version-file ]] - '@ - $env:SETUP_VP_NODE_VERSION = @' - $[[ inputs.node-version ]] - '@ - $env:SETUP_VP_NODE_VERSION_FILE = @' - $[[ inputs.node-version-file ]] - '@ - $env:SETUP_VP_CACHE_DEPENDENCY_PATH = @' - $[[ inputs.cache-dependency-path ]] - '@ - $env:SETUP_VP_WORKING_DIRECTORY = @' - $[[ inputs.working-directory ]] - '@ - $env:SETUP_VP_RUN_INSTALL = @' - $[[ inputs.run-install ]] - '@ - $env:SETUP_VP_SFW = @' - $[[ inputs.sfw ]] - '@ - $env:SETUP_VP_PACKAGE_MANAGER = @' - $[[ inputs.package-manager ]] - '@ - $env:SETUP_VP_NODE_MANAGER = @' - $[[ inputs.node-manager ]] - '@ - $env:SETUP_VP_REGISTRY_URL = @' - $[[ inputs.registry-url ]] - '@ - $env:SETUP_VP_SCOPE = @' - $[[ inputs.scope ]] - '@ - $env:SETUP_VP_SETUP_REF = @' - $[[ inputs.setup-ref ]] - '@ + # Copy inputs at runtime so they still override SETUP_VP_* variables. + $env:SETUP_VP_VERSION = $env:SETUP_VP_INPUT_VERSION + $env:SETUP_VP_VERSION_FILE = $env:SETUP_VP_INPUT_VERSION_FILE + $env:SETUP_VP_NODE_VERSION = $env:SETUP_VP_INPUT_NODE_VERSION + $env:SETUP_VP_NODE_VERSION_FILE = $env:SETUP_VP_INPUT_NODE_VERSION_FILE + $env:SETUP_VP_CACHE_DEPENDENCY_PATH = $env:SETUP_VP_INPUT_CACHE_DEPENDENCY_PATH + $env:SETUP_VP_WORKING_DIRECTORY = $env:SETUP_VP_INPUT_WORKING_DIRECTORY + $env:SETUP_VP_RUN_INSTALL = $env:SETUP_VP_INPUT_RUN_INSTALL + $env:SETUP_VP_PACKAGE_MANAGER = $env:SETUP_VP_INPUT_PACKAGE_MANAGER + $env:SETUP_VP_NODE_MANAGER = $env:SETUP_VP_INPUT_NODE_MANAGER + $env:SETUP_VP_REGISTRY_URL = $env:SETUP_VP_INPUT_REGISTRY_URL + $env:SETUP_VP_SCOPE = $env:SETUP_VP_INPUT_SCOPE + $env:SETUP_VP_SETUP_REF = $env:SETUP_VP_INPUT_SETUP_REF + # GitLab validates these inputs as a boolean and a fixed option list. + $env:SETUP_VP_SFW = '$[[ inputs.sfw ]]' if ('$[[ inputs.cache-policy ]]' -eq 'pull') { $env:SETUP_VP_CACHE_SAVE = 'false' } - !reference [.setup-vp-bootstrap, before_script] diff --git a/gitlab/setup-vp.yml b/gitlab/setup-vp.yml index 1960ec6..aeb4976 100644 --- a/gitlab/setup-vp.yml +++ b/gitlab/setup-vp.yml @@ -101,74 +101,62 @@ spec: . "$setup_vp_env_tmp" .setup-vp: + # Keep string inputs out of shell source and disable variable expansion. + variables: + SETUP_VP_INPUT_VERSION: + value: $[[ inputs.version ]] + expand: false + SETUP_VP_INPUT_VERSION_FILE: + value: $[[ inputs.version-file ]] + expand: false + SETUP_VP_INPUT_NODE_VERSION: + value: $[[ inputs.node-version ]] + expand: false + SETUP_VP_INPUT_NODE_VERSION_FILE: + value: $[[ inputs.node-version-file ]] + expand: false + SETUP_VP_INPUT_CACHE_DEPENDENCY_PATH: + value: $[[ inputs.cache-dependency-path ]] + expand: false + SETUP_VP_INPUT_WORKING_DIRECTORY: + value: $[[ inputs.working-directory ]] + expand: false + SETUP_VP_INPUT_RUN_INSTALL: + value: $[[ inputs.run-install ]] + expand: false + SETUP_VP_INPUT_PACKAGE_MANAGER: + value: $[[ inputs.package-manager ]] + expand: false + SETUP_VP_INPUT_NODE_MANAGER: + value: $[[ inputs.node-manager ]] + expand: false + SETUP_VP_INPUT_REGISTRY_URL: + value: $[[ inputs.registry-url ]] + expand: false + SETUP_VP_INPUT_SCOPE: + value: $[[ inputs.scope ]] + expand: false + SETUP_VP_INPUT_SETUP_REF: + value: $[[ inputs.setup-ref ]] + expand: false before_script: - | - SETUP_VP_VERSION="$(cat <<'SETUP_VP_VERSION_EOF' - $[[ inputs.version ]] - SETUP_VP_VERSION_EOF - )" - export SETUP_VP_VERSION - SETUP_VP_VERSION_FILE="$(cat <<'SETUP_VP_VERSION_FILE_EOF' - $[[ inputs.version-file ]] - SETUP_VP_VERSION_FILE_EOF - )" - export SETUP_VP_VERSION_FILE - SETUP_VP_NODE_VERSION="$(cat <<'SETUP_VP_NODE_VERSION_EOF' - $[[ inputs.node-version ]] - SETUP_VP_NODE_VERSION_EOF - )" - export SETUP_VP_NODE_VERSION - SETUP_VP_NODE_VERSION_FILE="$(cat <<'SETUP_VP_NODE_VERSION_FILE_EOF' - $[[ inputs.node-version-file ]] - SETUP_VP_NODE_VERSION_FILE_EOF - )" - export SETUP_VP_NODE_VERSION_FILE - SETUP_VP_CACHE_DEPENDENCY_PATH="$(cat <<'SETUP_VP_CACHE_DEPENDENCY_PATH_EOF' - $[[ inputs.cache-dependency-path ]] - SETUP_VP_CACHE_DEPENDENCY_PATH_EOF - )" - export SETUP_VP_CACHE_DEPENDENCY_PATH + # Copy inputs at runtime so they still override SETUP_VP_* variables. + export SETUP_VP_VERSION="$SETUP_VP_INPUT_VERSION" + export SETUP_VP_VERSION_FILE="$SETUP_VP_INPUT_VERSION_FILE" + export SETUP_VP_NODE_VERSION="$SETUP_VP_INPUT_NODE_VERSION" + export SETUP_VP_NODE_VERSION_FILE="$SETUP_VP_INPUT_NODE_VERSION_FILE" + export SETUP_VP_CACHE_DEPENDENCY_PATH="$SETUP_VP_INPUT_CACHE_DEPENDENCY_PATH" + export SETUP_VP_WORKING_DIRECTORY="$SETUP_VP_INPUT_WORKING_DIRECTORY" + export SETUP_VP_RUN_INSTALL="$SETUP_VP_INPUT_RUN_INSTALL" + export SETUP_VP_PACKAGE_MANAGER="$SETUP_VP_INPUT_PACKAGE_MANAGER" + export SETUP_VP_NODE_MANAGER="$SETUP_VP_INPUT_NODE_MANAGER" + export SETUP_VP_REGISTRY_URL="$SETUP_VP_INPUT_REGISTRY_URL" + export SETUP_VP_SCOPE="$SETUP_VP_INPUT_SCOPE" + export SETUP_VP_SETUP_REF="$SETUP_VP_INPUT_SETUP_REF" + # GitLab validates these inputs as a boolean and a fixed option list. + export SETUP_VP_SFW='$[[ inputs.sfw ]]' if [ '$[[ inputs.cache-policy ]]' = 'pull' ]; then export SETUP_VP_CACHE_SAVE=false; fi - SETUP_VP_WORKING_DIRECTORY="$(cat <<'SETUP_VP_WORKING_DIRECTORY_EOF' - $[[ inputs.working-directory ]] - SETUP_VP_WORKING_DIRECTORY_EOF - )" - export SETUP_VP_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 - SETUP_VP_SFW="$(cat <<'SETUP_VP_SFW_EOF' - $[[ inputs.sfw ]] - SETUP_VP_SFW_EOF - )" - export SETUP_VP_SFW - SETUP_VP_PACKAGE_MANAGER="$(cat <<'SETUP_VP_PACKAGE_MANAGER_EOF' - $[[ inputs.package-manager ]] - SETUP_VP_PACKAGE_MANAGER_EOF - )" - export SETUP_VP_PACKAGE_MANAGER - SETUP_VP_NODE_MANAGER="$(cat <<'SETUP_VP_NODE_MANAGER_EOF' - $[[ inputs.node-manager ]] - SETUP_VP_NODE_MANAGER_EOF - )" - export SETUP_VP_NODE_MANAGER - SETUP_VP_REGISTRY_URL="$(cat <<'SETUP_VP_REGISTRY_URL_EOF' - $[[ inputs.registry-url ]] - SETUP_VP_REGISTRY_URL_EOF - )" - export SETUP_VP_REGISTRY_URL - SETUP_VP_SCOPE="$(cat <<'SETUP_VP_SCOPE_EOF' - $[[ inputs.scope ]] - SETUP_VP_SCOPE_EOF - )" - export SETUP_VP_SCOPE - SETUP_VP_SETUP_REF="$(cat <<'SETUP_VP_SETUP_REF_EOF' - $[[ inputs.setup-ref ]] - SETUP_VP_SETUP_REF_EOF - )" - export SETUP_VP_SETUP_REF - !reference [.setup-vp-bootstrap, before_script] # Opt in to native GitLab persistence without replacing an existing job cache. diff --git a/src/gitlab/template.test.ts b/src/gitlab/template.test.ts index 2fe59e3..becb51d 100644 --- a/src/gitlab/template.test.ts +++ b/src/gitlab/template.test.ts @@ -1,4 +1,7 @@ -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { parseAllDocuments } from "yaml"; @@ -16,6 +19,91 @@ function readTemplate(name: string) { return { text, inputs: docs[0]!.toJSON().spec.inputs, jobs: docs[1]!.toJSON() }; } +type Shell = "bash" | "sh" | "powershell"; +const runtimeInputs = Object.keys(readTemplate("setup-vp.yml").inputs).filter( + (name) => name !== "cache-policy" && name !== "cache-namespace", +); +const unrestrictedInputs = runtimeInputs.filter((name) => { + const input = readTemplate("setup-vp.yml").inputs[name]; + return !input.type && !input.regex && !input.options; +}); + +function envName(input: string) { + return "SETUP_VP_" + input.toUpperCase().replaceAll("-", "_"); +} + +function runInputAssignments( + shell: Shell, + overrides: Record = {}, + cacheSave = "", +) { + const { inputs, jobs } = readTemplate( + shell === "powershell" ? "setup-vp-windows.yml" : "setup-vp.yml", + ); + const values = Object.fromEntries( + Object.entries(inputs).map(([name, input]) => [name, (input as { default: unknown }).default]), + ); + Object.assign(values, overrides); + const interpolate = (value: string) => + value.replace(/\$\[\[ inputs\.([a-z-]+) \]\]/g, (_, name: string) => String(values[name])); + const setup = jobs[".setup-vp"]; + const directory = mkdtempSync(join(tmpdir(), "setup-vp-gitlab-inputs-")); + const marker = join(directory, "injected"); + const dump = join(directory, "dump.mjs"); + const scriptPath = join(directory, shell === "powershell" ? "inputs.ps1" : "inputs.sh"); + const names = [...runtimeInputs.map(envName), "SETUP_VP_CACHE_SAVE"]; + writeFileSync( + dump, + `console.log(JSON.stringify(Object.fromEntries(${JSON.stringify(names)}.map(name => [name, process.env[name] ?? ""]))));`, + ); + // Interpolate the actual script too: restoring inline inputs must make the + // delimiter payloads execute and fail these regression tests. + writeFileSync( + scriptPath, + (shell === "powershell" ? "$ErrorActionPreference = 'Stop'\n" : "set -eu\n") + + interpolate(setup.before_script[0]) + + (shell === "powershell" + ? "\n& $env:SETUP_VP_TEST_NODE $env:SETUP_VP_TEST_DUMP\n" + : '\n"$SETUP_VP_TEST_NODE" "$SETUP_VP_TEST_DUMP"\n'), + ); + const env = { ...process.env }; + for (const name of Object.keys(env)) { + if (name.startsWith("SETUP_VP_")) delete env[name]; + } + // Component inputs must still replace pre-existing runtime variables. + for (const name of runtimeInputs) env[envName(name)] = "ignored"; + for (const [name, variable] of Object.entries(setup.variables ?? {})) { + const value = variable as { value: string; expand: boolean }; + expect(value.expand).toBe(false); + env[name] = interpolate(value.value); + } + try { + const result = spawnSync( + shell === "powershell" ? "powershell.exe" : shell, + shell === "powershell" + ? ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath] + : [scriptPath], + { + encoding: "utf8", + timeout: 10_000, + env: { + ...env, + SETUP_VP_CACHE_SAVE: cacheSave, + SETUP_VP_TEST_MARKER: marker, + SETUP_VP_TEST_NODE: process.execPath, + SETUP_VP_TEST_DUMP: dump, + }, + }, + ); + expect(result.error).toBeUndefined(); + expect(existsSync(marker)).toBe(false); + expect(result.status, result.stderr).toBe(0); + return JSON.parse(result.stdout); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + describe("GitLab native templates", () => { it.each(["setup-vp.yml", "setup-vp-windows.yml"])( "%s defaults to the package.json release", @@ -74,12 +162,82 @@ describe("GitLab native templates", () => { }, ); - it("uses literal PowerShell input strings and sources the generated environment", () => { + it("sources the generated PowerShell environment", () => { const { jobs } = readTemplate("setup-vp-windows.yml"); - expect(jobs[".setup-vp"].before_script[0]).toContain("\n'@\n"); const bootstrap = jobs[".setup-vp-bootstrap"].before_script[0]; expect(bootstrap).toContain("SETUP_VP_ENV_FORMAT = 'powershell'"); expect(bootstrap).toContain(". $envFile"); expect(bootstrap).toContain("finally"); }); + + it("passes identical literal string inputs through job variables on both platforms", () => { + const unix = readTemplate("setup-vp.yml"); + const windows = readTemplate("setup-vp-windows.yml"); + expect(unix.jobs[".setup-vp"].variables).toEqual(windows.jobs[".setup-vp"].variables); + for (const { jobs } of [unix, windows]) { + const setup = jobs[".setup-vp"]; + for (const name of runtimeInputs.filter((name) => name !== "sfw")) { + expect(Object.values(setup.variables ?? {})).toContainEqual({ + value: `$[[ inputs.${name} ]]`, + expand: false, + }); + expect(setup.before_script[0]).not.toContain(`$[[ inputs.${name} ]]`); + } + } + }); + + for (const shell of ["bash", "sh", "powershell"] as const) { + describe.skipIf((shell === "powershell") !== (process.platform === "win32"))( + `${shell} input assignments`, + () => { + it.each(unrestrictedInputs)("keeps delimiter payloads in %s as data", (name) => { + const delimiter = envName(name) + "_EOF"; + const payload = + shell === "powershell" + ? "false\n'@\nSet-Content -LiteralPath $env:SETUP_VP_TEST_MARKER -Value injected\n$null = @'" + : `false\n${delimiter}\nprintf injected > "$SETUP_VP_TEST_MARKER"\ncat <<'${delimiter}'`; + expect(runInputAssignments(shell, { [name]: payload })[envName(name)]).toBe(payload); + }); + + it("preserves defaults and input precedence over runtime variables", () => { + const { inputs } = readTemplate("setup-vp.yml"); + expect(runInputAssignments(shell)).toEqual({ + ...Object.fromEntries( + runtimeInputs.map((name) => [envName(name), String(inputs[name].default)]), + ), + SETUP_VP_CACHE_SAVE: "", + }); + }); + + it.each([ + "", + "quotes: '\"\n$CI_JOB_TOKEN ${TOKEN} $(echo injected) `echo injected`\r\nlast line\n\n", + ])("preserves empty and special-character input values: %j", (value) => { + const values = Object.fromEntries(unrestrictedInputs.map((name) => [name, value])); + const result = runInputAssignments(shell, values); + for (const name of unrestrictedInputs) expect(result[envName(name)]).toBe(value); + }); + + it("preserves structured inputs and handles validated boolean and cache inputs", () => { + const runInstall = + "- cwd: ./packages/app\n args: ['--frozen-lockfile']\n- cwd: ./packages/lib\n"; + const packageManager = "npm: false\npnpm: true\n"; + const result = runInputAssignments(shell, { + "run-install": runInstall, + "package-manager": packageManager, + sfw: true, + "cache-policy": "pull", + }); + expect(result.SETUP_VP_RUN_INSTALL).toBe(runInstall); + expect(result.SETUP_VP_PACKAGE_MANAGER).toBe(packageManager); + expect(result.SETUP_VP_SFW).toBe("true"); + expect(result.SETUP_VP_CACHE_SAVE).toBe("false"); + expect( + runInputAssignments(shell, { "cache-policy": "pull-push" }, "false") + .SETUP_VP_CACHE_SAVE, + ).toBe("false"); + }); + }, + ); + } });