Skip to content

Developer Guide

Development

Everything builds inside podman; only podman and just are needed on the host. All just recipes run in pinned builder containers (Wolfi-based Chainguard images, digest-pinned: rust for cargo, node for the Podman Desktop extension) with cached registry/target volumes and shared :z SELinux bind mounts (one label for every builder container, so a long-running just run daemon keeps working while other recipes run) — never run cargo/rustc or npm directly on the host.

Recipes

 1just             # list recipes
 2just build       # incremental debug build (cached registry + target volumes)
 3just release     # optimized build
 4just check       # cargo check in the build container
 5just test        # unit tests
 6just clippy      # lints (warnings are errors, same as CI)
 7just fmt         # rustfmt
 8just fmt-check   # fail if sources are not formatted
 9just bin         # debug build -> ./bin/{ocid,ocictl,ocitop}
10just run ARGS    # run the ocid daemon on the host network (state in ./.dev/ocid-home; OCID_HOME= for more nodes)
11just ctl ARGS    # run ocictl in the builder against the same state
12just shell       # interactive shell in the build container
13just e2e         # build, then run scripts/e2e.sh: two daemons on this host, driven by podman/curl/ocictl
14just ci          # fmt --check, clippy -D warnings, tests, e2e
15just image       # runtime image: podman build -> localhost/ocid:dev
16just pkg         # .deb + .rpm for the host arch into ./dist (via pinned nfpm)
17just brew        # verify Homebrew tap formula syntax (brew audit)
18just brew-local  # test-install working tree through a local private tap
19just ext-install # npm install for the Podman Desktop extension
20just ext-build   # build the extension (backend dist/ + webview media/)
21just ext-check   # typecheck the extension (tsc + svelte-check)
22just ext-smoke   # run the extension client against a throwaway daemon
23just ext-image   # build the extension OCI artifact (for the catalog)
24just publish-release VER # automate release: CI checks, bump, tag, GH release, tap update (via PR)
25just hooks       # install git hooks via pre-commit (fmt/clippy on commit, tests on push)
26just clean       # remove target volume, ./bin, ./dist

End-to-end tests

scripts/e2e.sh needs podman, curl, jq on the host (oras enables the referrers check). It covers publish → replicate → run, on-demand pull, Range/DELETE/metrics, follow/seed/pin windows with pruning, aliases, GC and offline ocictl.

Security scanning

Vulnerability scanning runs in CI (.github/workflows/security.yml): Trivy scans the container image and working tree, and OpenSSF Scorecard reviews the repository. See SECURITY.md for reporting and local scan commands.

Runtime container image

1podman run -d --name ocid -p 127.0.0.1:5050:5050 -v ocid-data:/data localhost/ocid:dev
2podman exec ocid ocictl status

The Containerfile is a multi-stage build: the pinned Rust builder compiles release binaries with SOURCE_DATE_EPOCH and path remapping for bit-for-bit reproducible output, then the pinned Wolfi (wolfi-base) runtime stage copies in just the three binaries and runs as the ocid user. Wolfi is a rolling base, so re-pin RUNTIME_IMAGE regularly to pick up fresh fixes.

Native packages

just pkg builds .deb + .rpm for the host architecture via nfpm (also digest-pinned) from packaging/nfpm.yaml, including the systemd unit (packaging/systemd/ocid.service) and maintainer scripts (packaging/scripts/). Releases build both amd64 and arm64 packages in CI (see .github/workflows/slsa.yml).

Homebrew tap & releases

Releases are distributed via safonas/homebrew-tap (brew install safonas/tap/ocid).

To cut a new release end-to-end (main is branch-protected, so the bump goes through a PR you merge manually):

1just cut-release <version>     # sync main, CI, bump, push release branch, open PR, then STOP
2# ... merge the PR ...
3just publish-release <version> # sync main, tag the merge, tap update, draft release (CI publishes)

publish-release switches to an updated main by itself (refusing on a dirty tree); just sync does the same standalone when you just want the latest main. cut-release never touches local main — it cuts the release branch straight from the fetched github/main tip, so local main only ever advances by fast-forwarding to github/main.

cut-release: 1. Verifies a clean tree and that the tag and branch don't exist yet. 2. Cuts release/<version> directly from the fetched github/main tip and runs just ci (all lints and tests) on it. 3. Bumps Cargo.toml and Cargo.lock, commits, pushes the branch and opens a PR — then stops for your manual merge.

publish-release (run on main after the PR merged): 1. Checks out an updated main whose Cargo.toml is at <version>. 2. Creates and pushes tag v<version> (also mirrored to Radicle; the Radicle push warns instead of failing if the node is offline). 3. Updates Formula/ocid.rb in safonas/homebrew-tap, audits with brew audit, and pushes the tap. 4. Creates a draft GitHub release and dispatches the SLSA workflow (.github/workflows/slsa.yml), which builds binaries and .deb/.rpm packages, attaches them plus SLSA provenance to the draft, and publishes the release once all jobs succeed.

Releases on this repo are immutable once published, which is why the release stays a draft until CI has attached every asset. If the CI run fails, fix the cause and retry with just republish <version> (builds always run from the tag, so main having moved on is fine).

When CI publishes the release, .github/workflows/brew-drift.yml asserts that the tap formula tracks the latest release and stays green.

To audit the formula syntax without cutting a release:

1just brew

To test building and installing your current working tree through Homebrew locally:

1just brew-local

Git hooks

Managed by .pre-commit-config.yaml (install with just hooks):

  • Pre-commit: whitespace, YAML/TOML/JSON sanity, large file check, shellcheck, just fmt-check, just clippy.
  • Pre-push: just test.