A SwiftPM build-tool plugin that generates a typed Swift Secrets enum from environment variables at build time. The generated file lives in the plugin's work directory and is compiled into the consuming target. Secrets never enter source control.
// Package.swift
.package(url: "https://github.com/heirloomlogic/Tightlip", from: "1.0.0"),Attach the plugin to a target:
.target(
name: "MyApp",
plugins: [.plugin(name: "Lipservice", package: "Tightlip")]
)Drop Secrets.yml at the target's source root (e.g. Sources/MyApp/Secrets.yml).
File > Add Package Dependencies...→ pastehttps://github.com/heirloomlogic/Tightlip→ set Dependency Rule to Up to Next Major from1.0.0. (Add Local...also works for vendored checkouts.)- In the target's
Build Phases > Run Build Tool Plug-ins, add Lipservice. - Create
<TargetName>/Secrets.ymlat the project root (the directory containing.xcodeproj).<TargetName>is the target's display name; the plugin resolves this path on the filesystem, not through Xcode's group tree, so the file's position in the Project Navigator is irrelevant. For a stock app template this is the<TargetName>/folder already at the top of the project. - Reference the generated enum anywhere in the target:
Secrets.revenueCatAPIKey.
If you drive this setup with an AI coding assistant, install the tightlip-ref skill. It teaches the assistant how to integrate Tightlip, add or rename secrets, configure per-environment keys, and debug Lipservice build failures.
# Claude Code
gh skill install heirloomlogic/skills tightlip-ref --agent claude-code --force --scope user
# Codex
gh skill install heirloomlogic/skills tightlip-ref --agent codex --force --scope user--force overwrites any existing copy, so re-run the same command to update to the latest version. --scope user installs the skill once for every project on the machine.
Tightlip reads a single config file, Secrets.yml, in one of two formats. The format is auto-detected from the first non-comment line.
# Secrets.yml
revenueCatAPIKey: REVENUECAT_API_KEY
hmacSigningKey: HMAC_KEYOne line per secret: <propertyName>: <ENV_VAR_NAME>. The left side becomes a static property on Secrets; the right side names an environment variable resolved at build time.
# Secrets.yml
staging:
revenueCatAPIKey: STAGING_REVENUECAT_API_KEY
hmacSigningKey: STAGING_HMAC_KEY
production:
revenueCatAPIKey: PROD_REVENUECAT_API_KEY
hmacSigningKey: PROD_HMAC_KEYEach top-level identifier followed by : (with no value) is an environment section. Lines within a section are indented exactly 2 spaces. All sections must declare the same set of property names. One section is selected per build — see Environment selection.
The parser is deliberately strict:
- Property names and env-var names must be bare ASCII identifiers (
[A-Za-z_][A-Za-z0-9_]*). No quoting. - Property names may not be Swift keywords (
class,default, …) or the names the generated enum reserves for itself (salt,decode) — they'd produce a non-compiling generated file, so the parser rejects them up front. #at the start of a line is a comment. Inline comments after a value are not supported.- Blank lines are fine. Tabs are not — anywhere.
- Flat mode: no leading whitespace on mapping lines.
- Sectioned mode: section headers at column 1, content at exactly 2-space indent.
- Duplicate keys, empty files, and anything else outside this grammar are parse errors with a line number.
Every declared secret is required at build time. If an env var is unset, the build fails with a message pointing at the missing variable. Truly optional values should be read from ProcessInfo at runtime rather than declared here.
Devs working on several apps on the same machine see bare names like REVENUECAT_API_KEY collide. Prefix every env var with an app-specific tag — <APP_PREFIX>_<SECRET> in screaming snake case (e.g. ACME_REVENUECAT_API_KEY). The plugin doesn't enforce this; the convention just keeps configs across projects from stepping on each other.
When a sectioned config is used, the build tool picks one section in this order:
TIGHTLIP_ENV— if set (to a non-empty value), it must match a section name exactly. Highest priority.- Automatic inference — when exactly two sections exist and exactly one is named
prodorproduction:CONFIGURATION=Release(Xcode) → theprod/productionsection.- Any other configuration (including
Debugand unset) → the other section.
- Error — if neither rule resolves (e.g. three sections without
TIGHTLIP_ENV), the build fails with a message listing available environments.
Flat configs have no environment concept and ignore all of this.
- Local dev: add
export TIGHTLIP_ENV=stagingto~/.zshenv, or leave it unset and let Debug builds pick the non-production section automatically. - CI release lane: set
TIGHTLIP_ENV=production, or rely onCONFIGURATION=Releaseif using Xcode. - More than two environments (qa, uat, etc.): always set
TIGHTLIP_ENVexplicitly.
Warning
CONFIGURATION is set by Xcode and xcodebuild only. A plain swift build -c release does not set it, so inference resolves to the non-production section — a release binary with staging keys. If you build releases with SwiftPM directly, always set TIGHTLIP_ENV=production on that lane. The build log's note: using environment '…' line tells you what was picked.
// Auto-generated by Tightlip. Do not edit.
// Regenerated on every build from environment variables.
// Environment: staging
import Foundation
nonisolated enum Secrets {
static let appAPIKey: String = Self.decode("4qO9...")
static let appBaseURL: String = Self.decode("9F2c...")
private static let salt: [UInt8] = [0x12, 0x34, /* ...32 bytes... */]
private static func decode(_ encoded: String) -> String { /* XOR + base64 */ }
}Call sites see plain String (Secrets.appAPIKey). The stored bytes are XOR-encoded against a 32-byte salt derived deterministically from the resolved values, so identical inputs produce byte-identical output, which avoids spurious downstream recompiles. Plaintext literals never appear in the compiled binary; strings against the shipped .app won't surface them.
Properties are emitted in alphabetical order. The enum is always named Secrets. The // Environment: comment appears only for sectioned configs.
By default the build tool sources ~/.zshenv in a clean zsh subshell, captures the resulting environment, and merges it with ProcessInfo.processInfo.environment (the build's own env). Per-key conflicts resolve in favor of ProcessInfo, so CI runners and Xcode Scheme env vars override anything in .zshenv.
This behaves identically regardless of how the build was launched — Xcode.app from Finder, Conductor, VS Code, xcodebuild from Terminal. This eliminates the common case where an env var works in the shell but Xcode can't see it.
If the configured file doesn't exist (typical on CI), the tool falls back to ProcessInfo only. Sourcing failures and timeouts (5s default) also fall back, with a single note to stderr.
One caveat: zsh -f skips all startup files except the system-wide /etc/zshenv. On machines where IT tooling lives there (some managed Macs), that file runs during capture too — if sourcing is slow or noisy, check there as well as your own env file.
Add a top-level envFile: directive before any secret declaration. The path is tilde-expanded against $HOME; relative paths resolve against the config's directory.
envFile: ~/.bash_profile
revenueCatAPIKey: REVENUECAT_API_KEYPer-shell recommendations:
| Shell | Recommended path | Notes |
|---|---|---|
| zsh | ~/.zshenv (default — directive can be omitted) |
Sourced cleanly with zsh -f |
| bash | ~/.bash_profile or ~/.bashrc |
Sourced by zsh; shell-compatible export syntax works |
| fish | ~/.config/tightlip.env (sidecar) |
Fish syntax isn't zsh-compatible; keep a file of export KEY=value lines |
| nushell / xonsh / etc. | ~/.tightlip.env (sidecar) |
Same sidecar pattern as fish |
CI runners typically have no .zshenv; the tool falls back to ProcessInfo and the job's env: block works unchanged.
The directive is recognized only as the first non-blank, non-comment line. Anything after a section header or property mapping is parsed as a secret declaration.
Relative envFile: paths resolve against Secrets.yml's directory, so envFile: secrets.env points at a sibling file inside the target:
# Sources/MyApp/Secrets.yml
envFile: secrets.env
revenueCatAPIKey: ACME_REVENUECAT_API_KEYThe file is shell-sourced, so use export KEY=value syntax, and gitignore it — only Secrets.yml belongs in source control. Note that a project-local file must be re-created in every git worktree; a machine-global ~/.zshenv is sourced identically across worktrees, which is usually what you want under Conductor.
Tightlip intentionally has no auto-discovered .env feature: the directive above already covers project-local files, and auto-discovery plus a bespoke dotenv parser would add an accidental-commit footgun and a value-parsing code path that shell-sourcing avoids.
error: environment variable X must be set to generate Secrets.Y — the env var is unset in both the sourced file and ProcessInfo. The accompanying note: line lists everything visible with the same prefix (e.g. ACME_*), which usually points at a typo. Confirm the key exists in your envFile (default ~/.zshenv).
note: sourcing /path/to/file ... using process environment only — the subshell that sources your envFile failed or timed out. Reproduce with zsh -f -c 'source <yourEnvFile>'. Typical causes: a tool inside .zshenv (e.g. mise, asdf) hitting something the sandbox blocks, or .zshenv taking longer than 5 seconds.
error: cannot determine environment: ... — a sectioned config is in use but the tool can't decide which section to build. Set TIGHTLIP_ENV to one of the listed names. This happens with more than two sections, or two sections where neither is named prod/production.
error: Secrets.yml:N: ... — the config didn't parse. Check the line against the Grammar rules. Common causes: tab characters (paste from a different editor), quoted values, nested indentation, inline comments after a value.
Plugin doesn't regenerate after changing an env var — expected: the build system tracks Secrets.yml as the command's only input, so changing an env var alone doesn't mark the generation step stale. Run xcodebuild clean (or Product → Clean Build Folder) and rebuild to pick up the new value.
Generated enum isn't visible in code — confirm the plugin is attached to the target (Build Phases > Run Build Tool Plug-ins in Xcode) and that Secrets.yml is at the expected path.
