Hermes Agent skills evolve fast — you create them, patch them in live sessions, and ship fixes continuously. Without a pipeline, that velocity becomes risk: a bad skill update breaks your production agent, and you won't know until it's too late. This tutorial builds a CI/CD pipeline that validates every change before it reaches production — Git for version control, Docker for isolated testing, sun-port for secure API exposure, and PostgreSQL for deployment audit.
Agent skills and configurations are the runtime DNA of your Hermes system. A single typo in a skill file — a wrong shell command, a missing dependency, a path that works on your machine but not production — can silently break cron jobs, webhook handlers, or interactive sessions.
Traditional CI/CD pipelines focus on application code. But agent configurations have unique failure modes:
curl, jq, docker, or git are installed when they aren'tA CI/CD pipeline catches these before they hit production. Here's the architecture we'll build:
┌─────────┐ ┌──────────┐ ┌───────────┐ ┌─────────────┐ ┌──────────┐
│ git push │───▶│ pre-commit│───▶│ Docker │───▶│ sun-port │───▶│ Hermes │
│ (skills) │ │ validate │ │ test │ │ (proxy) │ │ prod │
└─────────┘ └──────────┘ └───────────┘ └─────────────┘ └──────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────┐
│ PostgreSQL deployment audit log │
└──────────────────────────────────────────────────────────┘
The first gate: validate every skill before it even enters the commit. A Git pre-commit hook runs on your local machine and rejects broken changes immediately.
Create .git/hooks/pre-commit in your skills repository:
#!/bin/bash
# pre-commit — validate Hermes skills before commit
set -euo pipefail
SKILLS_DIR="skills"
HAS_ERROR=0
echo "🔍 Validating Hermes skills..."
# 1. Check YAML frontmatter for every SKILL.md
for skill_file in $(find "$SKILLS_DIR" -name "SKILL.md"); do
# Verify frontmatter has required fields
if ! head -20 "$skill_file" | grep -q "^name:"; then
echo "❌ $skill_file: missing 'name' in frontmatter"
HAS_ERROR=1
fi
if ! head -20 "$skill_file" | grep -q "^description:"; then
echo "❌ $skill_file: missing 'description' in frontmatter"
HAS_ERROR=1
fi
# Check for hardcoded secrets
if grep -Eq '(sk-[a-zA-Z0-9]{20,}|AKIA[A-Z0-9]{16}|ghp_[a-zA-Z0-9]{36})' "$skill_file"; then
echo "❌ $skill_file: hardcoded API key or token detected"
HAS_ERROR=1
fi
echo " ✓ $skill_file"
done
# 2. Verify no duplicate skill names
DUPS=$(find "$SKILLS_DIR" -name "SKILL.md" -exec head -20 {} \; \
| grep "^name:" | sort | uniq -d)
if [ -n "$DUPS" ]; then
echo "❌ Duplicate skill names found:"
echo "$DUPS"
HAS_ERROR=1
fi
if [ $HAS_ERROR -eq 0 ]; then
echo "✅ All skills validated"
else
echo "❌ Validation failed — commit rejected"
exit 1
fi
Make it executable and test it:
$ chmod +x .git/hooks/pre-commit
$ git add skills/devops/new-skill/SKILL.md
$ git commit -m "Add new skill"
🔍 Validating Hermes skills...
✓ skills/devops/new-skill/SKILL.md
✅ All skills validated
This hook catches the three most common mistakes: missing frontmatter fields, accidentally committed API keys, and duplicate skill names. It runs in milliseconds and becomes your first line of defense.
Expand the hook to check shell commands inside skills for common pitfalls:
# Inside pre-commit — check shell commands in code blocks
for skill_file in $(find "$SKILLS_DIR" -name "SKILL.md"); do
# Find shell commands and check for problematic patterns
if grep -Pn '^\$\s+.*\|.*sh$' "$skill_file" > /dev/null 2>&1; then
echo "⚠️ $skill_file: piped shell execution detected"
fi
# Flag potentially dangerous commands
if grep -Pq 'rm\s+-rf\s+/' "$skill_file"; then
echo "❌ $skill_file: dangerous rm -rf / detected"
HAS_ERROR=1
fi
done
The second gate: run every skill in a fresh Docker container to verify commands actually execute. This catches missing dependencies, wrong paths, and environment assumptions.
Create a test script at test-skills.sh:
#!/bin/bash
# test-skills.sh — Run Hermes Agent with each skill in Docker
set -euo pipefail
HERMES_IMAGE="hermes-agent:latest"
SKILLS_DIR="${1:-$HOME/.hermes/profiles/default/skills}"
RESULTS_DIR="/tmp/hermes-skill-tests"
PASS=0
FAIL=0
mkdir -p "$RESULTS_DIR"
for skill_path in $(find "$SKILLS_DIR" -name "SKILL.md"); do
skill_name=$(basename "$(dirname "$skill_path")")
skill_parent=$(basename "$(dirname "$(dirname "$skill_path")")")
echo "=== Testing: $skill_parent/$skill_name ==="
# Mount the skill into an isolated container and validate
if docker run --rm \
-v "$skill_path:/root/.hermes/profiles/default/skills/${skill_parent}/${skill_name}/SKILL.md:ro" \
-e HERMES_SKIP_SETUP=1 \
"$HERMES_IMAGE" \
hermes skills list 2>&1 | grep -q "$skill_name"; then
echo " ✅ PASS — skill loads successfully"
PASS=$((PASS + 1))
else
echo " ❌ FAIL — skill failed to load"
FAIL=$((FAIL + 1))
fi
done
echo ""
echo "Results: $PASS passed, $FAIL failed"
exit $FAIL
Run it against your skill library:
$ ./test-skills.sh
=== Testing: devops/docker-deploy ===
✅ PASS — skill loads successfully
=== Testing: devops/postgres-backup ===
✅ PASS — skill loads successfully
=== Testing: productivity/content-site-publisher ===
✅ PASS — skill loads successfully
Results: 3 passed, 0 failed
Loading a skill is the minimum bar. For production CI/CD, you need end-to-end testing — actually asking Hermes to use the skill on a representative task:
# e2e-test.sh — Exercise a skill with a real task
SKILL_NAME="${1:?Usage: $0 }"
TEST_WORKSPACE="/tmp/hermes-e2e-test"
rm -rf "$TEST_WORKSPACE"
mkdir -p "$TEST_WORKSPACE"
docker run --rm \
-v "$HOME/.hermes/profiles/default/skills:/root/.hermes/profiles/default/skills:ro" \
-v "$TEST_WORKSPACE:/workspace" \
-e HERMES_API_KEY="${HERMES_API_KEY}" \
hermes-agent:latest \
hermes run \
--profile test \
--workspace /workspace \
--input "Load the $SKILL_NAME skill and run its smoke test"
# Verify output
if [ -f "$TEST_WORKSPACE/smoke-test-passed" ]; then
echo "✅ E2E test passed for $SKILL_NAME"
else
echo "❌ E2E test failed for $SKILL_NAME"
exit 1
fi
When your CI/CD pipeline deploys to production, Hermes Agent needs to be reachable — for webhook triggers, API calls, or im-bot integrations. sun-port is a lightweight reverse proxy that sits in front of Hermes and handles authentication, rate limiting, and TLS termination.
sun-port runs as a Docker container, routing traffic to the Hermes Agent backend. Create a docker-compose.yml:
version: "3.9"
services:
sun-port:
image: sun-port:latest
container_name: sun-port
restart: unless-stopped
ports:
- "443:443"
- "80:80"
volumes:
- ./sun-port/config.yaml:/etc/sun-port/config.yaml:ro
- ./certs:/etc/sun-port/certs:ro
environment:
- SUN_PORT_LOG_LEVEL=info
networks:
- agent-net
depends_on:
- hermes-agent
hermes-agent:
image: hermes-agent:latest
container_name: hermes-agent
restart: unless-stopped
expose:
- "3000"
volumes:
- ./hermes-profiles:/root/.hermes/profiles:ro
- ./hermes-config.yaml:/root/.hermes/config.yaml:ro
environment:
- HERMES_API_KEY_FILE=/run/secrets/hermes_api_key
secrets:
- hermes_api_key
networks:
- agent-net
networks:
agent-net:
driver: bridge
secrets:
hermes_api_key:
file: ./secrets/hermes_api_key.txt
The sun-port config maps incoming requests to Hermes Agent endpoints with authentication:
# sun-port/config.yaml
server:
listen: ":443"
tls:
cert: /etc/sun-port/certs/fullchain.pem
key: /etc/sun-port/certs/privkey.pem
routes:
- match:
host: "agent-rule.com"
path: "/api/*"
backend:
url: "http://hermes-agent:3000"
timeout: 30s
auth:
type: bearer
token_file: /etc/sun-port/tokens/hermes-api.key
rate_limit:
requests_per_minute: 60
- match:
host: "agent-rule.com"
path: "/webhook/*"
backend:
url: "http://hermes-agent:3000"
timeout: 60s
auth:
type: hmac_sha256
secret_file: /etc/sun-port/tokens/webhook-secret.key
rate_limit:
requests_per_minute: 10
Start the stack and verify sun-port is routing correctly:
$ docker compose up -d
# Verify sun-port health
$ curl -s https://agent-rule.com/api/health
{"status":"ok","version":"1.0.0"}
# Test authenticated endpoint
$ curl -s -H "Authorization: Bearer $(cat secrets/hermes_api_key.txt)" \
https://agent-rule.com/api/hermes/sessions?limit=5
Every CI/CD pipeline needs observability. When a deployment breaks production, you need to know what changed, who pushed it, and when. PostgreSQL stores the deployment history as a queryable audit log.
Create the audit schema:
-- schema.sql
CREATE TABLE IF NOT EXISTS deployments (
id SERIAL PRIMARY KEY,
deployed_at TIMESTAMPTZ DEFAULT now(),
git_commit TEXT NOT NULL,
git_branch TEXT NOT NULL,
git_author TEXT,
skills_changed TEXT[], -- array of skill names
pipeline_status TEXT NOT NULL, -- 'pre-commit', 'docker-test', 'deployed', 'failed'
test_results JSONB, -- {"passed": 12, "failed": 0, "duration_ms": 4500}
deploy_duration_ms INTEGER,
error_message TEXT,
hermione_version TEXT
);
CREATE INDEX idx_deployments_commit ON deployments(git_commit);
CREATE INDEX idx_deployments_time ON deployments(deployed_at DESC);
CREATE INDEX idx_deployments_status ON deployments(pipeline_status);
-- View: recent failed deployments
CREATE VIEW recent_failures AS
SELECT git_commit, git_author, deployed_at, error_message
FROM deployments
WHERE pipeline_status = 'failed'
AND deployed_at > now() - INTERVAL '7 days'
ORDER BY deployed_at DESC;
After each pipeline stage, insert a record. Here's a script that runs at the end of your deploy workflow:
#!/bin/bash
# record-deploy.sh — Log deployment to PostgreSQL audit trail
set -euo pipefail
COMMIT=$(git rev-parse HEAD)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
AUTHOR=$(git log -1 --pretty=format:'%an <%ae>')
STATUS="${1:-deployed}"
TEST_RESULTS="${2:-{}}"
DURATION="${3:-0}"
# Get changed skills
CHANGED=$(git diff --name-only HEAD~1..HEAD -- skills/ \
| grep "SKILL.md" \
| xargs -I{} dirname {} \
| xargs -I{} basename {} \
| jq -R -s -c 'split("\n")[:-1]')
# Insert deployment record
psql -h localhost -U agent_rule -d agent_pipeline <
Find which skills fail most often:
SELECT unnest(skills_changed) AS skill,
COUNT(*) FILTER (WHERE pipeline_status = 'failed') AS failures,
COUNT(*) AS total_deploys,
ROUND(100.0 * COUNT(*) FILTER (WHERE pipeline_status = 'failed') / COUNT(*), 1) AS failure_pct
FROM deployments
WHERE deployed_at > now() - INTERVAL '30 days'
GROUP BY skill
HAVING COUNT(*) FILTER (WHERE pipeline_status = 'failed') > 0
ORDER BY failure_pct DESC;
This tells you exactly which skills are fragile — prioritize fixing or rewriting them. Combine with Git history (git log -- skills/devops/fragile-skill/) to see if a recent patch introduced the instability.
Now wire everything together into a single deploy script. This is what your CI/CD runner (GitHub Actions, cron job, or manual trigger) executes:
#!/bin/bash
# deploy.sh — Full CI/CD pipeline for Hermes Agent skills
set -euo pipefail
REPO_DIR="/opt/agent-skills"
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
START_TIME=$(date +%s%3N)
cd "$REPO_DIR"
COMMIT=$(git rev-parse HEAD)
echo "=== Hermes Agent CI/CD Pipeline ==="
echo "Commit: $COMMIT"
echo "Started: $TIMESTAMP"
echo ""
# --- Gate 1: Pre-commit validation ---
echo "📋 Gate 1: Pre-commit validation"
if [ -f .git/hooks/pre-commit ]; then
if ! .git/hooks/pre-commit; then
echo "❌ Pre-commit validation failed"
./record-deploy.sh failed '{}' 0
exit 1
fi
fi
echo "✅ Pre-commit passed"
echo ""
# --- Gate 2: Docker skill loading test ---
echo "🐳 Gate 2: Docker skill loading"
if ! ./test-skills.sh; then
echo "❌ Docker tests failed"
./record-deploy.sh failed '{}' 0
exit 1
fi
echo "✅ Docker tests passed"
echo ""
# --- Gate 3: Push to production server ---
echo "🚀 Gate 3: Deploy to production"
PROD_SERVER="root@96.44.169.217"
JUMP_HOST="root@104.207.81.51"
scp -o StrictHostKeyChecking=no -o ProxyJump="$JUMP_HOST:22022" \
-r skills/ "$PROD_SERVER:/opt/im-sun/hermes-skills/" 2>&1
END_TIME=$(date +%s%3N)
DURATION=$((END_TIME - START_TIME))
echo "✅ Deployment complete (${DURATION}ms)"
# Record success
./record-deploy.sh deployed \
"{\"passed\": $(find skills -name SKILL.md | wc -l), \"failed\": 0, \"duration_ms\": $DURATION}" \
$DURATION
echo ""
echo "=== Pipeline Summary ==="
echo "Commit: $COMMIT"
echo "Duration: ${DURATION}ms"
echo "Skills: $(find skills -name SKILL.md | wc -l) deployed"
echo "Status: ✅ SUCCESS"
Hermes Agent cron jobs are the natural trigger for automated deployments. Instead of running CI/CD on every git push, schedule your pipeline to run periodically and only deploy when changes are detected:
# Example Hermes cron configuration
hermes cron create \
--name "agent-skill-pipeline" \
--schedule "0 */6 * * *" \
--profile production \
--input "Run the full CI/CD pipeline for agent skills:
1. git pull the skills repository
2. Run pre-commit validation
3. Run Docker integration tests
4. If all pass, deploy to production via SCP
5. Record results to PostgreSQL audit trail
If no new commits, exit silently."
This means your agent skills are continuously validated and deployed — hands-free. When a team member patches a skill in a live session and commits it, the next cron cycle picks it up, tests it, and deploys it.
To check the pipeline's recent performance:
$ psql -d agent_pipeline -c "
SELECT deployed_at, git_commit, pipeline_status,
deploy_duration_ms, skills_changed
FROM deployments
ORDER BY deployed_at DESC
LIMIT 5;"
deployed_at | git_commit | pipeline_status | duration_ms | skills_changed
--------------------------+--------------+-----------------+-------------+------------------------
2026-08-12 06:00:05+00 | d42f8c3... | deployed | 12450 | {content-site-publisher}
2026-08-12 00:00:03+00 | a1b2c3d... | deployed | 11800 | {docker-deploy}
2026-08-11 18:00:04+00 | e5f6g7h... | deployed | 13100 | {postgres-backup}
2026-08-11 12:00:02+00 | i9j0k1l... | docker-test | 4600 | {health-check}
2026-08-11 12:00:02+00 | i9j0k1l... | failed | 0 | {health-check}
The last two rows tell a story: the health-check skill passed pre-commit validation but failed in Docker testing. The pipeline correctly blocked deployment, and the failure is recorded for investigation.
A CI/CD pipeline for Hermes Agent skills turns your agent from a one-shot tool into a continuously improving system. Git tracks every change, Docker validates every command, sun-port secures every endpoint, and PostgreSQL records every deployment. The result: an agent that gets better and safer with every commit.