From 3d1f45f473750bedd9d299d13a5fc3055c9bba42 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Sun, 30 Aug 2026 21:33:08 -0700 Subject: [PATCH] Stamp __VARLOCK_ENV blobs with the producing varlock version Blobs cross version boundaries in several supported flows: runtime glue bundled into integrations, varlock run children, global CLI vs local package. Until now the format compatibility was implicit. - getSerializedGraph records varlockVersion (baked from package.json at build time, treeshaken to just the version string) - automatic injected-env reuse re-resolves when the producer version differs (or is absent, matching the existing older-producer fallbacks); forced sandbox mode still trusts the blob since there is nothing to re-resolve from - initVarlockEnv warns once per process when the blob was produced by a different minor/major than the runtime code consuming it; patch skew is debug-only - SerializedEnvGraph documents the backward-compat requirement --- .bumpy/env-blob-version-stamp.md | 5 ++ .../content/docs/integrations/javascript.mdx | 1 + .../varlock/src/env-graph/lib/env-graph.ts | 9 ++ .../test/serialized-graph-filter.test.ts | 10 +++ .../varlock/src/lib/injected-env-reuse.ts | 16 +++- .../src/lib/test/injected-env-reuse.test.ts | 26 ++++++ packages/varlock/src/lib/varlock-version.ts | 12 +++ packages/varlock/src/runtime/env.ts | 26 ++++++ .../runtime/test/blob-version-skew.test.ts | 85 +++++++++++++++++++ 9 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 .bumpy/env-blob-version-stamp.md create mode 100644 packages/varlock/src/lib/varlock-version.ts create mode 100644 packages/varlock/src/runtime/test/blob-version-skew.test.ts diff --git a/.bumpy/env-blob-version-stamp.md b/.bumpy/env-blob-version-stamp.md new file mode 100644 index 000000000..ae8d7e128 --- /dev/null +++ b/.bumpy/env-blob-version-stamp.md @@ -0,0 +1,5 @@ +--- +varlock: minor +--- + +Serialized env blobs (__VARLOCK_ENV) now record the varlock version that produced them. Automatic blob reuse re-resolves when the producer version differs, and the runtime warns when env was resolved by a different varlock minor/major than the runtime code consuming it. diff --git a/packages/varlock-website/src/content/docs/integrations/javascript.mdx b/packages/varlock-website/src/content/docs/integrations/javascript.mdx index b62b5c7fb..9df3cfbb0 100644 --- a/packages/varlock-website/src/content/docs/integrations/javascript.mdx +++ b/packages/varlock-website/src/content/docs/integrations/javascript.mdx @@ -73,6 +73,7 @@ When the process was launched by [`varlock run`](/reference/cli/load-and-run/#ru Reuse only happens when a fresh resolution would produce the same result: - the blob was resolved in the same directory the app would resolve in (a root-level `varlock run` in a monorepo does not stop per-package resolution) +- it was produced by the same varlock version that is consuming it (a global CLI and a local package dependency at different versions re-resolve rather than trusting each other's format) - it resolved without errors - the `.env` files it was resolved from are unchanged on disk (editing an env file and restarting the app inside the same `varlock run` re-resolves and picks up the edit) - no env override recorded in the blob has changed since (`varlock run -- sh -c 'FOO=x node app.js'` re-resolves, so the new `FOO` wins) diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts index 5b4644a84..c5d52ba19 100644 --- a/packages/varlock/src/env-graph/lib/env-graph.ts +++ b/packages/varlock/src/env-graph/lib/env-graph.ts @@ -28,6 +28,7 @@ import { getErrorLocation } from './error-location'; import type { VarlockPlugin } from './plugins'; import { runWithResolutionContext, getResolutionContext } from './resolution-context'; import { getCiEnv, type CiEnvInfo } from '@varlock/ci-env-info'; +import { VARLOCK_VERSION } from '../../lib/varlock-version'; import { BUILTIN_VARS, isBuiltinVar } from './builtin-vars'; import { isVarlockReservedKey } from './reserved-vars'; import { normalizeOverrideKeys } from '../../lib/injected-env-provenance'; @@ -59,6 +60,13 @@ export type DefinitionSourceEntry = { }; export type SerializedEnvGraph = { + /** + * Version of the varlock package that produced this blob. Consumers can be at a + * different version (runtime glue bundled into an integration, a parent `varlock run`, + * a global CLI), so changes to this serialized format must stay backward compatible + * within a major. Absent on blobs from producers older than this field. + */ + varlockVersion?: string; basePath?: string; sources: Array<{ type: string; @@ -917,6 +925,7 @@ export class EnvGraph { getSerializedGraph(opts?: { includeInternal?: boolean, filterKeys?: Set }): SerializedEnvGraph { const serializedGraph: SerializedEnvGraph = { + varlockVersion: VARLOCK_VERSION, basePath: this.basePath, sources: [], config: {}, diff --git a/packages/varlock/src/env-graph/test/serialized-graph-filter.test.ts b/packages/varlock/src/env-graph/test/serialized-graph-filter.test.ts index 62ead3ad6..aab71d9b1 100644 --- a/packages/varlock/src/env-graph/test/serialized-graph-filter.test.ts +++ b/packages/varlock/src/env-graph/test/serialized-graph-filter.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import outdent from 'outdent'; import { EnvGraph } from '../index'; import { DotEnvFileDataSource } from '../lib/data-source'; +import { VARLOCK_VERSION } from '../../lib/varlock-version'; async function loadSchema(contents: string, overrideValues?: Record) { const g = new EnvGraph(); @@ -12,6 +13,15 @@ async function loadSchema(contents: string, overrideValues?: Record { + it('records the producing varlock version so consumers can detect skew', async () => { + const g = await loadSchema('FOO=bar'); + const blob = g.getSerializedGraph(); + expect(blob.varlockVersion).toBe(VARLOCK_VERSION); + expect(VARLOCK_VERSION).toMatch(/^\d+\.\d+\.\d+/); + }); +}); + describe('getSerializedGraph filterKeys', () => { it('excludes filtered-out items from config but keeps selected ones', async () => { const g = await loadSchema(outdent` diff --git a/packages/varlock/src/lib/injected-env-reuse.ts b/packages/varlock/src/lib/injected-env-reuse.ts index be290fdae..901e41c17 100644 --- a/packages/varlock/src/lib/injected-env-reuse.ts +++ b/packages/varlock/src/lib/injected-env-reuse.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import type { SerializedEnvGraph } from '../env-graph'; import { isEncryptedBlob, decryptEnvBlobSync } from '../runtime/crypto'; import { readVarlockPackageJsonConfig } from './package-json-config'; +import { VARLOCK_VERSION } from './varlock-version'; import { envValueMatchesBlobItem } from './injected-env-provenance'; import { hashEnvSourceContents } from './env-source-fingerprint'; @@ -177,7 +178,9 @@ export function evaluateInjectedEnvReuse(opts: { if (strippedInternalKeys.length) blobJson = JSON.stringify(parsedEnv); // explicit trust - the sandbox path. The blob is authoritative regardless of where it - // was resolved; directory/drift checks make no sense for a blob from another machine. + // was resolved; directory/drift checks make no sense for a blob from another machine, + // and neither does the producer-version check below (there is nothing to re-resolve + // from). Version skew there is surfaced by the runtime instead (see initVarlockEnv). if (mode === 'force') { return { reuse: true, parsedEnv, blobJson, strippedInternalKeys, @@ -190,6 +193,17 @@ export function evaluateInjectedEnvReuse(opts: { // surface a proper failure rather than booting the app on known-bad values if (parsedEnv.errors) return { reuse: false, reason: 'blob contains resolution errors' }; + // Producer version skew: the blob format is only guaranteed to match between identical + // versions (global CLI vs local package, or a parent `varlock run` from before an + // upgrade). Re-resolving through the current CLI is always correct, so don't risk it. + // Older producers didn't stamp a version, which is itself a mismatch. + if (parsedEnv.varlockVersion !== VARLOCK_VERSION) { + return { + reuse: false, + reason: `blob was produced by varlock ${parsedEnv.varlockVersion ?? '(unversioned)'}, current is ${VARLOCK_VERSION}`, + }; + } + // older producers may not have recorded basePath - we can't verify locality, so re-resolve if (!parsedEnv.basePath) return { reuse: false, reason: 'blob has no basePath recorded' }; diff --git a/packages/varlock/src/lib/test/injected-env-reuse.test.ts b/packages/varlock/src/lib/test/injected-env-reuse.test.ts index f04d24eb1..215485cbb 100644 --- a/packages/varlock/src/lib/test/injected-env-reuse.test.ts +++ b/packages/varlock/src/lib/test/injected-env-reuse.test.ts @@ -7,6 +7,7 @@ import os from 'node:os'; import { evaluateInjectedEnvReuse, USE_INJECTED_ENV_VAR } from '../injected-env-reuse'; import { encryptEnvBlobSync, generateEncryptionKeyHex } from '../../runtime/crypto'; import { hashEnvSourceContents } from '../env-source-fingerprint'; +import { VARLOCK_VERSION } from '../varlock-version'; let tempDir: string; @@ -24,6 +25,7 @@ afterEach(() => { function makeBlob(overrides?: Record) { return JSON.stringify({ + varlockVersion: VARLOCK_VERSION, basePath: tempDir, sources: [], settings: {}, @@ -89,6 +91,22 @@ describe('evaluateInjectedEnvReuse', () => { expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('errors') }); }); + test('does not reuse a blob produced by a different varlock version', () => { + const decision = evaluateInjectedEnvReuse({ + env: { __VARLOCK_ENV: makeBlob({ varlockVersion: '0.0.1' }) }, + cwd: tempDir, + }); + expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('produced by varlock 0.0.1') }); + }); + + test('does not reuse a blob from a producer that did not stamp a version', () => { + const decision = evaluateInjectedEnvReuse({ + env: { __VARLOCK_ENV: makeBlob({ varlockVersion: undefined }) }, + cwd: tempDir, + }); + expect(decision).toMatchObject({ reuse: false, reason: expect.stringContaining('(unversioned)') }); + }); + test('does not reuse a blob with no basePath', () => { const decision = evaluateInjectedEnvReuse({ env: { __VARLOCK_ENV: makeBlob({ basePath: undefined }) }, @@ -263,6 +281,14 @@ describe('evaluateInjectedEnvReuse', () => { }); expect(decision.reuse).toBe(true); }); + + test('forced mode ignores producer version skew (nothing to re-resolve from)', () => { + const decision = evaluateInjectedEnvReuse({ + env: { __VARLOCK_ENV: makeBlob({ varlockVersion: '0.0.1' }), [USE_INJECTED_ENV_VAR]: '1' }, + cwd: tempDir, + }); + expect(decision.reuse).toBe(true); + }); }); describe('override drift', () => { diff --git a/packages/varlock/src/lib/varlock-version.ts b/packages/varlock/src/lib/varlock-version.ts new file mode 100644 index 000000000..2fe9c168a --- /dev/null +++ b/packages/varlock/src/lib/varlock-version.ts @@ -0,0 +1,12 @@ +import packageJson from '../../package.json'; + +/** + * Version of this varlock package, baked into builds at bundle time. + * + * Used to stamp serialized `__VARLOCK_ENV` blobs with their producer version and to detect + * skew on the consumer side. Producers and consumers can legitimately be different builds: + * a parent `varlock run` vs a child process's varlock dependency, a global CLI vs a local + * package, or runtime code bundled into an integration (e.g. the nextjs @next/env + * replacement) vs the installed varlock that resolved the env. + */ +export const VARLOCK_VERSION: string = packageJson.version; diff --git a/packages/varlock/src/runtime/env.ts b/packages/varlock/src/runtime/env.ts index 0280483d5..60a4d97a7 100644 --- a/packages/varlock/src/runtime/env.ts +++ b/packages/varlock/src/runtime/env.ts @@ -2,6 +2,7 @@ import { redactString } from './lib/redaction'; import type { SerializedEnvGraph } from '../env-graph'; import { isBrowser } from '../lib/detect-runtime'; +import { VARLOCK_VERSION } from '../lib/varlock-version'; import { debug } from './lib/debug'; // TODO: would like to move all of the redaction utils out of this file @@ -496,6 +497,30 @@ export function getPreInjectionProcessEnv(): Record return getEnvState().originalProcessEnv; } +/** + * Surface producer/consumer version skew on the env blob. This runtime code can be a + * different build than the varlock that resolved the env - e.g. runtime glue bundled into + * an integration package, or a parent `varlock run` from before an upgrade. The serialized + * format must stay backward compatible within a major, so patch skew is only debug-logged; + * a minor/major difference gets one loud warning per process (flag on globalThis since + * multiple module instances share the blob). + */ +function checkBlobVersionSkew(blobVersion: string | undefined) { + if (blobVersion === VARLOCK_VERSION) return; + debug(`env blob produced by varlock ${blobVersion ?? '(unversioned)'}, this runtime code is from ${VARLOCK_VERSION}`); + if (!blobVersion) return; // pre-stamp producer - nothing more specific to say + const [blobMajor, blobMinor] = blobVersion.split('.'); + const [selfMajor, selfMinor] = VARLOCK_VERSION.split('.'); + if (blobMajor === selfMajor && blobMinor === selfMinor) return; + if ((globalThis as any).__varlockVersionSkewWarned) return; + (globalThis as any).__varlockVersionSkewWarned = true; + // eslint-disable-next-line no-console + console.warn( + `[varlock] env was resolved by varlock ${blobVersion}, but this runtime code is from varlock ${VARLOCK_VERSION}.` + + ' Update your varlock and integration packages together to keep versions aligned.', + ); +} + export function initVarlockEnv(opts?: { allowFail?: boolean, }) { @@ -550,6 +575,7 @@ export function initVarlockEnv(opts?: { ].join('\n')); throw new Error('initVarlockEnv failed'); } + checkBlobVersionSkew(serializedEnvData.varlockVersion); Object.assign(varlockSettings, serializedEnvData.settings); envState.configHasErrors = !!(serializedEnvData as any).errors; resetRedactionMap(serializedEnvData); diff --git a/packages/varlock/src/runtime/test/blob-version-skew.test.ts b/packages/varlock/src/runtime/test/blob-version-skew.test.ts new file mode 100644 index 000000000..0a19159e9 --- /dev/null +++ b/packages/varlock/src/runtime/test/blob-version-skew.test.ts @@ -0,0 +1,85 @@ +/* + Tests for the producer/consumer version-skew warning on __VARLOCK_ENV blobs. + The runtime code parsing a blob can be a different varlock build than the one that + produced it (bundled integration glue, parent `varlock run` from before an upgrade). + Patch-level skew is expected and silent; minor/major skew warns once per process. +*/ +import { + describe, it, expect, beforeEach, afterEach, vi, +} from 'vitest'; +import { VARLOCK_VERSION } from '../../lib/varlock-version'; + +const ENV_STATE_KEY = '__varlockEnvState'; +const REDACTION_STATE_KEY = '__varlockRedactionState'; +const SKEW_WARNED_KEY = '__varlockVersionSkewWarned'; + +const [selfMajor, selfMinor, selfPatch] = VARLOCK_VERSION.split('.').map((p) => parseInt(p, 10)); + +function makeEnvBlob(varlockVersion: string | undefined) { + return JSON.stringify({ + ...varlockVersion !== undefined ? { varlockVersion } : {}, + sources: [], + settings: {}, + config: { BVS_FOO: { value: 'foo-val', isSensitive: false } }, + }); +} + +async function importFreshEnvModuleCopy() { + vi.resetModules(); + return import('../env'); +} + +function cleanup() { + delete (globalThis as any)[ENV_STATE_KEY]; + delete (globalThis as any)[REDACTION_STATE_KEY]; + delete (globalThis as any)[SKEW_WARNED_KEY]; + delete process.env.__VARLOCK_ENV; + delete process.env.BVS_FOO; + vi.restoreAllMocks(); +} + +beforeEach(cleanup); +afterEach(cleanup); + +async function initWithBlobVersion(varlockVersion: string | undefined) { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + process.env.__VARLOCK_ENV = makeEnvBlob(varlockVersion); + const envModule = await importFreshEnvModuleCopy(); + envModule.initVarlockEnv(); + return warnSpy; +} + +describe('env blob version skew warning', () => { + it('matching version does not warn', async () => { + const warnSpy = await initWithBlobVersion(VARLOCK_VERSION); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('unstamped blob (older producer) does not warn', async () => { + const warnSpy = await initWithBlobVersion(undefined); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('patch-level skew does not warn', async () => { + const warnSpy = await initWithBlobVersion(`${selfMajor}.${selfMinor}.${selfPatch + 1}`); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('minor-level skew warns, but only once per process', async () => { + const skewedVersion = `${selfMajor}.${selfMinor + 1}.0`; + const warnSpy = await initWithBlobVersion(skewedVersion); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain(skewedVersion); + expect(warnSpy.mock.calls[0][0]).toContain(VARLOCK_VERSION); + + // a second init (even via a fresh module copy, as bundlers create) stays quiet + const envModule = await importFreshEnvModuleCopy(); + envModule.initVarlockEnv(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('major-level skew warns', async () => { + const warnSpy = await initWithBlobVersion(`${selfMajor + 1}.0.0`); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +});