Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-cli-deploy-yarn-workspaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/cli-v3": patch
---

Fix `trigger deploy` to detect and use the correct package manager (Yarn, pnpm, npm) and lockfile for builds. This fixes issues with Yarn Workspaces and ensures reproducible builds. (#2914)
57 changes: 44 additions & 13 deletions packages/cli-v3/src/build/buildWorker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas";
import { detectPackageManager, PackageManager } from "nypm";
import { BundleResult, bundleWorker, createBuildManifestFromBundle } from "./bundle.js";
import {
createBuildContext,
Expand All @@ -10,7 +11,7 @@ import {
import { createExternalsBuildExtension } from "./externals.js";
import { join, relative, sep } from "node:path";
import { generateContainerfile } from "../deploy/buildImage.js";
import { writeFile } from "node:fs/promises";
import { writeFile, copyFile } from "node:fs/promises";
import { buildManifestToJSON } from "../utilities/buildManifest.js";
import { readPackageJSON } from "pkg-types";
import { writeJSONFile } from "../utilities/fileSystem.js";
Expand Down Expand Up @@ -53,16 +54,16 @@ export async function buildWorker(options: BuildWorkerOptions) {
const buildContext = createBuildContext(options.target, resolvedConfig, {
logger: options.plain
? {
debug: (...args) => console.log(...args),
log: (...args) => console.log(...args),
warn: (...args) => console.log(...args),
progress: (message) => console.log(message),
spinner: (message) => {
const $spinner = spinner({ plain: true });
$spinner.start(message);
return $spinner;
},
}
debug: (...args) => console.log(...args),
log: (...args) => console.log(...args),
warn: (...args) => console.log(...args),
progress: (message) => console.log(message),
spinner: (message) => {
const $spinner = spinner({ plain: true });
$spinner.start(message);
return $spinner;
},
}
: undefined,
});
buildContext.prependExtension(externalsExtension);
Expand Down Expand Up @@ -208,8 +209,31 @@ async function writeDeployFiles({
true
);



const packageManager = await detectPackageManager(resolvedConfig.workingDir);

// lockFile can be a string or an array of strings
const lockFile = Array.isArray(packageManager?.lockFile)
? packageManager?.lockFile[0]
: packageManager?.lockFile;

if (lockFile) {
try {
await copyFile(
join(resolvedConfig.workingDir, lockFile),
join(outputPath, lockFile)
);
} catch (e) {
logger.debug("Failed to copy lockfile", {
lockFile,
error: e instanceof Error ? e.message : e,
});
}
}

await writeJSONFile(join(outputPath, "build.json"), buildManifestToJSON(buildManifest));
await writeContainerfile(outputPath, buildManifest);
await writeContainerfile(outputPath, buildManifest, packageManager, lockFile);
}

async function readProjectPackageJson(packageJsonPath: string) {
Expand All @@ -218,7 +242,12 @@ async function readProjectPackageJson(packageJsonPath: string) {
return packageJson;
}

async function writeContainerfile(outputPath: string, buildManifest: BuildManifest) {
async function writeContainerfile(
outputPath: string,
buildManifest: BuildManifest,
packageManager?: PackageManager | null,
lockfilePath?: string
) {
if (!buildManifest.runControllerEntryPoint || !buildManifest.indexControllerEntryPoint) {
throw new Error("Something went wrong with the build. Aborting deployment. [code 7789]");
}
Expand All @@ -229,6 +258,8 @@ async function writeContainerfile(outputPath: string, buildManifest: BuildManife
build: buildManifest.build,
image: buildManifest.image,
indexScript: buildManifest.indexControllerEntryPoint,
packageManager,
lockfilePath,
});

const containerfilePath = join(outputPath, "Containerfile");
Expand Down
50 changes: 50 additions & 0 deletions packages/cli-v3/src/deploy/buildImage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@

import { describe, it, expect } from "vitest";
import { generateContainerfile, GenerateContainerfileOptions } from "./buildImage.js";

describe("generateContainerfile", () => {
const defaultOptions: GenerateContainerfileOptions = {
runtime: "node",
build: {
env: {},
},
image: {},
indexScript: "index.js",
entrypoint: "entrypoint.js",
};

it("should generate npm install command by default", async () => {
const dockerfile = await generateContainerfile(defaultOptions);
expect(dockerfile).toContain("COPY --chown=node:node package.json ./");
expect(dockerfile).toContain("RUN npm i --no-audit --no-fund --no-save --no-package-lock");
});

it("should generate yarn install command when packageManager is yarn", async () => {
const options: GenerateContainerfileOptions = {
...defaultOptions,
packageManager: { name: "yarn", command: "yarn", version: "1.22.19" },
};
const dockerfile = await generateContainerfile(options);
expect(dockerfile).toContain("RUN yarn install");
});

it("should generate pnpm install command when packageManager is pnpm", async () => {
const options: GenerateContainerfileOptions = {
...defaultOptions,
packageManager: { name: "pnpm", command: "pnpm", version: "8.6.0" },
};
const dockerfile = await generateContainerfile(options);
expect(dockerfile).toContain("RUN corepack enable");
expect(dockerfile).toContain("RUN pnpm install");
});

it("should copy lockfile if provided", async () => {
const options: GenerateContainerfileOptions = {
...defaultOptions,
packageManager: { name: "yarn", command: "yarn", version: "1.22.19" },
lockfilePath: "yarn.lock",
};
const dockerfile = await generateContainerfile(options);
expect(dockerfile).toContain("COPY --chown=node:node yarn.lock ./");
});
});
40 changes: 32 additions & 8 deletions packages/cli-v3/src/deploy/buildImage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { logger } from "../utilities/logger.js";
import { PackageManager } from "nypm";
import { depot } from "@depot/cli";
import { x } from "tinyexec";
import { BuildManifest, BuildRuntime } from "@trigger.dev/core/v3/schemas";
Expand Down Expand Up @@ -550,13 +551,12 @@ async function localBuildImage(options: SelfHostedBuildImageOptions): Promise<Bu
options.noCache ? "--no-cache" : undefined,
...(useRegistryCache
? [
"--cache-to",
`type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=${projectCacheRef}${
cacheCompression === "zstd" ? ",compression=zstd" : ""
}`,
"--cache-from",
`type=registry,ref=${projectCacheRef}`,
]
"--cache-to",
`type=registry,mode=max,image-manifest=true,oci-mediatypes=true,ref=${projectCacheRef}${cacheCompression === "zstd" ? ",compression=zstd" : ""
}`,
"--cache-from",
`type=registry,ref=${projectCacheRef}`,
]
: []),
"--output",
outputOptions.join(","),
Expand Down Expand Up @@ -683,6 +683,8 @@ export type GenerateContainerfileOptions = {
image: BuildManifest["image"];
indexScript: string;
entrypoint: string;
packageManager?: PackageManager | null;
lockfilePath?: string;
};

const BASE_IMAGE: Record<BuildRuntime, string> = {
Expand Down Expand Up @@ -869,7 +871,11 @@ ENV NODE_ENV=production
ENV NPM_CONFIG_UPDATE_NOTIFIER=false

COPY --chown=node:node package.json ./
RUN npm i --no-audit --no-fund --no-save --no-package-lock
${options.lockfilePath
? `COPY --chown=node:node ${options.lockfilePath} ./`
: "# No lockfile path provided"
}
${getInstallCommand(options.packageManager)}

# Now copy all the files
# IMPORTANT: Do this after running npm install because npm i will wipe out the node_modules directory
Expand Down Expand Up @@ -1161,3 +1167,21 @@ function getOutputOptions({

return outputOptions;
}

function getInstallCommand(packageManager?: PackageManager | null) {
switch (packageManager?.name) {
case "yarn": {
return "RUN yarn install";
}
case "pnpm": {
return `
RUN corepack enable
RUN pnpm install
`;
Comment on lines +1177 to +1180

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 pnpm install command generates inconsistent Dockerfile formatting with extra whitespace

The getInstallCommand function for pnpm returns a template literal with leading newlines and 6 spaces of indentation before each RUN command.

Click to expand

Issue

The pnpm case at buildImage.ts:1176-1180 returns:

return `
      RUN corepack enable
      RUN pnpm install
      `;

This produces output with leading/trailing newlines and inconsistent indentation:

COPY --chown=node:node package.json ./
# No lockfile path provided

      RUN corepack enable
      RUN pnpm install
      

# Now copy all the files

Comparison

The npm and yarn cases return clean single-line strings without extra whitespace:

  • "RUN yarn install" (line 1174)
  • "RUN npm i --no-audit --no-fund --no-save --no-package-lock" (line 1184)

Impact

While Docker technically accepts indented RUN commands, this produces messy Dockerfile output that is inconsistent with the rest of the generated file. The extra blank lines and indentation make the Dockerfile harder to read and debug.

Recommendation: Use consistent formatting without extra whitespace:

case "pnpm": {
  return "RUN corepack enable\nRUN pnpm install";
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
case "npm":
default: {
return "RUN npm i --no-audit --no-fund --no-save --no-package-lock";
}
}
}
Comment on lines +1171 to +1187
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

npm ci yarn pnpm --frozen-lockfile reproducible builds best practices

💡 Result:

Goal: “fail the build if the lockfile would change”

Use the package manager’s CI / immutable install mode so dependency resolution is taken from the lockfile and the install aborts if the lockfile and manifests are out of sync.

npm (package-lock.json / npm-shrinkwrap.json)

  • CI command: npm ci
  • Key properties: requires an existing lockfile, errors if lockfile doesn’t match package.json, removes existing node_modules, and never writes package.json / lockfiles. [1]

Yarn

Yarn 1 (Classic)

  • CI command: yarn install --frozen-lockfile (fails if yarn.lock would need changes). [2]

Yarn 2+ (Berry / modern Yarn)

  • CI command: yarn install --immutable
  • Notes: --immutable defaults to true on CI, fails if the lockfile would be modified; --frozen-lockfile exists as an alias for backward compatibility (planned removal in a later release). [3]
  • For stricter reproducibility in “Zero-Installs” setups: yarn install --immutable --immutable-cache (and optionally --check-cache). [3]

pnpm (pnpm-lock.yaml)

  • CI command: pnpm install --frozen-lockfile
  • Notes: --frozen-lockfile defaults to true on CI (when a lockfile is present) and fails if the lockfile is out of sync / would need updates. [4]

Reproducible-build best practices (works across npm/Yarn/pnpm)

  1. Commit exactly one lockfile (package-lock.json or yarn.lock or pnpm-lock.yaml) and don’t mix package managers in the same repo.
  2. Pin the package manager version (so the same lockfile semantics/resolver are used everywhere), and use the pinned version in CI.
  3. Pin Node.js (major/minor) for CI and releases; native addons and resolution edge-cases can vary by Node version.
  4. Keep install flags/config consistent between lockfile generation and CI (example: npm notes that flags like --legacy-peer-deps used during npm install should also be used for npm ci, otherwise CI may fail). [1]
  5. Treat lockfile updates as code changes: update via the package manager, review diffs, and avoid manual edits.

Quick “CI install” command cheat-sheet

# npm
npm ci

# Yarn 1
yarn install --frozen-lockfile

# Yarn 2+
yarn install --immutable

# pnpm
pnpm install --frozen-lockfile

Sources: npm ci docs [1]; Yarn classic --frozen-lockfile docs [2]; Yarn modern --immutable / CI behavior docs [3]; pnpm --frozen-lockfile / CI default docs [4].


🏁 Script executed:

# Search for issue `#2914` reference in the repo
rg "2914" --type ts --type md --type json

# Look for context in buildImage.ts around the install command generation
fd buildImage.ts -x cat -n {} | head -100

Repository: triggerdotdev/trigger.dev

Length of output: 3348


🏁 Script executed:

# Check if there are any GitHub issue links or comments in the relevant files
rg "frozen-lockfile|npm ci|reproducible.*build" packages/cli-v3/src/deploy/ -A 2 -B 2

Repository: triggerdotdev/trigger.dev

Length of output: 51


🏁 Script executed:

# Find and examine buildImage.ts more carefully
fd buildImage.ts -x wc -l {}

Repository: triggerdotdev/trigger.dev

Length of output: 116


🏁 Script executed:

# Look at lines around 1171-1187 in buildImage.ts
fd buildImage.ts -x sed -n '1150,1200p' {}

Repository: triggerdotdev/trigger.dev

Length of output: 892


🏁 Script executed:

# Search for any issue/PR references related to reproducible builds or frozen-lockfile
rg "issue|PR|frozen.*lock|reproducible" packages/cli-v3/src/deploy/buildImage.ts -B 2 -A 2

Repository: triggerdotdev/trigger.dev

Length of output: 3565


🏁 Script executed:

# Check if there's a .yarn-version or .node-version or similar to understand which Yarn version is used
fd -e "versions" -e "npmrc" -e "yarnrc" -e "pnpmfile" packages/cli-v3/

Repository: triggerdotdev/trigger.dev

Length of output: 438


🏁 Script executed:

# Check package.json for packageManager field to see which version is pinned
rg '"packageManager"' packages/cli-v3/package.json

Repository: triggerdotdev/trigger.dev

Length of output: 51


🏁 Script executed:

# Check root package.json for packageManager field
cat package.json | grep -A 2 -B 2 "packageManager"

Repository: triggerdotdev/trigger.dev

Length of output: 184


🏁 Script executed:

# Check what lockfiles exist in repo to determine package manager versions
git ls-files | rg -E "yarn\.lock|pnpm-lock\.yaml|package-lock\.json" | head -5

Repository: triggerdotdev/trigger.dev

Length of output: 465


🏁 Script executed:

# Check for lockfiles to understand which package managers are used
git ls-files | grep -E "yarn\.lock|pnpm-lock\.yaml|package-lock\.json"

Repository: triggerdotdev/trigger.dev

Length of output: 956


🏁 Script executed:

# Check if there's any yarn version specification
cat .yarnrc.yml 2>/dev/null || cat .yarnrc 2>/dev/null || echo "No yarn config found"

Repository: triggerdotdev/trigger.dev

Length of output: 89


🏁 Script executed:

# Look at the context around lockfile copying in buildImage.ts to understand the full picture
sed -n '860,900p' packages/cli-v3/src/deploy/buildImage.ts

Repository: triggerdotdev/trigger.dev

Length of output: 1012


Install commands don't ensure reproducible builds.

The implementation should use CI/immutable install modes to fail if lockfiles are out of sync: npm ci for npm, --frozen-lockfile for yarn and pnpm. The current npm i with --no-package-lock explicitly prevents reproducible builds, and the pnpm case creates multiple Docker layers unnecessarily.

🔧 Proposed fix
 function getInstallCommand(packageManager?: PackageManager | null) {
   switch (packageManager?.name) {
     case "yarn": {
-      return "RUN yarn install";
+      return "RUN yarn install --frozen-lockfile";
     }
     case "pnpm": {
-      return `
-      RUN corepack enable
-      RUN pnpm install
-      `;
+      return "RUN corepack enable && pnpm install --frozen-lockfile";
     }
     case "npm":
     default: {
-      return "RUN npm i --no-audit --no-fund --no-save --no-package-lock";
+      return "RUN npm ci";
     }
   }
 }
🤖 Prompt for AI Agents
In `@packages/cli-v3/src/deploy/buildImage.ts` around lines 1171 - 1187,
getInstallCommand currently prevents reproducible installs and creates extra
Docker layers; update it so npm uses "npm ci" (not "npm i"), yarn uses a
frozen/CI mode (e.g., "yarn install --frozen-lockfile"), and pnpm runs corepack
enable and install in a single RUN with pnpm's frozen-lockfile flag (e.g.,
"corepack enable && pnpm install --frozen-lockfile") to avoid multiple layers;
modify the switch in getInstallCommand (check packageManager?.name) to return
these CI/immutable install commands and remove the
"--no-package-lock/--no-audit" pattern that disables lockfile enforcement.