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);