Build action for fizzy plugins.
A reusable GitHub Actions workflow that builds a Fizzy
plugin for every supported target, hashes each binary, assembles the author manifest.json, and
publishes both as GitHub Release assets.
A Fizzy plugin is a native dylib valid for exactly one (abi_fingerprint, os-arch) pair, so a
release ships one binary per target plus a manifest the in-app store reads — indirectly, via the
fizzyedit/plugins aggregator, which folds every
author's manifest into a static catalog served at https://plugins.fizzyed.it/catalog/. This
action automates the build matrix so you don't hand-build, hand-hash, and hand-write the
manifest each release.
tag v0.1.0 ──► build.yml ──► per-target dylib + sha256
│
▼ assemble_manifest.py (accumulates prior releases)
release assets: pixi-macos-aarch64.dylib, …, manifest.json
│
▼ registry/<id>.json points manifest_url at releases/latest
fizzyedit/plugins aggregator ──► catalog/ (summary.json + per-fingerprint
releases.json) ──► Fizzy store
For a tag like v0.1.0, the release gets:
<id>-macos-aarch64.dylib <id>-macos-x86_64.dylib
<id>-linux-x86_64.so <id>-linux-aarch64.so
<id>-windows-x86_64.dll <id>-windows-aarch64.dll
(the web module is **not** a release asset — see below)
manifest.json ← references the binaries above (url + sha256), accumulating older releases
All seven binaries are release assets, <id>-web-wasm32.wasm included. There is one publishing
story and it is the same on every target — host wherever you like, nothing to enable.
A browser cannot fetch a release asset directly (GitHub sends no access-control-allow-origin
on them), but that is a client problem and fizzy solves it client-side: the web app tries the
URL directly, and falls back to a CORS proxy on its own origin when the host does not allow it.
A plugin published on a host that does send CORS is fetched directly and never touches the
proxy. Either way the author publishes one set of assets and this workflow uploads them.
-
Pin the Fizzy SDK by URL in your
build.zig.zon— not a localpath. CI has no sibling checkout, so a.path = "../../fizzy/sdk"dependency fails there. Pin the SDK release asset for ansdk-v*tag (not the git archive of the monorepo — that pulls Velopack):.fizzy = .{ .url = "https://github.com/fizzyedit/fizzy/releases/download/sdk-v0.1.42/fizzy-sdk-v0.1.42.tar.gz", .hash = "<zig-package-hash>", // zig fetch --save=fizzy <url> },
That pin is the source of truth for
fizzy_sdk_versionand the ReleaseFastabi_fingerprint— the action reads both from the built dylib. You never copy them into workflow YAML. See fizzy'sdocs/PLUGINS.md§2.3 / §5. -
Add identity-only
plugin.zig.zonat the repo root (id/name/version/min_sdk_version). The pushed tag (vX.Y.Z) must equal.version. -
Make
zig buildinstall the plugin dylib tozig-out/<id>.<ext>— viafizzy.plugin.create+.install(generated dylib root; no authorroot.zig). If your build installs elsewhere, setartifact-path. -
Add the release workflow. Copy
examples/release.ymlto your plugin repo as.github/workflows/release.yml:name: Release on: push: tags: ["v*"] jobs: build: uses: fizzyedit/plugin-build-action/.github/workflows/build.yml@v5 permissions: contents: write with: zig-version: "0.16.0"
-
Register once in
fizzyedit/plugins— open a PR addingregistry/<id>.jsonwithmanifest_urlpointing at your latest release manifest:{ "id": "pixi", "name": "Pixi", "description": "Pixel-art editor for Fizzy.", "author": "foxnne", "homepage": "https://github.com/fizzyedit/pixi", "tags": ["editor", "pixel-art"], "manifest_url": "https://github.com/fizzyedit/pixi/releases/latest/download/manifest.json" }
git tag v0.1.0 && git push origin v0.1.0The workflow builds all targets, publishes the release with the binaries + manifest.json, and
the next fizzyedit/plugins aggregation (on merge, its 6-hourly cron, or a manual run) pulls your
plugin into the catalog. Subsequent releases need no registry PR — just tag again.
Bump the fizzy pin in build.zig.zon when you want a new SDK; the next tag's manifest picks up
the new fizzy_sdk_version / abi_fingerprint automatically. The manifest accumulates
releases (keyed by version + abi_fingerprint), so users on older SDKs keep matching an older
binary instead of seeing "needs a rebuild."
manifest.jsonnow carries top-levelname/description/tags, read straight off the caller'splugin.zig.zon(scripts/read_plugin_zon.py) and passed throughassemble_manifest.py's new--name/--description/--tags-jsonargs. This lets thefizzyedit/pluginsaggregator fall back to these when aregistry/<id>.jsonentry leaves its owndescription/tagsblank, instead of requiring authors to hand-duplicate both.- A tag bump (not just an additive change) because callers pin
uses: .../build.yml@v3, and this file's own "Resolve plugin-build-action ref" step hardcodes the matchingref="v3"literal for its auxiliary script checkout — neither picks up new script behavior without the ref moving.
- Build a seventh target,
web-wasm32: the wasm side module the fizzy web app fetches and links at runtime. Best-effort (continue-on-error), so it cannot fail a release for a plugin that does not build for the browser. - Carry
author/author_urlfromplugin.zig.zonintomanifest.json. - As in v4, the "Resolve plugin-build-action ref" step's hardcoded literal moves with the tag
(now
ref="v5"), so a later script change under v5 is actually picked up. - The release job's consensus check still requires every target that did build to agree on
abi_fingerprint/fizzy_sdk_version— the fingerprint is structural, so a wasm build reports the same value a native one does and adding the target does not disturb it.
- Derive
fizzy_sdk_version+abi_fingerprintfromzig-out/sdk-meta.json(emitted byfizzy.plugin.install) — removed as workflow inputs. - Cross-compile all 6 targets from
ubuntu-latest(plugins are unsigned; no macOS/Windows runners or code signing). - Read
id/version/min_sdk_versionfromplugin.zig.zon; release version defaults to the triggeringv*tag (must match the zon). - Caller
release.ymlonly needszig-version(optional overrides forid/version/artifact-path/targetsremain).
- Pre-fetch Zig package dependencies with retries before building, working around Zig 0.16
HttpConnectionClosingflakes when fetching GitHub deps (especially on Windows CI). - Use a workspace-local
ZIG_GLOBAL_CACHE_DIRand pre-create cachetmp/(fixes cold-cache zip-fetchFileNotFoundon CI).
Initial release.
| Input | Required | Default | Description |
|---|---|---|---|
zig-version |
no | 0.16.0 |
Zig toolchain version. |
id |
no | plugin.zig.zon .id |
Override plugin id (must match the zon if set). |
version |
no | tag without v |
Override release version (must match plugin.zig.zon .version). |
artifact-path |
no | zig-out/<id> |
Built dylib path (relative to repo root) without extension. |
targets |
no | all 7 | Comma-separated os_arch subset to build. Pass the six desktop keys to skip the web build entirely. |
| Secret | Required | Description |
|---|---|---|
build_env |
no | Environment for every zig build in the workflow, as KEY=VALUE lines. For a plugin whose build bakes in an app credential it cannot commit. |
jobs:
build:
uses: fizzyedit/plugin-build-action/.github/workflows/build.yml@v5
permissions:
contents: write
with:
zig-version: "0.16.0"
secrets:
build_env: ${{ secrets.PLUGIN_BUILD_ENV }}One blob rather than a named list, so a single plugin's variables never have to be declared in
this shared workflow. It is exported before the first zig build — including the pre-fetch —
because a build script that generates a file from these variables usually skips the work when
the file is already there, and an empty one written during the fetch would otherwise be the one
that ships.
- Setup reads
plugin.zig.zon, checks the tag/versioninput against.version, and builds the target matrix. - One build per target across the 6 desktop host arches, all cross-compiled with
-Dtarget=fromubuntu-latest. Plugins are unsigned pure Zig + vendored C (optional prebuilt.a/.libdeps are selected per target), so there is no macOS/Windows runner or signing step. - Plus
web-wasm32, best-effort. That job iscontinue-on-error, so a plugin that cannot build for the browser — C deps, threads, anything the freestanding target has no answer for — loses only its web download, never the release. Its job shows red in the run while the run itself stays green; pass an explicittargetslist to stop building it at all. The fizzy web app lists only plugins whose release has a binary for the host it is running on, so a desktop-only plugin is simply absent from the store in the browser, not broken in it. - Optimize mode: desktop targets build
ReleaseFast;web-wasm32buildsReleaseSmall, since the browser fetches and compiles the whole module before the plugin loads. Both share fizzy's "fast" safety class, so theabi_fingerprintis the same for every target. - Every target collects
zig-out/sdk-meta.json(written by the fizzy pin at build time). Publish requires all targets to agree on sdk/fingerprint, thenscripts/assemble_manifest.pymerges the new release into the priormanifest.json.
| Path | Role |
|---|---|
.github/workflows/build.yml |
The reusable (workflow_call) build + publish workflow. |
scripts/assemble_manifest.py |
Merges per-target sha256 fragments into manifest.json. |
scripts/read_plugin_zon.py |
Parses identity from plugin.zig.zon. |
scripts/read_plugin_exports.py |
Optional local helper: dlopen a native plugin and print exports. |
examples/release.yml |
Drop-in caller for a plugin repo. |