diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7a40f0d..6da9797 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -199,22 +199,87 @@ jobs: if: always() run: thv stop --all || true + test-skill-build-action: + name: Test Skill Build Action + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install ToolHive + uses: ./install + + - name: Create Test Skill + run: | + mkdir -p test-skill + cat > test-skill/SKILL.md << 'SKILLEOF' +--- +name: test-skill +description: "A test skill for CI validation" +version: "0.1.0" +license: Apache-2.0 +--- + +# Test Skill + +This is a test skill used to validate the skill-build action. +SKILLEOF + + - name: Test Build Action (default tag) + id: build + uses: ./skill-build + with: + path: ./test-skill + + - name: Verify Build Output + env: + REFERENCE: ${{ steps.build.outputs.reference }} + run: | + echo "Built reference: $REFERENCE" + + if [ -z "$REFERENCE" ]; then + echo "Error: No reference output from build" + exit 1 + fi + + echo "Skill build test passed" + + - name: Test Build Action (custom tag) + id: build-tagged + uses: ./skill-build + with: + path: ./test-skill + tag: test-skill:ci + + - name: Verify Tagged Build Output + env: + REFERENCE: ${{ steps.build-tagged.outputs.reference }} + run: | + echo "Built reference: $REFERENCE" + + if [ -z "$REFERENCE" ]; then + echo "Error: No reference output from tagged build" + exit 1 + fi + + echo "Tagged skill build test passed" + validate-action-metadata: name: Validate Action Metadata runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - + - name: Setup yq uses: mikefarah/yq@v4 - + - name: Validate install/action.yml run: | echo "Validating install/action.yml..." yq eval '.' install/action.yml > /dev/null echo "✓ install/action.yml is valid" - + - name: Validate run-mcp-server/action.yml run: | echo "Validating run-mcp-server/action.yml..." @@ -225,4 +290,10 @@ jobs: run: | echo "Validating mcp-config/action.yml..." yq eval '.' mcp-config/action.yml > /dev/null - echo "✓ mcp-config/action.yml is valid" \ No newline at end of file + echo "✓ mcp-config/action.yml is valid" + + - name: Validate skill-build/action.yml + run: | + echo "Validating skill-build/action.yml..." + yq eval '.' skill-build/action.yml > /dev/null + echo "✓ skill-build/action.yml is valid" \ No newline at end of file diff --git a/README.md b/README.md index 137b7df..d36119d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,18 @@ Persists MCP server configurations to a file and optionally uploads as an artifa [📖 Full Documentation](./mcp-config/README.md) +### 4. `skill-build` - Build ToolHive Skills + +Build and optionally push a ToolHive skill as an OCI artifact. + +**Features:** +- 🏗️ Build skills from a local directory containing `SKILL.md` +- 🏷️ Custom OCI tagging +- 📤 Push to any OCI-compatible registry (ghcr.io, Docker Hub, etc.) +- ✅ Validates skill structure before building + +[📖 Full Documentation](./skill-build/README.md) + ## 📚 Examples ### Basic Installation and Usage diff --git a/skill-build/README.md b/skill-build/README.md new file mode 100644 index 0000000..cc5e02d --- /dev/null +++ b/skill-build/README.md @@ -0,0 +1,164 @@ +# Build ToolHive Skill Action + +Build and optionally push a [ToolHive](https://github.com/stacklok/toolhive) skill as an OCI artifact. Skills are portable knowledge packages for AI agents following the [Agent Skills Specification](https://agentskills.io/specification). + +## Features + +- Build a skill from a local directory containing a `SKILL.md` +- Tag the resulting OCI artifact with a custom reference +- Optionally push to any OCI-compatible registry (ghcr.io, Docker Hub, etc.) +- Validates skill structure before building + +## Prerequisites + +- ToolHive CLI must be installed (use the [install action](../install/README.md)) +- For pushing: registry credentials must be configured before this action runs (e.g., via [docker/login-action](https://github.com/docker/login-action)) + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `path` | Path to the skill directory containing `SKILL.md` | Yes | - | +| `tag` | OCI tag for the built artifact (e.g., `ghcr.io/org/skill-name:v1.0.0`) | No | Skill name from `SKILL.md` | +| `push` | Push the artifact to a remote OCI registry | No | `false` | + +## Outputs + +| Output | Description | +|--------|-------------| +| `reference` | OCI reference of the built skill artifact | + +## Usage + +### Build Only + +```yaml +steps: + - uses: actions/checkout@v4 + + - uses: stacklok/toolhive-actions/install@v0 + + - name: Build skill + id: build + uses: stacklok/toolhive-actions/skill-build@v0 + with: + path: ./my-skill +``` + +### Build and Push to GHCR + +```yaml +steps: + - uses: actions/checkout@v4 + + - uses: stacklok/toolhive-actions/install@v0 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push skill + id: build + uses: stacklok/toolhive-actions/skill-build@v0 + with: + path: ./my-skill + tag: ghcr.io/${{ github.repository_owner }}/my-skill:latest + push: 'true' +``` + +### Build and Push with Semantic Versioning + +```yaml +steps: + - uses: actions/checkout@v4 + + - uses: stacklok/toolhive-actions/install@v0 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push skill + uses: stacklok/toolhive-actions/skill-build@v0 + with: + path: ./skills/my-skill + tag: ghcr.io/${{ github.repository_owner }}/my-skill:${{ github.ref_name }} + push: 'true' +``` + +## Skill Directory Structure + +A skill directory must contain at minimum a `SKILL.md` file with YAML frontmatter: + +``` +my-skill/ + SKILL.md # Required: skill definition with frontmatter + references/ # Optional: additional documentation + COMMANDS.md + EXAMPLES.md +``` + +Example `SKILL.md`: + +```markdown +--- +name: my-skill +description: "A brief description of what this skill does" +version: "0.1.0" +license: Apache-2.0 +--- + +# My Skill + +Detailed documentation and instructions for the AI agent... +``` + +See the [Agent Skills Specification](https://agentskills.io/specification) for the full format. + +## Platform Support + +| Platform | Status | +|----------|--------| +| Ubuntu (Linux) | Supported | +| macOS | Supported | +| Windows | Not yet tested | + +## Security + +- The action validates the skill directory structure before building +- No registry credentials are handled by this action — use a dedicated login action (e.g., `docker/login-action`) to configure credentials before pushing +- The ToolHive daemon is started on a random available port and stopped after the action completes + +## Troubleshooting + +### "ToolHive CLI (thv) is not installed" + +Add the install action before this action: + +```yaml +- uses: stacklok/toolhive-actions/install@v0 +``` + +### "SKILL.md not found" + +Ensure your `path` input points to a directory containing a valid `SKILL.md` file. + +### "ToolHive daemon failed to start" + +Check that the ToolHive version you installed supports the `skill` command. This feature requires a recent version of ToolHive. + +### Push fails with authentication error + +Ensure you have logged in to your registry before running this action. For example: + +```yaml +- uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} +``` diff --git a/skill-build/action.yml b/skill-build/action.yml new file mode 100644 index 0000000..32daaef --- /dev/null +++ b/skill-build/action.yml @@ -0,0 +1,143 @@ +name: 'Build ToolHive Skill' +description: 'Build and optionally push a ToolHive skill as an OCI artifact' +author: 'Stacklok' +branding: + icon: 'package' + color: 'purple' + +inputs: + path: + description: 'Path to the skill directory containing SKILL.md' + required: true + tag: + description: 'OCI tag for the built artifact (e.g., ghcr.io/org/skill-name:v1.0.0). Defaults to the skill name from SKILL.md.' + required: false + default: '' + push: + description: 'Push the built artifact to a remote OCI registry. Requires prior registry login (e.g., via docker/login-action).' + required: false + default: 'false' + +outputs: + reference: + description: 'OCI reference of the built skill artifact' + value: ${{ steps.build.outputs.reference }} + +runs: + using: 'composite' + steps: + - name: Check Prerequisites + shell: bash + env: + INPUT_PATH: ${{ inputs.path }} + run: | + if ! command -v thv &> /dev/null; then + echo "::error::ToolHive CLI (thv) is not installed. Please use the install action first." + exit 1 + fi + + if [ ! -d "$INPUT_PATH" ]; then + echo "::error::Skill directory not found: $INPUT_PATH" + exit 1 + fi + + if [ ! -f "$INPUT_PATH/SKILL.md" ]; then + echo "::error::SKILL.md not found in $INPUT_PATH" + exit 1 + fi + + - name: Start ToolHive Daemon + id: daemon + shell: bash + run: | + # Check if a daemon is already reachable (e.g., started by run-mcp-server) + EXISTING_URL="${TOOLHIVE_API_URL:-http://127.0.0.1:8080}" + if curl -sf "${EXISTING_URL}/api/v1beta/health" > /dev/null 2>&1; then + echo "ToolHive daemon already running at $EXISTING_URL" + PORT=$(echo "$EXISTING_URL" | sed -E 's|.*://[^:]+:([0-9]+).*|\1|') + echo "port=$PORT" >> $GITHUB_OUTPUT + echo "started=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Find an available port + PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()' 2>/dev/null) || { + echo "::warning::python3 not available for port selection, falling back to 8080" + PORT=8080 + } + + echo "Starting thv serve on port $PORT..." + thv serve --address "127.0.0.1:${PORT}" & + DAEMON_PID=$! + echo "pid=$DAEMON_PID" >> $GITHUB_OUTPUT + echo "port=$PORT" >> $GITHUB_OUTPUT + echo "started=true" >> $GITHUB_OUTPUT + + # Wait for daemon to be ready + MAX_WAIT=30 + ELAPSED=0 + while [ $ELAPSED -lt $MAX_WAIT ]; do + if ! kill -0 "$DAEMON_PID" 2>/dev/null; then + echo "::error::ToolHive daemon process exited unexpectedly" + wait "$DAEMON_PID" 2>/dev/null + exit 1 + fi + if curl -sf "http://127.0.0.1:${PORT}/api/v1beta/health" > /dev/null 2>&1; then + echo "ToolHive daemon is ready (port $PORT)" + break + fi + sleep 1 + ELAPSED=$((ELAPSED + 1)) + done + + if [ $ELAPSED -ge $MAX_WAIT ]; then + echo "::error::ToolHive daemon failed to start within ${MAX_WAIT}s" + kill "$DAEMON_PID" 2>/dev/null || true + exit 1 + fi + + - name: Build Skill + id: build + shell: bash + env: + TOOLHIVE_API_URL: http://127.0.0.1:${{ steps.daemon.outputs.port }} + INPUT_PATH: ${{ inputs.path }} + INPUT_TAG: ${{ inputs.tag }} + run: | + BUILD_ARGS=("$INPUT_PATH") + if [ -n "$INPUT_TAG" ]; then + BUILD_ARGS+=(--tag "$INPUT_TAG") + fi + + echo "Building skill from $INPUT_PATH..." + REFERENCE=$(thv skill build "${BUILD_ARGS[@]}" | tail -1) + + if [ -z "$REFERENCE" ]; then + echo "::error::Skill build returned empty reference" + exit 1 + fi + + echo "reference=$REFERENCE" >> $GITHUB_OUTPUT + echo "Built skill: $REFERENCE" + + - name: Push Skill + if: inputs.push == 'true' + shell: bash + env: + TOOLHIVE_API_URL: http://127.0.0.1:${{ steps.daemon.outputs.port }} + REFERENCE: ${{ steps.build.outputs.reference }} + run: | + echo "Pushing $REFERENCE..." + thv skill push "$REFERENCE" + echo "Pushed skill: $REFERENCE" + + - name: Stop ToolHive Daemon + if: always() && steps.daemon.outputs.started == 'true' + shell: bash + env: + DAEMON_PID: ${{ steps.daemon.outputs.pid }} + run: | + if [ -n "$DAEMON_PID" ]; then + kill "$DAEMON_PID" 2>/dev/null || true + wait "$DAEMON_PID" 2>/dev/null || true + fi