Skip to content
Crow CI
Codeberg

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"]
plugin.sh
#!/bin/bash
# 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

Objects and arrays are JSON-serialized:

settings:
targets:
- production
- staging
config:
retries: 3
timeout: 30

Results in:

  • PLUGIN_TARGETS='["production","staging"]'
  • PLUGIN_CONFIG='{"retries":"3","timeout":"30"}'

Parse these in your plugin:

#!/bin/bash
# Parse JSON array
TARGETS=$(echo "$PLUGIN_TARGETS" | jq -r '.[]')
# Parse JSON object
RETRIES=$(echo "$PLUGIN_CONFIG" | jq -r '.retries')

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:

Terminal window
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:

Terminal window
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:

Terminal window
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.