|
| 1 | +# Bug Investigation Report: v42.2.1 Deployment Validation Errors |
| 2 | + |
| 3 | +> Track: fix-vercel-validation-20260430 |
| 4 | +> Issue: [#359](https://github.com/amondnet/vercel-action/issues/359) |
| 5 | +
|
| 6 | +## 1. Reproduction Status |
| 7 | + |
| 8 | +**Reproduced** — both errors confirmed via static code-path tracing through `src/`, `dist/index.js` (bundled `@vercel/client`), and the Vercel CLI deploy code referenced by PR #350. |
| 9 | + |
| 10 | +### Reproduction Path — Bug 1 (`projectSettings.nodeVersion` rejected) |
| 11 | + |
| 12 | +1. `src/index.ts` reads action inputs and builds `ActionConfig`, then calls `client.deploy()`. |
| 13 | +2. `src/project-config.ts:51-52` — `readNodeVersion()` returns `parsed.engines?.node` as-is (e.g. `">=24.0.0"`). |
| 14 | +3. `src/project-config.ts:114-116` — `buildProjectConfig()` assigns `projectSettings.nodeVersion = nodeVersion` verbatim. |
| 15 | +4. `src/vercel-api.ts:138-140` — `applyProjectConfig()` sets `options.projectSettings = projectConfig.projectSettings`. |
| 16 | +5. `src/vercel-api.ts:177` — `createDeployment(clientOptions, deploymentOptions)` is called. |
| 17 | +6. `dist/index.js:35126-35129` — `@vercel/client.postDeployment` does `JSON.stringify({ ...deploymentOptions, files })`, sending `projectSettings.nodeVersion = ">=24.0.0"` to the REST API. |
| 18 | +7. **Failure**: Vercel REST API validates `projectSettings.nodeVersion` against the enum `["24.x","22.x","20.x"]` and returns `400 bad_request`. |
| 19 | + |
| 20 | +### Reproduction Path — Bug 2 (`nowConfig` rejected) |
| 21 | + |
| 22 | +1. `src/project-config.ts:98-100` — `buildProjectConfig()` sets `result.nowConfig = sanitizeNowConfig(vercelJson)`. |
| 23 | +2. `src/vercel-api.ts:135-137` — `applyProjectConfig()` does `Object.assign(options, { nowConfig: projectConfig.nowConfig })`. |
| 24 | +3. `src/vercel-api.ts:177` — `createDeployment(clientOptions, deploymentOptions)` is called. |
| 25 | +4. `dist/index.js:35126-35129` — `postDeployment` raw-spreads `deploymentOptions` into the JSON body, so `nowConfig` becomes a top-level REST API field. |
| 26 | +5. **Failure**: Vercel REST API rejects `nowConfig` as `additionalProperty` (`400 bad_request`). |
| 27 | + |
| 28 | +## 2. Root Cause Analysis |
| 29 | + |
| 30 | +### Bug 1 |
| 31 | +**Problem Location**: `src/project-config.ts:38-58`, function `readNodeVersion()`, lines 51-52. |
| 32 | + |
| 33 | +**Root Cause**: The function returns `parsed.engines?.node` verbatim. The Vercel REST API's `projectSettings.nodeVersion` field is an enum (`"24.x" | "22.x" | "20.x"`), not a free-form semver range. Any user value that is not already in canonical `NN.x` form (e.g., `">=24.0.0"`, `"^20.0.0"`, `"24.0.0"`, `">=18"`) is rejected with HTTP 400. |
| 34 | + |
| 35 | +The Vercel CLI normalizes this via `@vercel/build-utils` `getSupportedNodeVersion()` (visible at `dist/index.js:32044`). It runs `semver.intersects(o.range, engineRange)` against an internal `NODE_VERSIONS` array and returns the canonical `NN.x` string. The action does not perform this step. |
| 36 | + |
| 37 | +```typescript |
| 38 | +// src/project-config.ts:46-58 — current |
| 39 | +export function readNodeVersion(workingDirectory: string): string | undefined { |
| 40 | + const filePath = path.join(resolveWorkingDir(workingDirectory), 'package.json') |
| 41 | + let raw: string |
| 42 | + try { raw = readFileSync(filePath, 'utf8') } |
| 43 | + catch { return undefined } |
| 44 | + |
| 45 | + try { |
| 46 | + const parsed = JSON.parse(raw) as { engines?: { node?: unknown } } |
| 47 | + const node = parsed.engines?.node |
| 48 | + return typeof node === 'string' ? node : undefined // ← returns ">=24.0.0" unchanged |
| 49 | + } |
| 50 | + catch { return undefined } |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +### Bug 2 |
| 55 | +**Problem Location**: `src/vercel-api.ts:130-141`, function `applyProjectConfig()`, lines 135-137. |
| 56 | + |
| 57 | +**Root Cause**: The function injects `nowConfig` as a top-level field on `DeploymentOptions`. `@vercel/client.postDeployment` (`dist/index.js:35101`) raw-spreads `DeploymentOptions` into the JSON body. The Vercel REST API rejects `nowConfig` as `additionalProperty`. |
| 58 | + |
| 59 | +The comment claiming the API accepts `nowConfig` is incorrect: |
| 60 | + |
| 61 | +```typescript |
| 62 | +// src/vercel-api.ts:130-141 — current |
| 63 | +function applyProjectConfig(options: DeploymentOptions, config: ActionConfig): void { |
| 64 | + // nowConfig is accepted by the REST API but not declared in DeploymentOptions ← FALSE |
| 65 | + // (it's documented at https://vercel.com/docs/configuration#system-properties) |
| 66 | + const projectConfig = buildProjectConfig(config) |
| 67 | + if (projectConfig.nowConfig) { |
| 68 | + Object.assign(options, { nowConfig: projectConfig.nowConfig }) // ← rejected as additional property |
| 69 | + } |
| 70 | + if (projectConfig.projectSettings) { |
| 71 | + options.projectSettings = projectConfig.projectSettings |
| 72 | + } |
| 73 | +} |
| 74 | +``` |
| 75 | + |
| 76 | +The Vercel CLI's deploy command (referenced by PR #350 as the parity target, lines 494-571 in the CLI source) does **not** send `nowConfig`. Instead, it copies a select set of `vercel.json` keys (`buildCommand`, `installCommand`, `outputDirectory`, `framework`, `devCommand`) into `projectSettings`. |
| 77 | + |
| 78 | +## 3. Proposed Solutions |
| 79 | + |
| 80 | +### Bug 1 — Solution 1: Inline semver normalization (Recommended) |
| 81 | + |
| 82 | +Use the `semver` package (already bundled at `dist/index.js:21921` as a transitive dep) to map any input to the closest matching `NN.x` from a static list of supported versions. |
| 83 | + |
| 84 | +```typescript |
| 85 | +import semver from 'semver' |
| 86 | + |
| 87 | +const VERCEL_NODE_VERSIONS = ['24.x', '22.x', '20.x'] as const |
| 88 | + |
| 89 | +export function normalizeNodeVersion(input: string | undefined): string | undefined { |
| 90 | + if (!input) return undefined |
| 91 | + if ((VERCEL_NODE_VERSIONS as readonly string[]).includes(input)) return input |
| 92 | + for (const version of VERCEL_NODE_VERSIONS) { |
| 93 | + if (semver.intersects(input, version)) return version |
| 94 | + } |
| 95 | + return undefined |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +**Pros**: No new dependency; deterministic; matches Vercel CLI behavior. Easy to update when Vercel adds/removes supported versions. |
| 100 | +**Cons**: We hardcode the supported version list. If Vercel adds Node 26 silently, we miss it until we update. |
| 101 | + |
| 102 | +### Bug 1 — Solution 2: Import from `@vercel/build-utils` |
| 103 | + |
| 104 | +Reuse `getSupportedNodeVersion` directly from `@vercel/build-utils` (transitive via `@vercel/client`). |
| 105 | + |
| 106 | +**Pros**: Always in sync with Vercel. |
| 107 | +**Cons**: We pin to an internal API of a transitive dep — fragile across upgrades. Same risk we already documented for `@actions/http-client`. Reaching into `@vercel/build-utils` adds bundle weight and a stronger coupling than is warranted. |
| 108 | + |
| 109 | +### Bug 2 — Solution 1: Remove `nowConfig`, expand vercel.json keys into `projectSettings` (Recommended) |
| 110 | + |
| 111 | +```typescript |
| 112 | +// src/project-config.ts — replacement for nowConfig assignment in buildProjectConfig |
| 113 | +const PROJECT_SETTINGS_KEYS = [ |
| 114 | + 'buildCommand', |
| 115 | + 'installCommand', |
| 116 | + 'outputDirectory', |
| 117 | + 'framework', |
| 118 | + 'devCommand', |
| 119 | +] as const |
| 120 | + |
| 121 | +if (vercelJson) { |
| 122 | + for (const key of PROJECT_SETTINGS_KEYS) { |
| 123 | + if (key in vercelJson) { |
| 124 | + projectSettings[key] = vercelJson[key] as string | null |
| 125 | + } |
| 126 | + } |
| 127 | +} |
| 128 | +// Drop result.nowConfig entirely. |
| 129 | +``` |
| 130 | + |
| 131 | +**Pros**: Mirrors actual Vercel CLI behavior; honors `vercel.json` for the keys the API documents under `projectSettings`; small diff; preserves prototype-pollution safeguards because we only copy whitelisted keys. |
| 132 | +**Cons**: We hardcode the key list — adding support for new keys requires a code change. |
| 133 | + |
| 134 | +### Bug 2 — Solution 2: Keep `nowConfig` but unwrap inside `applyProjectConfig` |
| 135 | + |
| 136 | +Spread `nowConfig` keys directly into `projectSettings` and drop the `nowConfig` wrapper. |
| 137 | + |
| 138 | +**Pros**: Minimal whitelist; user-driven keys propagate. |
| 139 | +**Cons**: We have no idea which `vercel.json` keys the API actually accepts as `projectSettings` — this is a footgun. The whitelist approach is safer. |
| 140 | + |
| 141 | +## 4. Testing Requirements |
| 142 | + |
| 143 | +1. **Bug 1 scenarios**: Unit tests for the new `normalizeNodeVersion` (or the updated `readNodeVersion`) covering: `"24.x"` (passthrough), `">=24.0.0"`, `"^20.0.0"`, `"24.0.0"`, `">=18"`, `">=99.0.0"` (no match → undefined), `"not-a-version"`, `undefined`. |
| 144 | +2. **Bug 2 scenarios**: Unit test asserting `nowConfig` is absent from `DeploymentOptions` for any combination of `vercel.json` contents. |
| 145 | +3. **PR #350 regression**: Test asserting Hugo-style `vercel.json` with `"buildCommand": "./build.sh"` results in `projectSettings.buildCommand === "./build.sh"`. |
| 146 | +4. **Existing assertion update**: `src/__tests__/vercel-api.test.ts:379-398` currently asserts `deployOpts.nowConfig.buildCommand === './build.sh'` — must be updated to assert `deployOpts.projectSettings.buildCommand === './build.sh'` and `deployOpts.nowConfig === undefined`. |
| 147 | +5. **Integration**: Update `src/__integration__/vercel-api.test.ts` with a deployment whose `package.json` has `engines.node: ">=24.0.0"` and a `vercel.json` containing `buildCommand`; confirm the emulator receives the canonical-shape payload. |
| 148 | + |
| 149 | +## 5. Similar Code Patterns |
| 150 | + |
| 151 | +- **`src/vercel-api.ts:122-124`** — uses `Object.assign(options, { project: config.vercelProjectId })` for the `project` field. This is safe; `project` is an accepted Vercel API field. Leave it alone. |
| 152 | +- **No other locations** in `src/` read `package.json` engines or `vercel.json` and forward values to the Vercel API. The bug surface is entirely localized to `src/project-config.ts` + `src/vercel-api.ts:applyProjectConfig`. |
| 153 | + |
| 154 | +## 6. Recent Changes |
| 155 | + |
| 156 | +| Commit | PR | Date | Relevance | |
| 157 | +|---|---|---|---| |
| 158 | +| `4b001a4` | #352 | 2026-04-24 | Release v42.2.1 | |
| 159 | +| `ed665b2` | #350 | 2026-04-24 | **Introduces both bugs** — adds `readNodeVersion()` without normalization and `nowConfig` top-level injection | |
| 160 | +| `353227b` | #325 | (prior) | Migrated to API-based deployment via `@vercel/client` (background) | |
| 161 | + |
| 162 | +## 7. Test Coverage Gaps (current state) |
| 163 | + |
| 164 | +- `src/__tests__/project-config.test.ts` — covers `readNodeVersion` only with the canonical `"20.x"` value. No semver-range, exact-version, or unsupported-input test exists. |
| 165 | +- `src/__tests__/vercel-api.test.ts:379-398` — explicitly **asserts the broken behavior** (`deployOpts.nowConfig.buildCommand === './build.sh'`). This test must be updated when Bug 2 is fixed. |
| 166 | +- `src/__integration__/vercel-api.test.ts` — does not assert absence of `nowConfig` in the request body; would not have caught either regression. |
0 commit comments