Skip to content

Commit 5ff2e09

Browse files
committed
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
1 parent aad440b commit 5ff2e09

7 files changed

Lines changed: 672 additions & 0 deletions

File tree

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
discoverPlatformCommitSha,
4+
isAutomaticSkewProtectionEnabled,
5+
normalizeExternalDeploymentId,
6+
PLATFORM_COMMIT_SHA_ENV_VARS,
7+
resolveExternalDeploymentId,
8+
} from "./externalDeploymentId.js";
9+
10+
function reader(vars: Record<string, string | undefined>) {
11+
return (name: string) => vars[name];
12+
}
13+
14+
const SHA = "fa1eade47b73733d6312d5abfad33ce9e4068081";
15+
16+
describe("normalizeExternalDeploymentId", () => {
17+
it("trims surrounding whitespace", () => {
18+
expect(normalizeExternalDeploymentId(` ${SHA} `)).toBe(SHA);
19+
});
20+
21+
it.each([undefined, "", " ", "\t\n"])("treats %j as absent", (value) => {
22+
expect(normalizeExternalDeploymentId(value)).toBeUndefined();
23+
});
24+
25+
it("accepts exactly 128 characters", () => {
26+
expect(normalizeExternalDeploymentId("a".repeat(128))).toBe("a".repeat(128));
27+
});
28+
29+
it("skips a value longer than 128 characters rather than sending it to be rejected", () => {
30+
expect(normalizeExternalDeploymentId("a".repeat(129))).toBeUndefined();
31+
});
32+
33+
it("measures the length limit after trimming", () => {
34+
expect(normalizeExternalDeploymentId(` ${"a".repeat(128)} `)).toBe("a".repeat(128));
35+
});
36+
});
37+
38+
describe("isAutomaticSkewProtectionEnabled", () => {
39+
it.each([
40+
["1", true],
41+
["true", true],
42+
["TRUE", true],
43+
["True", true],
44+
[" 1 ", true],
45+
["0", false],
46+
["false", false],
47+
["", false],
48+
["yes", false],
49+
["on", false],
50+
["2", false],
51+
[undefined, false],
52+
])("reads %j as %s", (value, expected) => {
53+
expect(
54+
isAutomaticSkewProtectionEnabled(reader({ TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: value }))
55+
).toBe(expected);
56+
});
57+
});
58+
59+
describe("discoverPlatformCommitSha", () => {
60+
it("returns undefined when nothing is set", () => {
61+
expect(discoverPlatformCommitSha(reader({}))).toBeUndefined();
62+
});
63+
64+
it.each(PLATFORM_COMMIT_SHA_ENV_VARS)("reads %s", (name) => {
65+
expect(discoverPlatformCommitSha(reader({ [name]: SHA }))).toBe(SHA);
66+
});
67+
68+
it("prefers a hosting variable over a CI variable, because it describes the deployment that is running", () => {
69+
expect(
70+
discoverPlatformCommitSha(
71+
reader({ VERCEL_GIT_COMMIT_SHA: "vercel-sha", GITHUB_SHA: "github-sha" })
72+
)
73+
).toBe("vercel-sha");
74+
});
75+
76+
it("prefers a CI variable over the generic tier", () => {
77+
expect(
78+
discoverPlatformCommitSha(reader({ GITHUB_SHA: "github-sha", GIT_HASH: "generic-sha" }))
79+
).toBe("github-sha");
80+
});
81+
82+
it("falls back to the generic tier when nothing named is set", () => {
83+
expect(discoverPlatformCommitSha(reader({ COMMIT_HASH: "generic-sha" }))).toBe("generic-sha");
84+
});
85+
86+
it("honours the full hosting order", () => {
87+
const order = [
88+
"VERCEL_GIT_COMMIT_SHA",
89+
"RAILWAY_GIT_COMMIT_SHA",
90+
"RENDER_GIT_COMMIT",
91+
"CF_PAGES_COMMIT_SHA",
92+
"WORKERS_CI_COMMIT_SHA",
93+
"COMMIT_REF",
94+
"AWS_COMMIT_ID",
95+
"HEROKU_BUILD_COMMIT",
96+
"HEROKU_SLUG_COMMIT",
97+
"KOYEB_GIT_SHA",
98+
];
99+
100+
const vars: Record<string, string> = Object.fromEntries(order.map((n) => [n, n]));
101+
102+
for (const expected of order) {
103+
expect(discoverPlatformCommitSha(reader(vars))).toBe(expected);
104+
delete vars[expected];
105+
}
106+
});
107+
108+
it("skips an empty value and keeps looking", () => {
109+
expect(discoverPlatformCommitSha(reader({ VERCEL_GIT_COMMIT_SHA: "", GITHUB_SHA: SHA }))).toBe(
110+
SHA
111+
);
112+
});
113+
114+
it("skips an over-long value and keeps looking, rather than sending something that will be rejected", () => {
115+
expect(
116+
discoverPlatformCommitSha(reader({ VERCEL_GIT_COMMIT_SHA: "a".repeat(129), GITHUB_SHA: SHA }))
117+
).toBe(SHA);
118+
});
119+
120+
it("never reads CACHED_COMMIT_REF, which is the previous build's SHA", () => {
121+
expect(PLATFORM_COMMIT_SHA_ENV_VARS).not.toContain("CACHED_COMMIT_REF");
122+
expect(discoverPlatformCommitSha(reader({ CACHED_COMMIT_REF: SHA }))).toBeUndefined();
123+
});
124+
});
125+
126+
describe("resolveExternalDeploymentId", () => {
127+
it("returns nothing when no source yields a value", () => {
128+
expect(resolveExternalDeploymentId({ read: reader({}) })).toBeUndefined();
129+
});
130+
131+
it("honours a per-call id above everything else", () => {
132+
expect(
133+
resolveExternalDeploymentId({
134+
explicit: "per-call",
135+
clientConfig: "per-client",
136+
read: reader({
137+
TRIGGER_EXTERNAL_DEPLOYMENT_ID: "per-env",
138+
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: "1",
139+
VERCEL_GIT_COMMIT_SHA: "discovered",
140+
}),
141+
})
142+
).toBe("per-call");
143+
});
144+
145+
it("honours a per-client id above the environment and discovery", () => {
146+
expect(
147+
resolveExternalDeploymentId({
148+
clientConfig: "per-client",
149+
read: reader({
150+
TRIGGER_EXTERNAL_DEPLOYMENT_ID: "per-env",
151+
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: "1",
152+
VERCEL_GIT_COMMIT_SHA: "discovered",
153+
}),
154+
})
155+
).toBe("per-client");
156+
});
157+
158+
it("honours TRIGGER_EXTERNAL_DEPLOYMENT_ID above discovery", () => {
159+
expect(
160+
resolveExternalDeploymentId({
161+
read: reader({
162+
TRIGGER_EXTERNAL_DEPLOYMENT_ID: "per-env",
163+
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: "1",
164+
VERCEL_GIT_COMMIT_SHA: "discovered",
165+
}),
166+
})
167+
).toBe("per-env");
168+
});
169+
170+
it("honours an explicit id with no opt-in variable at all — the gate is on discovery, not pinning", () => {
171+
expect(
172+
resolveExternalDeploymentId({
173+
read: reader({ TRIGGER_EXTERNAL_DEPLOYMENT_ID: "per-env" }),
174+
})
175+
).toBe("per-env");
176+
});
177+
178+
it("honours a per-call id with no opt-in variable", () => {
179+
expect(resolveExternalDeploymentId({ explicit: "per-call", read: reader({}) })).toBe(
180+
"per-call"
181+
);
182+
});
183+
184+
it("discovers when the opt-in is exactly 1", () => {
185+
expect(
186+
resolveExternalDeploymentId({
187+
read: reader({
188+
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: "1",
189+
VERCEL_GIT_COMMIT_SHA: SHA,
190+
}),
191+
})
192+
).toBe(SHA);
193+
});
194+
195+
it.each(["0", "", "false", "yes", undefined])(
196+
"discovers nothing when the opt-in reads %j",
197+
(gate) => {
198+
expect(
199+
resolveExternalDeploymentId({
200+
read: reader({
201+
TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION: gate,
202+
VERCEL_GIT_COMMIT_SHA: SHA,
203+
}),
204+
})
205+
).toBeUndefined();
206+
}
207+
);
208+
209+
it("normalises whatever it resolves, whichever tier produced it", () => {
210+
expect(resolveExternalDeploymentId({ explicit: ` ${SHA} `, read: reader({}) })).toBe(SHA);
211+
expect(resolveExternalDeploymentId({ clientConfig: ` ${SHA} `, read: reader({}) })).toBe(SHA);
212+
expect(
213+
resolveExternalDeploymentId({ read: reader({ TRIGGER_EXTERNAL_DEPLOYMENT_ID: ` ${SHA} ` }) })
214+
).toBe(SHA);
215+
});
216+
217+
it("falls through a blank higher tier to a usable lower one", () => {
218+
expect(
219+
resolveExternalDeploymentId({
220+
explicit: " ",
221+
clientConfig: "",
222+
read: reader({ TRIGGER_EXTERNAL_DEPLOYMENT_ID: SHA }),
223+
})
224+
).toBe(SHA);
225+
});
226+
227+
it("reads the environment on every call, so a variable appearing later is picked up", () => {
228+
const vars: Record<string, string | undefined> = {};
229+
const read = reader(vars);
230+
231+
expect(resolveExternalDeploymentId({ read })).toBeUndefined();
232+
233+
vars.TRIGGER_EXTERNAL_DEPLOYMENT_ID = SHA;
234+
235+
expect(resolveExternalDeploymentId({ read })).toBe(SHA);
236+
});
237+
238+
it("reads nothing at all when the reader refuses, which is how a non-inheriting SDK scope behaves", () => {
239+
expect(
240+
resolveExternalDeploymentId({
241+
read: () => undefined,
242+
})
243+
).toBeUndefined();
244+
});
245+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
export const EXTERNAL_DEPLOYMENT_ID_ENV_VAR = "TRIGGER_EXTERNAL_DEPLOYMENT_ID";
2+
3+
export const AUTOMATIC_SKEW_PROTECTION_ENV_VAR = "TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION";
4+
5+
export const EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH = 128;
6+
7+
export type EnvVarReader = (name: string) => string | undefined;
8+
9+
export const PLATFORM_COMMIT_SHA_ENV_VARS = [
10+
"VERCEL_GIT_COMMIT_SHA",
11+
"RAILWAY_GIT_COMMIT_SHA",
12+
"RENDER_GIT_COMMIT",
13+
"CF_PAGES_COMMIT_SHA",
14+
"WORKERS_CI_COMMIT_SHA",
15+
"COMMIT_REF",
16+
"AWS_COMMIT_ID",
17+
"HEROKU_BUILD_COMMIT",
18+
"HEROKU_SLUG_COMMIT",
19+
"KOYEB_GIT_SHA",
20+
21+
"GITHUB_SHA",
22+
"CI_COMMIT_SHA",
23+
"CIRCLE_SHA1",
24+
"BITBUCKET_COMMIT",
25+
"BUILDKITE_COMMIT",
26+
"BUILD_SOURCEVERSION",
27+
"COMMIT_SHA",
28+
"DRONE_COMMIT_SHA",
29+
"GIT_COMMIT",
30+
"BUILD_VCS_NUMBER",
31+
"TRAVIS_COMMIT",
32+
33+
"COMMIT_SHA",
34+
"COMMIT_HASH",
35+
"GIT_COMMIT",
36+
"GIT_SHA",
37+
"GIT_HASH",
38+
] as const;
39+
40+
export function normalizeExternalDeploymentId(value: string | undefined): string | undefined {
41+
if (typeof value !== "string") {
42+
return undefined;
43+
}
44+
45+
const trimmed = value.trim();
46+
47+
if (trimmed === "" || trimmed.length > EXTERNAL_DEPLOYMENT_ID_MAX_LENGTH) {
48+
return undefined;
49+
}
50+
51+
return trimmed;
52+
}
53+
54+
export function isAutomaticSkewProtectionEnabled(read: EnvVarReader): boolean {
55+
const raw = read(AUTOMATIC_SKEW_PROTECTION_ENV_VAR);
56+
57+
if (typeof raw !== "string") {
58+
return false;
59+
}
60+
61+
const normalized = raw.trim().toLowerCase();
62+
63+
return normalized === "1" || normalized === "true";
64+
}
65+
66+
export function discoverPlatformCommitSha(read: EnvVarReader): string | undefined {
67+
for (const name of PLATFORM_COMMIT_SHA_ENV_VARS) {
68+
const candidate = normalizeExternalDeploymentId(read(name));
69+
70+
if (candidate) {
71+
return candidate;
72+
}
73+
}
74+
75+
return undefined;
76+
}
77+
78+
export type ResolveExternalDeploymentIdOptions = {
79+
explicit?: string;
80+
clientConfig?: string;
81+
read: EnvVarReader;
82+
};
83+
84+
export function resolveExternalDeploymentId({
85+
explicit,
86+
clientConfig,
87+
read,
88+
}: ResolveExternalDeploymentIdOptions): string | undefined {
89+
const fromCall = normalizeExternalDeploymentId(explicit);
90+
if (fromCall) return fromCall;
91+
92+
const fromClient = normalizeExternalDeploymentId(clientConfig);
93+
if (fromClient) return fromClient;
94+
95+
const fromEnv = normalizeExternalDeploymentId(read(EXTERNAL_DEPLOYMENT_ID_ENV_VAR));
96+
if (fromEnv) return fromEnv;
97+
98+
if (isAutomaticSkewProtectionEnabled(read)) {
99+
return discoverPlatformCommitSha(read);
100+
}
101+
102+
return undefined;
103+
}

packages/core/src/v3/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type { ApiPromise, OffsetLimitPagePromise, CursorPagePromise } from "./ap
55
export * from "./apiClient/errors.js";
66
export * from "./clock-api.js";
77
export * from "./errors.js";
8+
export * from "./externalDeploymentId.js";
89
export * from "./limits.js";
910
export * from "./logger-api.js";
1011
export * from "./runtime-api.js";

0 commit comments

Comments
 (0)