Contributing
envless is a Zig module. This page covers the full contributor
surface: getting set up, writing tests that fit, and cutting a release.
Development setup
Prerequisites
- Zig 0.13.0 — pinned in
zig/.zigversion. Install from ziglang.org/download. - age ≥ 1.2 —
brew install age/apt install age - sops ≥ 3.9 — install from getsops/sops releases
- A POSIX shell. Tests assume
/bin/shis available.
Clone and build
git clone https://github.com/biliboss/envless.gitcd envless/zig
zig build# → zig-out/bin/envless
./zig-out/bin/envless --versionzig build (see zig/build.zig) installs the binary
at zig-out/bin/envless. For an optimised build pass
-Doptimize=ReleaseSmall; for a version-stamped build pass
-Dversion=v0.X.Y.
Project layout
.├── zig/│ ├── build.zig # build graph (run, test, e2e, release)│ ├── .zigversion # pinned Zig version│ └── src/│ ├── main.zig # entrypoint (21 LOC)│ ├── cli/ # hand-rolled subcommand dispatcher│ ├── execenv.zig # env merge + child spawn│ ├── sops.zig # sops binary wrapper│ ├── store.zig # filesystem layout, KV│ ├── envparse.zig # .env parser│ └── e2e.zig # end-to-end test harness├── bench/ # benchmark harness + results (one bash exception)├── src/ # docs site (Astro + Starlight)├── public/ # docs assets├── spec/ # release acceptance specs└── .github/workflows/ # ci, release, docs, benchCoding style
- Standard Zig style:
zig fmt. No extra lint config (yet). - Caveman output convention: one line per action, all caps verb,
key=valuefields. Examples from the codebase:No spinners, no banners, no emojis.INIT identity=.envless/identity.key pubkey=age1...SET env=dev key=OPENAI_API_KEYMIGRATE src=.env env=dev keys=3
Adding a subcommand
One file per subcommand under zig/src/cli/, following the existing
examples:
const std = @import("std");const root = @import("root.zig");
pub fn run(ctx: *root.Context, args: []const []const u8) !u8 { var rest = std.ArrayList([]const u8).init(ctx.allocator); defer rest.deinit(); const env_opt = try root.popStringFlag(args, "--env", &rest); const env = env_opt orelse "dev"; _ = rest; try ctx.stdout.writer().print("FOO env={s}\n", .{env}); return 0;}Wire it in zig/src/cli/root.zig’s subcommand dispatch. Add inline
tests at the bottom of the file (test "..." { ... }) and add a case
to zig/src/e2e.zig.
Running locally
cd zigzig build run -- --versionzig build run -- initecho "v" | zig build run -- set TESTFor repeated dev cycles, zig build plus ./zig-out/bin/envless is
faster because there’s no compile-per-invocation.
Docs site
The docs site (this site) lives at the repo root and is an Astro 5 + Starlight 0.30 project. To work on it:
pnpm installpnpm dev # live reload on http://localhost:4321pnpm build # production build → dist/GH Pages deploys are handled by .github/workflows/docs.yml.
macOS 26 (Tahoe) blocker
Zig 0.13.0’s linker fails against the Tahoe SDK
(undefined symbol: _arc4random_buf, etc.). Locally on macOS 26,
build inside a Linux container (the documented OrbStack workflow in
AGENTS.md). CI on Ubuntu is unaffected.
Testing
envless keeps a small, fast test surface. The whole suite runs in
under three seconds on a laptop. The pattern: inline test "..."
blocks per module, plus a top-level zig/src/e2e.zig that builds the
real binary and shells out.
Running the suite
cd zigzig build test # 37 inline unit testszig build e2e # 6 end-to-end tests against the built binaryCI runs both on every PR (see .github/workflows/ci.yml).
Layout
| Path | Style | Purpose |
|---|---|---|
zig/src/envparse.zig (inline tests) | Table-driven | Pure-logic parser tests. No I/O. |
zig/src/execenv.zig (inline tests) | Table-driven | Env merge logic + run against /bin/sh. |
zig/src/sops.zig (inline tests) | Skip-if-missing | Roundtrip through real sops + age. |
zig/src/store.zig (inline tests) | Skip-if-missing | Full filesystem behaviour. |
zig/src/e2e.zig | Subprocess | Spawns the binary in each test and asserts on stdout/stderr/exit. |
The e2e suite is the executable spec — when a behaviour changes, the e2e test changes too. See Architecture → Lifecycle of a secret for what each e2e case covers.
The “skip if missing” pattern
Tests that require age-keygen or sops use this helper from
zig/src/e2e.zig:
fn skipIfMissing(comptime bins: []const []const u8) !void { const a = testing.allocator; inline for (bins) |b| { const ok = lookPath(a, b) catch false; if (!ok) return error.SkipZigTest; }}Use it at the top of any test that shells out. CI installs both binaries, so the skip only happens locally for contributors without them.
Writing a new test
For a new subcommand, add two:
- Inline
testblock next to the implementation. Keep it pure-logic wherever possible. - E2E case in
zig/src/e2e.zig. Follow the existing pattern:makeTmpDir()per test,runEnvlessOk(...), assert on stdout/stderr/exit code.
Table-driven where it pays off:
test "parse" { const cases = [_]struct { name: []const u8, in: []const u8, want: []const Entry }{ .{ .name = "empty", .in = "", .want = &.{} }, .{ .name = "single", .in = "A=1\n", .want = &.{ .{ "A", "1" } } }, // ... }; for (cases) |tc| { const got = try parse(testing.allocator, tc.in); defer freeEntries(testing.allocator, got); try testing.expectEqualSlices(Entry, tc.want, got); }}What to assert in e2e
- Exit code — see Exit codes.
- Stdout content. The caveman convention means it is predictable.
- File side-effects —
.envless/,secrets/,.gitignore. - Never assert on the encrypted bytes of
secrets/*.env.encdirectly — sops includes timestamps and per-call randomness.
What NOT to do
- No mocks for
sopsorage. They are real binaries; the e2e suite uses them. Mocking the boundary defeats the test’s purpose. - No
std.time.sleepto “wait for sops to finish.”std.process.Child.spawnAndWaitis synchronous. If you find yourself wanting a sleep, look harder. - No global fixtures. Each e2e test gets its own
makeTmpDir()and cleans it up. - No flaky network calls. v0.0.1 makes none — keep it that way.
Suite-time budget
We target sub-3-second zig build test && zig build e2e because slow
tests stop getting run. The e2e cases that shell out to sops each
take ~200 ms; that is the budget, not a license to add more. If a new
case takes longer than 500 ms, profile it.
Release process
envless releases on Git tags. The process is automated by
GitHub Actions.
Release flow
The pipeline (.github/workflows/release.yml)
triggers on push: tags: ['v*']:
- Check out at depth 0.
- Set up Zig 0.13.0 (
goto-bus-stop/setup-zig@v2). - Run
zig build release -Dversion=${{ github.ref_name }}fromzig/. This cross-builds the binary for the four release targets (x86_64-linux-gnu,aarch64-linux-gnu,x86_64-macos,aarch64-macos), produces onedist/envless_<version>_<target>.tar.gzper target, and writesdist/checksums.txtwith one<sha256> <basename>line per tarball. - Verify: exactly four tarballs plus a
checksums.txt. - Publish a draft GitHub Release with all tarballs and the
checksums file attached, via
softprops/action-gh-release@v2. A maintainer flips it to “Published” once the assets are verified.
Cutting a release locally
# 1. Make sure main is green and the changes you want are in.git checkout main && git pullcd zig && zig build test && zig build e2e
# 2. Bump the spec / docs as needed, then tag.git tag v0.0.2git push origin v0.0.2
# 3. Watch the release workflow finish, then publish the draft on# https://github.com/biliboss/envless/releases.The docs site is rebuilt by docs.yml on the next
push to main (or via workflow_dispatch). The Changelog page
picks up the new release from the GH API; no doc edits required.
Bench artifacts
After a release tag lands, bench.yml runs bench/run.sh and
appends a line to bench/history.jsonl; the docs changelog joins
that line to the release for the perf-delta display. See
Benchmarks for the methodology.
Versioning policy
Pre-1.0: SemVer-shaped but not SemVer-binding. The CLI surface is
allowed to break between minor versions. File-format compatibility
is guaranteed within 0.x — a secrets/dev.env.enc written by
0.0.1 will decrypt under 0.1.
Post-1.0: full SemVer. Breaking changes to the CLI or file format require a major bump.
Changelog discipline
The release notes you write on the GitHub Release page become the Changelog page. Keep them:
- Conventional-commits styled —
feat:,fix:,docs:,chore:,BREAKING:. - Linkable — reference issue / PR numbers.
- Concise — the perf delta table is rendered automatically; you do not need to restate it in prose.