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
26 changes: 26 additions & 0 deletions .changeset/opt-out-trigger-version-locking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@trigger.dev/core": minor
"@trigger.dev/sdk": minor
---

Allow opting out of `TRIGGER_VERSION` locking per-call and per-scope (fixes #3380).

`TriggerOptions.version` now accepts `null` in addition to a version string, and `ApiClientConfiguration` gains a `version?: string | null` field that applies to every trigger inside an `auth.withAuth(...)` scope. Passing `null` explicitly unpins the call: `lockToVersion` is omitted from the request and the server resolves to the current deployed version, ignoring the `TRIGGER_VERSION` environment variable.

Precedence (highest first): per-call `version` option, scoped `version` in `ApiClientConfiguration`, `TRIGGER_VERSION` env var. `undefined` at any level falls through to the next level; only `null` explicitly unpins.

Use cases:
- Cross-project triggers where the ambient `TRIGGER_VERSION` (e.g., injected by the Vercel integration for your "main" project) does not apply to a sibling project.
- One-off calls that should always run on the current deployed version regardless of the runtime environment.

```ts
// Scoped: every trigger inside this scope resolves to the current deployed version
await auth.withAuth({ secretKey, version: null }, async () => {
await tasks.trigger("some-task", payload);
});

// Per-call: only this call is unpinned
await tasks.trigger("some-task", payload, { version: null });
```

The existing string-pin behavior and `TRIGGER_VERSION` fallback are unchanged.
34 changes: 33 additions & 1 deletion packages/core/src/v3/apiClientManager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ export class APIClientManagerAPI {
const requestOptions = this.#getConfig()?.requestOptions;
const futureFlags = this.#getConfig()?.future;

return new ApiClient(this.baseURL, this.accessToken, this.branchName, requestOptions, futureFlags);
return new ApiClient(
this.baseURL,
this.accessToken,
this.branchName,
requestOptions,
futureFlags
);
}

clientOrThrow(config?: ApiClientConfiguration): ApiClient {
Expand All @@ -80,6 +86,32 @@ export class APIClientManagerAPI {
return new ApiClient(baseURL, accessToken, branchName, requestOptions, futureFlags);
}

/**
* Resolves the value to send as `lockToVersion` on a trigger request.
*
* Precedence (highest first):
* 1. Per-call `version` option (a string pins; `null` explicitly unpins).
* 2. Scoped `version` in `ApiClientConfiguration` (via `runWithConfig` / `auth.withAuth`).
* 3. `TRIGGER_VERSION` environment variable.
*
* Returns `undefined` when the result should not be sent (unpinned, server resolves
* to the current deployed version). Returns a version string when the request should
* be pinned to that specific version. `undefined` at any level falls through to the
* next level; only `null` explicitly unpins.
*/
resolveLockToVersion(callVersion?: string | null): string | undefined {
if (callVersion !== undefined) {
return callVersion === null ? undefined : callVersion;
}

const scopedVersion = this.#getConfig()?.version;
if (scopedVersion !== undefined) {
return scopedVersion === null ? undefined : scopedVersion;
}

return getEnvVar("TRIGGER_VERSION");
}

runWithConfig<R extends (...args: any[]) => Promise<any>>(
config: ApiClientConfiguration,
fn: R
Comment on lines 115 to 117
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 runWithConfig uses global state, not AsyncLocalStorage — concurrent scopes can collide

The runWithConfig method at packages/core/src/v3/apiClientManager/index.ts:115-126 stores scoped config in a global variable via registerGlobal, not via AsyncLocalStorage. If two concurrent async operations both call runWithConfig (e.g., two simultaneous auth.withAuth blocks), the second call overwrites the global config, and the first call's finally block then restores the wrong original. This is a pre-existing design issue (not introduced by this PR), but the new version field is now also subject to this race. In practice, this is likely fine for server-side usage where each request runs in isolation, but it's worth noting for library consumers running multiple scoped operations concurrently in the same process.

(Refers to lines 115-126)

Open in Devin Review

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

Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/v3/apiClientManager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ export type ApiClientConfiguration = {
* The preview branch name (for preview environments)
*/
previewBranch?: string;
/**
* Controls the `lockToVersion` applied to task triggers in this scope.
*
* - A version string (e.g. `"20250208.1"`) pins every trigger in the scope to that version.
* - `null` explicitly unpins: `lockToVersion` is omitted from the request and the server
* resolves to the current deployed version. Ignores the `TRIGGER_VERSION` environment
* variable. Use this when triggering into a project where the ambient `TRIGGER_VERSION`
* does not apply (for example, cross-project triggers).
* - Omitted (`undefined`) preserves the default behavior: per-call `version` option, then
* the `TRIGGER_VERSION` environment variable.
*
* A per-call `TriggerOptions.version` always wins over this scoped value.
*/
version?: string | null;
requestOptions?: ApiRequestOptions;
future?: ApiClientFutureFlags;
};
11 changes: 10 additions & 1 deletion packages/core/src/v3/types/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,16 +895,25 @@ export type TriggerOptions = {
* but you can specify a specific version to run here. You can also set the TRIGGER_VERSION environment
* variables to run a specific version for all tasks.
*
* Pass `null` to explicitly unpin this call: `lockToVersion` is omitted from the request and the
* server resolves to the current deployed version, ignoring the `TRIGGER_VERSION` environment
* variable. Useful when triggering into a project where the ambient `TRIGGER_VERSION` does not
* apply (for example, cross-project triggers).
*
* @example
*
* ```ts
* // Pin to a specific version
* await myTask.trigger({ foo: "bar" }, { version: "20250208.1" });
*
* // Explicitly use the current deployed version, ignoring TRIGGER_VERSION
* await myTask.trigger({ foo: "bar" }, { version: null });
* ```
*
* Note that this option is only available for `trigger` and NOT `triggerAndWait` (and their batch counterparts). The "wait" versions will always be locked
* to the same version as the parent task that is triggering the child tasks.
*/
version?: string;
version?: string | null;

/**
* Specify the region to run the task in. This overrides the default region set for your project in the dashboard.
Expand Down
105 changes: 105 additions & 0 deletions packages/core/test/apiClientManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { apiClientManager } from "../src/v3/apiClientManager-api.js";

const originalEnv = process.env.TRIGGER_VERSION;

describe("APIClientManagerAPI.resolveLockToVersion", () => {
beforeEach(() => {
delete process.env.TRIGGER_VERSION;
});

afterEach(() => {
if (originalEnv === undefined) {
delete process.env.TRIGGER_VERSION;
} else {
process.env.TRIGGER_VERSION = originalEnv;
}
apiClientManager.disable();
});

describe("without a scope override", () => {
it("returns undefined when no call version is given and TRIGGER_VERSION is unset", () => {
expect(apiClientManager.resolveLockToVersion()).toBeUndefined();
});

it("falls back to TRIGGER_VERSION when no call version is given", () => {
process.env.TRIGGER_VERSION = "20250101.1";
expect(apiClientManager.resolveLockToVersion()).toBe("20250101.1");
});

it("prefers a per-call version string over TRIGGER_VERSION", () => {
process.env.TRIGGER_VERSION = "20250101.1";
expect(apiClientManager.resolveLockToVersion("20250202.1")).toBe("20250202.1");
});

it("returns undefined when per-call version is null, even if TRIGGER_VERSION is set", () => {
process.env.TRIGGER_VERSION = "20250101.1";
expect(apiClientManager.resolveLockToVersion(null)).toBeUndefined();
});
});

describe("inside a scope with a version string", () => {
it("uses the scoped version when no call version is given", async () => {
process.env.TRIGGER_VERSION = "20250101.1";

await apiClientManager.runWithConfig({ version: "20250303.1" }, async () => {
expect(apiClientManager.resolveLockToVersion()).toBe("20250303.1");
});
});

it("lets a per-call version string win over the scope", async () => {
await apiClientManager.runWithConfig({ version: "20250303.1" }, async () => {
expect(apiClientManager.resolveLockToVersion("20250404.1")).toBe("20250404.1");
});
});

it("lets a per-call null win over the scope", async () => {
await apiClientManager.runWithConfig({ version: "20250303.1" }, async () => {
expect(apiClientManager.resolveLockToVersion(null)).toBeUndefined();
});
});
});

describe("inside a scope with version: null", () => {
it("ignores TRIGGER_VERSION when no call version is given", async () => {
process.env.TRIGGER_VERSION = "20250101.1";

await apiClientManager.runWithConfig({ version: null }, async () => {
expect(apiClientManager.resolveLockToVersion()).toBeUndefined();
});
});

it("lets a per-call version string win over the null scope", async () => {
await apiClientManager.runWithConfig({ version: null }, async () => {
expect(apiClientManager.resolveLockToVersion("20250505.1")).toBe("20250505.1");
});
});
});

describe("scope without a version key", () => {
it("falls back to TRIGGER_VERSION", async () => {
process.env.TRIGGER_VERSION = "20250101.1";

await apiClientManager.runWithConfig({ accessToken: "tr_test_xyz" }, async () => {
expect(apiClientManager.resolveLockToVersion()).toBe("20250101.1");
});
});

it("still respects a per-call null", async () => {
process.env.TRIGGER_VERSION = "20250101.1";

await apiClientManager.runWithConfig({ accessToken: "tr_test_xyz" }, async () => {
expect(apiClientManager.resolveLockToVersion(null)).toBeUndefined();
});
});
});

describe("scope with version: undefined explicitly", () => {
it("treats explicit undefined as 'no key' and falls back to TRIGGER_VERSION", async () => {
process.env.TRIGGER_VERSION = "20250101.1";

await apiClientManager.runWithConfig({ version: undefined }, async () => {
expect(apiClientManager.resolveLockToVersion()).toBe("20250101.1");
});
});
});
});
15 changes: 7 additions & 8 deletions packages/trigger-sdk/src/v3/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
createErrorTaskError,
defaultRetryOptions,
flattenIdempotencyKey,
getEnvVar,
getIdempotencyKeyOptions,
getSchemaParseFn,
InitOutput,
Expand Down Expand Up @@ -650,7 +649,7 @@ export async function batchTriggerById<TTask extends AnyTask>(
machine: item.options?.machine,
priority: item.options?.priority,
region: item.options?.region,
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
lockToVersion: apiClientManager.resolveLockToVersion(item.options?.version),
debounce: item.options?.debounce,
},
};
Expand Down Expand Up @@ -1166,7 +1165,7 @@ export async function batchTriggerTasks<TTasks extends readonly AnyTask[]>(
machine: item.options?.machine,
priority: item.options?.priority,
region: item.options?.region,
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
lockToVersion: apiClientManager.resolveLockToVersion(item.options?.version),
debounce: item.options?.debounce,
},
};
Expand Down Expand Up @@ -1830,7 +1829,7 @@ async function* transformBatchItemsStream<TTask extends AnyTask>(
machine: item.options?.machine,
priority: item.options?.priority,
region: item.options?.region,
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
lockToVersion: apiClientManager.resolveLockToVersion(item.options?.version),
debounce: item.options?.debounce,
},
};
Expand Down Expand Up @@ -1933,7 +1932,7 @@ async function* transformBatchByTaskItemsStream<TTasks extends readonly AnyTask[
machine: item.options?.machine,
priority: item.options?.priority,
region: item.options?.region,
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
lockToVersion: apiClientManager.resolveLockToVersion(item.options?.version),
debounce: item.options?.debounce,
},
};
Expand Down Expand Up @@ -2037,7 +2036,7 @@ async function* transformSingleTaskBatchItemsStream<TPayload>(
machine: item.options?.machine,
priority: item.options?.priority,
region: item.options?.region,
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
lockToVersion: apiClientManager.resolveLockToVersion(item.options?.version),
debounce: item.options?.debounce,
},
};
Expand Down Expand Up @@ -2146,7 +2145,7 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
machine: options?.machine,
priority: options?.priority,
region: options?.region,
lockToVersion: options?.version ?? getEnvVar("TRIGGER_VERSION"),
lockToVersion: apiClientManager.resolveLockToVersion(options?.version),
debounce: options?.debounce,
},
},
Expand Down Expand Up @@ -2232,7 +2231,7 @@ async function batchTrigger_internal<TRunTypes extends AnyRunTypes>(
machine: item.options?.machine,
priority: item.options?.priority,
region: item.options?.region,
lockToVersion: item.options?.version ?? getEnvVar("TRIGGER_VERSION"),
lockToVersion: apiClientManager.resolveLockToVersion(item.options?.version),
},
};
})
Expand Down