Skip to content
Crow CI

Creating Plugins

Build your own plugins to extend Crow CI with custom functionality.

A Crow CI plugin is a container image that:

  1. Receives configuration via PLUGIN_* environment variables
  2. Executes a fixed set of commands via ENTRYPOINT
  3. Cannot have its CMD overridden by users

This architecture ensures plugins execute predictably, making them safe for handling secrets.

Here’s a minimal plugin structure:

FROM alpine:latest

# Install dependencies
RUN apk add --no-cache bash curl

# Copy plugin script
COPY plugin.sh /usr/local/bin/plugin.sh
RUN chmod +x /usr/local/bin/plugin.sh

# Fixed entrypoint - cannot be overridden
ENTRYPOINT ["/usr/local/bin/plugin.sh"]
#!/bin/bash
# plugin.sh

# Read settings from environment
echo "Server: ${PLUGIN_SERVER}"
echo "Message: ${PLUGIN_MESSAGE}"

# Plugin logic here
curl -X POST "${PLUGIN_SERVER}/api/notify" \
  -H "Authorization: Bearer ${PLUGIN_TOKEN}" \
  -d "message=${PLUGIN_MESSAGE}"

When a user configures a plugin:

steps:
  - name: notify
    image: my-plugin
    settings:
      server: https://api.example.com
      message: Hello World
      retry_count: 3

Crow transforms settings into environment variables:

SettingEnvironment Variable
serverPLUGIN_SERVER
messagePLUGIN_MESSAGE
retry_countPLUGIN_RETRY_COUNT
  • Settings are uppercased: myValuePLUGIN_MYVALUE
  • Dashes become underscores: my-settingPLUGIN_MY_SETTING
  • Nested objects become JSON strings

How a setting is encoded depends on its type:

Setting typeEncoding
Object (map)JSON
Array containing objects or nested arraysJSON
Array of plain values (strings, numbers, booleans)Comma-separated
settings:
  targets:
    - production
    - staging
  config:
    retries: 3
    timeout: 30
  registries:
    - registry: https://codeberg.org
      username: crow

Results in:

  • PLUGIN_TARGETS='production,staging'
  • PLUGIN_CONFIG='{"retries":3,"timeout":30}'
  • PLUGIN_REGISTRIES='[{"registry":"https://codeberg.org","username":"crow"}]'

An array of plain values is never JSON-encoded, regardless of how many elements it has. A single-element array such as targets: [production] becomes PLUGIN_TARGETS='production', and an empty array becomes an empty string. Numbers and booleans keep their type inside JSON output, so retries: 3 becomes 3 and not "3".

Parse these in your plugin:

#!/bin/bash
# Split a comma-separated list of plain values
IFS=',' read -ra TARGETS <<< "$PLUGIN_TARGETS"

# Read a field from a JSON object
RETRIES=$(echo "$PLUGIN_CONFIG" | jq -r '.retries')

# Iterate over a JSON array of objects
echo "$PLUGIN_REGISTRIES" | jq -r '.[].registry'

The comma-separated encoding exists because most plugins are written in Go using urfave/cli, whose slice flags split environment variables on commas by default. PLUGIN_TARGETS=production,staging therefore arrives as a ready-made []string without any parsing code in the plugin.

That splitting has no escape mechanism, so a value that itself contains a comma cannot be represented in an array of plain values. Nest the array inside an object to have it encoded as JSON instead:

settings:
  files:
    paths:
      - 'file,with,commas.txt'
      - CHANGELOG.md

Results in PLUGIN_FILES='{"paths":["file,with,commas.txt","CHANGELOG.md"]}'.

A Go plugin reading this must turn off the default splitting, which would otherwise split the JSON on its own commas:

cmd := &cli.Command{
    DisableSliceFlagSeparator: true,
    // ...
}

The setting then arrives as a single string, ready to decode with encoding/json.

Crow prohibits using environment: with plugins for security. All inputs must come through settings:.

# ❌ Not allowed
steps:
  - name: deploy
    image: my-plugin
    environment:
      SECRET_KEY: value

# ✅ Required approach
steps:
  - name: deploy
    image: my-plugin
    settings:
      secret_key: value

