From ce31898e3bd4294e955710b1f03c7c38664b9ddb Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 2 Jul 2026 17:07:30 -0700 Subject: [PATCH 1/2] [core] Correct byte-stream framing capability cutoff for stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The framedByteStreams capability was gated at minVersion 4.5.0, but stable 4.5.0 was published before the framing backport (#2380) merged, so 4.5.0 deployments cannot unframe framed byte streams — they silently pipe length-prefixed bytes through to user code. Gate at 4.6.0 instead, the release that actually ships framing. Additionally, replace the plain minimum-version check with a semver range: every 5.0.0-beta.x compares above any 4.x version, but betas below 5.0.0-beta.15 predate framing, so a plain >=4.6.0 check would classify them as framing-capable and corrupt byte streams written to runs or deployments from that window. Co-Authored-By: Claude Fable 5 --- .changeset/framing-capability-cutoff.md | 6 ++++ packages/core/src/capabilities.test.ts | 28 +++++++++++++---- packages/core/src/capabilities.ts | 41 ++++++++++++++++++------- 3 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 .changeset/framing-capability-cutoff.md diff --git a/.changeset/framing-capability-cutoff.md b/.changeset/framing-capability-cutoff.md new file mode 100644 index 0000000000..9e9e167863 --- /dev/null +++ b/.changeset/framing-capability-cutoff.md @@ -0,0 +1,6 @@ +--- +"@workflow/core": patch +"workflow": patch +--- + +Correct the byte-stream framing capability cutoff so framed byte streams are never written to deployments that cannot decode them diff --git a/packages/core/src/capabilities.test.ts b/packages/core/src/capabilities.test.ts index 8cb8404874..08fd4e1601 100644 --- a/packages/core/src/capabilities.test.ts +++ b/packages/core/src/capabilities.test.ts @@ -77,26 +77,42 @@ describe('getRunCapabilities', () => { it.each([ // pre-cutoff: encryption introduced in 4.2.0-beta.64; framing ships - // in the stable 4.5.0 release, so any earlier 4.x version is too old + // in the stable 4.6.0 release, so any earlier 4.x version is too old. + // 4.5.0 in particular was published before the framing backport + // merged, so it silently pipes framed bytes through to the user. '4.2.0-beta.64', '4.2.0', '4.4.0', + '4.5.0', + '4.5.1', // betas published before framing shipped must read as raw — // a false positive here means framed writes to a consumer that // cannot unframe them - '4.5.0-beta.14', + '4.6.0-beta.14', + // 5.0.0 betas below beta.15 predate framing but compare above every + // 4.x version, so they specifically must not match a plain >=4.6.0 + // check (runs created on those deployments record these versions) + '5.0.0-beta.0', + '5.0.0-beta.14', ])('is false for pre-framing version %s', (version) => { expect(getRunCapabilities(version).framedByteStreams).toBe(false); }); - it('is true at the exact cutoff version (4.5.0)', () => { - expect(getRunCapabilities('4.5.0').framedByteStreams).toBe(true); + it('is true at the exact stable cutoff version (4.6.0)', () => { + expect(getRunCapabilities('4.6.0').framedByteStreams).toBe(true); + }); + + it('is true at the exact beta cutoff version (5.0.0-beta.15)', () => { + expect(getRunCapabilities('5.0.0-beta.15').framedByteStreams).toBe(true); }); it.each([ - '4.5.1', - '4.6.0', + '4.6.1', + '4.7.0', + '5.0.0-beta.16', '5.0.0', + // future prereleases above the cutoff must be recognized as capable + '5.1.0-beta.0', '6.0.0', ])('is true for post-framing version %s', (version) => { expect(getRunCapabilities(version).framedByteStreams).toBe(true); diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts index a36cabb828..556b77ff7d 100644 --- a/packages/core/src/capabilities.ts +++ b/packages/core/src/capabilities.ts @@ -27,7 +27,10 @@ * - `encr` (AES-256-GCM encryption): added in `4.2.0-beta.64` * Commit: 7618ac36 "Wire AES-GCM encryption into serialization layer (#1251)" * https://github.com/vercel/workflow/commit/7618ac36 - * - `framedByteStreams` (wire-level chunk framing for byte streams): added in `4.5.0` + * - `framedByteStreams` (wire-level chunk framing for byte streams): added in + * `4.6.0` on the stable line and `5.0.0-beta.15` on the beta line + * Commit: f2ad726 "Add wire-level framing for byte streams (#1853)" + * https://github.com/vercel/workflow/commit/f2ad726 */ import semver from 'semver'; @@ -75,17 +78,31 @@ const FORMAT_VERSION_TABLE: ReadonlyArray<{ /** * Maps non-format capability flags (booleans on `RunCapabilities`) to the - * minimum `@workflow/core` version that introduced support for them. + * semver range of `@workflow/core` versions that support them. + * + * These are ranges rather than simple minimum versions because published + * release lines interleave: every `5.0.0-beta.x` compares above every + * `4.x` version, but betas below `5.0.0-beta.15` predate byte-stream + * framing. A plain `>= 4.6.0` check would classify those betas as + * framing-capable and write framed bytes to a consumer that cannot + * unframe them (silent corruption). Classifying a capable version as + * incapable is always safe — it merely falls back to the legacy format. + * + * Ranges are evaluated with `includePrerelease: true` so that future + * prereleases above a cutoff (e.g. `5.1.0-beta.0`) are recognized as + * capable. */ const CAPABILITY_VERSION_TABLE: ReadonlyArray<{ capability: keyof Omit; - minVersion: string; - // TODO(release): verify this matches the actual version that ships byte-stream - // framing. If a "Version Packages (beta)" PR merges before this change, bump - // to the next beta. A too-low cutoff makes new producers write framed bytes to - // consumers that cannot unframe them (silent corruption); too-high merely - // delays the optimization (safe). -}> = [{ capability: 'framedByteStreams', minVersion: '4.5.0' }]; + range: string; +}> = [ + { + capability: 'framedByteStreams', + // Stable line: shipped in 4.6.0. Beta line: shipped in 5.0.0-beta.15; + // 5.0.0-beta.0 through 5.0.0-beta.14 must read as raw. + range: '>=4.6.0 <5.0.0-0 || >=5.0.0-beta.15', + }, +]; /** * The set of formats supported by all specVersion 2 runs, regardless of @@ -128,8 +145,10 @@ export function getRunCapabilities( framedByteStreams: false, }; - for (const { capability, minVersion } of CAPABILITY_VERSION_TABLE) { - if (semver.gte(workflowCoreVersion, minVersion)) { + for (const { capability, range } of CAPABILITY_VERSION_TABLE) { + if ( + semver.satisfies(workflowCoreVersion, range, { includePrerelease: true }) + ) { result[capability] = true; } } From dcc069a2a3157a29e927fd65e121922706b2b1b0 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 2 Jul 2026 20:48:56 -0700 Subject: [PATCH 2/2] Apply suggestion from @VaguelySerious Signed-off-by: Peter Wielander --- .changeset/framing-capability-cutoff.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/framing-capability-cutoff.md b/.changeset/framing-capability-cutoff.md index 9e9e167863..2b9604bd44 100644 --- a/.changeset/framing-capability-cutoff.md +++ b/.changeset/framing-capability-cutoff.md @@ -1,6 +1,5 @@ --- "@workflow/core": patch -"workflow": patch --- Correct the byte-stream framing capability cutoff so framed byte streams are never written to deployments that cannot decode them