Skip to content

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:

git tag v0.1.0
git push --tags

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.

GOOS=js GOARCH=wasm capy build greet -o greet.wasm

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"]
docker build -t greet:0.1.0 .
docker run --rm -v "$PWD:/work" -w /work greet:0.1.0 run hello.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:

brew tap you/greet
brew install greet

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"]
}
cd pkg && npm publish --access public

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:

GOFLAGS='-ldflags=-X main.version=v1.4.2' capy build greet -o greet
./greet --version
# greet 1.4.2

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:

  1. -trimpath removes the build-machine's absolute file paths.
  2. -ldflags=-s -w removes the symbol table and DWARF debug info, both of which can otherwise differ between builds.
  3. Fix SOURCE_DATE_EPOCH for 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:

minisign -Sm greet-v1.4.2 -s ~/.minisign/greet.key

Publish the .minisig file alongside the binary on the release page. Users verify with:

minisign -Vm greet-v1.4.2 -P "$(cat greet.pub)"

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:

capy greet --help
capy greet run hello.greet

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:

#!/usr/bin/env -S capy --lib greet
greet "world"
chmod +x hello.greet
./hello.greet
# → Hello from greet, world!

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

#!/usr/bin/env capy --lib greet
greet "world"

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)

#!/usr/local/bin/capy --lib greet
greet "world"

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:

#!/usr/local/bin/greet run
greet "world"

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.capy form 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