Your plugin receives secrets as regular environment variables. Best practices:

  1. Never log secrets - Avoid echoing sensitive values
  2. Document required secrets - Clearly list what credentials are needed
  3. Minimize secret scope - Only request what’s necessary
#!/bin/bash

# Bad - logs the token
echo "Using token: ${PLUGIN_TOKEN}"

# Good - confirms token exists without exposing it
if [[ -z "${PLUGIN_TOKEN}" ]]; then
  echo "Error: TOKEN setting is required"
  exit 1
fi
echo "Token configured ✓"

Always validate and sanitize inputs:

#!/bin/bash
set -euo pipefail

# Required settings
: "${PLUGIN_SERVER:?SERVER setting is required}"
: "${PLUGIN_TOKEN:?TOKEN setting is required}"

# Optional with defaults
PLUGIN_TIMEOUT="${PLUGIN_TIMEOUT:-30}"
PLUGIN_RETRIES="${PLUGIN_RETRIES:-3}"

# Validate format
if [[ ! "${PLUGIN_SERVER}" =~ ^https?:// ]]; then
  echo "Error: SERVER must be a valid URL"
  exit 1
fi

Go is ideal for plugins due to single-binary output:

package main

import (
    "fmt"
    "os"
)

func main() {
    server := os.Getenv("PLUGIN_SERVER")
    token := os.Getenv("PLUGIN_TOKEN")

    if server == "" || token == "" {
        fmt.Println("Error: SERVER and TOKEN are required")
        os.Exit(1)
    }

    // Plugin logic here
    fmt.Printf("Deploying to %s\n", server)
}
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o /plugin

FROM alpine:latest
COPY --from=builder /plugin /usr/local/bin/plugin
ENTRYPOINT ["/usr/local/bin/plugin"]
#!/usr/bin/env python3
import os
import sys
import json

def main():
    server = os.environ.get('PLUGIN_SERVER')
    token = os.environ.get('PLUGIN_TOKEN')

    if not server or not token:
        print("Error: SERVER and TOKEN are required")
        sys.exit(1)

    # Handle complex settings (JSON)
    targets_json = os.environ.get('PLUGIN_TARGETS', '[]')
    targets = json.loads(targets_json)

    # Plugin logic here
    print(f"Deploying to {server}")
    for target in targets:
        print(f"  - {target}")

if __name__ == '__main__':
    main()
#!/usr/bin/env node
const server = process.env.PLUGIN_SERVER;
const token = process.env.PLUGIN_TOKEN;

if (!server || !token) {
  console.error('Error: SERVER and TOKEN are required');
  process.exit(1);
}

// Handle complex settings
const targets = JSON.parse(process.env.PLUGIN_TARGETS || '[]');

// Plugin logic here
console.log(`Deploying to ${server}`);
targets.forEach(target => console.log(`  - ${target}`));

Test your plugin locally by setting environment variables:

docker build -t my-plugin .

docker run --rm \
  -e PLUGIN_SERVER=https://api.example.com \
  -e PLUGIN_TOKEN=test-token \
  -e PLUGIN_MESSAGE="Hello World" \
  my-plugin

Create a test pipeline:

steps:
  - name: test-plugin
    image: my-plugin:dev
    settings:
      server: https://api.example.com
      token:
        from_secret: test_token
      message: "Test message"

Push your plugin to a container registry:

docker build -t codeberg.org/myuser/my-plugin:1.0.0 .
docker push codeberg.org/myuser/my-plugin:1.0.0

Create a README.md with:

  1. Description - What the plugin does
  2. Settings - All available settings with types and defaults
  3. Secrets - Required credentials
  4. Examples - Working pipeline configurations
  5. Changelog - Version history

Use semantic versioning for plugin releases:

  • 1.0.0 - Initial stable release
  • 1.1.0 - New features, backwards compatible
  • 2.0.0 - Breaking changes

Tag your images with both specific versions and major version aliases:

docker tag my-plugin:1.2.3 my-plugin:1
docker tag my-plugin:1.2.3 my-plugin:1.2
docker push my-plugin:1.2.3
docker push my-plugin:1.2
docker push my-plugin:1

To add your plugin to the available plugins page, open a pull request to the Crow CI repository.