From 5ff2e099900ca89a855e2faf90ffa0ae55d7f645 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 17 Aug 2026 17:38:10 +0200 Subject: [PATCH] feat(core): external deployment id wire contract An external deployment id is an opaque, caller-chosen name for a release - a commit SHA, a CI run id, a release tag. This adds the shared contract that both halves of the feature read, and nothing else: no deploy writes one yet and no trigger sends one. ExternalDeploymentId is defined once and reused by InitializeDeploymentRequestBody.externalId and TriggerTaskRequestBody.options.externalDeploymentId, so a value accepted by one half can never be rejected by the other. A value that is blank once trimmed is treated as absent rather than rejected, so an unset CI variable expanding to an empty string is not a 400. The 128 character limit fits a SHA-256 commit hash with room for composite ids, and EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH is the single source of truth that the request schemas and the CLI both read. RunAnnotations.externalDeploymentId records the request, not the outcome: lockedToVersionId and taskVersion are overwritten when a run locks, whereas this stays true forever, and it can carry the pin for a run parked before its deployment exists. Also lands the runtime discovery helpers as pure functions over an environment reader: the explicit TRIGGER_EXTERNAL_DEPLOYMENT_ID variable, the platform and CI commit-SHA table, and the TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION gate. Nothing calls them yet. refs TRI-13000 --- .../core/src/v3/externalDeploymentId.test.ts | 245 ++++++++++++++++++ packages/core/src/v3/externalDeploymentId.ts | 103 ++++++++ packages/core/src/v3/index.ts | 1 + packages/core/src/v3/schemas/api-type.test.ts | 166 ++++++++++++ packages/core/src/v3/schemas/api.ts | 35 +++ packages/core/src/v3/schemas/runEngine.ts | 1 + .../triggerExternalDeploymentId.test.ts | 121 +++++++++ 7 files changed, 672 insertions(+) create mode 100644 packages/core/src/v3/externalDeploymentId.test.ts create mode 100644 packages/core/src/v3/externalDeploymentId.ts create mode 100644 packages/core/src/v3/schemas/triggerExternalDeploymentId.test.ts diff --git a/packages/core/src/v3/externalDeploymentId.test.ts b/packages/core/src/v3/externalDeploymentId.test.ts new file mode 100644 index 00000000000..0724f9295b9 --- /dev/null +++ b/packages/core/src/v3/externalDeploymentId.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import { + discoverPlatformCommitSha, + isAutomaticSkewProtectionEnabled, + normalizeExternalDeploymentId, + PLATFORM_COMMIT_SHA_ENV_VARS, + resolveExternalDeploymentId, +} from "./externalDeploymentId.js"; + +function reader(vars: Record) { + return (name: string) => vars[name]; +} + +const SHA = "fa1eade47b73733d6312d5abfad33ce9e4068081"; + +describe("normalizeExternalDeploymentId", () => { + it("trims surrounding whitespace", () => { + expect(normalizeExternalDeploymentId(` ${SHA} `)).toBe(SHA); + }); + + it.each([undefined, "", " ", "\t\n"])("treats %j as absent", (value) => { + expect(normalizeExternalDeploymentId(value)).toBeUndefined(); + }); + + it("accepts exactly 128 characters", () => { + expect(normalizeExternalDeploymentId("a".repeat(128))).toBe("a".repeat(128)); + }); + + it("skips a value longer than 128 characters rather than sending it to be rejected", () => { + expect(normalizeExternalDeploymentId("a".repeat(129))).toBeUndefined(); + }); + + it("measures the length limit after trimming", () => { + expect(normalizeExternalDeploymentId(` ${"a".repeat(128)} `)).toBe("a".repeat(128)); + }); +}); + +describe("isAutomaticSkewProtectionEnabled", () => { + it.each([ + ["1", true], + ["true", true], + ["TRUE", true], + ["True", true], + [" 1 ", true], + ["0", false], + ["false", false], + ["", false], + ["yes", false], + ["on", false], + ["2", false], + [undefined, false], + ])("reads %j as %s", (value, expected) => { + expect( + isAutomaticSkewProtectionEnabled(reader({ TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: value })) + ).toBe(expected); + }); +}); + +describe("discoverPlatformCommitSha", () => { + it("returns undefined when nothing is set", () => { + expect(discoverPlatformCommitSha(reader({}))).toBeUndefined(); + }); + + it.each(PLATFORM_COMMIT_SHA_ENV_VARS)("reads %s", (name) => { + expect(discoverPlatformCommitSha(reader({ [name]: SHA }))).toBe(SHA); + }); + + it("prefers a hosting variable over a CI variable, because it describes the deployment that is running", () => { + expect( + discoverPlatformCommitSha( + reader({ VERCEL_GIT_COMMIT_SHA: "vercel-sha", GITHUB_SHA: "github-sha" }) + ) + ).toBe("vercel-sha"); + }); + + it("prefers a CI variable over the generic tier", () => { + expect( + discoverPlatformCommitSha(reader({ GITHUB_SHA: "github-sha", GIT_HASH: "generic-sha" })) + ).toBe("github-sha"); + }); + + it("falls back to the generic tier when nothing named is set", () => { + expect(discoverPlatformCommitSha(reader({ COMMIT_HASH: "generic-sha" }))).toBe("generic-sha"); + }); + + it("honours the full hosting order", () => { + const order = [ + "VERCEL_GIT_COMMIT_SHA", + "RAILWAY_GIT_COMMIT_SHA", + "RENDER_GIT_COMMIT", + "CF_PAGES_COMMIT_SHA", + "WORKERS_CI_COMMIT_SHA", + "COMMIT_REF", + "AWS_COMMIT_ID", + "HEROKU_BUILD_COMMIT", + "HEROKU_SLUG_COMMIT", + "KOYEB_GIT_SHA", + ]; + + const vars: Record = Object.fromEntries(order.map((n) => [n, n])); + + for (const expected of order) { + expect(discoverPlatformCommitSha(reader(vars))).toBe(expected); + delete vars[expected]; + } + }); + + it("skips an empty value and keeps looking", () => { + expect(discoverPlatformCommitSha(reader({ VERCEL_GIT_COMMIT_SHA: "", GITHUB_SHA: SHA }))).toBe( + SHA + ); + }); + + it("skips an over-long value and keeps looking, rather than sending something that will be rejected", () => { + expect( + discoverPlatformCommitSha(reader({ VERCEL_GIT_COMMIT_SHA: "a".repeat(129), GITHUB_SHA: SHA })) + ).toBe(SHA); + }); + + it("never reads CACHED_COMMIT_REF, which is the previous build's SHA", () => { + expect(PLATFORM_COMMIT_SHA_ENV_VARS).not.toContain("CACHED_COMMIT_REF"); + expect(discoverPlatformCommitSha(reader({ CACHED_COMMIT_REF: SHA }))).toBeUndefined(); + }); +}); + +describe("resolveExternalDeploymentId", () => { + it("returns nothing when no source yields a value", () => { + expect(resolveExternalDeploymentId({ read: reader({}) })).toBeUndefined(); + }); + + it("honours a per-call id above everything else", () => { + expect( + resolveExternalDeploymentId({ + explicit: "per-call", + clientConfig: "per-client", + read: reader({ + TRIGGER_EXTERNAL_DEPLOYMENT_ID: "per-env", + TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: "1", + VERCEL_GIT_COMMIT_SHA: "discovered", + }), + }) + ).toBe("per-call"); + }); + + it("honours a per-client id above the environment and discovery", () => { + expect( + resolveExternalDeploymentId({ + clientConfig: "per-client", + read: reader({ + TRIGGER_EXTERNAL_DEPLOYMENT_ID: "per-env", + TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: "1", + VERCEL_GIT_COMMIT_SHA: "discovered", + }), + }) + ).toBe("per-client"); + }); + + it("honours TRIGGER_EXTERNAL_DEPLOYMENT_ID above discovery", () => { + expect( + resolveExternalDeploymentId({ + read: reader({ + TRIGGER_EXTERNAL_DEPLOYMENT_ID: "per-env", + TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: "1", + VERCEL_GIT_COMMIT_SHA: "discovered", + }), + }) + ).toBe("per-env"); + }); + + it("honours an explicit id with no opt-in variable at all — the gate is on discovery, not pinning", () => { + expect( + resolveExternalDeploymentId({ + read: reader({ TRIGGER_EXTERNAL_DEPLOYMENT_ID: "per-env" }), + }) + ).toBe("per-env"); + }); + + it("honours a per-call id with no opt-in variable", () => { + expect(resolveExternalDeploymentId({ explicit: "per-call", read: reader({}) })).toBe( + "per-call" + ); + }); + + it("discovers when the opt-in is exactly 1", () => { + expect( + resolveExternalDeploymentId({ + read: reader({ + TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: "1", + VERCEL_GIT_COMMIT_SHA: SHA, + }), + }) + ).toBe(SHA); + }); + + it.each(["0", "", "false", "yes", undefined])( + "discovers nothing when the opt-in reads %j", + (gate) => { + expect( + resolveExternalDeploymentId({ + read: reader({ + TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: gate, + VERCEL_GIT_COMMIT_SHA: SHA, + }), + }) + ).toBeUndefined(); + } + ); + + it("normalises whatever it resolves, whichever tier produced it", () => { + expect(resolveExternalDeploymentId({ explicit: ` ${SHA} `, read: reader({}) })).toBe(SHA); + expect(resolveExternalDeploymentId({ clientConfig: ` ${SHA} `, read: reader({}) })).toBe(SHA); + expect( + resolveExternalDeploymentId({ read: reader({ TRIGGER_EXTERNAL_DEPLOYMENT_ID: ` ${SHA} ` }) }) + ).toBe(SHA); + }); + + it("falls through a blank higher tier to a usable lower one", () => { + expect( + resolveExternalDeploymentId({ + explicit: " ", + clientConfig: "", + read: reader({ TRIGGER_EXTERNAL_DEPLOYMENT_ID: SHA }), + }) + ).toBe(SHA); + }); + + it("reads the environment on every call, so a variable appearing later is picked up", () => { + const vars: Record = {}; + const read = reader(vars); + + expect(resolveExternalDeploymentId({ read })).toBeUndefined(); + + vars.TRIGGER_EXTERNAL_DEPLOYMENT_ID = SHA; + + expect(resolveExternalDeploymentId({ read })).toBe(SHA); + }); + + it("reads nothing at all when the reader refuses, which is how a non-inheriting SDK scope behaves", () => { + expect( + resolveExternalDeploymentId({ + read: () => undefined, + }) + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/v3/externalDeploymentId.ts b/packages/core/src/v3/externalDeploymentId.ts new file mode 100644 index 00000000000..6ffa183f64c --- /dev/null +++ b/packages/core/src/v3/externalDeploymentId.ts @@ -0,0 +1,103 @@ +export const EXTERNAL_DEPLOYMENT_ID_ENV_VAR = "TRIGGER_EXTERNAL_DEPLOYMENT_ID"; + +export const AUTOMATIC_SKEW_PROTECTION_ENV_VAR = "TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION"; + +export const EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH = 128; + +export type EnvVarReader = (name: string) => string | undefined; + +export const PLATFORM_COMMIT_SHA_ENV_VARS = [ + "VERCEL_GIT_COMMIT_SHA", + "RAILWAY_GIT_COMMIT_SHA", + "RENDER_GIT_COMMIT", + "CF_PAGES_COMMIT_SHA", + "WORKERS_CI_COMMIT_SHA", + "COMMIT_REF", + "AWS_COMMIT_ID", + "HEROKU_BUILD_COMMIT", + "HEROKU_SLUG_COMMIT", + "KOYEB_GIT_SHA", + + "GITHUB_SHA", + "CI_COMMIT_SHA", + "CIRCLE_SHA1", + "BITBUCKET_COMMIT", + "BUILDKITE_COMMIT", + "BUILD_SOURCEVERSION", + "COMMIT_SHA", + "DRONE_COMMIT_SHA", + "GIT_COMMIT", + "BUILD_VCS_NUMBER", + "TRAVIS_COMMIT", + + "COMMIT_SHA", + "COMMIT_HASH", + "GIT_COMMIT", + "GIT_SHA", + "GIT_HASH", +] as const; + +export function normalizeExternalDeploymentId(value: string | undefined): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const trimmed = value.trim(); + + if (trimmed === "" || trimmed.length > EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH) { + return undefined; + } + + return trimmed; +} + +export function isAutomaticSkewProtectionEnabled(read: EnvVarReader): boolean { + const raw = read(AUTOMATIC_SKEW_PROTECTION_ENV_VAR); + + if (typeof raw !== "string") { + return false; + } + + const normalized = raw.trim().toLowerCase(); + + return normalized === "1" || normalized === "true"; +} + +export function discoverPlatformCommitSha(read: EnvVarReader): string | undefined { + for (const name of PLATFORM_COMMIT_SHA_ENV_VARS) { + const candidate = normalizeExternalDeploymentId(read(name)); + + if (candidate) { + return candidate; + } + } + + return undefined; +} + +export type ResolveExternalDeploymentIdOptions = { + explicit?: string; + clientConfig?: string; + read: EnvVarReader; +}; + +export function resolveExternalDeploymentId({ + explicit, + clientConfig, + read, +}: ResolveExternalDeploymentIdOptions): string | undefined { + const fromCall = normalizeExternalDeploymentId(explicit); + if (fromCall) return fromCall; + + const fromClient = normalizeExternalDeploymentId(clientConfig); + if (fromClient) return fromClient; + + const fromEnv = normalizeExternalDeploymentId(read(EXTERNAL_DEPLOYMENT_ID_ENV_VAR)); + if (fromEnv) return fromEnv; + + if (isAutomaticSkewProtectionEnabled(read)) { + return discoverPlatformCommitSha(read); + } + + return undefined; +} diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 10551fca6f6..f2916f88576 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -5,6 +5,7 @@ export type { ApiPromise, OffsetLimitPagePromise, CursorPagePromise } from "./ap export * from "./apiClient/errors.js"; export * from "./clock-api.js"; export * from "./errors.js"; +export * from "./externalDeploymentId.js"; export * from "./limits.js"; export * from "./logger-api.js"; export * from "./runtime-api.js"; diff --git a/packages/core/src/v3/schemas/api-type.test.ts b/packages/core/src/v3/schemas/api-type.test.ts index 39fb3131ce1..2c195a806fe 100644 --- a/packages/core/src/v3/schemas/api-type.test.ts +++ b/packages/core/src/v3/schemas/api-type.test.ts @@ -109,6 +109,172 @@ describe("InitializeDeploymentRequestBody", () => { }); }); + describe("externalId and force", () => { + it("accepts an externalId on the non-native variant", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: "a1b2c3d4e5f6", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.externalId).toBe("a1b2c3d4e5f6"); + } + }); + + it("accepts an externalId on the native variant", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + isNativeBuild: true, + externalId: "a1b2c3d4e5f6", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.externalId).toBe("a1b2c3d4e5f6"); + } + }); + + it("accepts a free-form externalId, imposing no format", () => { + for (const externalId of [ + "refs/tags/v1.2.3_rc:4-final", + "release 2026-08-07", + "build #4821", + "déployé-en-français", + "🚀 ship it", + '{"run":42}', + ]) { + const result = InitializeDeploymentRequestBody.safeParse({ ...base, externalId }); + expect(result.success, `expected ${externalId} to be accepted`).toBe(true); + if (result.success) { + expect(result.data.externalId).toBe(externalId); + } + } + }); + + it("trims surrounding whitespace but keeps whitespace inside the value", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: " release 2026-08-07 ", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.externalId).toBe("release 2026-08-07"); + } + }); + + it("treats a blank externalId as absent", () => { + const result = InitializeDeploymentRequestBody.safeParse({ ...base, externalId: "" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.externalId).toBeUndefined(); + } + }); + + it("treats a whitespace-only externalId as absent", () => { + const result = InitializeDeploymentRequestBody.safeParse({ ...base, externalId: " " }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.externalId).toBeUndefined(); + } + }); + + it("accepts an externalId of exactly 128 characters", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: "a".repeat(128), + }); + expect(result.success).toBe(true); + }); + + it("accepts a 64-character SHA-256 commit hash", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: "a".repeat(64), + }); + expect(result.success).toBe(true); + }); + + it("accepts a 40-character commit SHA", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: "e3f1c0a9b7d24e5f6081a2b3c4d5e6f708192a3b", + }); + expect(result.success).toBe(true); + }); + + it("rejects an externalId longer than 128 characters", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: "a".repeat(129), + }); + expect(result.success).toBe(false); + }); + + it("names the limit in the rejection message", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: "a".repeat(129), + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("externalId must be at most 128 characters"); + } + }); + + it("measures the length limit after trimming", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: ` ${"a".repeat(128)} `, + }); + expect(result.success).toBe(true); + }); + + it("leaves force absent when omitted, which reads as not forced", () => { + const result = InitializeDeploymentRequestBody.safeParse(base); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.force ?? false).toBe(false); + } + }); + + it("accepts force alongside an externalId", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: "a1b2c3", + force: true, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.force).toBe(true); + } + }); + + it("rejects force without an externalId", () => { + const result = InitializeDeploymentRequestBody.safeParse({ ...base, force: true }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe("force requires externalId"); + } + }); + + it("rejects force when the externalId is blank", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + externalId: " ", + force: true, + }); + expect(result.success).toBe(false); + }); + + it("rejects force without an externalId on the native variant too", () => { + const result = InitializeDeploymentRequestBody.safeParse({ + ...base, + isNativeBuild: true, + force: true, + }); + expect(result.success).toBe(false); + }); + }); + describe("type-level checks", () => { it("native variant exposes native-specific fields", () => { const result = InitializeDeploymentRequestBody.parse({ diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 12e991cfef9..94c0f7948ed 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { DeserializedJsonSchema } from "../../schemas/json.js"; +import { EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH } from "../externalDeploymentId.js"; import { FlushedRunMetadata, GitMeta, @@ -205,6 +206,15 @@ export type IdempotencyKeyOptionsSchema = z.infer String(value)); +const ExternalDeploymentId = z.preprocess((value) => { + if (typeof value !== "string") { + return value; + } + + const trimmed = value.trim(); + return trimmed === "" ? undefined : trimmed; +}, z.string().max(EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH, `externalId must be at most ${EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH} characters`).optional()); + export const TriggerTaskRequestBody = z .object({ payload: z.any(), @@ -236,6 +246,14 @@ export const TriggerTaskRequestBody = z * Automatically set when using `triggerAndWait` or `batchTriggerAndWait` */ lockToVersion: z.string().optional(), + /** + * The external deployment id the calling application belongs to — a commit SHA, a + * CI run id, a release tag. Independent of `lockToVersion`: the SDK reports every + * pinning signal it can see and the server decides which one governs, so a trigger + * carrying both sends both. Resolution is environment-scoped, and an id naming a + * deployment that has not landed yet parks the run rather than failing it. + */ + externalDeploymentId: ExternalDeploymentId, queue: z .object({ @@ -334,6 +352,8 @@ export const BatchTriggerTaskItem = z.object({ /** The original user-provided idempotency key and scope */ idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), lockToVersion: z.string().optional(), + /** See `TriggerTaskRequestBody.options.externalDeploymentId`. */ + externalDeploymentId: ExternalDeploymentId, machine: MachinePresetName.optional(), maxAttempts: z.number().int().optional(), maxDuration: z.number().optional(), @@ -676,7 +696,11 @@ export const InitializeDeploymentResponseBody = z.object({ version: z.string(), imageTag: z.string(), imagePlatform: z.string(), + externalId: z.string().optional(), + outcome: z.enum(["created", "existing"]).optional(), + isPromoted: z.boolean().optional(), externalBuildData: ExternalBuildData.optional().nullable(), + canceledDeployments: z.array(z.object({ version: z.string(), shortCode: z.string() })).optional(), eventStream: z .object({ s2: z.object({ @@ -702,6 +726,8 @@ const InitializeDeploymentRequestBodyBase = z.object({ isLocalBuild: z.boolean().optional(), triggeredVia: DeploymentTriggeredVia.optional(), buildId: z.string().optional(), + externalId: ExternalDeploymentId, + force: z.boolean().optional(), }); type BaseOutput = z.output; @@ -727,6 +753,14 @@ const InitializeDeploymentRequestBodyFull = InitializeDeploymentRequestBodyBase. artifactKey: z.string().optional(), configFilePath: z.string().optional(), skipEnqueue: z.boolean().optional().default(false), +}).superRefine((data, ctx) => { + if (data.force && !data.externalId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["force"], + message: "force requires externalId", + }); + } }); export const InitializeDeploymentRequestBody = InitializeDeploymentRequestBodyFull.transform( @@ -810,6 +844,7 @@ export const GetDeploymentResponseBody = z.object({ commitSHA: z.string().nullish(), externalBuildData: ExternalBuildData.optional().nullable(), errorData: DeploymentErrorData.nullish(), + canceledReason: z.string().nullish(), worker: z .object({ id: z.string(), diff --git a/packages/core/src/v3/schemas/runEngine.ts b/packages/core/src/v3/schemas/runEngine.ts index 92144364494..64a32b7cfda 100644 --- a/packages/core/src/v3/schemas/runEngine.ts +++ b/packages/core/src/v3/schemas/runEngine.ts @@ -25,6 +25,7 @@ export const RunAnnotations = z.object({ rootTriggerSource: TriggerSource, rootScheduleId: z.string().optional(), taskKind: TaskKind.optional(), + externalDeploymentId: z.string().optional(), }); export type RunAnnotations = z.infer; diff --git a/packages/core/src/v3/schemas/triggerExternalDeploymentId.test.ts b/packages/core/src/v3/schemas/triggerExternalDeploymentId.test.ts new file mode 100644 index 00000000000..1629eb6d0f3 --- /dev/null +++ b/packages/core/src/v3/schemas/triggerExternalDeploymentId.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { BatchTriggerTaskItem, TriggerTaskRequestBody } from "./api.js"; +import { RunAnnotations } from "./runEngine.js"; + +describe("TriggerTaskRequestBody.options.externalDeploymentId", () => { + it("accepts an id alongside lockToVersion — neither suppresses the other", () => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { lockToVersion: "20260807.1", externalDeploymentId: "commit-abc" }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.options?.lockToVersion).toBe("20260807.1"); + expect(result.data.options?.externalDeploymentId).toBe("commit-abc"); + } + }); + + it("imposes no format", () => { + for (const id of ["a1b2c3", "v1.2.3", "release/2026-08-07", "refs/heads/main", "1"]) { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { externalDeploymentId: id }, + }); + expect(result.success, `expected ${id} to be accepted`).toBe(true); + } + }); + + it("trims surrounding whitespace", () => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { externalDeploymentId: " commit-abc " }, + }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.options?.externalDeploymentId).toBe("commit-abc"); + }); + + it.each(["", " "])("treats %j as absent rather than a 400", (value) => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { externalDeploymentId: value }, + }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.options?.externalDeploymentId).toBeUndefined(); + }); + + it("accepts exactly 128 characters and rejects 129", () => { + expect( + TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { externalDeploymentId: "a".repeat(128) }, + }).success + ).toBe(true); + + expect( + TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { externalDeploymentId: "a".repeat(129) }, + }).success + ).toBe(false); + }); + + it("measures the length limit after trimming, matching the deploy side", () => { + expect( + TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { externalDeploymentId: ` ${"a".repeat(128)} ` }, + }).success + ).toBe(true); + }); +}); + +describe("BatchTriggerTaskItem.options.externalDeploymentId", () => { + it("carries the id per item, so it survives asynchronous materialisation", () => { + const result = BatchTriggerTaskItem.safeParse({ + task: "my-task", + payload: {}, + options: { externalDeploymentId: " commit-abc " }, + }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.options?.externalDeploymentId).toBe("commit-abc"); + }); + + it("applies the same 128-character cap as the single-trigger body", () => { + expect( + BatchTriggerTaskItem.safeParse({ + task: "my-task", + payload: {}, + options: { externalDeploymentId: "a".repeat(129) }, + }).success + ).toBe(false); + }); +}); + +describe("RunAnnotations.externalDeploymentId", () => { + it("is optional, so every existing annotations blob still parses", () => { + const result = RunAnnotations.safeParse({ + triggerSource: "sdk", + triggerAction: "trigger", + rootTriggerSource: "sdk", + }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.externalDeploymentId).toBeUndefined(); + }); + + it("round-trips the id when present", () => { + const result = RunAnnotations.safeParse({ + triggerSource: "sdk", + triggerAction: "trigger", + rootTriggerSource: "sdk", + externalDeploymentId: "commit-abc", + }); + + expect(result.success).toBe(true); + if (result.success) expect(result.data.externalDeploymentId).toBe("commit-abc"); + }); +});