Repository: vercel/next.js
Pull request: Add custom deploy and logs env for next-deploy

## Summary

Adds support for custom deploy scripts in the `next-deploy` test mode via two new environment variables:

- `NEXT_TEST_DEPLOY_SCRIPT_PATH` - Path to a custom script that deploys the project and outputs the deployment URL to stdout
- `NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH` - Path to a custom script that fetches build logs (required when using custom deploy script)

This allows running deploy tests against non-Vercel deployment targets.

### Priority Order

1. `NEXT_TEST_DEPLOY_URL` - If set, uses existing deployment (no deploy happens)
2. `NEXT_TEST_DEPLOY_SCRIPT_PATH` - If set, uses custom deploy script  
3. Default Vercel CLI logic - If neither is set

### Script Interface

**Deploy script** receives:
- `NEXT_TEST_DIR` - The test directory containing the Next.js project
- All test-specific environment variables
- Must output deployment URL to stdout

**Logs script** receives:
- `NEXT_TEST_DIR` - The test directory
- `NEXT_TEST_DEPLOY_URL` - The deployment URL
- Must output build logs containing `BUILD_ID: <id>` pattern

Verified existing flow is still working in: https://github.com/vercel/next.js/actions/runs/21465304133

Changed files:
filename: test/lib/next-modes/next-deploy.ts
patch: @@ -33,12 +33,180 @@ export class NextDeployInstance extends NextInstance {
     return this._buildId
   }
 
