Compile cookbook¶
Recipes for packaging and distributing a Capy library — as a native
CLI, a WASM module, a Docker image, an npm package, or a GitHub
release. Each recipe is a complete scenario with the commands to
run and the artefacts you end up with.
For the conceptual walkthrough first, see Compiling a Capy library.
The running example throughout is a tiny greet library — same one
the walkthrough uses — so every command in this page is verifiable
against a real on-disk artifact.
Recipe 1 — Ship a CLI to your team (multi-target tarball)¶
Scenario: you have a library; you want to give your team a single tarball with every binary they might need.
mkdir -p dist
for t in \
"linux amd64" "linux arm64" \
"darwin amd64" "darwin arm64" \
"windows amd64"
do
set -- $t # split into $1=os $2=arch
out="greet-$1-$2"
[ "$1" = "windows" ] && out="$out.exe"
GOOS=$1 GOARCH=$2 GOFLAGS='-trimpath -ldflags=-s -w' \
capy build greet -o "dist/$out"
done
(cd dist && shasum -a 256 greet-* > SHA256SUMS)
tar czf greet-v0.1.0.tgz dist/
Output (real measurements, stripped):
| File | Size |
|---|---|
greet-linux-amd64 |
3.8 MB |
greet-linux-arm64 |
3.7 MB |
greet-darwin-arm64 |
3.7 MB |
greet-windows-amd64.exe |
4.0 MB |
SHA256SUMS |
421 B |
Anyone on your team tar xzfs the tarball and runs the right binary
for their platform.
Recipe 2 — GitHub release workflow¶
Scenario: push a tag → CI builds every target → uploads them to the GitHub release page.
.github/workflows/release.yml:
name: release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { goos: linux, goarch: amd64, ext: '' }
- { goos: linux, goarch: arm64, ext: '' }
- { goos: darwin, goarch: amd64, ext: '' }
- { goos: darwin, goarch: arm64, ext: '' }
- { goos: windows, goarch: amd64, ext: '.exe' }
- { goos: js, goarch: wasm, ext: '.wasm' }
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with: { go-version: '1.22', cache: true }
- name: Install capy
run: go install github.com/olivierdevelops/capy/cmd/capy@latest
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOFLAGS: '-trimpath -ldflags=-s -w'
run: |
OUT="greet-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }}"
capy build greet -o "$OUT"
ls -lh "$OUT"
- uses: actions/upload-artifact@v4
with:
name: greet-${{ matrix.goos }}-${{ matrix.goarch }}
path: greet-*
release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { path: dist, merge-multiple: true }
- name: Checksum
run: |
cd dist
sha256sum greet-* > SHA256SUMS
- uses: softprops/action-gh-release@v2
with:
files: dist/*
Tag and push:
The release page ends up with six binaries plus SHA256SUMS.
Recipe 3 — Browser-embedded library as a JS module¶
Scenario: you want a small webpage where users paste source code and see your library render it, with no server.
There are two viable strategies:
A. Use the engine's wasm build + load library dynamically¶
Best when you want to swap libraries at runtime (e.g. a playground).
# 1. Build the engine for wasm.
GOOS=js GOARCH=wasm go build -o engine.wasm \
github.com/olivierdevelops/capy/cmd/capy-wasm
# 2. Copy Go's wasm loader.
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .
<!doctype html>
<html>
<body>
<textarea id="src">greet "world"</textarea>
<pre id="out"></pre>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
const LIBRARY = `
name "greet"
extension "txt"
function greet
arg literal "greet"
arg capture who string
write \`Hello, \${unquote who}!
\`
end
`;
WebAssembly.instantiateStreaming(fetch("engine.wasm"), go.importObject)
.then(r => {
go.run(r.instance);
// After go.run, globalThis.capyRun is defined:
document.getElementById("src").addEventListener("input", e => {
const res = capyRun(LIBRARY, e.target.value);
document.getElementById("out").textContent =
res.ok ? res.output : res.error;
});
});
</script>
</body>
</html>
The library lives in the JS source — you can edit it without rebuilding the wasm. Good for tutorials, playgrounds, and live demos.
B. Use capy build … --wasm with the library baked in¶
Best when the library is fixed and you want a smaller surface area.
greet.wasm is ~7 MB (unstripped) and contains the library hard-
coded. Use the same wasm_exec.js shim, but pass argv so the
embedded binary dispatches to the right command:
<script>
const go = new Go();
go.argv = ["greet", "run", "/dev/stdin"];
// Wire stdin from a textarea and capture stdout. See cmd/capy-wasm
// for a polished version of this pattern.
</script>
The library is now an immutable artefact you can pin and version. If you want to update the library, rebuild the wasm.
Recipe 4 — Static-site generator¶
Scenario: Markdown is too restrictive, you want your own
authoring DSL (*.recipe, *.recipedb, whatever). Run it at build
time to emit a tree of HTML files.
# In your site repo:
recipes/
├── pasta-carbonara.recipe
├── ramen.recipe
└── ...
# Library that renders each .recipe into HTML.
recipe.capy
Build script (build.sh):
#!/usr/bin/env bash
set -euo pipefail
mkdir -p _site
for f in recipes/*.recipe; do
out="_site/$(basename "${f%.recipe}.html")"
capy run recipe.capy "$f" > "$out"
echo " wrote $out"
done
echo "✓ rendered $(ls _site/*.html | wc -l) pages"
Wire it into Netlify / Vercel / GitHub Pages as the build command. The deployed site never runs Capy — the artifacts are plain HTML.
For something fancier, the library can declare a build command
that does the loop internally:
command "build"
description "Render every .recipe in this dir into _site/"
let recipes = (exec_capture "ls" "recipes/")
for f in (split recipes "\n")
if f
let out = (compile (concat "recipes/" f))
write_file (concat "_site/" (replace f ".recipe" ".html")) out
end
end
print "done"
end
Now capy recipe build is your whole publishing pipeline.
Recipe 5 — Docker container shipping the binary¶
Scenario: put the CLI in a slim image for use in CI pipelines or container deployments.
Dockerfile:
# ─── build stage ────────────────────────────────────────────
FROM golang:1.22-alpine AS build
RUN apk add --no-cache git
RUN go install github.com/olivierdevelops/capy/cmd/capy@latest
WORKDIR /src
COPY greet.capy .
RUN GOFLAGS='-trimpath -ldflags=-s -w' capy build greet -o /out/greet
# ─── runtime stage ──────────────────────────────────────────
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/greet /usr/local/bin/greet
ENTRYPOINT ["/usr/local/bin/greet"]
Image size: ~6 MB (distroless static + a 3.8 MB stripped binary). The image has no shell, no package manager, no Go — just the embedded library + the dispatching runtime.
Recipe 6 — Homebrew formula¶
Scenario: make your library brew install-able on a Homebrew tap.
After Recipe 2 ships releases:
homebrew-greet/Formula/greet.rb:
class Greet < Formula
desc "A tiny greet DSL"
homepage "https://github.com/you/greet"
version "0.1.0"
on_macos do
on_arm do
url "https://github.com/you/greet/releases/download/v0.1.0/greet-darwin-arm64"
sha256 "<paste from SHA256SUMS>"
end
on_intel do
url "https://github.com/you/greet/releases/download/v0.1.0/greet-darwin-amd64"
sha256 "<paste from SHA256SUMS>"
end
end
on_linux do
on_arm do
url "https://github.com/you/greet/releases/download/v0.1.0/greet-linux-arm64"
sha256 "<paste from SHA256SUMS>"
end
on_intel do
url "https://github.com/you/greet/releases/download/v0.1.0/greet-linux-amd64"
sha256 "<paste from SHA256SUMS>"
end
end
def install
bin.install Dir["greet-*"].first => "greet"
end
test do
assert_match "Hello", shell_output("#{bin}/greet --help")
end
end
After the release publishes:
Recipe 7 — npm package wrapping the WASM¶
Scenario: distribute the library as a JavaScript dependency that
npm install greet-ers can use without seeing Capy at all.
# 1. Build the wasm.
GOOS=js GOARCH=wasm capy build greet -o pkg/greet.wasm
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" pkg/
pkg/index.js:
import "./wasm_exec.js";
import fs from "node:fs/promises";
let initialized = null;
async function init() {
if (initialized) return initialized;
const go = new Go();
const wasm = await fs.readFile(new URL("./greet.wasm", import.meta.url));
const inst = await WebAssembly.instantiate(wasm, go.importObject);
// Wire stdin/stdout for the embedded binary (see cmd/capy-wasm for
// an alternative that exposes a function-style API instead).
initialized = { go, inst };
return initialized;
}
export async function greet(scriptSrc) {
await init();
// ... feed scriptSrc to stdin, capture stdout ...
}
pkg/package.json:
{
"name": "@yourname/greet",
"version": "0.1.0",
"main": "index.js",
"type": "module",
"files": ["index.js", "greet.wasm", "wasm_exec.js"]
}
For a smoother JS API, use the engine capy-wasm approach (Recipe
3A) and publish a thin JS wrapper that calls capyRun(library,
script) — no stdin plumbing required.
Recipe 8 — CI testing the library across platforms¶
Scenario: make sure your *.recipe parsing works on every OS
your users might be on.
name: test
on: [push, pull_request]
jobs:
parse:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with: { go-version: '1.22', cache: true }
- run: go install github.com/olivierdevelops/capy/cmd/capy@latest
- run: capy check greet.capy
- name: Parse every example script
shell: bash
run: |
for f in examples/*.greet; do
capy run greet.capy "$f" > /dev/null
echo " ok $f"
done
capy check validates the library standalone (catches grammar
errors, missing closers, unknown types). The loop ensures every
example script in the repo still produces an output across all three
OSes.
Recipe 9 — Pin both Capy AND the library into the binary¶
Scenario: you ship a greet v1.4.2 binary and want both
greet --version AND the library's reported version to be visible.
The build's main.version is what --version prints:
The library's own manifest version (version "0.1.0" at the top of
greet.capy) is what greet --help shows in the per-command help
header. They can differ — greet the CLI vs greet the language
spec. For releases you usually want them in sync; bump them together
with a release script:
#!/usr/bin/env bash
set -euo pipefail
NEW="$1"
sed -i.bak "s/^version[[:space:]]*\".*\"/version \"$NEW\"/" greet.capy
git add greet.capy && git commit -m "release: $NEW"
git tag "v$NEW" && git push --follow-tags
CI (Recipe 2) takes it from there.
Recipe 10 — Reproducible signed releases¶
Scenario: prove the binary on the release page matches the source. Anyone can rebuild the exact same bytes.
Three ingredients:
-trimpathremoves the build-machine's absolute file paths.-ldflags=-s -wremoves the symbol table and DWARF debug info, both of which can otherwise differ between builds.- Fix
SOURCE_DATE_EPOCHfor any timestamps the Go linker embeds.
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)
GOFLAGS='-trimpath -ldflags=-s -w' \
capy build greet -o greet-v1.4.2
# Verify bit-for-bit reproduction:
shasum -a 256 greet-v1.4.2
Run the same incantation on a different machine with the same Capy + Go versions and the same source tree — the SHA-256 will match.
Sign the artefacts with minisign or cosign:
Publish the .minisig file alongside the binary on the release
page. Users verify with:
Recipe 11 — A no-build distribution path¶
Scenario: your audience already has capy installed (e.g.
internal teammates, or you've documented the prereq). Skip building
binaries entirely.
# Just ship the .capy file.
mkdir -p ~/.capy/libs
cp greet.capy ~/.capy/libs/greet.capy
# Or via `capy lib install` if you've published to a tap-like URL.
capy lib new greet ~/.capy/libs/ # scaffolds a template
Users put the file on their CAPY_LIBS, and from any directory:
Tiny artefact (a few KB), no compilation step. The cost is that
every user needs capy installed — which is itself a single
go install or release-binary download.
Recipe 12 — Shebang scripts: .greet files that run themselves¶
Scenario: make your DSL source files directly executable so
users chmod +x script.greet && ./script.greet instead of having
to remember the capy invocation.
Capy strips a leading #! line before lexing, so any of these
shebangs work. Three forms, three trade-offs:
Form A: env -S (most portable, recommended)¶
Works on macOS (any modern version) and Linux (GNU coreutils 8.30+
ships env -S, which is every distro from 2018 onward — Ubuntu
20.04+, Debian 11+, RHEL/Rocky 9+, Alpine 3.16+). The -S ("split
string") flag lets env see capy --lib greet as two arguments.
Form B: env without -S¶
Works on macOS (it splits on whitespace by default) and on Linux
distros with GNU env 8.30+. Older Linux (CentOS 7, Ubuntu 18.04)
would treat "capy --lib greet" as a single binary name and fail.
Prefer Form A unless you control the deployment.
Form C: Absolute path (no env)¶
Most portable — no env quirks — but hard-codes the path to the
capy binary. Use for internal tooling where everyone has the
same install location.
Form D: Standalone binary (no capy required)¶
After Recipe 1 / 2
ships a built greet binary, the shebang invokes the binary
directly:
The user doesn't need capy installed; the library is embedded in
the greet binary. The trust warning that fires for "library not
on CAPY_LIBS" is automatically suppressed in built standalone
binaries — they set CAPY_TRUST=1 internally because the embedded
library is the binary's identity.
PATH and library resolution¶
For Forms A–C the library must be discoverable. Either:
- Drop it on
CAPY_LIBS(~/.capy/libs/greet.capy), or - Run the script from a directory that contains
greet.capy(CWD is part of the default search path), or - Use the
--lib /absolute/path/to/lib.capyform to pin a specific file.
For Form D the binary IS the library — no resolution needed.
Putting it together¶
# Install once, system-wide.
sudo cp capy /usr/local/bin/capy
sudo mkdir -p /usr/local/share/capy/libs
sudo cp greet.capy /usr/local/share/capy/libs/
export CAPY_LIBS=/usr/local/share/capy/libs
# Now any user can write:
cat > /tmp/hello.greet <<'EOF'
#!/usr/bin/env -S capy --lib greet
greet "world"
EOF
chmod +x /tmp/hello.greet
/tmp/hello.greet
# → Hello from greet, world!
The file is now indistinguishable from a Python / Ruby / Lua script as far as the OS is concerned — execute permission flag + shebang, the kernel does the rest.
When to pick which recipe¶
| You want… | Recipe |
|---|---|
One binary your team can curl and run |
1 (multi-target tarball) |
| Automated GitHub releases on every tag | 2 (release workflow) |
| A live playground for your DSL | 3A (engine wasm + dynamic library) |
| An embeddable DSL renderer in a Node/browser package | 3B + 7 (wasm + npm) |
| A docs site whose authoring format is your own | 4 (static-site generator) |
| A Docker image for CI pipelines | 5 (distroless container) |
brew install greet |
2 + 6 (releases + Homebrew tap) |
| Cross-OS CI for your library | 8 (test matrix) |
| Reproducible, signed builds | 10 (-trimpath + minisign) |
| Lowest-friction internal sharing | 11 (just ship the .capy file) |
Source files that run themselves (./script.greet) |
12 (shebang scripts) |
See also¶
- Compiling a Capy library — the fundamentals + walkthrough.
- Library commands +
CAPY_LIBS— how command bodies actually work. - Embedding Capy in Go — when you want Capy linked into a larger Go program instead of producing a standalone binary.
- Capy for AI agents — the agent-side story, where the binary becomes a tool an LLM can call.