- Rust 46.6%
- Nix 43.7%
- Just 7.5%
- Shell 2.2%
| .woodpecker | ||
| core | ||
| nix | ||
| plugins | ||
| scripts | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| flake.lock | ||
| flake.nix | ||
| justfile | ||
| LICENSE | ||
| README.md | ||
| renovate.json | ||
| rust-toolchain.toml | ||
| TODO.md | ||
| treefmt.nix | ||
woodpecker-plugins
A Rust workspace of Woodpecker CI plugins. Each plugin is an
independently versioned binary that ships as its own OCI image, built reproducibly with
Nix dockerTools.
Layout
woodpecker-plugins/
├── Cargo.toml # workspace root (Rust plugins only)
├── flake.nix # discovers plugins, exposes binary + image per plugin
├── nix/
│ └── mk-rust-plugin.nix # shared Rust build helper
├── core/ # shared library: Plugin trait, env-var settings loader, tracing
└── plugins/
├── example-plugin/ # Rust plugin (uses mkRustPlugin)
│ ├── Cargo.toml
│ ├── default.nix # build entry point
│ └── src/main.rs
└── flake-lock-checker/ # non-Rust plugin wrapping pkgs.flake-checker
└── default.nix
Every plugin owns a plugins/<name>/default.nix that returns a derivation. The flake
auto-discovers any directory under plugins/ with that file, so adding a plugin requires
no flake edits — Rust or otherwise.
Rust plugins additionally depend on woodpecker-plugins-core for the Plugin trait,
env-var settings loading, tracing initialisation, and a unified error type, so main.rs
stays tiny.
Build & test (Cargo)
cargo build # whole workspace
cargo test --workspace
cargo clippy --workspace -- -D warnings
cargo fmt --all
Or via just:
just build
just test
just lint
just fmt
Build a plugin OCI image (Nix)
The flake auto-discovers plugins/<name> directories that contain a default.nix and
exposes:
| Attribute | What it produces |
|---|---|
.#<name> |
release binary for the plugin |
.#<name>-image |
OCI image tarball tagged with the derivation's version |
.#default |
symlinkJoin of every plugin binary |
# Build the binary
nix build .#example-plugin
# Build the image
nix build .#example-plugin-image
docker load < result
docker run --rm -e PLUGIN_MESSAGE=hello woodpecker-plugin-example-plugin:0.1.0
First-build cargoHash bootstrap
buildRustPackage requires cargoHash to be pinned. The flake ships with a placeholder; the
first nix build will fail with something like:
error: hash mismatch in fixed-output derivation '/nix/store/...'
specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
got: sha256-<real-hash>=
Copy <real-hash> into flake.nix (the cargoHash attr) and rebuild. The same hash works
for every plugin because the workspace shares one Cargo.lock.
Adding a new plugin
A plugin is whichever directory you create under plugins/ with a default.nix that
returns a derivation. The flake picks it up automatically.
Rust plugin
cargo new --bin plugins/my-plugin
In plugins/my-plugin/Cargo.toml:
[package]
name = "my-plugin"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
woodpecker-plugins-core = { path = "../../core" }
serde = { workspace = true }
tokio = { workspace = true }
In plugins/my-plugin/default.nix:
{ mkRustPlugin }:
mkRustPlugin { name = "my-plugin"; }
Implement Plugin for your type and let the helper drive it. The trait uses RPITIT, so
write a bare async fn run — no #[async_trait]:
use serde::Deserialize;
use woodpecker_plugins_core::{Plugin, PluginError, run_plugin};
#[derive(Debug, Deserialize)]
struct Settings {
target: String,
}
struct MyPlugin;
impl Plugin for MyPlugin {
type Settings = Settings;
async fn run(settings: Self::Settings) -> Result<(), PluginError> {
tracing::info!(target = %settings.target, "running");
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), PluginError> {
run_plugin::<MyPlugin>().await
}
Plugin wrapping a nixpkgs package
When the work is already done by something in nixpkgs, skip the Rust crate entirely and
hand the flake a derivation directly. plugins/flake-lock-checker/default.nix is the
reference example: it wraps pkgs.flake-checker with writeShellApplication, translates
PLUGIN_* env vars to CLI flags, and pins the version literally so the release tag and
image tag agree.
{ lib, writeShellApplication, jq }:
let version = "1.7.1"; in
assert lib.assertMsg (jq.version == version)
"plugin pin (${version}) != nixpkgs (${jq.version}); bump or override src";
(writeShellApplication {
name = "jq-plugin";
runtimeInputs = [ jq ];
text = ''jq "$PLUGIN_FILTER" "$PLUGIN_INPUT"'';
}).overrideAttrs (old: {
inherit version;
meta = (old.meta or {}) // { mainProgram = "jq-plugin"; };
})
The image tag and release-tag check both read drv.version, so any non-Rust plugin must
expose a version attribute on its returned derivation.
Configuration
Plugins receive their configuration from environment variables prefixed with PLUGIN_, the
Woodpecker plugin convention. Field
names are matched case-insensitively against the prefixed env vars:
| Settings field | Env var |
|---|---|
message: String |
PLUGIN_MESSAGE |
uppercase: bool |
PLUGIN_UPPERCASE |
Use #[serde(default)] on Settings fields that are optional.
Releasing
Tag the commit with <plugin-name>-v<version>, e.g. example-plugin-v0.1.0. The
.woodpecker/release.yaml pipeline parses the tag, builds .#<plugin-name>-image, and
pushes the image to the configured registry. Before pushing it asserts
nix eval --raw .#<plugin-name>.version equals <version> from the tag, so a tag that
disagrees with the derivation fails fast. For Rust plugins the version comes from
plugins/<name>/Cargo.toml; for nixpkgs-wrapped plugins it comes from the literal
version in their default.nix. Bump the relevant source before tagging.
License
MIT — see LICENSE.