Skip to content

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/sh is available.

Clone and build

Terminal window
git clone https://github.com/biliboss/envless.git
cd envless/zig
zig build
# → zig-out/bin/envless
./zig-out/bin/envless --version

zig 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, bench

Coding style

  • Standard Zig style: zig fmt. No extra lint config (yet).
  • Caveman output convention: one line per action, all caps verb, key=value fields. Examples from the codebase:
    INIT identity=.envless/identity.key pubkey=age1...
    SET env=dev key=OPENAI_API_KEY
    MIGRATE src=.env env=dev keys=3
    No spinners, no banners, no emojis.

Adding a subcommand

One file per subcommand under zig/src/cli/, following the existing examples:

zig/src/cli/foo.zig
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

Terminal window
cd zig
zig build run -- --version
zig build run -- init
echo "v" | zig build run -- set TEST

For 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:

Terminal window
pnpm install
pnpm dev # live reload on http://localhost:4321
pnpm 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

Terminal window
cd zig
zig build test # 37 inline unit tests
zig build e2e # 6 end-to-end tests against the built binary

CI runs both on every PR (see .github/workflows/ci.yml).

Layout

PathStylePurpose
zig/src/envparse.zig (inline tests)Table-drivenPure-logic parser tests. No I/O.
zig/src/execenv.zig (inline tests)Table-drivenEnv merge logic + run against /bin/sh.
zig/src/sops.zig (inline tests)Skip-if-missingRoundtrip through real sops + age.
zig/src/store.zig (inline tests)Skip-if-missingFull filesystem behaviour.
zig/src/e2e.zigSubprocessSpawns 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:

  1. Inline test block next to the implementation. Keep it pure-logic wherever possible.
  2. 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.enc directly — sops includes timestamps and per-call randomness.

What NOT to do

  • No mocks for sops or age. They are real binaries; the e2e suite uses them. Mocking the boundary defeats the test’s purpose.
  • No std.time.sleep to “wait for sops to finish.” std.process.Child.spawnAndWait is 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*']:

  1. Check out at depth 0.
  2. Set up Zig 0.13.0 (goto-bus-stop/setup-zig@v2).
  3. Run zig build release -Dversion=${{ github.ref_name }} from zig/. This cross-builds the binary for the four release targets (x86_64-linux-gnu, aarch64-linux-gnu, x86_64-macos, aarch64-macos), produces one dist/envless_<version>_<target>.tar.gz per target, and writes dist/checksums.txt with one <sha256> <basename> line per tarball.
  4. Verify: exactly four tarballs plus a checksums.txt.
  5. 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

Terminal window
# 1. Make sure main is green and the changes you want are in.
git checkout main && git pull
cd zig && zig build test && zig build e2e
# 2. Bump the spec / docs as needed, then tag.
git tag v0.0.2
git 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 styledfeat:, 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.