+  private async deployUsingCustomScript(): Promise<{ url: string }> {
+    const deployScriptPath = process.env.NEXT_TEST_DEPLOY_SCRIPT_PATH!
+
+    require('console').log(
+      `Deploying project using custom script: ${deployScriptPath}`
+    )
+
+    // Prepare environment variables to pass to the deploy script
+    const scriptEnv = {
+      ...process.env,
+      // Pass the test directory to the script
+      NEXT_TEST_DIR: this.testDir,
+      // Pass test-specific env vars
+      ...this.env,
+    }
+
+    const deployRes = await execa(deployScriptPath, [], {
+      cwd: this.testDir,
+      env: scriptEnv,
+      reject: false,
+      stderr: 'inherit',
+    })
+
+    if (deployRes.exitCode !== 0) {
+      throw new Error(
+        `Custom deploy script failed: ${deployRes.stdout} ${deployRes.stderr} (${deployRes.exitCode})`
+      )
+    }
+
+    // The script should output the deployment URL to stdout
+    const url = deployRes.stdout.trim()
+    if (!url) {
+      throw new Error(
+        'Custom deploy script did not return a deployment URL on stdout'
+      )
+    }
+
+    // Validate it's a proper URL
+    try {
+      new URL(url)
+    } catch (err) {
+      throw new Error(`Custom deploy script returned invalid URL: ${url}`, {
+        cause: err,
+      })
+    }
+
+    return { url }
+  }
+
+  private async fetchBuildLogsUsingCustomScript(): Promise<string> {
+    const logsScriptPath = process.env.NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH!
+
+    require('console').log(
+      `Fetching build logs using custom script: ${logsScriptPath}`
+    )
+
+    const scriptEnv = {
+      ...process.env,
+      NEXT_TEST_DIR: this.testDir,
+      // Pass the deployment URL to the logs script
+      NEXT_TEST_DEPLOY_URL: this._url,
+      ...this.env,
+    }
+
+    const logsRes = await execa(logsScriptPath, [], {
+      cwd: this.testDir,
+      env: scriptEnv,
+      reject: false,
+    })
+
+    if (logsRes.exitCode !== 0) {
+      throw new Error(
+        `Custom deploy logs script failed: ${logsRes.stdout} ${logsRes.stderr} (${logsRes.exitCode})`
+      )
+    }
+
+    // The script should output the build logs to stdout
+    return logsRes.stdout + logsRes.stderr
+  }
+
   public async setup(parentSpan: Span) {
     super.setup(parentSpan)
     await super.createTestDir({ parentSpan, skipInstall: true })
 
     const existingDeployUrl = process.env.NEXT_TEST_DEPLOY_URL?.trim()
+    const customDeployScriptPath =
+      process.env.NEXT_TEST_DEPLOY_SCRIPT_PATH?.trim()
+    const customLogsScriptPath =
+      process.env.NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH?.trim()
+
+    // Check if using an existing deployment URL (takes priority)
+    if (existingDeployUrl) {
+      try {
+        this._url = new URL(existingDeployUrl).toString()
+      } catch (err) {
+        throw new Error(
+          `Invalid NEXT_TEST_DEPLOY_URL value: ${existingDeployUrl}`,
+          { cause: err }
+        )
+      }
+      require('console').log(`Using existing deployment URL: ${this._url}`)
+
+      this._parsedUrl = new URL(this._url)
+
+      // Configure proxy address if needed
+      await this.configureProxyAddress()
+
+      // Use custom logs script if provided, otherwise use Vercel CLI
+      if (customLogsScriptPath) {
+        this._cliOutput = await this.fetchBuildLogsUsingCustomScript()
+      } else {
+        // Use vercel inspect to get logs for existing deployment
+        const buildLogs = await execa(
+          'vercel',
+          ['inspect', '--logs', this._url],
+          {
+            env: process.env,
+            reject: false,
+          }
+        )
+        if (buildLogs.exitCode !== 0) {
+          throw new Error(
+            `Failed to get build output logs: ${buildLogs.stderr}`
+          )
+        }
+        this._cliOutput = buildLogs.stdout + buildLogs.stderr
+      }
+
+      const buildId = this._cliOutput.match(/BUILD_ID: (.+)/)?.[1]?.trim()
+
+      if (!buildId) {
+        throw new Error(`Failed to get buildId from logs ${this._cliOutput}`)
+      }
+      this._buildId = buildId
+
+      require('console').log(`Got buildId: ${this._buildId}`)
+      return
+    }
+
+    // Check if using custom deploy script
+    if (customDeployScriptPath) {
+      if (!customLogsScriptPath) {
+        throw new Error(
+          'NEXT_TEST_DEPLOY_LOGS_SCRIPT_PATH is required when using NEXT_TEST_DEPLOY_SCRIPT_PATH'
+        )
+      }
+
+      const { url } = await this.deployUsingCustomScript()
+      this._url = url
+
+      this._parsedUrl = new URL(this._url)
+
+      // Configure proxy address if needed
+      await this.configureProxyAddress()
+
+      require('console').log(`Deployment URL: ${this._url}`)
+
+      // Use the custom logs script to get build logs and extract buildId
+      this._cliOutput = await this.fetchBuildLogsUsingCustomScript()
+
+      const buildId = this._cliOutput.match(/BUILD_ID: (.+)/)?.[1]?.trim()
+
+      if (!buildId) {
+        throw new Error(
+          `Failed to get buildId from custom deploy logs ${this._cliOutput}`
+        )
+      }
+      this._buildId = buildId
+
+      require('console').log(`Got buildId: ${this._buildId}`)
+      return
+    }
 
+    // Original Vercel CLI deployment logic
     // ensure Vercel CLI is installed
     try {
       const res = await execa('vercel', ['--version'])
@@ -90,106 +258,129 @@ export class NextDeployInstance extends NextInstance {
       )
       vercelFlags.push('--global-config', vcConfigDir)
     }
-    if (existingDeployUrl) {
-      try {
-        this._url = new URL(existingDeployUrl).toString()
-      } catch (err) {
-        throw new Error(
-          `Invalid NEXT_TEST_DEPLOY_URL value: ${existingDeployUrl}`,
-          { cause: err }
-        )
+
+    require('console').log(`Linking project at ${this.testDir}`)
+
+    // link the project
+    const linkRes = await execa(
+      'vercel',
+      ['link', '-p', projectEnv.TEST_PROJECT_NAME, '--yes', ...vercelFlags],
+      {
+        cwd: this.testDir,
+        env: vercelEnv,
+        reject: false,
       }
-      require('console').log(`Using existing deployment URL: ${this._url}`)
-    } else {
-      require('console').log(`Linking project at ${this.testDir}`)
-
-      // link the project
-      const linkRes = await execa(
-        'vercel',
-        ['link', '-p', projectEnv.TEST_PROJECT_NAME, '--yes', ...vercelFlags],
-        {
-          cwd: this.testDir,
-          env: vercelEnv,
-          reject: false,
-        }
+    )
+
+    if (linkRes.exitCode !== 0) {
+      throw new Error(
+        `Failed to link project ${linkRes.stdout} ${linkRes.stderr} (${linkRes.exitCode})`
       )
+    }
+    require('console').log(`Deploying project at ${this.testDir}`)
 
-      if (linkRes.exitCode !== 0) {
-        throw new Error(
-          `Failed to link project ${linkRes.stdout} ${linkRes.stderr} (${linkRes.exitCode})`
-        )
-      }
-      require('console').log(`Deploying project at ${this.testDir}`)
+    const additionalEnv: string[] = []
 
-      const additionalEnv: string[] = []
+    for (const key of Object.keys(this.env || {})) {
+      additionalEnv.push(`${key}=${this.env[key]}`)
+    }
 
-      for (const key of Object.keys(this.env || {})) {
-        additionalEnv.push(`${key}=${this.env[key]}`)
-      }
+    additionalEnv.push(
+      `VERCEL_CLI_VERSION=${process.env.VERCEL_CLI_VERSION || 'vercel@latest'}`
+    )
+
+    // Add experimental feature flags
 
+    if (process.env.__NEXT_CACHE_COMPONENTS) {
       additionalEnv.push(
-        `VERCEL_CLI_VERSION=${process.env.VERCEL_CLI_VERSION || 'vercel@latest'}`
+        `NEXT_PRIVATE_EXPERIMENTAL_CACHE_COMPONENTS=${process.env.__NEXT_CACHE_COMPONENTS}`
       )
+    }
 
-      // Add experimental feature flags
-
-      if (process.env.__NEXT_CACHE_COMPONENTS) {
-        additionalEnv.push(
-          `NEXT_PRIVATE_EXPERIMENTAL_CACHE_COMPONENTS=${process.env.__NEXT_CACHE_COMPONENTS}`
-        )
-      }
+    if (process.env.IS_TURBOPACK_TEST) {
+      additionalEnv.push(`IS_TURBOPACK_TEST=1`)
+    }
+    if (process.env.IS_WEBPACK_TEST) {
+      additionalEnv.push(`IS_WEBPACK_TEST=1`)
+    }
+    if (process.env.NEXT_ENABLE_ADAPTER) {
+      additionalEnv.push(`NEXT_ENABLE_ADAPTER=1`)
+    }
 
-      if (process.env.IS_TURBOPACK_TEST) {
-        additionalEnv.push(`IS_TURBOPACK_TEST=1`)
-      }
-      if (process.env.IS_WEBPACK_TEST) {
-        additionalEnv.push(`IS_WEBPACK_TEST=1`)
-      }
-      if (process.env.NEXT_ENABLE_ADAPTER) {
-        additionalEnv.push(`NEXT_ENABLE_ADAPTER=1`)
+    const deployRes = await execa(
+      'vercel',
+      [
+        'deploy',
+        '--build-env',
+        'NEXT_PRIVATE_TEST_MODE=e2e',
+        '--build-env',
+        'NEXT_TELEMETRY_DISABLED=1',
+        '--build-env',
+        'VERCEL_NEXT_BUNDLED_SERVER=1',
+        ...additionalEnv.flatMap((pair) => [
+          '--env',
+          pair,
+          '--build-env',
+          pair,
+        ]),
+        '--force',
+        ...vercelFlags,
+      ],
+      {
+        cwd: this.testDir,
+        env: vercelEnv,
+        reject: false,
+        // This will print deployment information earlier to the console so we
+        // don't have to wait until the deployment is complete to get the
+        // inspect URL.
+        stderr: 'inherit',
       }
+    )
 
-      const deployRes = await execa(
-        'vercel',
-        [
-          'deploy',
-          '--build-env',
-          'NEXT_PRIVATE_TEST_MODE=e2e',
-          '--build-env',
-          'NEXT_TELEMETRY_DISABLED=1',
-          '--build-env',
-          'VERCEL_NEXT_BUNDLED_SERVER=1',
-          ...additionalEnv.flatMap((pair) => [
-            '--env',
-            pair,
-            '--build-env',
-            pair,
-          ]),
-          '--force',
-          ...vercelFlags,
-        ],
-        {
-          cwd: this.testDir,
-          env: vercelEnv,
-          reject: false,
-          // This will print deployment information earlier to the console so we
-          // don't have to wait until the deployment is complete to get the
-          // inspect URL.
-          stderr: 'inherit',
-        }
+    if (deployRes.exitCode !== 0) {
+      throw new Error(
+        `Failed to deploy project ${deployRes.stdout} ${deployRes.stderr} (${deployRes.exitCode})`
       )
+    }
 
-      if (deployRes.exitCode !== 0) {
-        throw new Error(
-          `Failed to deploy project ${deployRes.stdout} ${deployRes.stderr} (${deployRes.exitCode})`
-        )
+    // the CLI gives just the deployment URL back when not a TTY
+    this._url = deployRes.stdout
+    this._parsedUrl = new URL(this._url)
+
+    // Configure proxy address if needed
+    await this.configureProxyAddress()
+
+    require('console').log(`Deployment URL: ${this._url}`)
+
+    // Use the vercel inspect command to get the CLI output from the build.
+    const buildLogs = await execa(
+      'vercel',
+      ['inspect', '--logs', this._url, ...vercelFlags],
+      {
+        env: vercelEnv,
+        reject: false,
       }
+    )
+    if (buildLogs.exitCode !== 0) {
+      throw new Error(`Failed to get build output logs: ${buildLogs.stderr}`)
+    }
+    // TODO: Combine with runtime logs (via `vercel logs`)
+    // Build logs seem to be piped to stderr, so we'll combine them to make sure we get all the logs.
+    this._cliOutput = buildLogs.stdout + buildLogs.stderr
 
-      // the CLI gives just the deployment URL back when not a TTY
-      this._url = deployRes.stdout
+    const buildId = this._cliOutput.match(/BUILD_ID: (.+)/)?.[1]?.trim()
+
+    if (!buildId) {
+      throw new Error(`Failed to get buildId from logs ${this._cliOutput}`)
     }
-    this._parsedUrl = new URL(this._url)
+    this._buildId = buildId
+
+    require('console').log(`Got buildId: ${this._buildId}`)
+    // Use the stdout from the logs command as the CLI output. The CLI will
+    // output other unrelated logs to stderr.
+  }
 
+  private async configureProxyAddress(): Promise<void> {
     // If configured, we should configure the `/etc/hosts` file to point the
     // deployment domain to the specified proxy address.
     if (
@@ -219,35 +410,6 @@ export class NextDeployInstance extends NextInstance {
 
       require('console').log(`Proxy address written to hosts file`)
     }
-
-    require('console').log(`Deployment URL: ${this._url}`)
-
-    // Use the vercel inspect command to get the CLI output from the build.
-    const buildLogs = await execa(
-      'vercel',
-      ['inspect', '--logs', this._url, ...vercelFlags],
-      {
-        env: vercelEnv,
-        reject: false,
-      }
-    )
-    if (buildLogs.exitCode !== 0) {
-      throw new Error(`Failed to get build output logs: ${buildLogs.stderr}`)
-    }
-    // TODO: Combine with runtime logs (via `vercel logs`)
-    // Build logs seem to be piped to stderr, so we'll combine them to make sure we get all the logs.
-    this._cliOutput = buildLogs.stdout + buildLogs.stderr
-
-    const buildId = this._cliOutput.match(/BUILD_ID: (.+)/)?.[1]?.trim()
-
-    if (!buildId) {
-      throw new Error(`Failed to get buildId from logs ${this._cliOutput}`)
-    }
-    this._buildId = buildId
-
-    require('console').log(`Got buildId: ${this._buildId}`)
-    // Use the stdout from the logs command as the CLI output. The CLI will
-    // output other unrelated logs to stderr.
   }
 
   public async destroy() {