diff --git a/.changeset/sixty-mugs-cry.md b/.changeset/sixty-mugs-cry.md
new file mode 100644
index 00000000..588c6121
--- /dev/null
+++ b/.changeset/sixty-mugs-cry.md
@@ -0,0 +1,6 @@
+---
+"@varlock/ci-env-info": patch
+"varlock": patch
+---
+
+new auto-inferred VARLOCK_ENV from ci info (uses new ci-env-info package)
\ No newline at end of file
diff --git a/.cursor/rules/no-js-extension-imports.mdc b/.cursor/rules/no-js-extension-imports.mdc
new file mode 100644
index 00000000..b2cd8554
--- /dev/null
+++ b/.cursor/rules/no-js-extension-imports.mdc
@@ -0,0 +1,35 @@
+---
+description: Do not use .js (or .ts) extensions in TypeScript import/export paths
+globs: "*.ts"
+alwaysApply: false
+---
+# No .js Extension in TypeScript Imports
+
+
+name: no_js_extension_imports
+description: |
+ This project uses TypeScript with "moduleResolution": "bundler" (or "node16"/"nodenext" in a way that relies on the bundler to resolve modules). Do not add .js or .ts extensions to import or export paths in TypeScript files.
+
+filters:
+ - type: file_extension
+ pattern: "\\.ts$"
+
+actions:
+ - type: suggest
+ message: |
+ Do not use .js or .ts in TypeScript import/export paths. We use bundler module resolution, so write:
+
+ import { foo } from './bar';
+ export { foo } from './bar';
+
+ Not:
+
+ import { foo } from './bar.js';
+ export { foo } from './bar.js';
+
+ This keeps code consistent and avoids confusion between source and output paths.
+
+metadata:
+ priority: high
+ version: 1.0
+
diff --git a/bun.lock b/bun.lock
index 349ab07a..4c202f12 100644
--- a/bun.lock
+++ b/bun.lock
@@ -21,6 +21,16 @@
"typescript-eslint": "^8.39.0",
},
},
+ "packages/ci-env-info": {
+ "name": "@varlock/ci-env-info",
+ "version": "0.0.0",
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "tsup": "catalog:",
+ "typescript": "catalog:",
+ "vitest": "catalog:",
+ },
+ },
"packages/env-spec-parser": {
"name": "@env-spec/parser",
"version": "0.1.0",
@@ -204,6 +214,7 @@
"@sindresorhus/is": "catalog:",
"@types/node": "catalog:",
"@types/semver": "^7.7.1",
+ "@varlock/ci-env-info": "workspace:^",
"ansis": "catalog:",
"browser-or-node": "^3.0.0",
"ci-info": "^4.3.1",
@@ -1145,6 +1156,8 @@
"@varlock/bitwarden-plugin": ["@varlock/bitwarden-plugin@workspace:packages/plugins/bitwarden"],
+ "@varlock/ci-env-info": ["@varlock/ci-env-info@workspace:packages/ci-env-info"],
+
"@varlock/google-secret-manager-plugin": ["@varlock/google-secret-manager-plugin@workspace:packages/plugins/google-secret-manager"],
"@varlock/infisical-plugin": ["@varlock/infisical-plugin@workspace:packages/plugins/infisical"],
diff --git a/packages/ci-env-info/README.md b/packages/ci-env-info/README.md
new file mode 100644
index 00000000..7a5aa73d
--- /dev/null
+++ b/packages/ci-env-info/README.md
@@ -0,0 +1,42 @@
+# @varlock/ci-env-info
+
+Detect the current CI/deploy platform and expose normalized metadata: repo, branch, PR number, commit SHA, build URL, deployment environment, and more.
+
+This package powers the `VARLOCK_*` builtin variables in [Varlock](https://varlock.dev). It can also be used standalone.
+
+## Usage
+
+```ts
+import { getCiEnvFromProcess, getCiEnv } from '@varlock/ci-env-info';
+
+// Use current process.env
+const info = getCiEnvFromProcess();
+if (info.isCI) {
+ console.log('Platform:', info.name);
+ console.log('Repo:', info.fullRepoName);
+ console.log('Branch:', info.branch);
+ console.log('Commit:', info.commitShaShort);
+ console.log('Environment:', info.environment);
+}
+
+// Or pass an env record (useful for testing)
+const info2 = getCiEnv({ GITHUB_ACTIONS: 'true', GITHUB_REPOSITORY: 'owner/repo' });
+```
+
+## API
+
+- **`getCiEnv(env: EnvRecord): CiEnvInfo`** – Returns CI environment info from the given env record.
+- **`getCiEnvFromProcess(): CiEnvInfo`** – Convenience wrapper: calls `getCiEnv(process.env)`.
+- **`EnvRecord`** – `Record`.
+- **`CiEnvInfo`** – `isCI`, `name`, `docsUrl`, `isPR`, `repo`, `fullRepoName`, `branch`, `prNumber`, `commitSha`, `commitShaShort`, `environment`, `runId`, `buildUrl`, `workflowName`, `actor`, `eventName`, `raw`.
+- **`DeploymentEnvironment`** – `'development' | 'preview' | 'staging' | 'production' | 'test'`.
+
+## Supported platforms
+
+GitHub Actions, GitLab CI, Vercel, Netlify, Cloudflare Pages, Cloudflare Workers, AWS CodeBuild, Azure Pipelines, Bitbucket Pipelines, Buildkite, CircleCI, Jenkins, Render, Travis CI, and many more.
+
+Platforms are defined in TypeScript with no external dependencies. Detection uses environment variables specific to each platform.
+
+## Learn more
+
+Check out the [Varlock docs](https://varlock.dev) for more about how this fits into env var management.
diff --git a/packages/ci-env-info/package.json b/packages/ci-env-info/package.json
new file mode 100644
index 00000000..d433cf8a
--- /dev/null
+++ b/packages/ci-env-info/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "@varlock/ci-env-info",
+ "description": "Detect CI environment and normalize other data like environment, repo, branch, PR, commit, etc.",
+ "version": "0.0.0",
+ "type": "module",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/dmno-dev/varlock.git",
+ "directory": "packages/ci-env-info"
+ },
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": ["dist"],
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "test": "vitest",
+ "test:ci": "vitest --run"
+ },
+ "keywords": ["ci", "environment", "detection", "github-actions", "vercel", "netlify", "env vars"],
+ "author": "dmno-dev",
+ "license": "MIT",
+ "engines": { "node": ">=22" },
+ "devDependencies": {
+ "@types/node": "catalog:",
+ "tsup": "catalog:",
+ "typescript": "catalog:",
+ "vitest": "catalog:"
+ }
+}
diff --git a/packages/ci-env-info/src/detect.ts b/packages/ci-env-info/src/detect.ts
new file mode 100644
index 00000000..2b1a6e81
--- /dev/null
+++ b/packages/ci-env-info/src/detect.ts
@@ -0,0 +1,151 @@
+import type { CiEnvInfo, EnvRecord, PlatformDefinition } from './types';
+import {
+ type DeploymentEnvironment, type RepoParts,
+ mapToDeploymentEnvironment,
+ parsePrNumber,
+ parseRepoSlug,
+ refToBranch,
+ shortSha,
+} from './normalize';
+import { PLATFORMS } from './platforms';
+
+const VALID_ENVIRONMENTS: Set = new Set(
+ ['development', 'preview', 'staging', 'production', 'test'],
+);
+
+function runDetect(platform: PlatformDefinition, env: EnvRecord): boolean {
+ const d = platform.detect;
+ if (typeof d === 'string') return !!env[d];
+ return d(env);
+}
+
+function runExtractor(
+ platform: PlatformDefinition,
+ key: keyof PlatformDefinition,
+ env: EnvRecord,
+): T | undefined {
+ const ext = platform[key];
+ if (ext === undefined) return undefined;
+ if (typeof ext === 'function') return (ext as (env: EnvRecord) => T | undefined)(env);
+ if (key === 'environment' && typeof ext === 'object' && ext !== null && 'var' in ext && 'map' in ext) {
+ return mapToDeploymentEnvironment(env[ext.var], ext.map) as T;
+ }
+ const raw = env[ext as string];
+ if (raw === undefined || raw === '') return undefined;
+ switch (key) {
+ case 'repo':
+ return parseRepoSlug(raw) as T;
+ case 'branch': {
+ const branch = refToBranch(raw);
+ return (branch ?? raw) as T;
+ }
+ case 'prNumber':
+ return parsePrNumber(raw) as T;
+ case 'commitSha':
+ return raw as T;
+ case 'environment':
+ return VALID_ENVIRONMENTS.has(raw) ? raw as T : undefined;
+ default:
+ return raw as T;
+ }
+}
+
+function buildRaw(
+ platform: PlatformDefinition,
+ env: EnvRecord,
+): Record | undefined {
+ const keys = [
+ 'repo',
+ 'branch',
+ 'prNumber',
+ 'commitSha',
+ 'environment',
+ 'runId',
+ 'buildUrl',
+ 'workflowName',
+ 'actor',
+ 'eventName',
+ ] as const;
+ const raw: Record = {};
+ let hasAny = false;
+ for (const k of keys) {
+ const ext = platform[k];
+ if (typeof ext === 'string' && env[ext]) {
+ raw[ext] = env[ext]!;
+ hasAny = true;
+ }
+ if (k === 'environment' && typeof ext === 'object' && ext !== null && 'var' in ext && env[ext.var]) {
+ raw[ext.var] = env[ext.var]!;
+ hasAny = true;
+ }
+ }
+ return hasAny ? raw : undefined;
+}
+
+/**
+ * Compute CiEnvInfo from the given env record. Uses in-package platform definitions.
+ * If env.CI === 'false', returns isCI: false (escape hatch).
+ */
+export function getCiEnv(env: EnvRecord): CiEnvInfo {
+ const e = env;
+
+ if (e.CI === 'false') {
+ return { isCI: false };
+ }
+
+ for (const platform of PLATFORMS) {
+ if (!runDetect(platform, e)) continue;
+
+ const repo = runExtractor(platform, 'repo', e);
+ const commitSha = runExtractor(platform, 'commitSha', e);
+ let isPR: boolean | undefined;
+ if (platform.isPR !== undefined) {
+ isPR = typeof platform.isPR === 'string' ? !!e[platform.isPR] : platform.isPR(e);
+ } else {
+ isPR = runExtractor(platform, 'prNumber', e) !== undefined;
+ }
+ const info: CiEnvInfo = {
+ isCI: true,
+ name: platform.name,
+ docsUrl: platform.docsUrl,
+ isPR,
+ repo,
+ fullRepoName: repo ? `${repo.owner}/${repo.name}` : undefined,
+ branch: runExtractor(platform, 'branch', e),
+ prNumber: runExtractor(platform, 'prNumber', e),
+ commitSha,
+ commitShaShort: shortSha(commitSha),
+ environment: runExtractor(platform, 'environment', e),
+ runId: runExtractor(platform, 'runId', e),
+ buildUrl: runExtractor(platform, 'buildUrl', e),
+ workflowName: runExtractor(platform, 'workflowName', e),
+ actor: runExtractor(platform, 'actor', e),
+ eventName: runExtractor(platform, 'eventName', e),
+ raw: buildRaw(platform, e),
+ };
+ return info;
+ }
+
+ // Generic CI (e.g. CI=true but no vendor matched)
+ const isCI = e.CI === 'true'
+ || !!e.BUILD_ID
+ || !!e.BUILD_NUMBER
+ || !!e.CI_APP_ID
+ || !!e.CI_BUILD_ID
+ || !!e.CI_BUILD_NUMBER
+ || !!e.CI_NAME
+ || !!e.CONTINUOUS_INTEGRATION
+ || !!e.RUN_ID;
+
+ return {
+ isCI: !!isCI,
+ };
+}
+
+/**
+ * Convenience: compute CiEnvInfo from the current process.env.
+ * Equivalent to getCiEnv(process.env).
+ */
+export function getCiEnvFromProcess(): CiEnvInfo {
+ return getCiEnv(process.env);
+}
diff --git a/packages/ci-env-info/src/index.ts b/packages/ci-env-info/src/index.ts
new file mode 100644
index 00000000..51585fc2
--- /dev/null
+++ b/packages/ci-env-info/src/index.ts
@@ -0,0 +1,3 @@
+export { getCiEnv, getCiEnvFromProcess } from './detect';
+export type { CiEnvInfo, EnvRecord } from './types';
+export type { DeploymentEnvironment } from './normalize';
diff --git a/packages/ci-env-info/src/normalize.ts b/packages/ci-env-info/src/normalize.ts
new file mode 100644
index 00000000..2cc45d29
--- /dev/null
+++ b/packages/ci-env-info/src/normalize.ts
@@ -0,0 +1,103 @@
+/**
+ * Shared parsers for CI env values: ref → branch, repo string → { owner, name }, env value → deployment enum.
+ */
+
+export type DeploymentEnvironment = (
+ 'development'
+ | 'preview'
+ | 'staging'
+ | 'production'
+ | 'test'
+);
+
+export interface RepoParts {
+ owner: string;
+ name: string;
+}
+
+/**
+ * Parse a git ref (e.g. GITHUB_REF) into a short branch name.
+ * - refs/heads/feat/foo → feat/foo
+ * - refs/pull/123/merge → (PR context; returns undefined or branch from HEAD)
+ */
+export function refToBranch(ref: string | undefined): string | undefined {
+ if (!ref || typeof ref !== 'string') return undefined;
+ const s = ref.trim();
+ if (s.startsWith('refs/heads/')) return s.slice('refs/heads/'.length);
+ if (s.startsWith('refs/head/')) return s.slice('refs/head/'.length);
+ return s;
+}
+
+/**
+ * Parse a "owner/repo" string into { owner, name }.
+ * Handles URLs that end with owner/repo or .git.
+ */
+export function parseRepoSlug(s: string | undefined): RepoParts | undefined {
+ if (!s || typeof s !== 'string') return undefined;
+ const trimmed = s.trim();
+ if (!trimmed) return undefined;
+ // URL like https://github.com/owner/repo or https://github.com/owner/repo.git
+ let slug = trimmed;
+ try {
+ if (trimmed.includes('://') || trimmed.startsWith('git@')) {
+ const url = new URL(trimmed.replace(/^git@([^:]+):/, 'https://$1/'));
+ const path = url.pathname.replace(/^\/+/, '').replace(/\.git$/, '');
+ const parts = path.split('/');
+ if (parts.length >= 2) {
+ slug = `${parts[0]}/${parts[1]}`;
+ }
+ }
+ } catch {
+ // not a URL, use as-is
+ }
+ const idx = slug.indexOf('/');
+ if (idx <= 0 || idx === slug.length - 1) return undefined;
+ const owner = slug.slice(0, idx);
+ const name = slug.slice(idx + 1).replace(/\.git$/, '');
+ if (!owner || !name) return undefined;
+ return { owner, name };
+}
+
+/**
+ * Parse PR number from env (string or number). For URLs (e.g. CircleCI CIRCLE_PULL_REQUEST),
+ * extract the number from the path.
+ */
+export function parsePrNumber(value: string | number | undefined): number | undefined {
+ if (value === undefined || value === '') return undefined;
+ if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
+ const s = String(value).trim();
+ if (!s) return undefined;
+ const n = parseInt(s, 10);
+ if (!Number.isNaN(n) && n > 0) return n;
+ // URL like https://github.com/owner/repo/pull/123
+ const match = s.match(/\/pull\/(\d+)(?:\/|$)/) ?? s.match(/(\d+)$/);
+ if (match) {
+ const num = parseInt(match[1], 10);
+ if (!Number.isNaN(num) && num > 0) return num;
+ }
+ return undefined;
+}
+
+/**
+ * Shorten commit SHA to 7 chars (or keep as-is if already short).
+ */
+export function shortSha(sha: string | undefined): string | undefined {
+ if (!sha || typeof sha !== 'string') return undefined;
+ const s = sha.trim();
+ if (s.length >= 7) return s.slice(0, 7);
+ return s || undefined;
+}
+
+/**
+ * Map a platform-specific string to our normalized deployment environment.
+ * Uses the given map (platform value -> enum); lookup is case-insensitive, then falls back to raw value.
+ */
+export function mapToDeploymentEnvironment(
+ value: string | undefined,
+ map: Record,
+): DeploymentEnvironment | undefined {
+ if (!value || typeof value !== 'string') return undefined;
+ const trimmed = value.trim();
+ const key = trimmed.toLowerCase();
+ return map[key] ?? map[trimmed];
+}
diff --git a/packages/ci-env-info/src/platforms.ts b/packages/ci-env-info/src/platforms.ts
new file mode 100644
index 00000000..13fed840
--- /dev/null
+++ b/packages/ci-env-info/src/platforms.ts
@@ -0,0 +1,370 @@
+import type { EnvRecord, PlatformDefinition } from './types';
+import {
+ parsePrNumber,
+ parseRepoSlug,
+ refToBranch,
+} from './normalize';
+
+/** Helper: detect when env key has one of the values */
+function envEq(name: string, value: string): (env: EnvRecord) => boolean {
+ return (env) => env[name] === value;
+}
+
+/** Helper: detect when all of the env vars are set */
+function envAll(...names: Array): (env: EnvRecord) => boolean {
+ return (env) => names.every((n) => !!env[n]);
+}
+
+/** Helper: detect when any of the env vars are set */
+function envAny(...names: Array): (env: EnvRecord) => boolean {
+ return (env) => names.some((n) => !!env[n]);
+}
+
+export const PLATFORMS: Array = [
+ // Very common (alpha) ---
+ {
+ name: 'Cloudflare Pages',
+ docsUrl: 'https://developers.cloudflare.com/pages/configuration/build-configuration/#environment-variables',
+ detect: 'CF_PAGES',
+ branch: 'CF_PAGES_BRANCH',
+ commitSha: 'CF_PAGES_COMMIT_SHA',
+ buildUrl: 'CF_PAGES_URL',
+ },
+ {
+ name: 'Cloudflare Workers',
+ docsUrl: 'https://developers.cloudflare.com/workers/ci-cd/builds/configuration/#default-variables',
+ detect: 'WORKERS_CI',
+ branch: 'WORKERS_CI_BRANCH',
+ commitSha: 'WORKERS_CI_COMMIT_SHA',
+ runId: 'WORKERS_CI_BUILD_UUID',
+ },
+ {
+ name: 'GitHub Actions',
+ docsUrl: 'https://docs.github.com/en/actions/learn-github-actions/variables',
+ detect: 'GITHUB_ACTIONS',
+ isPR: (env) => env.GITHUB_EVENT_NAME === 'pull_request',
+ prNumber: (env) => parsePrNumber(env.GITHUB_EVENT_NUMBER),
+ repo: (env) => parseRepoSlug(env.GITHUB_REPOSITORY),
+ branch: (env) => refToBranch(env.GITHUB_REF),
+ commitSha: 'GITHUB_SHA',
+ runId: 'GITHUB_RUN_ID',
+ buildUrl: (env) => {
+ const server = env.GITHUB_SERVER_URL;
+ const repo = env.GITHUB_REPOSITORY;
+ const runId = env.GITHUB_RUN_ID;
+ if (server && repo && runId) return `${server}/${repo}/actions/runs/${runId}`;
+ return undefined;
+ },
+ workflowName: 'GITHUB_WORKFLOW',
+ actor: 'GITHUB_ACTOR',
+ eventName: 'GITHUB_EVENT_NAME',
+ },
+ {
+ name: 'GitLab CI',
+ docsUrl: 'https://docs.gitlab.com/ee/ci/variables/predefined_variables.html',
+ detect: 'GITLAB_CI',
+ isPR: 'CI_MERGE_REQUEST_ID',
+ prNumber: 'CI_MERGE_REQUEST_ID',
+ repo: (env) => {
+ const path = env.CI_PROJECT_PATH;
+ if (!path) return undefined;
+ const parts = path.split('/').filter(Boolean);
+ if (parts.length >= 2) {
+ return { owner: parts.slice(0, -1).join('/'), name: parts[parts.length - 1]! };
+ }
+ if (parts.length === 1) return { owner: parts[0]!, name: parts[0]! };
+ return undefined;
+ },
+ branch: 'CI_COMMIT_REF_NAME',
+ commitSha: 'CI_COMMIT_SHA',
+ runId: 'CI_PIPELINE_ID',
+ buildUrl: 'CI_PIPELINE_URL',
+ },
+ {
+ name: 'Netlify CI',
+ docsUrl: 'https://docs.netlify.com/configure-builds/environment-variables',
+ detect: 'NETLIFY',
+ isPR: (env) => env.PULL_REQUEST !== undefined && env.PULL_REQUEST !== 'false',
+ repo: (env) => parseRepoSlug(env.REPOSITORY_URL),
+ branch: (env) => env.HEAD ?? env.BRANCH,
+ commitSha: 'COMMIT_REF',
+ runId: 'BUILD_ID',
+ buildUrl: (env) => (env.DEPLOY_URL ? `https://${env.DEPLOY_URL}` : undefined),
+ environment: {
+ var: 'CONTEXT',
+ map: {
+ production: 'production',
+ 'deploy-preview': 'preview',
+ 'branch-deploy': 'preview',
+ dev: 'development',
+ },
+ },
+ },
+ {
+ name: 'Vercel',
+ docsUrl: 'https://vercel.com/docs/environment-variables/system-environment-variables',
+ detect: envAny('NOW_BUILDER', 'VERCEL'),
+ isPR: 'VERCEL_GIT_PULL_REQUEST_ID',
+ prNumber: 'VERCEL_GIT_PULL_REQUEST_ID',
+ repo: (env) => {
+ const owner = env.VERCEL_GIT_REPO_OWNER;
+ const name = env.VERCEL_GIT_REPO_SLUG;
+ if (owner && name) return { owner, name };
+ return parseRepoSlug(env.VERCEL_GIT_REPO_SLUG);
+ },
+ branch: 'VERCEL_GIT_COMMIT_REF',
+ commitSha: 'VERCEL_GIT_COMMIT_SHA',
+ buildUrl: (env) => (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : undefined),
+ environment: 'VERCEL_ENV',
+ runId: 'VERCEL_DEPLOYMENT_ID',
+ },
+
+ // Somewhat common (alpha) ---
+ {
+ name: 'AWS CodeBuild',
+ docsUrl: 'https://docs.aws.amazon.com/codebuild/latest/userguide/build-env-ref-env-vars.html',
+ detect: 'CODEBUILD_BUILD_ARN',
+ isPR: (env) => ['PULL_REQUEST_CREATED', 'PULL_REQUEST_UPDATED', 'PULL_REQUEST_REOPENED'].includes(
+ env.CODEBUILD_WEBHOOK_EVENT ?? '',
+ ),
+ repo: (env) => parseRepoSlug(env.CODEBUILD_SOURCE_REPO_URL),
+ branch: (env) => {
+ const ref = env.CODEBUILD_WEBHOOK_HEAD_REF;
+ return ref ? ref.replace(/^refs\/heads\//, '') : undefined;
+ },
+ commitSha: 'CODEBUILD_RESOLVED_SOURCE_VERSION',
+ },
+ {
+ name: 'Azure Pipelines',
+ docsUrl:
+ 'https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml',
+ detect: 'TF_BUILD',
+ isPR: (env) => env.BUILD_REASON === 'PullRequest',
+ prNumber: (env) => parsePrNumber(env.SYSTEM_PULLREQUEST_PULLREQUESTID),
+ branch: 'BUILD_SOURCEBRANCHNAME',
+ commitSha: 'BUILD_SOURCEVERSION',
+ },
+ {
+ name: 'Bitbucket Pipelines',
+ docsUrl: 'https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets',
+ detect: 'BITBUCKET_COMMIT',
+ isPR: 'BITBUCKET_PR_ID',
+ prNumber: 'BITBUCKET_PR_ID',
+ repo: (env) => {
+ const owner = env.BITBUCKET_REPO_OWNER;
+ const name = env.BITBUCKET_REPO_FULL_NAME?.split('/').pop() ?? env.BITBUCKET_REPO_SLUG;
+ if (owner && name) return { owner, name };
+ return parseRepoSlug(env.BITBUCKET_REPO_FULL_NAME);
+ },
+ branch: 'BITBUCKET_BRANCH',
+ commitSha: 'BITBUCKET_COMMIT',
+ },
+ {
+ name: 'Buildkite',
+ docsUrl: 'https://buildkite.com/docs/pipelines/environment-variables',
+ detect: 'BUILDKITE',
+ isPR: (env) => env.BUILDKITE_PULL_REQUEST !== undefined && env.BUILDKITE_PULL_REQUEST !== 'false',
+ prNumber: 'BUILDKITE_PULL_REQUEST',
+ repo: (env) => parseRepoSlug(env.BUILDKITE_REPO),
+ branch: 'BUILDKITE_BRANCH',
+ commitSha: 'BUILDKITE_COMMIT',
+ runId: 'BUILDKITE_BUILD_ID',
+ buildUrl: 'BUILDKITE_BUILD_URL',
+ },
+ {
+ name: 'CircleCI',
+ docsUrl: 'https://circleci.com/docs/env-vars',
+ detect: 'CIRCLECI',
+ isPR: 'CIRCLE_PULL_REQUEST',
+ prNumber: 'CIRCLE_PULL_REQUEST',
+ repo: (env) => parseRepoSlug(env.CIRCLE_REPOSITORY_URL),
+ branch: 'CIRCLE_BRANCH',
+ commitSha: 'CIRCLE_SHA1',
+ runId: 'CIRCLE_BUILD_NUM',
+ buildUrl: 'CIRCLE_BUILD_URL',
+ },
+ {
+ name: 'Jenkins',
+ docsUrl: 'https://www.jenkins.io/doc/book/security/environment-variables',
+ detect: envAll('JENKINS_URL', 'BUILD_ID'),
+ isPR: (env) => !!(env.ghprbPullId ?? env.CHANGE_ID),
+ prNumber: (env) => parsePrNumber(env.ghprbPullId ?? env.CHANGE_ID),
+ },
+ {
+ name: 'Render',
+ docsUrl: 'https://render.com/docs/environment-variables',
+ detect: 'RENDER',
+ isPR: (env) => env.IS_PULL_REQUEST === 'true',
+ repo: (env) => parseRepoSlug(env.RENDER_GIT_REPO_SLUG),
+ branch: 'RENDER_GIT_BRANCH',
+ commitSha: 'RENDER_GIT_COMMIT',
+ },
+ {
+ name: 'Travis CI',
+ docsUrl: 'https://docs.travis-ci.com/user/environment-variables',
+ detect: 'TRAVIS',
+ isPR: (env) => env.TRAVIS_PULL_REQUEST !== undefined && env.TRAVIS_PULL_REQUEST !== 'false',
+ prNumber: 'TRAVIS_PULL_REQUEST',
+ repo: (env) => parseRepoSlug(env.TRAVIS_REPO_SLUG),
+ branch: (env) => env.TRAVIS_BRANCH,
+ commitSha: 'TRAVIS_COMMIT',
+ },
+
+ // Less common (alpha) ---
+ {
+ name: 'Agola CI',
+ docsUrl: 'https://agola.io/doc/concepts/secrets_variables.html',
+ detect: 'AGOLA_GIT_REF',
+ isPR: 'AGOLA_PULL_REQUEST_ID',
+ prNumber: 'AGOLA_PULL_REQUEST_ID',
+ },
+ { name: 'Alpic', detect: 'ALPIC_HOST' },
+ {
+ name: 'Appcircle',
+ detect: 'AC_APPCIRCLE',
+ isPR: (env) => env.AC_GIT_PR !== undefined && env.AC_GIT_PR !== 'false',
+ prNumber: 'AC_GIT_PR',
+ },
+ {
+ name: 'AppVeyor',
+ docsUrl: 'https://www.appveyor.com/docs/environment-variables',
+ detect: 'APPVEYOR',
+ isPR: 'APPVEYOR_PULL_REQUEST_NUMBER',
+ prNumber: 'APPVEYOR_PULL_REQUEST_NUMBER',
+ repo: (env) => parseRepoSlug(env.APPVEYOR_REPO_NAME),
+ branch: 'APPVEYOR_REPO_BRANCH',
+ commitSha: 'APPVEYOR_REPO_COMMIT',
+ },
+ { name: 'Bamboo', detect: 'bamboo_planKey' },
+ {
+ name: 'Bitrise',
+ docsUrl: 'https://docs.bitrise.io/en/bitrise-ci/references/available-environment-variables.html',
+ detect: 'BITRISE_IO',
+ isPR: 'BITRISE_PULL_REQUEST',
+ prNumber: 'BITRISE_PULL_REQUEST',
+ branch: 'BITRISE_GIT_BRANCH',
+ commitSha: 'BITRISE_GIT_COMMIT',
+ },
+ {
+ name: 'Buddy',
+ detect: 'BUDDY_WORKSPACE_ID',
+ isPR: 'BUDDY_EXECUTION_PULL_REQUEST_ID',
+ prNumber: 'BUDDY_EXECUTION_PULL_REQUEST_ID',
+ },
+ {
+ name: 'Cirrus CI',
+ docsUrl: 'https://cirrus-ci.org/guide/writing-tasks',
+ detect: 'CIRRUS_CI',
+ isPR: 'CIRRUS_PR',
+ prNumber: 'CIRRUS_PR',
+ repo: (env) => parseRepoSlug(env.CIRRUS_REPO_FULL_NAME),
+ branch: 'CIRRUS_BRANCH',
+ commitSha: 'CIRRUS_CHANGE_IN_REPO',
+ },
+ {
+ name: 'Codefresh',
+ docsUrl: 'https://codefresh.io/docs/docs/pipelines/variables',
+ detect: 'CF_BUILD_ID',
+ isPR: (env) => !!(env.CF_PULL_REQUEST_NUMBER ?? env.CF_PULL_REQUEST_ID),
+ prNumber: (env) => parsePrNumber(env.CF_PULL_REQUEST_NUMBER ?? env.CF_PULL_REQUEST_ID),
+ branch: 'CF_BRANCH',
+ commitSha: 'CF_REVISION',
+ },
+ { name: 'Codemagic', detect: 'CM_BUILD_ID', prNumber: 'CM_PULL_REQUEST' },
+ { name: 'Codeship', detect: (env) => env.CI_NAME === 'codeship' },
+ {
+ name: 'Drone',
+ docsUrl: 'https://docs.drone.io/pipeline/environment/reference',
+ detect: 'DRONE',
+ isPR: (env) => env.DRONE_BUILD_EVENT === 'pull_request',
+ prNumber: 'DRONE_PULL_REQUEST',
+ repo: (env) => {
+ const full = env.DRONE_REPO;
+ if (full) return parseRepoSlug(full);
+ const owner = env.DRONE_REPO_OWNER;
+ const name = env.DRONE_REPO_NAME;
+ if (owner && name) return { owner, name };
+ return undefined;
+ },
+ branch: 'DRONE_COMMIT_BRANCH',
+ commitSha: 'DRONE_COMMIT_SHA',
+ },
+ { name: 'dsari', detect: 'DSARI' },
+ { name: 'Earthly', detect: 'EARTHLY_CI' },
+ {
+ name: 'Expo Application Services',
+ docsUrl: 'https://docs.expo.dev/build-reference/variables',
+ detect: 'EAS_BUILD',
+ commitSha: 'EAS_BUILD_GIT_COMMIT_HASH',
+ },
+ { name: 'Gerrit', detect: 'GERRIT_PROJECT' },
+ { name: 'Gitea Actions', detect: 'GITEA_ACTIONS' },
+ { name: 'GoCD', detect: 'GO_PIPELINE_LABEL' },
+ { name: 'Google Cloud Build', detect: 'BUILDER_OUTPUT' }, // not actually set by default, user has to set it within their yaml config
+ { name: 'Heroku', detect: (env) => (env.NODE ?? '').includes('/app/.heroku/node/bin/node') },
+ { name: 'Hudson', detect: 'HUDSON_URL' },
+ {
+ name: 'LayerCI',
+ detect: 'LAYERCI',
+ isPR: 'LAYERCI_PULL_REQUEST',
+ prNumber: 'LAYERCI_PULL_REQUEST',
+ },
+ { name: 'Magnum CI', detect: 'MAGNUM' },
+ {
+ name: 'Nevercode',
+ detect: 'NEVERCODE',
+ isPR: (env) => env.NEVERCODE_PULL_REQUEST !== undefined && env.NEVERCODE_PULL_REQUEST !== 'false',
+ prNumber: 'NEVERCODE_PULL_REQUEST',
+ },
+ { name: 'Prow', detect: 'PROW_JOB_ID' },
+ { name: 'ReleaseHub', detect: 'RELEASE_BUILD_ID' },
+ {
+ name: 'Sail CI',
+ detect: 'SAILCI',
+ isPR: 'SAIL_PULL_REQUEST_NUMBER',
+ prNumber: 'SAIL_PULL_REQUEST_NUMBER',
+ },
+ {
+ name: 'Screwdriver',
+ detect: 'SCREWDRIVER',
+ isPR: (env) => env.SD_PULL_REQUEST !== undefined && env.SD_PULL_REQUEST !== 'false',
+ prNumber: 'SD_PULL_REQUEST',
+ },
+ {
+ name: 'Semaphore',
+ docsUrl: 'https://docs.semaphoreci.com/reference/env-vars',
+ detect: 'SEMAPHORE',
+ isPR: 'PULL_REQUEST_NUMBER',
+ prNumber: 'PULL_REQUEST_NUMBER',
+ repo: (env) => parseRepoSlug(env.SEMAPHORE_GIT_REPO_SLUG),
+ branch: 'SEMAPHORE_GIT_BRANCH',
+ commitSha: 'SEMAPHORE_GIT_SHA',
+ },
+ { name: 'Sourcehut', detect: (env) => env.CI_NAME === 'sourcehut' },
+ { name: 'Strider CD', detect: 'STRIDER' },
+ { name: 'TaskCluster', detect: envAny('TASK_ID', 'RUN_ID') },
+ {
+ name: 'TeamCity',
+ docsUrl: 'https://www.jetbrains.com/help/teamcity/predefined-build-parameters.html',
+ detect: 'TEAMCITY_VERSION',
+ },
+ {
+ name: 'Vela',
+ detect: 'VELA',
+ isPR: (env) => env.VELA_PULL_REQUEST === '1',
+ prNumber: 'VELA_PULL_REQUEST',
+ },
+ { name: 'Visual Studio App Center', detect: 'APPCENTER_BUILD_ID' },
+ {
+ name: 'Woodpecker',
+ docsUrl: 'https://woodpecker-ci.org/docs/usage/environment',
+ detect: envEq('CI', 'woodpecker'),
+ isPR: (env) => env.CI_BUILD_EVENT === 'pull_request',
+ repo: (env) => parseRepoSlug(env.CI_REPO),
+ branch: 'CI_COMMIT_BRANCH',
+ prNumber: (env) => parsePrNumber(env.CI_COMMIT_PULL_REQUEST),
+ commitSha: 'CI_COMMIT_SHA',
+ },
+ { name: 'Xcode Cloud', detect: 'CI_XCODE_PROJECT', prNumber: 'CI_PULL_REQUEST_NUMBER' },
+ { name: 'Xcode Server', detect: 'XCS' },
+];
diff --git a/packages/ci-env-info/src/types.ts b/packages/ci-env-info/src/types.ts
new file mode 100644
index 00000000..e459ac44
--- /dev/null
+++ b/packages/ci-env-info/src/types.ts
@@ -0,0 +1,62 @@
+import type { DeploymentEnvironment, RepoParts } from './normalize';
+
+/** Env-like object: keys are variable names, values are string or undefined (missing). */
+export type EnvRecord = Record;
+
+export interface CiEnvInfo {
+ isCI: boolean;
+ name?: string;
+ docsUrl?: string;
+ isPR?: boolean;
+ repo?: RepoParts;
+ fullRepoName?: string;
+ branch?: string;
+ prNumber?: number;
+ commitSha?: string;
+ commitShaShort?: string;
+ environment?: DeploymentEnvironment;
+ /** Unique run/build id (e.g. GITHUB_RUN_ID, BUILD_ID) */
+ runId?: string;
+ /** URL to the run or deploy in the CI/deploy UI */
+ buildUrl?: string;
+ /** Workflow or pipeline name */
+ workflowName?: string;
+ /** User or app that triggered the run (e.g. GITHUB_ACTOR) */
+ actor?: string;
+ /** Event type (e.g. push, pull_request, workflow_dispatch) */
+ eventName?: string;
+ raw?: Record;
+}
+
+export type DetectFn = (env: EnvRecord) => boolean;
+
+/** String = env var name (truthy check); function = custom detection */
+export type Detect = string | DetectFn;
+
+/** String = env var name (value passed to default parser); function = custom extractor */
+export type Extractor = string | ((env: EnvRecord) => T | undefined);
+
+/** Inline env var + value map for normalized deployment environment */
+export interface EnvironmentMap {
+ var: string;
+ map: Record;
+}
+
+export interface PlatformDefinition {
+ name: string;
+ docsUrl?: string;
+ detect: Detect;
+ /** Optional: env var name (truthy = PR) or function to detect PR (else inferred from prNumber) */
+ isPR?: string | DetectFn;
+ repo?: Extractor;
+ branch?: Extractor;
+ prNumber?: Extractor;
+ commitSha?: Extractor;
+ /** Env var name, value map, or custom extractor */
+ environment?: Extractor | EnvironmentMap;
+ runId?: Extractor;
+ buildUrl?: Extractor;
+ workflowName?: Extractor;
+ actor?: Extractor;
+ eventName?: Extractor;
+}
diff --git a/packages/ci-env-info/test/ci-env.test.ts b/packages/ci-env-info/test/ci-env.test.ts
new file mode 100644
index 00000000..e099a2f2
--- /dev/null
+++ b/packages/ci-env-info/test/ci-env.test.ts
@@ -0,0 +1,248 @@
+import { describe, expect, it } from 'vitest';
+import { getCiEnv } from '../src/index';
+import type { CiEnvInfo, EnvRecord } from '../src/types';
+
+/**
+ * Runs getCiEnv(env) and asserts that the result matches each key in expected.
+ * Use for concise, declarative tests.
+ */
+function expectCiEnv(
+ env: EnvRecord,
+ expected: Partial & { raw?: Record },
+): CiEnvInfo {
+ const info = getCiEnv(env);
+ for (const key of Object.keys(expected) as Array) {
+ const exp = expected[key];
+ if (key === 'raw') {
+ expect(info.raw).toBeDefined();
+ if (exp !== undefined && typeof exp === 'object' && exp !== null) {
+ for (const [k, v] of Object.entries(exp)) {
+ expect(info.raw![k]).toBe(v);
+ }
+ }
+ } else {
+ expect(info[key]).toEqual(exp);
+ }
+ }
+ return info;
+}
+
+describe('getCiEnv', () => {
+ it('returns isCI: false when env is empty', () => {
+ expectCiEnv({}, { isCI: false, name: undefined });
+ });
+
+ it('returns isCI: false when CI=false (escape hatch)', () => {
+ expectCiEnv(
+ {
+ CI: 'false',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ { isCI: false },
+ );
+ });
+
+ it('detects GitHub Actions and extracts repo, branch, commit, isPR, runId, buildUrl, workflowName, actor, eventName', () => {
+ expectCiEnv(
+ {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REPOSITORY: 'dmno-dev/varlock',
+ GITHUB_REF: 'refs/heads/main',
+ GITHUB_SHA: 'abc123def456',
+ GITHUB_EVENT_NAME: 'push',
+ GITHUB_SERVER_URL: 'https://github.com',
+ GITHUB_RUN_ID: '123456789',
+ GITHUB_WORKFLOW: 'CI',
+ GITHUB_ACTOR: 'octocat',
+ },
+ {
+ isCI: true,
+ name: 'GitHub Actions',
+ repo: { owner: 'dmno-dev', name: 'varlock' },
+ fullRepoName: 'dmno-dev/varlock',
+ branch: 'main',
+ commitSha: 'abc123def456',
+ commitShaShort: 'abc123d',
+ isPR: false,
+ prNumber: undefined,
+ runId: '123456789',
+ buildUrl: 'https://github.com/dmno-dev/varlock/actions/runs/123456789',
+ workflowName: 'CI',
+ actor: 'octocat',
+ eventName: 'push',
+ },
+ );
+ });
+
+ it('detects GitHub Actions PR and extracts prNumber', () => {
+ expectCiEnv(
+ {
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REPOSITORY: 'owner/repo',
+ GITHUB_REF: 'refs/pull/42/merge',
+ GITHUB_SHA: 'abc123',
+ GITHUB_EVENT_NAME: 'pull_request',
+ GITHUB_EVENT_NUMBER: '42',
+ },
+ {
+ isCI: true,
+ name: 'GitHub Actions',
+ isPR: true,
+ prNumber: 42,
+ fullRepoName: 'owner/repo',
+ },
+ );
+ });
+
+ it('detects Vercel and extracts environment and buildUrl', () => {
+ expectCiEnv(
+ {
+ VERCEL: '1',
+ VERCEL_GIT_REPO_OWNER: 'dmno-dev',
+ VERCEL_GIT_REPO_SLUG: 'varlock',
+ VERCEL_GIT_COMMIT_REF: 'main',
+ VERCEL_GIT_COMMIT_SHA: 'abc123',
+ VERCEL_ENV: 'production',
+ VERCEL_URL: 'varlock-abc123.vercel.app',
+ },
+ {
+ isCI: true,
+ name: 'Vercel',
+ repo: { owner: 'dmno-dev', name: 'varlock' },
+ fullRepoName: 'dmno-dev/varlock',
+ branch: 'main',
+ commitSha: 'abc123',
+ environment: 'production',
+ buildUrl: 'https://varlock-abc123.vercel.app',
+ },
+ );
+ });
+
+ it('detects Vercel preview environment', () => {
+ expectCiEnv(
+ { VERCEL: '1', VERCEL_ENV: 'preview' },
+ { environment: 'preview' },
+ );
+ });
+
+ it('detects Netlify and extracts repo, branch, commit, context, runId, buildUrl', () => {
+ expectCiEnv(
+ {
+ NETLIFY: 'true',
+ REPOSITORY_URL: 'https://github.com/owner/repo.git',
+ BRANCH: 'main',
+ COMMIT_REF: 'abc123def',
+ CONTEXT: 'production',
+ BUILD_ID: 'build-abc-123',
+ DEPLOY_URL: 'random-name-123.netlify.app',
+ },
+ {
+ isCI: true,
+ name: 'Netlify CI',
+ repo: { owner: 'owner', name: 'repo' },
+ fullRepoName: 'owner/repo',
+ branch: 'main',
+ commitSha: 'abc123def',
+ environment: 'production',
+ runId: 'build-abc-123',
+ buildUrl: 'https://random-name-123.netlify.app',
+ },
+ );
+ });
+
+ it('detects CircleCI and extracts repo, branch, PR, commit', () => {
+ expectCiEnv(
+ {
+ CIRCLECI: 'true',
+ CIRCLE_REPOSITORY_URL: 'https://github.com/owner/repo',
+ CIRCLE_BRANCH: 'feat/foo',
+ CIRCLE_SHA1: 'deadbeef123456',
+ CIRCLE_PULL_REQUEST: 'https://github.com/owner/repo/pull/99',
+ },
+ {
+ isCI: true,
+ name: 'CircleCI',
+ repo: { owner: 'owner', name: 'repo' },
+ fullRepoName: 'owner/repo',
+ branch: 'feat/foo',
+ commitSha: 'deadbeef123456',
+ prNumber: 99,
+ },
+ );
+ });
+
+ it('detects GitLab CI and extracts repo, branch, MR, commit', () => {
+ expectCiEnv(
+ {
+ GITLAB_CI: 'true',
+ CI_PROJECT_PATH: 'group/subgroup/project',
+ CI_COMMIT_REF_NAME: 'main',
+ CI_COMMIT_SHA: 'abc123',
+ CI_MERGE_REQUEST_ID: '10',
+ },
+ {
+ isCI: true,
+ name: 'GitLab CI',
+ repo: { owner: 'group/subgroup', name: 'project' },
+ fullRepoName: 'group/subgroup/project',
+ },
+ );
+ });
+
+ it('detects Cloudflare Workers CI', () => {
+ expectCiEnv(
+ {
+ WORKERS_CI: '1',
+ WORKERS_CI_BRANCH: 'main',
+ WORKERS_CI_COMMIT_SHA: 'sha123',
+ },
+ {
+ isCI: true,
+ name: 'Cloudflare Workers',
+ branch: 'main',
+ commitSha: 'sha123',
+ },
+ );
+ });
+
+ it('detects Buildkite and extracts runId, buildUrl', () => {
+ expectCiEnv(
+ {
+ BUILDKITE: 'true',
+ BUILDKITE_REPO: 'https://github.com/owner/repo',
+ BUILDKITE_BRANCH: 'main',
+ BUILDKITE_COMMIT: 'abc123',
+ BUILDKITE_BUILD_ID: 'build-uuid-456',
+ BUILDKITE_BUILD_URL: 'https://buildkite.com/org/pipeline/builds/456',
+ },
+ {
+ isCI: true,
+ name: 'Buildkite',
+ fullRepoName: 'owner/repo',
+ branch: 'main',
+ commitSha: 'abc123',
+ runId: 'build-uuid-456',
+ buildUrl: 'https://buildkite.com/org/pipeline/builds/456',
+ },
+ );
+ });
+
+ it('includes raw env vars when extractors use string keys', () => {
+ expectCiEnv(
+ {
+ BUILDKITE: 'true',
+ BUILDKITE_REPO: 'https://github.com/owner/repo',
+ BUILDKITE_BRANCH: 'main',
+ BUILDKITE_COMMIT: 'abc123',
+ },
+ {
+ raw: {
+ BUILDKITE_BRANCH: 'main',
+ BUILDKITE_COMMIT: 'abc123',
+ },
+ },
+ );
+ });
+});
diff --git a/packages/ci-env-info/tsconfig.json b/packages/ci-env-info/tsconfig.json
new file mode 100644
index 00000000..916a629f
--- /dev/null
+++ b/packages/ci-env-info/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": true,
+ "declarationMap": true,
+ "lib": ["ES2023"],
+ "strict": true,
+ "skipLibCheck": true,
+ "customConditions": ["ts-src"]
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/ci-env-info/tsup.config.ts b/packages/ci-env-info/tsup.config.ts
new file mode 100644
index 00000000..a49086e9
--- /dev/null
+++ b/packages/ci-env-info/tsup.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ dts: true,
+ sourcemap: true,
+ treeshake: true,
+ clean: true,
+ outDir: 'dist',
+ format: ['esm'],
+ splitting: false,
+ target: 'esnext',
+});
diff --git a/packages/varlock-website/.env.schema b/packages/varlock-website/.env.schema
index b332349d..8925475c 100644
--- a/packages/varlock-website/.env.schema
+++ b/packages/varlock-website/.env.schema
@@ -1,18 +1,13 @@
# This .env file uses https://varlock.dev
#
# @defaultRequired=infer @defaultSensitive=false
-# @currentEnv=$APP_ENV
+# @currentEnv=$VARLOCK_ENV
# @generateTypes(lang='ts', path='env.d.ts')
# ---
-# our env flag, used to toggle loading of env-specific files
-# will be inferred using branch on deployed envs, or default to development otherwise
+# our env flag - populated automatically based on cloudflare's injected info
# @type=enum(development, preview, production, test)
-APP_ENV=remap($WORKERS_CI_BRANCH, production="main", preview=regex(.*), development=undefined)
-
-# Current branch, injected by cloudflare workers builds
-# @docsUrl="https://developers.cloudflare.com/workers/ci-cd/builds/configuration/#environment-variables"
-WORKERS_CI_BRANCH=
+VARLOCK_ENV=
# these are only used in astro.config.ts so we dont need to worry about prefixes and getting into client code
diff --git a/packages/varlock-website/astro.config.ts b/packages/varlock-website/astro.config.ts
index 0072d3af..a7ad5004 100644
--- a/packages/varlock-website/astro.config.ts
+++ b/packages/varlock-website/astro.config.ts
@@ -185,6 +185,7 @@ export default defineConfig({
{ label: 'Item decorators', slug: 'reference/item-decorators' },
{ label: '> @type data types', slug: 'reference/data-types' },
{ label: 'Value functions', slug: 'reference/functions' },
+ { label: 'Builtin variables', slug: 'reference/builtin-variables', badge: 'new' },
],
},
{
@@ -215,7 +216,7 @@ export default defineConfig({
{
userAgent: '*',
// The next line enables or disables the crawling on the `robots.txt` level
- disallow: ENV.APP_ENV === 'production' ? '' : '/',
+ disallow: ENV.VARLOCK_ENV === 'production' ? '' : '/',
},
],
}),
diff --git a/packages/varlock-website/src/content/docs/guides/environments.mdx b/packages/varlock-website/src/content/docs/guides/environments.mdx
index 0b6b7158..0d79d9aa 100644
--- a/packages/varlock-website/src/content/docs/guides/environments.mdx
+++ b/packages/varlock-website/src/content/docs/guides/environments.mdx
@@ -31,6 +31,15 @@ The files are applied with a specific precedence (increasing):
- `.env.[currentEnv]` - environment-specific values
- `.env.[currentEnv].local` - environment-specific local overrides (gitignored)
+:::tip[Auto-detect with `VARLOCK_ENV`]
+Instead of managing your own environment flag, you can use the built-in `$VARLOCK_ENV` variable which auto-detects the environment from your CI/deploy platform. See the [builtin variables reference](/reference/builtin-variables/) for details.
+
+```env-spec title=".env.schema"
+# @currentEnv=$VARLOCK_ENV
+# ---
+```
+:::
+
For example, consider the following `.env.schema`:
```env-spec title=".env.schema"
# @currentEnv=$APP_ENV
diff --git a/packages/varlock-website/src/content/docs/reference/builtin-variables.mdx b/packages/varlock-website/src/content/docs/reference/builtin-variables.mdx
new file mode 100644
index 00000000..81d9dbf5
--- /dev/null
+++ b/packages/varlock-website/src/content/docs/reference/builtin-variables.mdx
@@ -0,0 +1,157 @@
+---
+title: "Builtin variables"
+description: Auto-detected VARLOCK_* variables for CI platform, branch, commit, and environment info
+---
+
+Varlock provides a set of **builtin `VARLOCK_*` variables** that are automatically populated with information about the current CI/deploy platform, git branch, commit, and inferred deployment environment. They are entirely **opt-in** — they only exist in your schema when you reference them.
+
+## Usage
+
+Builtin variables are activated when you reference them via `$VARLOCK_*` in a value expression:
+
+```env-spec title=".env.schema"
+# @currentEnv=$VARLOCK_ENV
+# ---
+BUILD_TAG="build-$VARLOCK_COMMIT_SHA_SHORT"
+DB_URL=if(
+ eq($VARLOCK_ENV, development),
+ postgres://localhost/myapp,
+ postgres://${VARLOCK_ENV}-db.example.com/myapp
+)
+```
+
+If you want to include a builtin variable in your resolved env without referencing it from another item, just define it with an empty value — varlock will populate it automatically:
+
+```env-spec title=".env.schema"
+VARLOCK_BRANCH=
+VARLOCK_COMMIT_SHA_SHORT=
+```
+
+You can also use `VARLOCK_ENV` as your environment flag with `@currentEnv`, which means you don't need to create your own `APP_ENV` variable — Varlock will auto-detect the environment for you.
+
+:::caution[Verify detection works for your setup]
+Auto-detection is based on environment variables set by each CI/deploy platform. Different platforms expose different information, and detection heuristics (especially branch-to-environment inference) may not match your conventions.
+
+**Always verify that the detected values match your expectations** before relying on them in production. You can check the resolved values using `varlock run -- env | grep VARLOCK_` or by inspecting the output of `varlock load`.
+:::
+
+
+## Builtin Vars
+
+### `VARLOCK_ENV`
+
+**Type:** `string` — one of `development`, `preview`, `staging`, `production`, `test`
+
+The inferred deployment environment. Detection follows this priority:
+
+1. **Test environment** — detected from `NODE_ENV=test`, `VITEST`, `JEST_WORKER_ID`, or `VITEST_POOL_ID`
+2. **Platform-provided** — uses the platform's own environment concept (e.g., Vercel's `VERCEL_ENV`, Netlify's `CONTEXT`)
+3. **Branch inference** — in CI, infers from branch name: `main`/`master`/`production`/`prod` → `production`, `staging`/`stage`/`develop`/`dev` → `staging`, `qa`/`test` → `test`, anything else → `preview`
+4. **CI fallback** — if in CI but no branch info is available, defaults to `preview`
+5. **Local fallback** — if not in CI, defaults to `development`
+
+#### Using with `@currentEnv`
+
+```env-spec title=".env.schema"
+# @currentEnv=$VARLOCK_ENV
+# ---
+DB_HOST=if(forEnv(production), "prod-db.example.com", "localhost")
+DB_NAME=myapp
+DB_URL="postgres://$DB_HOST/$DB_NAME"
+```
+
+#### Test environment caveat
+
+:::caution[Test runners and `VARLOCK_ENV`]
+Many test runners (Vitest, Jest, etc.) set `NODE_ENV=test` **after** the process has started — often after varlock has already loaded and resolved your env vars. This means `VARLOCK_ENV` may not detect `test` automatically in all setups.
+
+If you depend on `VARLOCK_ENV=test` to load `.env.test` or toggle behavior via `forEnv(test)`, **explicitly pass it** when running tests:
+
+```bash
+VARLOCK_ENV=test bun run test
+# or
+VARLOCK_ENV=test varlock run -- vitest
+```
+
+This is the same pattern recommended for any environment flag — see the [environments guide](/guides/environments/) for more details.
+:::
+
+
+### `VARLOCK_IS_CI`
+
+**Type:** `string` — `"true"` or `"false"`
+
+Whether the current process is running in a CI environment.
+
+
+### `VARLOCK_BRANCH`
+
+**Type:** `string | undefined`
+
+The current git branch name, as reported by the CI platform. Undefined when not in CI or when the platform doesn't expose branch info.
+
+
+### `VARLOCK_PR_NUMBER`
+
+**Type:** `string | undefined`
+
+The pull/merge request number, if the current build is for a PR. Undefined otherwise.
+
+
+### `VARLOCK_COMMIT_SHA`
+
+**Type:** `string | undefined`
+
+The full git commit SHA.
+
+
+### `VARLOCK_COMMIT_SHA_SHORT`
+
+**Type:** `string | undefined`
+
+The short (7-character) git commit SHA.
+
+
+### `VARLOCK_PLATFORM`
+
+**Type:** `string | undefined`
+
+The name of the detected CI/deploy platform (e.g., `"GitHub Actions"`, `"Vercel"`, `"Netlify CI"`).
+
+
+### `VARLOCK_BUILD_URL`
+
+**Type:** `string | undefined`
+
+A URL linking to the current build or deploy in the CI platform's UI.
+
+
+### `VARLOCK_REPO`
+
+**Type:** `string | undefined`
+
+The repository name in `owner/repo` format.
+
+
+## Supported platforms
+
+Detection is built-in for these platforms (no configuration required):
+
+- GitHub Actions
+- GitLab CI
+- Vercel
+- Netlify
+- Cloudflare Pages / Workers
+- AWS CodeBuild
+- Azure Pipelines
+- Bitbucket Pipelines
+- Buildkite
+- CircleCI
+- Jenkins
+- Render
+- Travis CI
+- and [many more](https://github.com/dmno-dev/varlock/tree/main/packages/ci-env-info/src/platforms.ts)
+
+Not all platforms expose all fields. For example, some may not provide branch name or PR number.
+
+CI/deploy platform detection is powered by [`@varlock/ci-env-info`](https://www.npmjs.com/package/@varlock/ci-env-info), which can also be used as a standalone package.
diff --git a/packages/varlock/package.json b/packages/varlock/package.json
index 4068a8c4..eabc13c6 100644
--- a/packages/varlock/package.json
+++ b/packages/varlock/package.json
@@ -107,11 +107,12 @@
"devDependencies": {
"@clack/core": "^1.0.0",
"@clack/prompts": "^1.0.0",
+ "@env-spec/parser": "workspace:*",
"@env-spec/utils": "workspace:*",
"@sindresorhus/is": "catalog:",
"@types/node": "catalog:",
"@types/semver": "^7.7.1",
- "@env-spec/parser": "workspace:*",
+ "@varlock/ci-env-info": "workspace:^",
"ansis": "catalog:",
"browser-or-node": "^3.0.0",
"ci-info": "^4.3.1",
diff --git a/packages/varlock/src/cli/commands/load.command.ts b/packages/varlock/src/cli/commands/load.command.ts
index 3d387cdb..dc62507d 100644
--- a/packages/varlock/src/cli/commands/load.command.ts
+++ b/packages/varlock/src/cli/commands/load.command.ts
@@ -91,7 +91,7 @@ export const commandFn: TypedGunshiCommandFn = async (ctx) =
checkForConfigErrors(envGraph, { showAll });
if (format === 'pretty') {
- for (const itemKey in envGraph.configSchema) {
+ for (const itemKey of envGraph.sortedConfigKeys) {
const item = envGraph.configSchema[itemKey];
console.log(getItemSummary(item));
}
diff --git a/packages/varlock/src/cli/helpers/error-checks.ts b/packages/varlock/src/cli/helpers/error-checks.ts
index d518a75e..c1a67531 100644
--- a/packages/varlock/src/cli/helpers/error-checks.ts
+++ b/packages/varlock/src/cli/helpers/error-checks.ts
@@ -101,7 +101,9 @@ export function checkForConfigErrors(envGraph: EnvGraph, opts?: {
- const failingItems = _.filter(_.values(envGraph.configSchema), (item: ConfigItem) => item.validationState === 'error');
+ const failingItems = envGraph.sortedConfigKeys
+ .map((k) => envGraph.configSchema[k])
+ .filter((item) => item.validationState === 'error');
// TODO: use service.isValid?
if (failingItems.length > 0) {
@@ -119,7 +121,9 @@ export function checkForConfigErrors(envGraph: EnvGraph, opts?: {
ansis.italic.gray('(remove `--show-all` flag to hide)'),
]));
console.error();
- const validItems = _.filter(_.values(envGraph.configSchema), (i: ConfigItem) => !!i.isValid);
+ const validItems = envGraph.sortedConfigKeys
+ .map((k) => envGraph.configSchema[k])
+ .filter((i) => !!i.isValid);
_.each(validItems, (item: ConfigItem) => {
console.error(getItemSummary(item));
});
diff --git a/packages/varlock/src/env-graph/index.ts b/packages/varlock/src/env-graph/index.ts
index 1303a7b9..b16d2827 100644
--- a/packages/varlock/src/env-graph/index.ts
+++ b/packages/varlock/src/env-graph/index.ts
@@ -10,3 +10,6 @@ export {
VarlockError,
ConfigLoadError, SchemaError, ValidationError, CoercionError, ResolutionError,
} from './lib/errors';
+export {
+ BUILTIN_VARS, isBuiltinVar,
+} from './lib/builtin-vars';
diff --git a/packages/varlock/src/env-graph/lib/builtin-vars.ts b/packages/varlock/src/env-graph/lib/builtin-vars.ts
new file mode 100644
index 00000000..20ae0720
--- /dev/null
+++ b/packages/varlock/src/env-graph/lib/builtin-vars.ts
@@ -0,0 +1,112 @@
+import { type CiEnvInfo, type DeploymentEnvironment } from '@varlock/ci-env-info';
+
+export type BuiltinVarDef = {
+ name: string;
+ description: string;
+ resolver: (ciEnv: CiEnvInfo, processEnv: Record) => string | undefined;
+};
+
+/**
+ * Detect if we're running in a test environment.
+ * This check runs first, even before CI detection.
+ */
+function detectTestEnvironment(env: Record): boolean {
+ return !!(
+ env.NODE_ENV === 'test'
+ || env.JEST_WORKER_ID
+ || env.VITEST
+ || env.VITEST_POOL_ID
+ );
+}
+
+/**
+ * Infer deployment environment from branch name.
+ * Used when CI is detected but platform doesn't provide explicit environment.
+ */
+function inferFromBranch(branch: string): DeploymentEnvironment {
+ const lower = branch.toLowerCase();
+ if (['main', 'master', 'production', 'prod'].includes(lower)) return 'production';
+ if (['staging', 'stage', 'develop', 'dev'].includes(lower)) return 'staging';
+ if (['qa', 'test'].includes(lower)) return 'test';
+ return 'preview';
+}
+
+/**
+ * Multi-tier environment detection strategy:
+ * 1. Test environment detection (NODE_ENV=test, JEST_WORKER_ID, VITEST, etc.)
+ * 2. Platform-provided environment (Vercel VERCEL_ENV, Netlify CONTEXT)
+ * 3. Branch name inference (main/master→production, staging/develop→staging, others→preview)
+ * 4. deployed in CI → preview
+ * 5. not CI, default to development
+ */
+function inferVarlockEnv(ciEnv: CiEnvInfo, processEnv: Record): DeploymentEnvironment {
+ // Tier 1: Test detection (runs first, even in CI)
+ if (detectTestEnvironment(processEnv)) return 'test';
+
+ // Tier 2: Platform-provided (Vercel, Netlify, etc.)
+ if (ciEnv.environment) return ciEnv.environment;
+
+ // Tier 3: Branch inference (when in CI with branch info)
+ if (ciEnv.isCI && ciEnv.branch) return inferFromBranch(ciEnv.branch);
+
+ // Tier 4: deployed in CI = preview
+ if (ciEnv.isCI) return 'preview';
+
+ // Tier 5: not CI, default to development
+ return 'development';
+}
+
+export const BUILTIN_VARS: Record = {
+ VARLOCK_ENV: {
+ name: 'VARLOCK_ENV',
+ description: 'Auto-detected deployment environment (development, preview, staging, production, test)',
+ resolver: (ciEnv, processEnv) => inferVarlockEnv(ciEnv, processEnv),
+ },
+ VARLOCK_IS_CI: {
+ name: 'VARLOCK_IS_CI',
+ description: 'Whether running in a CI environment ("true" or "false")',
+ resolver: (ciEnv) => (ciEnv.isCI ? 'true' : 'false'),
+ },
+ VARLOCK_BRANCH: {
+ name: 'VARLOCK_BRANCH',
+ description: 'Current git branch name',
+ resolver: (ciEnv) => ciEnv.branch,
+ },
+ VARLOCK_PR_NUMBER: {
+ name: 'VARLOCK_PR_NUMBER',
+ description: 'Pull request number if in PR context',
+ resolver: (ciEnv) => ciEnv.prNumber?.toString(),
+ },
+ VARLOCK_COMMIT_SHA: {
+ name: 'VARLOCK_COMMIT_SHA',
+ description: 'Full commit SHA',
+ resolver: (ciEnv) => ciEnv.commitSha,
+ },
+ VARLOCK_COMMIT_SHA_SHORT: {
+ name: 'VARLOCK_COMMIT_SHA_SHORT',
+ description: 'Short (7-char) commit SHA',
+ resolver: (ciEnv) => ciEnv.commitShaShort,
+ },
+ VARLOCK_PLATFORM: {
+ name: 'VARLOCK_PLATFORM',
+ description: 'CI platform name (e.g., "GitHub Actions", "Vercel")',
+ resolver: (ciEnv) => ciEnv.name,
+ },
+ VARLOCK_BUILD_URL: {
+ name: 'VARLOCK_BUILD_URL',
+ description: 'Link to the CI build/deploy',
+ resolver: (ciEnv) => ciEnv.buildUrl,
+ },
+ VARLOCK_REPO: {
+ name: 'VARLOCK_REPO',
+ description: 'Repository name in owner/repo format',
+ resolver: (ciEnv) => ciEnv.fullRepoName,
+ },
+};
+
+/**
+ * Check if a key is a builtin VARLOCK_* variable.
+ */
+export function isBuiltinVar(key: string): boolean {
+ return key in BUILTIN_VARS;
+}
diff --git a/packages/varlock/src/env-graph/lib/config-item.ts b/packages/varlock/src/env-graph/lib/config-item.ts
index d937a40d..26f46f9f 100644
--- a/packages/varlock/src/env-graph/lib/config-item.ts
+++ b/packages/varlock/src/env-graph/lib/config-item.ts
@@ -27,11 +27,17 @@ export type ConfigItemDef = {
};
export type ConfigItemDefAndSource = {
itemDef: ConfigItemDef;
- source: EnvGraphDataSource;
+ source?: EnvGraphDataSource;
};
export class ConfigItem {
+ /** Whether this is a builtin VARLOCK_* variable */
+ isBuiltin?: boolean;
+
+ /** Programmatic definitions not tied to a data source (e.g. builtin vars) */
+ _internalDefs: Array = [];
+
constructor(
readonly envGraph: EnvGraph,
readonly key: string,
@@ -40,6 +46,7 @@ export class ConfigItem {
/**
* fetch ordered list of definitions for this item, by following up sorted data sources list
+ * internal defs (builtins) are appended last (lowest priority)
*/
get defs() {
// TODO: this is somewhat inneficient, because some of the checks on the data source follow up the parent chain
@@ -53,6 +60,7 @@ export class ConfigItem {
const itemDef = source.configItemDefs[this.key];
if (itemDef) defs.push({ itemDef, source });
}
+ defs.push(...this._internalDefs);
return defs;
}
@@ -103,8 +111,18 @@ export class ConfigItem {
return new StaticValueResolver(this.envGraph.overrideValues[this.key]);
}
+ const hasInternalResolver = this._internalDefs.some((d) => d.itemDef.resolver);
for (const def of this.defs) {
if (def.itemDef.resolver) {
+ // Skip empty static values when an internal fallback resolver exists
+ // (e.g., user defines VARLOCK_ENV= to add decorators — the builtin resolver still applies)
+ if (
+ hasInternalResolver
+ && def.itemDef.resolver instanceof StaticValueResolver
+ && !def.itemDef.resolver.staticValue
+ ) {
+ continue;
+ }
return def.itemDef.resolver;
}
}
@@ -152,7 +170,7 @@ export class ConfigItem {
if (def.itemDef.parsedValue && !def.itemDef.resolver) {
def.itemDef.resolver = convertParsedValueToResolvers(
def.itemDef.parsedValue,
- def.source,
+ def.source!, // parsedValue is only set for source-backed defs
this.envGraph.registeredResolverFunctions,
);
}
@@ -161,7 +179,9 @@ export class ConfigItem {
// process decorators and decorator value resolvers
for (const def of this.defs) {
- def.itemDef.decorators = def.itemDef.parsedDecorators?.map((d) => new ItemDecoratorInstance(this, def.source, d));
+ def.itemDef.decorators = def.itemDef.parsedDecorators?.map(
+ (d) => new ItemDecoratorInstance(this, def.source!, d),
+ );
// track all non fn call decs used in this definition - so we can error if used twice
const decKeysInThisDef = new Set();
const allDecsInThisDef = def.itemDef.decorators?.map((d) => d.name);
@@ -294,8 +314,8 @@ export class ConfigItem {
}
}
- // Root-level @defaultRequired
- const defaultRequiredDec = def.source.getRootDec('defaultRequired');
+ // Root-level @defaultRequired (skip for defs without a source, e.g. builtins)
+ const defaultRequiredDec = def.source?.getRootDec('defaultRequired');
if (defaultRequiredDec) {
const defaultRequiredVal = await defaultRequiredDec.resolve();
// @defaultRequired = true/false
@@ -361,7 +381,8 @@ export class ConfigItem {
// we skip `defaultSensitive` behaviour if the data type specifies sensitivity
if (sensitiveFromDataType !== undefined) continue;
- const defaultSensitiveDec = def.source.getRootDec('defaultSensitive');
+ // skip for defs without a source (e.g. builtins)
+ const defaultSensitiveDec = def.source?.getRootDec('defaultSensitive');
if (defaultSensitiveDec) {
if (!defaultSensitiveDec.decValueResolver) throw new Error('expected defaultSensitive to have a value resolver');
// special case for inferFromPrefix()
diff --git a/packages/varlock/src/env-graph/lib/data-source.ts b/packages/varlock/src/env-graph/lib/data-source.ts
index 87b15c2c..51237592 100644
--- a/packages/varlock/src/env-graph/lib/data-source.ts
+++ b/packages/varlock/src/env-graph/lib/data-source.ts
@@ -14,6 +14,7 @@ import { ParseError, SchemaError } from './errors';
import { pathExists } from '@env-spec/utils/fs-utils';
import { processPluginInstallDecorators } from './plugins';
import { RootDecoratorInstance } from './decorators';
+import { isBuiltinVar } from './builtin-vars';
const DATA_SOURCE_TYPES = Object.freeze({
schema: {
@@ -215,10 +216,16 @@ export abstract class EnvGraphDataSource {
}
if (envFlagItemKey) {
- if (!this.configItemDefs[envFlagItemKey]) {
+ if (!this.configItemDefs[envFlagItemKey] && !isBuiltinVar(envFlagItemKey)) {
this._loadingError = new Error(`environment flag "${envFlagItemKey}" must be defined within this schema`);
return;
}
+
+ // If it's a builtin var, register it now
+ if (isBuiltinVar(envFlagItemKey)) {
+ this.graph.registerBuiltinVar(envFlagItemKey);
+ }
+
// Always set the envFlagKey so parent directories can check it
// (even if we're skipping processing for a file partial import)
this.setEnvFlag(envFlagItemKey);
diff --git a/packages/varlock/src/env-graph/lib/env-graph.ts b/packages/varlock/src/env-graph/lib/env-graph.ts
index 273cdd24..9c62ab01 100644
--- a/packages/varlock/src/env-graph/lib/env-graph.ts
+++ b/packages/varlock/src/env-graph/lib/env-graph.ts
@@ -3,7 +3,7 @@ import path from 'node:path';
import { ConfigItem } from './config-item';
import { EnvGraphDataSource, FileBasedDataSource } from './data-source';
-import { BaseResolvers, type ResolverChildClass } from './resolver';
+import { BaseResolvers, createResolver, type ResolverChildClass } from './resolver';
import { BaseDataTypes, type EnvGraphDataTypeFactory } from './data-types';
import { findGraphCycles, getTransitiveDeps, type GraphAdjacencyList } from './graph-utils';
import { ResolutionError, SchemaError } from './errors';
@@ -14,6 +14,8 @@ import {
} from './decorators';
import { getErrorLocation } from './error-location';
import type { VarlockPlugin } from './plugins';
+import { getCiEnv, type CiEnvInfo } from '@varlock/ci-env-info';
+import { BUILTIN_VARS, isBuiltinVar } from './builtin-vars';
const processExists = !!globalThis.process;
const originalProcessEnv = { ...processExists && process.env };
@@ -132,6 +134,82 @@ export class EnvGraph {
this.overrideValues = originalProcessEnv;
}
+ /**
+ * Override for process.env used by builtin var detection.
+ * When set, builtin vars use this instead of the real process.env.
+ * Primarily useful for testing.
+ */
+ processEnvOverride?: Record;
+
+ /** Cached CI env info, computed lazily from processEnvOverride or real process.env */
+ private _cachedCiEnv?: CiEnvInfo;
+ get ciEnvInfo(): CiEnvInfo {
+ this._cachedCiEnv ??= getCiEnv(this.processEnvOverride ?? process.env);
+ return this._cachedCiEnv;
+ }
+
+ /** The process env record used for builtin var detection */
+ get processEnvForBuiltins(): Record {
+ return this.processEnvOverride ?? process.env;
+ }
+
+ /**
+ * Register a builtin VARLOCK_* variable.
+ * Attaches an internal def with the builtin resolver so it flows through the normal pipeline.
+ * If the item already exists (user-defined), the internal def is added as a fallback.
+ */
+ registerBuiltinVar(key: string) {
+ const builtinDef = BUILTIN_VARS[key];
+ if (!builtinDef) throw new Error(`Unknown builtin var: ${key}`);
+
+ let item = this.configSchema[key];
+
+ // Already has builtin def attached — nothing to do
+ if (item?._internalDefs.length) return;
+
+ // Need to capture `this` (the graph) for the resolver closure
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ const graph = this;
+
+ // Create the resolver for this builtin var
+ const BuiltinVarResolver = createResolver({
+ name: `\0builtin:${key}`,
+ description: builtinDef.description,
+ inferredType: 'string',
+ async resolve() {
+ return builtinDef.resolver(graph.ciEnvInfo, graph.processEnvForBuiltins);
+ },
+ });
+
+ if (!item) {
+ // No user definition — create the item from scratch
+ item = new ConfigItem(this, key);
+ // Pre-set defaults — builtins are optional and public.
+ // processRequired/processSensitive will not override these since the
+ // internal def has no decorators and no source with root-level defaults.
+ item._isRequired = false;
+ item._isSensitive = false;
+ // Set dataType directly since registerBuiltinVar is called synchronously
+ // during resolver processing, and the item may not get a process() call
+ // from the finishLoad loop (for...in doesn't reliably visit new keys).
+ item.dataType = this.dataTypesRegistry.string();
+ this.configSchema[key] = item;
+ }
+
+ item.isBuiltin = true;
+
+ // Attach an internal def with description and resolver.
+ // For user-defined items, this sits at lowest priority in defs —
+ // the builtin resolver acts as a fallback when no explicit value is set.
+ item._internalDefs.push({
+ itemDef: {
+ description: builtinDef.description,
+ parsedValue: undefined,
+ resolver: new BuiltinVarResolver([], undefined, undefined),
+ },
+ });
+ }
+
async setRootDataSource(source: EnvGraphDataSource) {
if (this.rootDataSource) throw new Error('root data source already set');
this.rootDataSource = source;
@@ -148,6 +226,12 @@ export class EnvGraph {
if (plugin.loadingError) return;
}
+ // Attach builtin defs to any user-defined VARLOCK_* items
+ // (they may have been defined directly without a $VARLOCK_* reference)
+ for (const key in this.configSchema) {
+ if (isBuiltinVar(key)) this.registerBuiltinVar(key);
+ }
+
// process root decorators
let processingError = false;
for (const source of this.sortedDataSources) {
@@ -307,9 +391,20 @@ export class EnvGraph {
await this.resolveEnvValues([...transitiveDeps, key]);
}
+ /** config keys with builtin vars first, then user-defined in schema order */
+ get sortedConfigKeys() {
+ const builtinKeys: Array = [];
+ const userKeys: Array = [];
+ for (const key in this.configSchema) {
+ if (this.configSchema[key].isBuiltin) builtinKeys.push(key);
+ else userKeys.push(key);
+ }
+ return [...builtinKeys, ...userKeys];
+ }
+
getResolvedEnvObject() {
const envObject: Record = {};
- for (const itemKey in this.configSchema) {
+ for (const itemKey of this.sortedConfigKeys) {
const item = this.configSchema[itemKey];
envObject[itemKey] = item.resolvedValue;
}
@@ -330,7 +425,7 @@ export class EnvGraph {
path: source instanceof FileBasedDataSource ? path.relative(this.basePath ?? '', source.fullPath) : undefined,
});
}
- for (const itemKey in this.configSchema) {
+ for (const itemKey of this.sortedConfigKeys) {
const item = this.configSchema[itemKey];
serializedGraph.config[itemKey] = {
value: item.resolvedValue,
diff --git a/packages/varlock/src/env-graph/lib/resolver.ts b/packages/varlock/src/env-graph/lib/resolver.ts
index c0c634fe..6b9fc810 100644
--- a/packages/varlock/src/env-graph/lib/resolver.ts
+++ b/packages/varlock/src/env-graph/lib/resolver.ts
@@ -12,6 +12,7 @@ import { ResolutionError, SchemaError, VarlockError } from './errors';
import type { EnvGraphDataSource } from './data-source';
import { DecoratorInstance } from './decorators';
import { getErrorLocation } from './error-location';
+import { isBuiltinVar } from './builtin-vars';
const execAsync = promisify(exec);
@@ -376,6 +377,12 @@ export const RefResolver: typeof Resolver = createResolver({
if (typeof refKey !== 'string') {
throw new SchemaError('expects a string keyname passed in');
}
+
+ // Auto-register builtin vars when referenced
+ if (isBuiltinVar(refKey) && !this.envGraph!.configSchema[refKey]) {
+ this.envGraph!.registerBuiltinVar(refKey);
+ }
+
this.addDep(refKey);
return refKey;
},
diff --git a/packages/varlock/src/env-graph/lib/type-generation.ts b/packages/varlock/src/env-graph/lib/type-generation.ts
index e34ae2cc..46a5ab38 100644
--- a/packages/varlock/src/env-graph/lib/type-generation.ts
+++ b/packages/varlock/src/env-graph/lib/type-generation.ts
@@ -174,7 +174,7 @@ export async function generateTsTypesSrc(graph: EnvGraph) {
const exposedKeys: Array = [];
const exposedNonSensitiveKeys: Array = [];
- for (const itemKey in graph.configSchema) {
+ for (const itemKey of graph.sortedConfigKeys) {
const configItem = graph.configSchema[itemKey];
// generate the TS type for the item in the full schema
tsSrc.push(...await getTsDefinitionForItem(configItem, 1));
diff --git a/packages/varlock/src/env-graph/test/builtin-vars.test.ts b/packages/varlock/src/env-graph/test/builtin-vars.test.ts
new file mode 100644
index 00000000..a2ed411d
--- /dev/null
+++ b/packages/varlock/src/env-graph/test/builtin-vars.test.ts
@@ -0,0 +1,250 @@
+import { describe, test } from 'vitest';
+import { envFilesTest } from './helpers/generic-test';
+
+describe('VARLOCK_* builtin variables', () => {
+ describe('lazy registration', () => {
+ test('builtin vars are not in schema unless referenced', envFilesTest({
+ envFile: 'OTHER_VAR=foo',
+ expectValues: { OTHER_VAR: 'foo' },
+ expectNotInSchema: ['VARLOCK_ENV', 'VARLOCK_IS_CI'],
+ }));
+
+ test('builtin vars are registered when referenced via $VARLOCK_*', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV\nIS_CI=$VARLOCK_IS_CI',
+ processEnv: {},
+ expectValues: {
+ VARLOCK_ENV: 'development',
+ VARLOCK_IS_CI: 'false',
+ },
+ expectSensitive: {
+ VARLOCK_ENV: false,
+ VARLOCK_IS_CI: false,
+ },
+ expectRequired: {
+ VARLOCK_ENV: false,
+ VARLOCK_IS_CI: false,
+ },
+ }));
+ });
+
+ describe('VARLOCK_ENV environment detection', () => {
+ test('detects test environment from NODE_ENV=test', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: { NODE_ENV: 'test' },
+ expectValues: { VARLOCK_ENV: 'test' },
+ }));
+
+ test('detects test environment from VITEST', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: { VITEST: 'true' },
+ expectValues: { VARLOCK_ENV: 'test' },
+ }));
+
+ test('detects test environment from JEST_WORKER_ID', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: { JEST_WORKER_ID: '1' },
+ expectValues: { VARLOCK_ENV: 'test' },
+ }));
+
+ test('detects development when not in CI', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: {},
+ expectValues: { VARLOCK_ENV: 'development' },
+ }));
+
+ test('infers production from main branch', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REF: 'refs/heads/main',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: { VARLOCK_ENV: 'production' },
+ }));
+
+ test('infers production from master branch', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REF: 'refs/heads/master',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: { VARLOCK_ENV: 'production' },
+ }));
+
+ test('infers staging from develop branch', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REF: 'refs/heads/develop',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: { VARLOCK_ENV: 'staging' },
+ }));
+
+ test('infers preview from feature branch', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REF: 'refs/heads/feature/my-feature',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: { VARLOCK_ENV: 'preview' },
+ }));
+
+ test('uses platform-provided environment (Vercel)', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: {
+ VERCEL: '1',
+ VERCEL_ENV: 'production',
+ },
+ expectValues: { VARLOCK_ENV: 'production' },
+ }));
+
+ test('defaults to preview when in CI but unknown branch', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: { CI: 'true' },
+ expectValues: { VARLOCK_ENV: 'preview' },
+ }));
+
+ test('test detection takes priority over platform env', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: {
+ NODE_ENV: 'test',
+ VERCEL: '1',
+ VERCEL_ENV: 'production',
+ },
+ expectValues: { VARLOCK_ENV: 'test' },
+ }));
+ });
+
+ describe('VARLOCK_IS_CI', () => {
+ test('returns "false" when not in CI', envFilesTest({
+ envFile: 'MY_VAR=$VARLOCK_IS_CI',
+ processEnv: {},
+ expectValues: { VARLOCK_IS_CI: 'false' },
+ }));
+
+ test('returns "true" when in CI', envFilesTest({
+ envFile: 'MY_VAR=$VARLOCK_IS_CI',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: { VARLOCK_IS_CI: 'true' },
+ }));
+ });
+
+ describe('CI platform variables', () => {
+ test('VARLOCK_BRANCH returns branch name', envFilesTest({
+ envFile: 'MY_VAR=$VARLOCK_BRANCH',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REF: 'refs/heads/feature-branch',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: { VARLOCK_BRANCH: 'feature-branch' },
+ }));
+
+ test('VARLOCK_COMMIT_SHA and VARLOCK_COMMIT_SHA_SHORT', envFilesTest({
+ envFile: 'SHA=$VARLOCK_COMMIT_SHA\nSHA_SHORT=$VARLOCK_COMMIT_SHA_SHORT',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_SHA: 'abc1234567890def1234567890abcdef12345678',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: {
+ VARLOCK_COMMIT_SHA: 'abc1234567890def1234567890abcdef12345678',
+ VARLOCK_COMMIT_SHA_SHORT: 'abc1234',
+ },
+ }));
+
+ test('VARLOCK_PLATFORM returns CI platform name', envFilesTest({
+ envFile: 'MY_VAR=$VARLOCK_PLATFORM',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: { VARLOCK_PLATFORM: 'GitHub Actions' },
+ }));
+
+ test('VARLOCK_REPO returns owner/repo format', envFilesTest({
+ envFile: 'MY_VAR=$VARLOCK_REPO',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_REPOSITORY: 'my-org/my-repo',
+ },
+ expectValues: { VARLOCK_REPO: 'my-org/my-repo' },
+ }));
+ });
+
+ describe('builtin vars in string interpolation', () => {
+ test('can use VARLOCK_* in concat expressions', envFilesTest({
+ envFile: 'DEBUG_INFO="SHA: $VARLOCK_COMMIT_SHA_SHORT"',
+ processEnv: {
+ CI: 'true',
+ GITHUB_ACTIONS: 'true',
+ GITHUB_SHA: 'abc1234567890',
+ GITHUB_REPOSITORY: 'owner/repo',
+ },
+ expectValues: { DEBUG_INFO: 'SHA: abc1234' },
+ }));
+ });
+
+ describe('explicit definition with empty value', () => {
+ test('defining VARLOCK_ENV= still uses builtin auto-detection', envFilesTest({
+ envFile: 'VARLOCK_ENV=',
+ processEnv: { NODE_ENV: 'test' },
+ expectValues: { VARLOCK_ENV: 'test' },
+ }));
+
+ test('defining VARLOCK_ENV with explicit value overrides auto-detection', envFilesTest({
+ envFile: 'VARLOCK_ENV=production',
+ processEnv: {},
+ expectValues: { VARLOCK_ENV: 'production' },
+ }));
+
+ test('process.env override still takes precedence over builtin', envFilesTest({
+ envFile: 'MY_ENV=$VARLOCK_ENV',
+ processEnv: {},
+ overrideValues: { VARLOCK_ENV: 'staging' },
+ expectValues: { VARLOCK_ENV: 'staging' },
+ }));
+ });
+
+ describe('@currentEnv=$VARLOCK_ENV', () => {
+ test('VARLOCK_ENV works with @currentEnv and env-specific files', envFilesTest({
+ files: {
+ '.env.schema': '# @currentEnv=$VARLOCK_ENV\n# ---\nITEM1=default-value',
+ '.env.test': 'ITEM1=test-value',
+ '.env.development': 'ITEM1=dev-value',
+ },
+ processEnv: { NODE_ENV: 'test' },
+ expectValues: {
+ VARLOCK_ENV: 'test',
+ ITEM1: 'test-value',
+ },
+ }));
+
+ test('VARLOCK_ENV works with @currentEnv for development', envFilesTest({
+ files: {
+ '.env.schema': '# @currentEnv=$VARLOCK_ENV\n# ---\nITEM1=default-value',
+ '.env.development': 'ITEM1=dev-value',
+ },
+ processEnv: {},
+ expectValues: {
+ VARLOCK_ENV: 'development',
+ ITEM1: 'dev-value',
+ },
+ }));
+ });
+});
diff --git a/packages/varlock/src/env-graph/test/helpers/generic-test.ts b/packages/varlock/src/env-graph/test/helpers/generic-test.ts
index 4a69be42..2f76f9b9 100644
--- a/packages/varlock/src/env-graph/test/helpers/generic-test.ts
+++ b/packages/varlock/src/env-graph/test/helpers/generic-test.ts
@@ -14,6 +14,8 @@ export function envFilesTest(spec: {
files?: Record;
fallbackEnv?: string,
overrideValues?: Record;
+ /** Override process.env for builtin var detection (avoids modifying real process.env) */
+ processEnv?: Record;
debug?: boolean;
earlyError?: boolean;
loadingError?: boolean;
@@ -32,6 +34,7 @@ export function envFilesTest(spec: {
const g = new EnvGraph();
if (spec.overrideValues) g.overrideValues = spec.overrideValues;
if (spec.fallbackEnv) g.envFlagFallback = spec.fallbackEnv;
+ if (spec.processEnv) g.processEnvOverride = spec.processEnv;
if (spec.files) {
g.setVirtualImports(currentDir, spec.files);
const source = new DirectoryDataSource(currentDir);